From a68f0be414f1015be2e433da1da0a1d092a481bc Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Thu, 7 May 2026 12:50:38 -0700 Subject: [PATCH] docs: Phase 6 SendGrid design spec --- .../2026-05-07-phase6-sendgrid-design.md | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-07-phase6-sendgrid-design.md diff --git a/docs/superpowers/specs/2026-05-07-phase6-sendgrid-design.md b/docs/superpowers/specs/2026-05-07-phase6-sendgrid-design.md new file mode 100644 index 0000000..b0847c3 --- /dev/null +++ b/docs/superpowers/specs/2026-05-07-phase6-sendgrid-design.md @@ -0,0 +1,210 @@ +# 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