From 5a41402644748c866ea0743352539a095e55142b Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Fri, 8 May 2026 12:12:05 -0700 Subject: [PATCH] feat: wire SendGridEmailBackend with from_email/reply_to settings Co-Authored-By: Claude Sonnet 4.6 (1M context) --- backend/app/config.py | 2 ++ backend/app/services/email.py | 29 +++++++++++++++------ backend/tests/test_email_backend.py | 40 +++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 backend/tests/test_email_backend.py diff --git a/backend/app/config.py b/backend/app/config.py index 6543457..0fe3fbd 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -8,6 +8,8 @@ class Settings(BaseSettings): DATABASE_URL: str = "" SENDGRID_API_KEY: Optional[str] = None + SENDGRID_FROM_EMAIL: str = "peter@research.bike" + SENDGRID_REPLY_TO: str = "peter@research.bike" EMAIL_BACKEND: str = "console" LUCKY_CA_URL: str = "https://www.luckyncal.com" # Swiftly product API (replaces Playwright path). The bearer JWT is diff --git a/backend/app/services/email.py b/backend/app/services/email.py index af61b49..d9ed734 100644 --- a/backend/app/services/email.py +++ b/backend/app/services/email.py @@ -8,12 +8,14 @@ intentionally left to be wired in R3-C. from __future__ import annotations import json -import os import sys from datetime import datetime, timezone from pathlib import Path from typing import Optional, Protocol +from sendgrid import SendGridAPIClient +from sendgrid.helpers.mail import Mail, ReplyTo + from app.config import settings @@ -66,11 +68,10 @@ class ConsoleEmailBackend: class SendGridEmailBackend: - """Stub. Wire in R3-C (real SendGrid client + sandbox mode + retries). - - Deliberately raises so a misconfigured prod env fails loudly instead of - silently dropping mail. - """ + def __init__(self) -> None: + self._client = SendGridAPIClient(settings.SENDGRID_API_KEY) + self._from_email = settings.SENDGRID_FROM_EMAIL + self._reply_to = settings.SENDGRID_REPLY_TO def send( self, @@ -78,8 +79,20 @@ class SendGridEmailBackend: subject: str, html: str, text: Optional[str] = None, - ) -> None: # pragma: no cover - stub - raise NotImplementedError("Wire SendGrid in R3-C") + ) -> 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}" + ) def get_email_backend() -> EmailBackend: diff --git a/backend/tests/test_email_backend.py b/backend/tests/test_email_backend.py new file mode 100644 index 0000000..5a020d7 --- /dev/null +++ b/backend/tests/test_email_backend.py @@ -0,0 +1,40 @@ +"""Unit tests for SendGridEmailBackend — no Postgres needed.""" +import pytest + + +def test_sendgrid_send_calls_api(monkeypatch): + calls = [] + + class FakeResponse: + status_code = 202 + body = b"" + + class FakeClient: + def send(self, msg): + calls.append(msg) + return FakeResponse() + + monkeypatch.setattr("app.services.email.SendGridAPIClient", lambda _key: FakeClient()) + + from app.services.email import SendGridEmailBackend + backend = SendGridEmailBackend() + backend.send(to="to@example.com", subject="Hello", html="

Hi

") + + assert len(calls) == 1 + + +def test_sendgrid_raises_on_4xx(monkeypatch): + class FakeResponse: + status_code = 400 + body = b"bad request" + + class FakeClient: + def send(self, msg): + return FakeResponse() + + monkeypatch.setattr("app.services.email.SendGridAPIClient", lambda _key: FakeClient()) + + from app.services.email import SendGridEmailBackend + backend = SendGridEmailBackend() + with pytest.raises(RuntimeError, match="400"): + backend.send(to="to@example.com", subject="Hello", html="

Hi

")