7.7 KiB
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
- Wire
SendGridEmailBackend.send()using thesendgridPython library. - Add
SENDGRID_FROM_EMAIL/SENDGRID_REPLY_TOto Settings. - New
step_reminderorchestrator step — 1-hour pre-deadline nudge for members who have not yet voted. - Alembic migration 0009 — add
reminded_at TIMESTAMPTZ NULLtoweekly_run. - 6th scheduler job — Fri 16:00 PT →
run_step("reminder"). - #P5-a — HTML-escape recipe/ingredient names in
step_emailandstep_finalizetemplates. - #P5-b — Remove dead
getattrdefault instep_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:
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)
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:
- Return immediately if
run.reminded_at is not None. - Return immediately if
run.emailed_at is None(proposal never sent). - Load the
MealPlanfor(run.family_id, run.week_start_date). If none, return. - Collect PENDING
MealPlanItemIDs. - For each
FamilyMemberwith a non-null email: queryMealPlanVotefor votes by this member on the pending items. If no un-voted items remain, skip. - For un-voted items, issue new tokens via
issue_token(item.id, member.id)and build vote links. - 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.
- Subject:
- 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:
policy = getattr(family, "pending_approval_policy", "approve")
with:
policy = family.pending_approval_policy
backend/alembic/versions/0009_phase6_reminded_at.py (create)
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":
STEPS = ["scrape", "generate", "email", "reminder", "deadline", "finalize"]
backend/app/api/admin.py (modify)
Add "reminder" to _VALID_STEPS:
_VALID_STEPS = {"scrape", "generate", "email", "reminder", "deadline", "finalize"}
backend/app/scheduler/__main__.py (modify)
Add 6th job:
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: monkeypatchSendGridAPIClient.sendto return a mock 202 response; assertMailobject has correctto,from_email,reply_to,subject,html_content.test_sendgrid_backend_raises_on_error: mock returns 400; assertRuntimeErrorraised.test_step_reminder_idempotent:reminded_atalready set → no sends.test_step_reminder_skips_if_no_proposal:emailed_atis 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.pytests forstep_email/step_finalizepass unchanged (they monkeypatchget_email_backend, not the SendGrid client directly).
Target: ~125 tests total (115 existing + ~10 new).
Environment variables
# 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