feat: wire SendGridEmailBackend with from_email/reply_to settings

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 12:12:05 -07:00
co-authored by Claude Sonnet 4.6
parent 63ad306a61
commit 5a41402644
3 changed files with 63 additions and 8 deletions
+2
View File
@@ -8,6 +8,8 @@ class Settings(BaseSettings):
DATABASE_URL: str = "" DATABASE_URL: str = ""
SENDGRID_API_KEY: Optional[str] = None SENDGRID_API_KEY: Optional[str] = None
SENDGRID_FROM_EMAIL: str = "peter@research.bike"
SENDGRID_REPLY_TO: str = "peter@research.bike"
EMAIL_BACKEND: str = "console" EMAIL_BACKEND: str = "console"
LUCKY_CA_URL: str = "https://www.luckyncal.com" LUCKY_CA_URL: str = "https://www.luckyncal.com"
# Swiftly product API (replaces Playwright path). The bearer JWT is # Swiftly product API (replaces Playwright path). The bearer JWT is
+21 -8
View File
@@ -8,12 +8,14 @@ intentionally left to be wired in R3-C.
from __future__ import annotations from __future__ import annotations
import json import json
import os
import sys import sys
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Optional, Protocol from typing import Optional, Protocol
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail, ReplyTo
from app.config import settings from app.config import settings
@@ -66,11 +68,10 @@ class ConsoleEmailBackend:
class SendGridEmailBackend: class SendGridEmailBackend:
"""Stub. Wire in R3-C (real SendGrid client + sandbox mode + retries). def __init__(self) -> None:
self._client = SendGridAPIClient(settings.SENDGRID_API_KEY)
Deliberately raises so a misconfigured prod env fails loudly instead of self._from_email = settings.SENDGRID_FROM_EMAIL
silently dropping mail. self._reply_to = settings.SENDGRID_REPLY_TO
"""
def send( def send(
self, self,
@@ -78,8 +79,20 @@ class SendGridEmailBackend:
subject: str, subject: str,
html: str, html: str,
text: Optional[str] = None, text: Optional[str] = None,
) -> None: # pragma: no cover - stub ) -> None:
raise NotImplementedError("Wire SendGrid in R3-C") 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: def get_email_backend() -> EmailBackend:
+40
View File
@@ -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="<p>Hi</p>")
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="<p>Hi</p>")