# 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
