|
| 1 | +# Staging Email Provider Options |
| 2 | + |
| 3 | +## Current State |
| 4 | + |
| 5 | +The staging environment has no working email delivery. `MAIL_HOST` defaults to `localhost:1025` (Mailpit dev sink), which silently fails inside the Azure Container App. Affected flows: |
| 6 | + |
| 7 | +- Password reset (forgot-password sends a tokenized link) |
| 8 | +- Email verification (signup sends a confirmation link) |
| 9 | +- Payment reminders and overdue notifications (Celery worker jobs) |
| 10 | + |
| 11 | +Regardless of provider chosen, the following code changes are required: |
| 12 | + |
| 13 | +| File | Change | |
| 14 | +|------|--------| |
| 15 | +| `backend/app/services/email_service.py` | Add TLS + auth to SMTP, or swap to provider SDK | |
| 16 | +| `backend/app/services/email_service.py` | Replace hardcoded `http://localhost:5173` reset URL with config value | |
| 17 | +| `backend/app/services/email_service.py` | Replace `noreply@lendq.local` sender with real address | |
| 18 | +| `backend/app/config.py` | Add `MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_FROM`, `FRONTEND_URL` config | |
| 19 | +| `ops/azure/modules/container-apps.bicep` | Add mail env vars to container app | |
| 20 | +| `.github/workflows/deploy-staging.yml` | Set mail env vars or secrets | |
| 21 | + |
| 22 | +--- |
| 23 | + |
| 24 | +## Option 1: Resend |
| 25 | + |
| 26 | +**What**: Developer-focused transactional email API. REST/SDK-based, no SMTP needed. |
| 27 | + |
| 28 | +| | | |
| 29 | +|---|---| |
| 30 | +| **Free tier** | 100 emails/day, 1 domain | |
| 31 | +| **Setup effort** | ~30 min | |
| 32 | +| **Domain verification** | Required (DNS TXT record) OR use `onboarding@resend.dev` for testing | |
| 33 | +| **SDK** | `pip install resend` — 5-line send call | |
| 34 | +| **Human in the loop** | DNS record for domain verification (unless using test sender) | |
| 35 | + |
| 36 | +**Pros** |
| 37 | +- Excellent developer experience, minimal code |
| 38 | +- Built-in delivery tracking and logs in dashboard |
| 39 | +- Generous free tier for staging |
| 40 | +- Webhook support for bounce/complaint handling |
| 41 | +- No SMTP configuration complexity |
| 42 | + |
| 43 | +**Cons** |
| 44 | +- Third-party dependency outside Azure ecosystem |
| 45 | +- Requires Resend account creation (manual signup) |
| 46 | +- Domain verification needs DNS access |
| 47 | +- Vendor lock-in on API (though easy to swap) |
| 48 | + |
| 49 | +**Can be fully automated (no human)?** Partially. Code changes and deployment can be automated. Account signup and DNS verification require a human. Using the `onboarding@resend.dev` test sender avoids DNS but limits the sender address. |
| 50 | + |
| 51 | +**Code change**: |
| 52 | +```python |
| 53 | +import resend |
| 54 | +resend.api_key = os.environ["RESEND_API_KEY"] |
| 55 | + |
| 56 | +def send_email(self, to, subject, body): |
| 57 | + resend.Emails.send({ |
| 58 | + "from": os.environ.get("MAIL_FROM", "noreply@lendq.com"), |
| 59 | + "to": to, |
| 60 | + "subject": subject, |
| 61 | + "html": body, |
| 62 | + }) |
| 63 | +``` |
| 64 | + |
| 65 | +--- |
| 66 | + |
| 67 | +## Option 2: Gmail SMTP with App Password |
| 68 | + |
| 69 | +**What**: Use a Gmail account as an SMTP relay via App Password. |
| 70 | + |
| 71 | +| | | |
| 72 | +|---|---| |
| 73 | +| **Free tier** | 500 emails/day | |
| 74 | +| **Setup effort** | ~15 min | |
| 75 | +| **Domain verification** | None (sends from @gmail.com) | |
| 76 | +| **SDK** | None — uses stdlib `smtplib` already in code | |
| 77 | +| **Human in the loop** | Must create App Password in Google Account settings (requires 2FA enabled) | |
| 78 | + |
| 79 | +**Pros** |
| 80 | +- Zero cost |
| 81 | +- No new dependencies — uses existing `smtplib` code with minor changes |
| 82 | +- No domain verification needed |
| 83 | +- Familiar and well-documented |
| 84 | +- Fast to set up |
| 85 | + |
| 86 | +**Cons** |
| 87 | +- Sends from a @gmail.com address (not branded) |
| 88 | +- Google can throttle or block if flagged as spam |
| 89 | +- App Password must be created manually in Google Account |
| 90 | +- Not suitable for production (rate limits, deliverability) |
| 91 | +- Tied to a personal/shared Google account |
| 92 | + |
| 93 | +**Can be fully automated (no human)?** No. Creating the App Password requires logging into Google Account with 2FA and generating the password manually. Once created, the rest (code + deploy) is automatable. |
| 94 | + |
| 95 | +**Code change** (minimal — add 2 lines to existing `send_email`): |
| 96 | +```python |
| 97 | +with smtplib.SMTP(host, port) as server: |
| 98 | + server.starttls() |
| 99 | + server.login(username, password) # MAIL_USERNAME, MAIL_PASSWORD from env |
| 100 | + server.send_message(msg) |
| 101 | +``` |
| 102 | + |
| 103 | +**Env vars**: |
| 104 | +``` |
| 105 | +MAIL_HOST=smtp.gmail.com |
| 106 | +MAIL_PORT=587 |
| 107 | +MAIL_USERNAME=your-gmail@gmail.com |
| 108 | +MAIL_PASSWORD=xxxx-xxxx-xxxx-xxxx # App Password |
| 109 | +MAIL_FROM=your-gmail@gmail.com |
| 110 | +``` |
| 111 | + |
| 112 | +--- |
| 113 | + |
| 114 | +## Option 3: Azure Communication Services (ACS) Email |
| 115 | + |
| 116 | +**What**: Azure-native email service. Supports SMTP relay or REST API. |
| 117 | + |
| 118 | +| | | |
| 119 | +|---|---| |
| 120 | +| **Free tier** | 100 emails/day on ACS free tier | |
| 121 | +| **Setup effort** | ~45 min | |
| 122 | +| **Domain verification** | Required (Azure-managed subdomain available as shortcut) | |
| 123 | +| **SDK** | `pip install azure-communication-email` or use SMTP relay | |
| 124 | +| **Human in the loop** | Portal provisioning of ACS resource + email domain | |
| 125 | + |
| 126 | +**Pros** |
| 127 | +- Native to Azure — same billing, same portal, same IAM |
| 128 | +- Azure-managed subdomain option (`xxxxxxxx.azurecomm.net`) avoids custom DNS |
| 129 | +- SMTP relay option means zero code change (just set env vars) |
| 130 | +- Enterprise-grade deliverability and compliance |
| 131 | +- Managed Identity auth possible (no API keys) |
| 132 | + |
| 133 | +**Cons** |
| 134 | +- More complex initial setup (ACS resource → Communication Service → Email → Domain → Sender) |
| 135 | +- Azure portal steps cannot be fully scripted via Bicep (email domain provisioning is partially manual) |
| 136 | +- Verbose SDK compared to Resend |
| 137 | +- Overkill for staging-only use |
| 138 | + |
| 139 | +**Can be fully automated (no human)?** No. The ACS resource can be created via Bicep, but email domain provisioning and sender address setup require portal interaction. The Azure-managed subdomain shortcut reduces DNS work but still needs portal clicks. |
| 140 | + |
| 141 | +**SMTP relay approach** (zero code change): |
| 142 | +``` |
| 143 | +MAIL_HOST=smtp.azurecomm.net |
| 144 | +MAIL_PORT=587 |
| 145 | +MAIL_USERNAME=<ACS-resource-name>.<entra-app-id>.<tenant-id> |
| 146 | +MAIL_PASSWORD=<entra-client-secret> |
| 147 | +MAIL_FROM=DoNotReply@xxxxxxxx.azurecomm.net |
| 148 | +``` |
| 149 | + |
| 150 | +--- |
| 151 | + |
| 152 | +## Option 4: Mailpit Container (catch-all, no real delivery) |
| 153 | + |
| 154 | +**What**: Deploy Mailpit as a sidecar container in the staging Container App environment. Catches all emails in a web UI without delivering them. |
| 155 | + |
| 156 | +| | | |
| 157 | +|---|---| |
| 158 | +| **Free tier** | Free (open-source container) | |
| 159 | +| **Setup effort** | ~30 min | |
| 160 | +| **Domain verification** | None | |
| 161 | +| **SDK** | None — existing SMTP code works as-is | |
| 162 | +| **Human in the loop** | None | |
| 163 | + |
| 164 | +**Pros** |
| 165 | +- **Fully automatable** — no accounts, no DNS, no secrets |
| 166 | +- Zero code changes to email service (just point MAIL_HOST to sidecar) |
| 167 | +- Web UI to inspect all caught emails (HTML rendering, headers, attachments) |
| 168 | +- Perfect for E2E testing — verify email content without real delivery |
| 169 | +- No risk of accidentally emailing real users |
| 170 | + |
| 171 | +**Cons** |
| 172 | +- Emails are not actually delivered — cannot test real inbox delivery |
| 173 | +- Requires an additional container (small resource cost) |
| 174 | +- Mailpit web UI needs to be exposed (ingress config) or accessed via port-forward |
| 175 | +- Not useful if the goal is to test deliverability to real inboxes |
| 176 | + |
| 177 | +**Can be fully automated (no human)?** **Yes.** Add a Mailpit container to the Bicep template, set `MAIL_HOST` to its internal hostname, and deploy. No accounts, no DNS, no manual steps. |
| 178 | + |
| 179 | +**Bicep addition**: |
| 180 | +```bicep |
| 181 | +resource mailpit 'Microsoft.App/containerApps@2023-05-01' = { |
| 182 | + name: 'lendq-mailpit-${environmentName}' |
| 183 | + properties: { |
| 184 | + configuration: { |
| 185 | + ingress: { external: true, targetPort: 8025 } // Web UI |
| 186 | + } |
| 187 | + template: { |
| 188 | + containers: [{ |
| 189 | + name: 'mailpit' |
| 190 | + image: 'axllent/mailpit:latest' |
| 191 | + resources: { cpu: json('0.25'), memory: '0.5Gi' } |
| 192 | + }] |
| 193 | + } |
| 194 | + } |
| 195 | +} |
| 196 | +``` |
| 197 | + |
| 198 | +**Env var**: `MAIL_HOST=lendq-mailpit-staging` (internal DNS within Container App Environment) |
| 199 | + |
| 200 | +--- |
| 201 | + |
| 202 | +## Option 5: Mailtrap |
| 203 | + |
| 204 | +**What**: Email testing platform with a fake SMTP inbox. Catches emails in a shared inbox with a web UI. Also offers a sending API for real delivery. |
| 205 | + |
| 206 | +| | | |
| 207 | +|---|---| |
| 208 | +| **Free tier** | 100 test emails/month (inbox), 1,000 emails/month (sending API) | |
| 209 | +| **Setup effort** | ~15 min | |
| 210 | +| **Domain verification** | None for test inbox; required for sending API | |
| 211 | +| **SDK** | None — standard SMTP credentials | |
| 212 | +| **Human in the loop** | Account signup only | |
| 213 | + |
| 214 | +**Pros** |
| 215 | +- Purpose-built for staging/testing |
| 216 | +- SMTP inbox catches emails — great for E2E testing email content |
| 217 | +- Also has a real sending mode if needed later |
| 218 | +- Team inbox sharing (multiple people can view caught emails) |
| 219 | +- Spam score analysis on caught emails |
| 220 | + |
| 221 | +**Cons** |
| 222 | +- Free tier is limited (100 test emails/month) |
| 223 | +- Third-party account required |
| 224 | +- Not real delivery in inbox mode (same limitation as Mailpit) |
| 225 | +- Less generous than Mailpit for high-volume test runs |
| 226 | + |
| 227 | +**Can be fully automated (no human)?** No. Account signup is manual. After that, code + deploy is automatable. |
| 228 | + |
| 229 | +**Env vars** (from Mailtrap inbox credentials): |
| 230 | +``` |
| 231 | +MAIL_HOST=sandbox.smtp.mailtrap.io |
| 232 | +MAIL_PORT=587 |
| 233 | +MAIL_USERNAME=<from-mailtrap-dashboard> |
| 234 | +MAIL_PASSWORD=<from-mailtrap-dashboard> |
| 235 | +``` |
| 236 | + |
| 237 | +--- |
| 238 | + |
| 239 | +## Comparison Matrix |
| 240 | + |
| 241 | +| Criteria | Resend | Gmail SMTP | Azure ACS | Mailpit | Mailtrap | |
| 242 | +|----------|--------|------------|-----------|---------|----------| |
| 243 | +| **Real delivery** | Yes | Yes | Yes | No | No (inbox) / Yes (sending) | |
| 244 | +| **Free tier** | 100/day | 500/day | 100/day | Unlimited | 100/month | |
| 245 | +| **Code changes** | Replace SMTP with SDK | Add 2 lines | None (SMTP relay) or SDK | None | Add 2 lines | |
| 246 | +| **DNS/domain work** | Yes (or use test sender) | No | Partial (managed subdomain) | No | No | |
| 247 | +| **No human needed** | No | No | No | **Yes** | No | |
| 248 | +| **Azure-native** | No | No | **Yes** | Partial (container) | No | |
| 249 | +| **Best for** | Real delivery + DX | Quick & dirty | Production path | E2E test verification | Team email testing | |
| 250 | +| **Setup time** | 30 min | 15 min | 45 min | 30 min | 15 min | |
| 251 | + |
| 252 | +--- |
| 253 | + |
| 254 | +## Recommendation |
| 255 | + |
| 256 | +| Goal | Recommended option | |
| 257 | +|------|--------------------| |
| 258 | +| **Verify email content in E2E tests (no human)** | **Mailpit** — fully automatable, zero accounts | |
| 259 | +| **Quick test with real delivery** | **Gmail SMTP** — fastest setup, needs 1 App Password | |
| 260 | +| **Production-ready path** | **Azure ACS** — stays in ecosystem, scales to production | |
| 261 | +| **Best developer experience** | **Resend** — cleanest API, best docs, easy to swap later | |
0 commit comments