Skip to content

Commit 952eb12

Browse files
authored
feat(mailer): add AWS SES and SMTP providers with auto-detect fallback (#4710)
* feat(mailer): add AWS SES and SMTP providers with auto-detect fallback * fix(mailer): cast SES options to bridge duplicate @aws-sdk type identities * fix(mailer): dedupe aws-sdk-sesv2, address review feedback - Force a single @aws-sdk/client-sesv2 install via root package.json overrides; @types/nodemailer pulled in a nested copy whose nominal class brand made the two SDK type identities incompatible, breaking the CI build. With one install the cast disappears. - Batch result message now reports successCount instead of sendable.length when entries are skipped, so "5 emails sent" no longer overstates delivery on partial failures. - SMTP provider now warns when SMTP_HOST is set without SMTP_PORT, and when only one of SMTP_USER/SMTP_PASS is set — both previously silent misconfigurations. - SMTP_SECURE schema is z.boolean() to match every other boolean in env.ts; runtime parsing is still handled by envBoolean. - Strip the verbose TSDoc comments I had added. * fix(mailer): exact sent counts in batch results, restore SES type cast - mergeBatchResults: data.count and the message now report only emails that were actually delivered, not skipped-unsubscribed ones (they returned success: true and inflated the count). Empty-sendable branch distinguishes "all unsubscribed" from "mixed skip/failure" so the message stops lying when some entries fail validation. - ses.ts: revert the package.json override approach (bun honors it locally but CI still installs a nested @types/nodemailer copy). Reinstate the `as unknown as` cast with a single-line WHY comment. * fix(mailer): annotate double-cast in ses provider for strict api-validation * fix(mailer): batch degrades isUnsubscribed errors to per-entry failures A transient DB error in isUnsubscribed used to abort the whole batch because the call sat outside the per-email try/catch in prepareBatch. Wrap the unsubscribe check inside the same catch so a rejection becomes a per-recipient failure, matching sendEmail's behavior. Lock it in with a regression test.
1 parent 1af6538 commit 952eb12

15 files changed

Lines changed: 676 additions & 409 deletions

File tree

apps/docs/content/docs/en/self-hosting/environment-variables.mdx

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,48 @@ import { Callout } from 'fumadocs-ui/components/callout'
6666
| `API_ENCRYPTION_KEY` | Encrypts stored API keys (32 hex chars): `openssl rand -hex 32` |
6767
| `COPILOT_API_KEY` | API key for copilot features |
6868
| `ADMIN_API_KEY` | Admin API key for GitOps operations |
69-
| `RESEND_API_KEY` | Email service for notifications |
7069
| `ALLOWED_LOGIN_DOMAINS` | Restrict signups to domains (comma-separated) |
7170
| `ALLOWED_LOGIN_EMAILS` | Restrict signups to specific emails (comma-separated) |
7271
| `DISABLE_REGISTRATION` | Set to `true` to disable new user signups |
7372

73+
## Email Providers
74+
75+
Configure one provider — the mailer auto-detects in priority order: **Resend → AWS SES → SMTP → Azure Communication Services**. If none are configured, emails are logged to the console instead.
76+
77+
| Variable | Description |
78+
|----------|-------------|
79+
| `FROM_EMAIL_ADDRESS` | Sender address (e.g. `Sim <noreply@example.com>`). Falls back to `noreply@EMAIL_DOMAIN`. |
80+
| `EMAIL_DOMAIN` | Default domain when `FROM_EMAIL_ADDRESS` is unset |
81+
| `EMAIL_VERIFICATION_ENABLED` | Set to `true` to require email verification on signup |
82+
83+
**Resend**
84+
85+
| Variable | Description |
86+
|----------|-------------|
87+
| `RESEND_API_KEY` | API key from [resend.com](https://resend.com) |
88+
89+
**AWS SES**
90+
91+
| Variable | Description |
92+
|----------|-------------|
93+
| `AWS_SES_REGION` | AWS region for SES (e.g. `us-east-1`). Credentials are resolved through the standard AWS SDK provider chain (env vars, IRSA, ECS/EC2 instance role, SSO). |
94+
95+
**SMTP** (works with MailHog, Postfix, SendGrid SMTP, etc.)
96+
97+
| Variable | Description |
98+
|----------|-------------|
99+
| `SMTP_HOST` | SMTP server hostname |
100+
| `SMTP_PORT` | `465` for implicit TLS, `587` for STARTTLS, `25` for plain |
101+
| `SMTP_USER` | Optional — omit for unauthenticated relays |
102+
| `SMTP_PASS` | Optional — omit for unauthenticated relays |
103+
| `SMTP_SECURE` | Set to `true` to force TLS on connect; auto-true on port 465 |
104+
105+
**Azure Communication Services**
106+
107+
| Variable | Description |
108+
|----------|-------------|
109+
| `AZURE_ACS_CONNECTION_STRING` | Azure Communication Services connection string |
110+
74111
## Example .env
75112

76113
```bash

apps/sim/.env.example

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,30 @@ INTERNAL_API_SECRET=your_internal_api_secret # Use `openssl rand -hex 32` to gen
1919
API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt api keys
2020

2121
# Email Provider (Optional)
22-
# RESEND_API_KEY= # Uncomment and add your key from https://resend.com to send actual emails
23-
# If left commented out, emails will be logged to console instead
22+
# Configure ONE provider — the mailer auto-detects in priority order:
23+
# Resend → AWS SES → SMTP → Azure Communication Services. If none are
24+
# configured, emails are logged to console instead.
25+
#
26+
# Resend
27+
# RESEND_API_KEY= # API key from https://resend.com
28+
#
29+
# AWS SES (credentials resolved via the standard AWS provider chain:
30+
# env vars, shared config, ECS/EKS task role, EC2 instance profile, SSO)
31+
# AWS_SES_REGION=us-east-1
32+
#
33+
# SMTP (works with MailHog locally: host=localhost port=1025, no auth)
34+
# SMTP_HOST=smtp.example.com
35+
# SMTP_PORT=587 # 465 = implicit TLS, 587 = STARTTLS, 25 = plain
36+
# SMTP_USER= # Optional — omit for unauthenticated relays
37+
# SMTP_PASS= # Optional — omit for unauthenticated relays
38+
# SMTP_SECURE= # Set "true" to force TLS on connect; auto-true on port 465
39+
#
40+
# Azure Communication Services
41+
# AZURE_ACS_CONNECTION_STRING=
42+
#
43+
# Shared sender configuration
44+
# FROM_EMAIL_ADDRESS="Sim <noreply@example.com>"
45+
# EMAIL_DOMAIN=example.com # Fallback when FROM_EMAIL_ADDRESS is unset
2446

2547
# Local AI Models (Optional)
2648
# OLLAMA_URL=http://localhost:11434 # URL for local Ollama server - uncomment if using local models

apps/sim/lib/core/config/env.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,12 @@ export const env = createEnv({
9999
PERSONAL_EMAIL_FROM: z.string().min(1).optional(), // From address for personalized emails
100100
EMAIL_DOMAIN: z.string().min(1).optional(), // Domain for sending emails (fallback when FROM_EMAIL_ADDRESS not set)
101101
AZURE_ACS_CONNECTION_STRING: z.string().optional(), // Azure Communication Services connection string
102+
AWS_SES_REGION: z.string().min(1).optional(), // AWS region for SES (credentials resolved via default SDK provider chain)
103+
SMTP_HOST: z.string().min(1).optional(), // SMTP server hostname
104+
SMTP_PORT: z.coerce.number().int().min(1).max(65535).optional(),
105+
SMTP_USER: z.string().min(1).optional(), // SMTP username
106+
SMTP_PASS: z.string().min(1).optional(), // SMTP password
107+
SMTP_SECURE: z.boolean().optional(), // Force TLS on connect (defaults to true on port 465); read via envBoolean to handle string values from process.env
102108

103109
// SMS & Messaging
104110
TWILIO_ACCOUNT_SID: z.string().min(1).optional(), // Twilio Account SID for SMS sending
@@ -555,3 +561,16 @@ export function envNumber(
555561
const parsed = Number(value)
556562
return Number.isFinite(parsed) && parsed >= min ? parsed : fallback
557563
}
564+
565+
/**
566+
* Coerce an env-derived value to a boolean. Returns `undefined` when unset
567+
* so callers can apply context-aware defaults. Required because
568+
* `Boolean("false") === true`, so `z.coerce.boolean()` would silently flip
569+
* the meaning of `MY_FLAG=false`.
570+
*/
571+
export function envBoolean(value: boolean | string | undefined | null): boolean | undefined {
572+
if (typeof value === 'boolean') return value
573+
if (value === undefined || value === null || value === '') return undefined
574+
const normalized = String(value).trim().toLowerCase()
575+
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on'
576+
}

apps/sim/lib/messaging/email/mailer.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,5 +251,19 @@ describe('mailer', () => {
251251

252252
expect(isUnsubscribed).not.toHaveBeenCalled()
253253
})
254+
255+
it('should degrade isUnsubscribed rejections to per-entry failures', async () => {
256+
;(isUnsubscribed as Mock).mockRejectedValue(new Error('Database connection failed'))
257+
258+
const result = await sendBatchEmails({
259+
emails: [
260+
{ ...testEmailOptions, to: 'user1@example.com', emailType: 'marketing' as EmailType },
261+
{ ...testEmailOptions, to: 'user2@example.com', emailType: 'marketing' as EmailType },
262+
],
263+
})
264+
265+
expect(result.results).toHaveLength(2)
266+
expect(result.results.every((r) => r.success === false)).toBe(true)
267+
})
254268
})
255269
})

0 commit comments

Comments
 (0)