Public Access
211 lines
7.7 KiB
Markdown
211 lines
7.7 KiB
Markdown
# Phase 6 — SendGrid Email Integration Design
|
|
|
|
> Status: Approved 2026-05-07. Implementation plan forthcoming.
|
|
|
|
---
|
|
|
|
## Goal
|
|
|
|
Replace the `ConsoleEmailBackend` JSONL stub with a live SendGrid backend, add a pre-deadline reminder email, and close two minor hygiene issues from Phase 5.
|
|
|
|
---
|
|
|
|
## Scope
|
|
|
|
1. Wire `SendGridEmailBackend.send()` using the `sendgrid` Python library.
|
|
2. Add `SENDGRID_FROM_EMAIL` / `SENDGRID_REPLY_TO` to Settings.
|
|
3. New `step_reminder` orchestrator step — 1-hour pre-deadline nudge for members who have not yet voted.
|
|
4. Alembic migration 0009 — add `reminded_at TIMESTAMPTZ NULL` to `weekly_run`.
|
|
5. 6th scheduler job — Fri 16:00 PT → `run_step("reminder")`.
|
|
6. #P5-a — HTML-escape recipe/ingredient names in `step_email` and `step_finalize` templates.
|
|
7. #P5-b — Remove dead `getattr` default in `step_deadline`.
|
|
|
|
Out of scope: SendGrid sandbox mode, retry logic, denial notification email, per-family sender config, HTML template module.
|
|
|
|
---
|
|
|
|
## Architecture
|
|
|
|
No structural changes. `email.py` keeps its three-class layout (Protocol + Console + SendGrid) and `get_email_backend()` factory. The `EmailBackend` Protocol signature is unchanged — `send(to, subject, html, text=None)`. Sender identity (`from_email`, `reply_to`) is read from Settings inside `SendGridEmailBackend.__init__`, invisible to callers.
|
|
|
|
`step_reminder` is a standard orchestrator step: lives in `steps.py`, has its own `reminded_at` idempotency column, is registered in `STEPS` (between `email` and `deadline`), and is reachable via the existing admin `POST /api/admin/orchestrate/reminder` endpoint.
|
|
|
|
---
|
|
|
|
## Components
|
|
|
|
### `backend/app/services/email.py` (modify)
|
|
|
|
Replace `SendGridEmailBackend.send()` stub with:
|
|
|
|
```python
|
|
from sendgrid import SendGridAPIClient
|
|
from sendgrid.helpers.mail import Mail, ReplyTo
|
|
|
|
class SendGridEmailBackend:
|
|
def __init__(self):
|
|
self._client = SendGridAPIClient(settings.SENDGRID_API_KEY)
|
|
self._from_email = settings.SENDGRID_FROM_EMAIL
|
|
self._reply_to = settings.SENDGRID_REPLY_TO
|
|
|
|
def send(self, to: str, subject: str, html: str, text: Optional[str] = None) -> None:
|
|
message = Mail(
|
|
from_email=self._from_email,
|
|
to_emails=to,
|
|
subject=subject,
|
|
html_content=html,
|
|
plain_text_content=text,
|
|
)
|
|
message.reply_to = ReplyTo(self._reply_to)
|
|
response = self._client.send(message)
|
|
if response.status_code >= 400:
|
|
raise RuntimeError(
|
|
f"SendGrid error {response.status_code}: {response.body}"
|
|
)
|
|
```
|
|
|
|
### `backend/app/config.py` (modify)
|
|
|
|
```python
|
|
SENDGRID_FROM_EMAIL: str = "peter@research.bike"
|
|
SENDGRID_REPLY_TO: str = "peter@research.bike"
|
|
```
|
|
|
|
### `backend/app/services/orchestrator/steps.py` (modify)
|
|
|
|
**`step_reminder(run, db)`** — new function:
|
|
|
|
1. Return immediately if `run.reminded_at is not None`.
|
|
2. Return immediately if `run.emailed_at is None` (proposal never sent).
|
|
3. Load the `MealPlan` for `(run.family_id, run.week_start_date)`. If none, return.
|
|
4. Collect PENDING `MealPlanItem` IDs.
|
|
5. For each `FamilyMember` with a non-null email: query `MealPlanVote` for votes by this member on the pending items. If no un-voted items remain, skip.
|
|
6. For un-voted items, issue new tokens via `issue_token(item.id, member.id)` and build vote links.
|
|
7. Send reminder email:
|
|
- Subject: `f"Meal plan vote closes in 1 hour — week of {run.week_start_date}"`
|
|
- Body: member name, list of un-voted meals with vote links, deadline note.
|
|
8. Set `run.reminded_at = datetime.now(timezone.utc)`, commit.
|
|
|
|
**HTML-escape (#P5-a):** Wrap every user-derived string (recipe name, ingredient name) in `html.escape()` before interpolating into f-string HTML in `step_email` and `step_finalize`.
|
|
|
|
**`getattr` cleanup (#P5-b):** In `step_deadline`, replace:
|
|
```python
|
|
policy = getattr(family, "pending_approval_policy", "approve")
|
|
```
|
|
with:
|
|
```python
|
|
policy = family.pending_approval_policy
|
|
```
|
|
|
|
### `backend/alembic/versions/0009_phase6_reminded_at.py` (create)
|
|
|
|
```python
|
|
def upgrade():
|
|
op.add_column("weekly_run", sa.Column("reminded_at", sa.DateTime(timezone=True), nullable=True))
|
|
|
|
def downgrade():
|
|
op.drop_column("weekly_run", "reminded_at")
|
|
```
|
|
|
|
### `backend/app/models/__init__.py` (modify)
|
|
|
|
Add `reminded_at = Column(DateTime(timezone=True), nullable=True)` to `WeeklyRun`.
|
|
|
|
### `backend/app/services/orchestrator/runner.py` (modify)
|
|
|
|
Add `"reminder"` to `STEPS` list between `"email"` and `"deadline"`:
|
|
|
|
```python
|
|
STEPS = ["scrape", "generate", "email", "reminder", "deadline", "finalize"]
|
|
```
|
|
|
|
### `backend/app/api/admin.py` (modify)
|
|
|
|
Add `"reminder"` to `_VALID_STEPS`:
|
|
|
|
```python
|
|
_VALID_STEPS = {"scrape", "generate", "email", "reminder", "deadline", "finalize"}
|
|
```
|
|
|
|
### `backend/app/scheduler/__main__.py` (modify)
|
|
|
|
Add 6th job:
|
|
|
|
```python
|
|
scheduler.add_job(
|
|
lambda: run_step("reminder"),
|
|
CronTrigger(day_of_week="fri", hour=16, minute=0, timezone="America/Los_Angeles"),
|
|
id="reminder",
|
|
name="step_reminder",
|
|
)
|
|
```
|
|
|
|
### `requirements.txt` (verify / add)
|
|
|
|
Ensure `sendgrid` (or `sendgrid-python`) is present. Add if missing.
|
|
|
|
---
|
|
|
|
## Data flow
|
|
|
|
```
|
|
Fri 02:00 step_scrape → weekly_run.scraped_at
|
|
Fri 05:00 step_generate → weekly_run.generated_at
|
|
Fri 06:00 step_email → weekly_run.emailed_at (proposal + vote links)
|
|
Fri 16:00 step_reminder → weekly_run.reminded_at (nudge for non-voters)
|
|
Fri 17:00 step_deadline → weekly_run.deadline_passed_at
|
|
Fri 18:00 step_finalize → weekly_run.finalized_at (shopping list)
|
|
```
|
|
|
|
---
|
|
|
|
## Error handling
|
|
|
|
`SendGridEmailBackend.send()` raises `RuntimeError` on non-2xx. The orchestrator's existing per-step error handler in `runner.py` catches it, sets `weekly_run.error_step` / `weekly_run.error_message`, and logs it. No additional retry logic.
|
|
|
|
---
|
|
|
|
## Testing
|
|
|
|
- `test_sendgrid_backend_send`: monkeypatch `SendGridAPIClient.send` to return a mock 202 response; assert `Mail` object has correct `to`, `from_email`, `reply_to`, `subject`, `html_content`.
|
|
- `test_sendgrid_backend_raises_on_error`: mock returns 400; assert `RuntimeError` raised.
|
|
- `test_step_reminder_idempotent`: `reminded_at` already set → no sends.
|
|
- `test_step_reminder_skips_if_no_proposal`: `emailed_at` is None → no sends.
|
|
- `test_step_reminder_sends_only_to_non_voters`: one member voted, one didn't → only one send.
|
|
- `test_step_reminder_no_pending_items`: all items approved/denied → no sends.
|
|
- Existing `test_orchestrator.py` tests for `step_email` / `step_finalize` pass unchanged (they monkeypatch `get_email_backend`, not the SendGrid client directly).
|
|
|
|
Target: ~125 tests total (115 existing + ~10 new).
|
|
|
|
---
|
|
|
|
## Environment variables
|
|
|
|
```bash
|
|
# Required for EMAIL_BACKEND=sendgrid
|
|
SENDGRID_API_KEY=SG.xxx
|
|
SENDGRID_FROM_EMAIL=peter@research.bike
|
|
SENDGRID_REPLY_TO=peter@research.bike
|
|
```
|
|
|
|
`EMAIL_BACKEND=console` remains the default; no SendGrid calls are made unless explicitly set.
|
|
|
|
---
|
|
|
|
## Files touched
|
|
|
|
| File | Action |
|
|
|---|---|
|
|
| `backend/app/services/email.py` | Modify — wire SendGridEmailBackend |
|
|
| `backend/app/config.py` | Modify — add SENDGRID_FROM_EMAIL / SENDGRID_REPLY_TO |
|
|
| `backend/app/services/orchestrator/steps.py` | Modify — add step_reminder; #P5-a html.escape; #P5-b getattr cleanup |
|
|
| `backend/app/services/orchestrator/runner.py` | Modify — add "reminder" to STEPS |
|
|
| `backend/app/api/admin.py` | Modify — add "reminder" to _VALID_STEPS |
|
|
| `backend/app/scheduler/__main__.py` | Modify — add 6th Fri 16:00 job |
|
|
| `backend/app/models/__init__.py` | Modify — add reminded_at to WeeklyRun |
|
|
| `backend/alembic/versions/0009_phase6_reminded_at.py` | Create — add reminded_at column |
|
|
| `backend/requirements.txt` | Verify/add sendgrid |
|
|
| `backend/tests/test_orchestrator.py` | Modify — add ~10 new tests |
|
|
|
|
Last updated: 2026-05-07
|