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
7 changes: 7 additions & 0 deletions apps/docs/content/docs/en/platform/self-hosting/email.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,13 @@ SMTP_USER=apikey # omit for unauthenticated relays
SMTP_PASS=... # omit for unauthenticated relays
# SMTP_SECURE=true # only for implicit TLS. Leave unset on 587 — it is
# automatic on 465, and forcing it on a STARTTLS port fails to connect
# SMTP_EHLO_NAME=mail.yourdomain.com # only if the relay expects an identity other
# than the domain Sim is served from
FROM_EMAIL_ADDRESS="Sim <noreply@yourdomain.com>"
```

Sim greets the relay with the domain it is served from, so `SMTP_EHLO_NAME` is rarely needed.

For Google Workspace without a service account:

```bash
Expand Down Expand Up @@ -164,11 +168,14 @@ kubectl logs -n simstudio -l app.kubernetes.io/component=app --tail=100 | grep -

**Mail lands in spam** — configure SPF, DKIM, and DMARC for your sending domain. This is on your DNS, not on Sim.

**SMTP fails at the greeting with `421-4.7.0 Try again later, closing connection. (EHLO)`** — the relay rejected how the client identified itself, not your credentials or IP allowlist. Strict relays, Google Workspace's among them, refuse a greeting that is not a fully-qualified domain name. Sim greets with the domain it is served from, so this should not occur; if it does, set `SMTP_EHLO_NAME` to a dotted hostname the relay accepts. Older releases always greeted as `[127.0.0.1]` on Kubernetes — upgrade rather than working around it.

**Nothing at all happens and no error appears** — no provider is configured. The logs will show one line naming the recipient and subject.

<FAQ items={[
{ question: "How does Sim pick a provider?", answer: "Every configured provider is active, tried in a fixed order — Resend, AWS SES, SMTP, Azure Communication Services, Gmail — with the next one used only if the previous fails. The earliest configured provider handles normal traffic; the rest act as automatic failover."},
{ question: "Why does my Gmail-sent mail come from the wrong address?", answer: "Gmail rewrites From addresses it does not recognize. FROM_EMAIL_ADDRESS must match GMAIL_SENDER or one of that user's registered aliases."},
{ question: "Can I use SES without access keys?", answer: "Yes. Credentials resolve through the standard AWS provider chain, so an IRSA role on EKS or an instance profile on EC2 works — set only AWS_SES_REGION and grant ses:SendEmail and ses:SendRawEmail."},
{ question: "Is the Google Workspace SMTP relay easier than the Gmail API?", answer: "Usually yes — it needs no service account or domain-wide delegation, just SMTP_HOST=smtp-relay.gmail.com on port 587 and an allowlist entry for your egress IP in the Workspace admin console. Use the Gmail API path when you cannot allowlist a stable egress IP." },
{ question: "What does Sim send as the SMTP EHLO name?", answer: "The domain the app is served from, derived from NEXT_PUBLIC_APP_URL. That matters on Kubernetes, where pod hostnames have no dot and the underlying mail library would otherwise fall back to the address literal [127.0.0.1] — a greeting strict relays reject outright. Set SMTP_EHLO_NAME to override it." },
]} />
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ Configure at least one. Every configured provider stays active and is tried in o
| Shared | `FROM_EMAIL_ADDRESS`, `EMAIL_DOMAIN`, `EMAIL_VERIFICATION_ENABLED` |
| Resend | `RESEND_API_KEY` |
| AWS SES | `AWS_SES_REGION` (credentials via the AWS provider chain) |
| SMTP | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_SECURE` |
| SMTP | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_SECURE`, `SMTP_EHLO_NAME` |
| Azure ACS | `AZURE_ACS_CONNECTION_STRING` |
| Gmail | `GMAIL_CREDENTIALS_JSON`, `GMAIL_SENDER` |

Expand Down
1 change: 1 addition & 0 deletions apps/sim/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
# SMTP_USER= # Optional — omit for unauthenticated relays
# SMTP_PASS= # Optional — omit for unauthenticated relays
# SMTP_SECURE= # Set "true" to force TLS on connect; auto-true on port 465
# SMTP_EHLO_NAME= # EHLO hostname; defaults to the app's own domain. Set only if the relay expects a different one
#
# Azure Communication Services
# AZURE_ACS_CONNECTION_STRING=
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/app/api/tools/smtp/send/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getSmtpEhloName } from '@/lib/messaging/email/ehlo'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
Expand Down Expand Up @@ -73,6 +74,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
user: validatedData.smtpUsername,
pass: validatedData.smtpPassword,
},
name: getSmtpEhloName(),
tls:
validatedData.smtpSecure === 'None'
? { rejectUnauthorized: false, servername: validatedData.smtpHost }
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/core/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ export const env = createEnv({
SMTP_USER: z.string().min(1).optional(), // SMTP username
SMTP_PASS: z.string().min(1).optional(), // SMTP password
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
SMTP_EHLO_NAME: z.string().min(1).optional(), // Hostname sent in the SMTP EHLO greeting (defaults to the app's own domain); set when the relay expects a different identity
GMAIL_CREDENTIALS_JSON: z.string().optional(), // Inline Google service-account JSON with domain-wide delegation for the Gmail API mail provider
GMAIL_SENDER: z.string().min(1).optional(), // Google Workspace user the Gmail service account impersonates when sending (e.g., noreply@yourdomain.com)

Expand Down
80 changes: 80 additions & 0 deletions apps/sim/lib/messaging/email/ehlo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* @vitest-environment node
*/
import { resetEnvMock, resetUrlsMock, setEnv, urlsMockFns } from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it } from 'vitest'
import { getSmtpEhloName } from '@/lib/messaging/email/ehlo'

afterAll(() => {
resetEnvMock()
resetUrlsMock()
})

beforeEach(() => {
resetEnvMock()
setEnv({ SMTP_EHLO_NAME: undefined })
urlsMockFns.mockGetEmailDomain.mockReturnValue('sim.example.com')
})

describe('getSmtpEhloName', () => {
it("falls back to the app's own domain so k8s pods never greet as [127.0.0.1]", () => {
expect(getSmtpEhloName()).toBe('sim.example.com')
})

it('prefers an explicitly configured SMTP_EHLO_NAME', () => {
setEnv({ SMTP_EHLO_NAME: 'mail.yourdomain.com' })
expect(getSmtpEhloName()).toBe('mail.yourdomain.com')
})

it('trims surrounding whitespace', () => {
setEnv({ SMTP_EHLO_NAME: ' mail.yourdomain.com ' })
expect(getSmtpEhloName()).toBe('mail.yourdomain.com')
})

it('accepts an RFC 5321 address literal', () => {
setEnv({ SMTP_EHLO_NAME: '[203.0.113.5]' })
expect(getSmtpEhloName()).toBe('[203.0.113.5]')
})

it.each(['[IPv6:2001:db8::1]', '[ipv6:2001:db8::1]', '[IPV6:2001:db8::1]'])(
'accepts the RFC 5321 IPv6 address literal %s, whose tag is case-insensitive',
(literal) => {
setEnv({ SMTP_EHLO_NAME: literal })
expect(getSmtpEhloName()).toBe(literal)
}
)

it.each(['[::::]', '[13]', '[999.1.1.1]', '[2001:db8::1]', '[IPv6:203.0.113.5]'])(
'ignores the malformed address literal %s rather than letting the relay refuse it',
(literal) => {
setEnv({ SMTP_EHLO_NAME: literal })
expect(getSmtpEhloName()).toBe('sim.example.com')
}
)

it('ignores a dotless name, which strict relays reject just like the literal', () => {
setEnv({ SMTP_EHLO_NAME: 'sim-app' })
expect(getSmtpEhloName()).toBe('sim.example.com')
})

it('ignores a name carrying CRLF rather than passing it into the EHLO command', () => {
setEnv({ SMTP_EHLO_NAME: 'evil.com\r\nMAIL FROM:<attacker@evil.com>' })
expect(getSmtpEhloName()).toBe('sim.example.com')
})

it('strips a port from the app domain instead of falling back over it', () => {
urlsMockFns.mockGetEmailDomain.mockReturnValue('sim.example.com:8443')
expect(getSmtpEhloName()).toBe('sim.example.com')
})

it("returns undefined for a dev app domain, leaving nodemailer's default", () => {
urlsMockFns.mockGetEmailDomain.mockReturnValue('localhost:3000')
expect(getSmtpEhloName()).toBeUndefined()
})

it('returns undefined when neither source yields a qualified name', () => {
setEnv({ SMTP_EHLO_NAME: 'localhost' })
urlsMockFns.mockGetEmailDomain.mockReturnValue('localhost')
expect(getSmtpEhloName()).toBeUndefined()
})
})
80 changes: 80 additions & 0 deletions apps/sim/lib/messaging/email/ehlo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { isIPv4, isIPv6 } from 'node:net'
import { createLogger } from '@sim/logger'
import { env } from '@/lib/core/config/env'
import { getEmailDomain } from '@/lib/core/utils/urls'

const logger = createLogger('SmtpEhloName')

/**
* A dotted FQDN built from RFC 1035 labels. A single dotless label is
* deliberately rejected: strict relays treat it the same way they treat the
* loopback literal this module exists to avoid.
*/
const FQDN_PATTERN =
/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$/

/** RFC 5321 §4.1.3 tags the IPv6 form, case-insensitively per RFC 5234 §2.3. */
const IPV6_TAG_PATTERN = /^IPv6:/i

/**
* An RFC 5321 §4.1.3 address literal — `[192.0.2.1]` or `[IPv6:2001:db8::1]`.
* The address itself is parsed rather than pattern-matched, so a bracketed
* value that merely looks like one (`[::::]`, `[13]`) is refused here instead
* of at the relay.
*/
function isAddressLiteral(value: string): boolean {
if (!value.startsWith('[') || !value.endsWith(']')) return false
const inner = value.slice(1, -1)
return IPV6_TAG_PATTERN.test(inner) ? isIPv6(inner.slice(5)) : isIPv4(inner)
}

function isValidEhloName(value: string): boolean {
return value.length <= 255 && (FQDN_PATTERN.test(value) || isAddressLiteral(value))
}

/**
* Drops a trailing `:port`, leaving an IPv6 literal such as `[::1]` intact.
* `getEmailDomain` reports a URL's `host`, so a deployment served on a
* non-default port would otherwise carry one into the greeting, where it is
* not a legal domain.
*/
function stripPort(host: string): string {
const lastColon = host.lastIndexOf(':')
return lastColon === -1 || host.indexOf(']') > lastColon ? host : host.slice(0, lastColon)
}

let warnedInvalidName = false

/**
* Resolves the hostname to send in the SMTP `EHLO` greeting, or `undefined` to
* leave nodemailer's own default in place.
*
* Nodemailer derives its default from `os.hostname()` and substitutes the
* address literal `[127.0.0.1]` whenever that name contains no dot. Kubernetes
* pod hostnames never contain one, so on every k8s deployment Sim introduces
* itself to the relay as loopback. Strict relays read that as a misconfigured
* client and refuse the session before any mail moves — Google Workspace's
* `smtp-relay.gmail.com` answers `421-4.7.0 Try again later, closing
* connection`, which surfaces as "All email providers failed" on invitations
* and verification mail.
*
* RFC 5321 §4.1.4 asks the client to greet with its own fully-qualified domain
* name, so the deployment's own domain is the correct answer when the host
* cannot supply one. `SMTP_EHLO_NAME` overrides it for relays that expect a
* different identity than the app is served from.
*/
export function getSmtpEhloName(): string | undefined {
const configured = env.SMTP_EHLO_NAME?.trim()
if (configured) {
if (isValidEhloName(configured)) return configured
if (!warnedInvalidName) {
warnedInvalidName = true
logger.warn(
'SMTP_EHLO_NAME is not a fully-qualified domain name or address literal; ignoring it. Set it to a dotted hostname such as mail.yourdomain.com.'
)
}
}

const appDomain = stripPort(getEmailDomain())
return isValidEhloName(appDomain) ? appDomain : undefined
Comment thread
waleedlatif1 marked this conversation as resolved.
}
2 changes: 2 additions & 0 deletions apps/sim/lib/messaging/email/providers/smtp.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import nodemailer from 'nodemailer'
import { env, envBoolean, envNumber } from '@/lib/core/config/env'
import { getSmtpEhloName } from '@/lib/messaging/email/ehlo'
import { sendViaNodemailer } from '@/lib/messaging/email/providers/_nodemailer'
import type { MailProvider } from '@/lib/messaging/email/types'

Expand Down Expand Up @@ -31,6 +32,7 @@ export function createSmtpProvider(): MailProvider | null {
port,
secure: envBoolean(env.SMTP_SECURE) ?? port === 465,
auth: user && pass ? { user, pass } : undefined,
name: getSmtpEhloName(),
})

return {
Expand Down
2 changes: 1 addition & 1 deletion helm/sim/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ apiVersion: v2
name: sim
description: A Helm chart for Sim - the open-source AI workspace where teams build, deploy, and manage AI agents
type: application
version: 1.5.2
version: 1.5.3
appVersion: "v0.7.44"
kubeVersion: ">=1.25.0-0"
home: https://sim.ai
Expand Down
4 changes: 4 additions & 0 deletions helm/sim/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,10 @@
"type": "string",
"description": "Set to 'true' to force TLS on connect. Defaults to true when SMTP_PORT=465."
},
"SMTP_EHLO_NAME": {
"type": "string",
"description": "Hostname sent in the SMTP EHLO greeting. Defaults to the domain the app is served from; set only when the relay expects a different identity."
},
"GMAIL_CREDENTIALS_JSON": {
"type": "string",
"description": "Inline Google service-account JSON with domain-wide delegation for the Gmail API mail provider."
Expand Down
1 change: 1 addition & 0 deletions helm/sim/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ app:
SMTP_USER: "" # SMTP username (optional — omit for unauthenticated relays like MailHog)
SMTP_PASS: "" # SMTP password (optional — omit for unauthenticated relays)
SMTP_SECURE: "" # Set to "true" to force TLS on connect; defaults to true when SMTP_PORT=465
SMTP_EHLO_NAME: "" # Hostname sent in the SMTP EHLO greeting; defaults to the app's own domain. Set only if the relay expects a different one
GMAIL_CREDENTIALS_JSON: "" # Inline Google service-account JSON with domain-wide delegation for the Gmail API mail provider
GMAIL_SENDER: "" # Google Workspace user the Gmail service account impersonates when sending (e.g., noreply@yourdomain.com)

Expand Down
Loading