feat: Phase 2 Core Send API — POST /send, templates, email log #4
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "3-phase-2-core-send-api-post-send-template"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Changes
src/pal_e_mail/schemas.py: Pydantic models — SendRequest (with mutual exclusion validator for template/html), SendResponse, LogEntry, LogResponsesrc/pal_e_mail/services/sender.py: Gmail sender service with per-account client caching, send+log in one call, GmailAPIError handlingsrc/pal_e_mail/services/template.py: Template fetching from MinIO CDN with {{key}} replacement, brand wrapper HTML (basketball-api pattern), html_to_plain_text convertersrc/pal_e_mail/routes/send.py: POST /send — template mode, custom HTML mode, plain text fallback, error mapping (404/400/502)src/pal_e_mail/routes/log.py: GET /log with chainable filters + limit/offset pagination + newest-first orderingsrc/pal_e_mail/main.py: Router includes for send and logtests/conftest.py: SQLite in-memory test database with StaticPool for thread safetytests/test_send.py: 7 tests covering template, custom HTML, plain text, validation, sender errors, Gmail failures, success loggingtests/test_template.py: 8 tests covering fetch+replace, HTTP errors, brand wrapper structure+escaping, html_to_plain_texttests/test_log.py: 9 tests covering all filters, pagination, and orderingTest Plan
ruff check .passes cleanruff format --check .passes cleanpytest tests/ -v(28 passed, 0 failed)Review Checklist
Related
plan-pal-e-mail— Phase 2: Core Send APIReview
Result: PASS -- 28/28 tests green, ruff clean, no warnings.
Findings
datetime.utcnow()deprecation (test_log.py) -- Python 3.12+ deprecatesdatetime.utcnow(). Replaced withdatetime.now(UTC). Fixed in follow-up commit.Verified
ruff check .-- all checks passedruff format --check .-- 20 files already formattedpytest tests/ -v-- 28 passed, 0 warningsgmail_sdk(confirmed re-exported from__init__)StaticPool+check_same_thread=Falsefor thread safety with TestClientPR #4 Review
DOMAIN REVIEW
Tech stack: Python 3.12+ / FastAPI (sync) / SQLAlchemy 2.0 (mapped_column) / Pydantic v2 / httpx / gmail-sdk-ldraney / Alembic / pytest
FastAPI patterns -- Clean and correct.
Depends(get_db)properly injected. Session lifecycle handled viaget_dbgenerator indatabase.py.response_modelon both endpoints enforces response shape.Query()withge/leconstraints -- proper validation.SQLAlchemy patterns -- Solid.
StaticPool+check_same_thread=Falsefor SQLite in-memory test database is the correct pattern.db.add()+db.commit()in sender.py is correct for sync sessions. Both success and failure paths commit their log entries.with_entities(func.count(...))for total count before pagination is efficient -- avoids loading all rows.001_email_log.py) matches the model definition exactly. Column types, nullability, indexes, and server defaults all align withmodels.py.Gmail SDK integration -- Verified against installed SDK source (
gmail_sdk.messages.MessagesMixin.send_message).client.send_message(to=..., subject=..., body=..., html_body=...)matches the SDK exactly.GmailAPIErroris the correct exception class, properly caught and logged.result.get("id")for gmail_message_id is correct -- SDK returns the Gmail message resource dict.Template / MinIO CDN -- Well structured.
fetch_template()correctly differentiates 404 (TemplateNotFoundError) from other HTTP errors (TemplateError). Route maps these to 404 and 502 respectively.str.replace()for{{key}}patterns. Adequate for email templates.XSS protection --
brand_wrapper()properly callshtml.escape(brand_name)before interpolating into HTML. Thesafe_brandvariable is used in both the header<h1>and footer<p>. Good.Module-level client caching --
_clients: dict[str, GmailClient] = {}is a reasonable process-level cache. Tests properly clear it withsender_mod._clients.clear(). Note: this means a stale/expired token stays cached until process restart. Acceptable for Phase 2; token refresh is a Phase 3+ concern.Brand wrapper faithfulness -- The color palette (
#0a0a0a,#141414,#d42026,#f0f0f0) and structure (outer > card > header with red border-bottom > body > footer with mdash) follow the basketball-api branded email pattern. Font stack is system fonts. Max-width 600px is email standard.Pydantic models -- Clean.
SendRequestusesmodel_validator(mode="after")for template/html mutual exclusion. ReturnsSelfper PEP 484. Correct.LogEntryusesConfigDict(from_attributes=True)for ORM mode. Correct for SQLAlchemy mapped objects.data: dict[str, str] = {}uses mutable default -- safe in Pydantic v2 (each instance gets a copy).BLOCKERS
None.
All new functionality has test coverage:
Total: 24 tests for new code + 4 for edge cases = 28. No untested paths.
No secrets committed. No
.envfiles. Config reads from env vars with safe defaults. The MinIO CDN URL default (https://minio-api.tail5b443a.ts.net/assets/email-templates) is infrastructure-internal, not a secret.No unvalidated user input. Sender string is validated against filesystem (token file existence). Template/project strings are used to construct a URL to the internal MinIO CDN -- no SQL injection risk (parameterized via SQLAlchemy), no path traversal (httpx URL construction). Brand name is HTML-escaped.
No DRY violations in auth/security paths. The
get_gmail_client()function is the single auth entry point.NITS
body_htmlnot escaped inbrand_wrapper()(template.py:71): Thebody_htmlparameter is inserted raw into the wrapper. This is by design (it IS HTML), but worth a docstring note clarifying that callers are responsible for ensuringbody_htmlis trusted/sanitized. Currently the two callers are: (a)fetch_template()output (from MinIO CDN, trusted), and (b)req.htmlfrom the API request. For (b), the caller sends their own HTML to wrap -- this is a "send your own email" API, not user-generated content, so raw insertion is acceptable. A brief docstring note would make the trust boundary explicit.html_to_plain_textHTML entity handling: The function strips tags but does not decode HTML entities like&,—, etc. So— MyBrandin the footer becomes the literal string— MyBrandin plain text rather than-- MyBrand. Minor for email previews but worth noting for future improvement.noqa: E501onbrand_wrapper(template.py:45): The function signature itself is not over 120 chars. Thenoqasuppresses a warning that may have existed during development but the line is clean now. Could remove the suppression.pytest-asyncioin dev dependencies (pyproject.toml:19): Not used anywhere -- all tests and code are sync. Harmless but unnecessary dependency.Mutable default in Pydantic model (schemas.py:18):
data: dict[str, str] = {}-- safe in Pydantic v2, butField(default_factory=dict)is the more explicit pattern and avoids any future confusion.No
tests/__init__.pycontent: The file exists but is empty. Works fine for pytest discovery, just noting it's there.import osunused inmain.py?: Actually used on line 20 foros.environ.get("BUILD_SHA", "dev")in the healthz endpoint. Not from this PR (pre-existing). No issue.SOP COMPLIANCE
3-phase-2-core-send-api-post-send-templatereferences issue #3plan-pal-e-mail -- Phase 2: Core Send APIfeat: Phase 2 Core Send API)PROCESS OBSERVATIONS
StaticPooland fully mocked external dependencies (gmail-sdk, httpx). No network calls, no service containers needed. CI-friendly.VERDICT: APPROVED