Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions echo/server/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,12 @@ EMBEDDING_API_VERSION=
# Email (SendGrid)
############################################################
# API key for SendGrid transactional emails. Leave empty to disable sends.
# For EU data residency this must be an EU regional subuser key.
SENDGRID_API_KEY=
# Data residency region: "eu" (default, routes via api.eu.sendgrid.com) or
# "global". "eu" keeps recipient PII and content in EU data centers and
# requires the key above to belong to an EU regional subuser.
SENDGRID_REGION=eu
EMAIL_FROM=do-not-reply@dembrane.com
EMAIL_FROM_NAME=dembrane
# Where "Request upgrade" CTAs route. Shared inbox per matrix v1.1 §11.
Expand Down
9 changes: 9 additions & 0 deletions echo/server/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ Cross-cutting rules (Directus, BFF, LLM model groups, Dramatiq/gevent, transcrip
- Embeddings: populate `EMBEDDING_*` env vars (model, key, base URL, version) before calling `dembrane.embedding.embed_text`. The placeholder in `dembrane/embedding.py` is not yet the production implementation
- Production API uses a **custom asyncio uvicorn worker** (`dembrane.asyncio_uvicorn_worker.AsyncioUvicornWorker`); avoid `uvloop` for `nest_asyncio` compatibility

## Email (two independent senders)

There are two separate email paths and they are easy to conflate. They use different transports, keys, and config.

1. **App transactional email**: `dembrane/email.py` → SendGrid **HTTP API**, keyed by `SENDGRID_API_KEY`. This is the main path: workspace invites, tier notifications, digests, auth emails. Called from `tasks.py` (Dramatiq) and `api/v2/*`. The Python app does **not** read any `*_SMTP_*` vars.
2. **Directus-native email**: the `echo-directus` container → SendGrid **SMTP** (`smtp.sendgrid.net`), keyed by `EMAIL_SMTP_PASSWORD` (sealed as `DIRECTUS_EMAIL_SMTP_PASSWORD`). Only for emails Directus generates itself. Configured on the Directus deployment, never in `dembrane/settings.py`.

EU data residency: `SENDGRID_REGION=eu` makes `email.py` route through `api.eu.sendgrid.com` (`set_sendgrid_data_residency`), but the key must belong to an **EU regional subuser**, a global-account key is not EU-resident even on the EU host. The Directus path must be moved separately (`smtp.eu.sendgrid.net` + EU key); changing one does not affect the other.

## Background task design

When fixing or extending Dramatiq flows:
Expand Down
6 changes: 6 additions & 0 deletions echo/server/dembrane/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,12 @@ def send_email_sync(
)

sg = SendGridAPIClient(api_key)
# Pin the send to a data-residency region. "eu" routes through
# api.eu.sendgrid.com so recipient PII and content stay in the EU.
# The key must belong to a subuser in this region.
region = settings.email.sendgrid_region
if region and region != "global":
sg.set_sendgrid_data_residency(region)
response = sg.send(message)

if response.status_code >= 400:
Expand Down
9 changes: 9 additions & 0 deletions echo/server/dembrane/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,15 @@ class EmailSettings(BaseSettings):
"SENDGRID_API_KEY", "EMAIL__SENDGRID_API_KEY", "EMAIL_SMTP_PASSWORD"
),
)
# SendGrid data residency region. "eu" routes sends through
# api.eu.sendgrid.com so recipient PII and content stay in EU data
# centers (GDPR). Requires an EU regional subuser key. "global" uses
# api.sendgrid.com. The key must belong to a subuser in this region.
sendgrid_region: str = Field(
default="eu",
alias="SENDGRID_REGION",
validation_alias=AliasChoices("SENDGRID_REGION", "EMAIL__SENDGRID_REGION"),
)
Comment on lines +330 to +334

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Constrain and normalize sendgrid_region at config load.

Line 330 currently accepts arbitrary strings; typos/casing like EU or euu will flow into the SendGrid routing call and can break all sends (downstream queue jobs then retry/fail noisily).

💡 Suggested fix
 class EmailSettings(BaseSettings):
@@
-    sendgrid_region: str = Field(
+    sendgrid_region: Literal["eu", "global"] = Field(
         default="eu",
         alias="SENDGRID_REGION",
         validation_alias=AliasChoices("SENDGRID_REGION", "EMAIL__SENDGRID_REGION"),
     )
+
+    `@field_validator`("sendgrid_region", mode="before")
+    `@classmethod`
+    def normalize_sendgrid_region(cls, value: Any) -> str:
+        region = str(value or "eu").strip().lower()
+        if region not in {"eu", "global"}:
+            raise ValueError("SENDGRID_REGION must be 'eu' or 'global'")
+        return region
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@echo/server/dembrane/settings.py` around lines 330 - 334, The sendgrid_region
Field currently accepts arbitrary strings; add normalization and validation so
only allowed regions are used: define a SendGridRegion enum (e.g., values 'eu'
and 'us') or add a Pydantic validator named normalize_sendgrid_region for the
sendgrid_region field that strips whitespace, lowercases the input, maps common
synonyms if desired, and raises a ValueError for invalid values; update the
sendgrid_region Field declaration (the sendgrid_region symbol and its
Field/AliasChoices usage) to use the enum or validated value so typos/casing
like "EU " or "euu" are rejected/normalized at config load.

from_email: str = Field(
default="do-not-reply@dembrane.com",
alias="EMAIL_FROM",
Expand Down
Loading