Skip to content

Commit 9dc828f

Browse files
authored
fix(email): greet SMTP relays with a qualified hostname instead of [127.0.0.1] (#6799)
* fix(email): greet SMTP relays with a qualified hostname instead of [127.0.0.1] Nodemailer derives the EHLO greeting 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 every k8s deployment introduced itself to the relay as loopback and strict relays refused the session before any mail moved. Send the domain the app is served from instead, as RFC 5321 4.1.4 asks, with SMTP_EHLO_NAME to override it for relays that expect a different identity. * fix(email): parse EHLO address literals and drop a port from the app domain Review round 1. The bracketed branch matched a character class rather than an address, so [::::] and [13] reached the relay as a greeting it would refuse. Parse the address with node:net instead, which also admits the RFC 5321 IPv6: form. getEmailDomain reports a URL host, so a deployment served on a non-default port failed the qualified-name check and fell back to nodemailer's default — [127.0.0.1] again on Kubernetes, the exact failure this change exists to fix. Strip the port before validating. * fix(email): accept any casing of the IPv6 literal tag, and stop owning SMTP_EHLO_NAME in setup Review round 2. RFC 5321 tags the IPv6 address-literal form, and RFC 5234 makes ABNF string literals case-insensitive, so [ipv6:2001:db8::1] is as valid as [IPv6:...]. The exact-prefix check routed it to isIPv4 and discarded it. Drop SMTP_EHLO_NAME from the email capability's optional fields. SMTP_SECURE, the same kind of optional transport knob on the same provider, is not modelled there either, and claiming the field obliged the setup wizard to prompt for it — a field whose entire purpose is to stay unset now that the default is right.
1 parent d17a11f commit 9dc828f

11 files changed

Lines changed: 180 additions & 2 deletions

File tree

apps/docs/content/docs/en/platform/self-hosting/email.mdx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,13 @@ SMTP_USER=apikey # omit for unauthenticated relays
7171
SMTP_PASS=... # omit for unauthenticated relays
7272
# SMTP_SECURE=true # only for implicit TLS. Leave unset on 587 — it is
7373
# automatic on 465, and forcing it on a STARTTLS port fails to connect
74+
# SMTP_EHLO_NAME=mail.yourdomain.com # only if the relay expects an identity other
75+
# than the domain Sim is served from
7476
FROM_EMAIL_ADDRESS="Sim <noreply@yourdomain.com>"
7577
```
7678

79+
Sim greets the relay with the domain it is served from, so `SMTP_EHLO_NAME` is rarely needed.
80+
7781
For Google Workspace without a service account:
7882

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

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

171+
**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.
172+
167173
**Nothing at all happens and no error appears** — no provider is configured. The logs will show one line naming the recipient and subject.
168174

169175
<FAQ items={[
170176
{ 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."},
171177
{ 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."},
172178
{ 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."},
173179
{ 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." },
180+
{ 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." },
174181
]} />

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ Configure at least one. Every configured provider stays active and is tried in o
222222
| Shared | `FROM_EMAIL_ADDRESS`, `EMAIL_DOMAIN`, `EMAIL_VERIFICATION_ENABLED` |
223223
| Resend | `RESEND_API_KEY` |
224224
| AWS SES | `AWS_SES_REGION` (credentials via the AWS provider chain) |
225-
| SMTP | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_SECURE` |
225+
| SMTP | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_SECURE`, `SMTP_EHLO_NAME` |
226226
| Azure ACS | `AZURE_ACS_CONNECTION_STRING` |
227227
| Gmail | `GMAIL_CREDENTIALS_JSON`, `GMAIL_SENDER` |
228228

apps/sim/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
7373
# SMTP_USER= # Optional — omit for unauthenticated relays
7474
# SMTP_PASS= # Optional — omit for unauthenticated relays
7575
# SMTP_SECURE= # Set "true" to force TLS on connect; auto-true on port 465
76+
# SMTP_EHLO_NAME= # EHLO hostname; defaults to the app's own domain. Set only if the relay expects a different one
7677
#
7778
# Azure Communication Services
7879
# AZURE_ACS_CONNECTION_STRING=

apps/sim/app/api/tools/smtp/send/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid'
88
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
99
import { generateRequestId } from '@/lib/core/utils/request'
1010
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
11+
import { getSmtpEhloName } from '@/lib/messaging/email/ehlo'
1112
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
1213
import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
1314
import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
@@ -73,6 +74,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
7374
user: validatedData.smtpUsername,
7475
pass: validatedData.smtpPassword,
7576
},
77+
name: getSmtpEhloName(),
7678
tls:
7779
validatedData.smtpSecure === 'None'
7880
? { rejectUnauthorized: false, servername: validatedData.smtpHost }

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ export const env = createEnv({
170170
SMTP_USER: z.string().min(1).optional(), // SMTP username
171171
SMTP_PASS: z.string().min(1).optional(), // SMTP password
172172
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
173+
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
173174
GMAIL_CREDENTIALS_JSON: z.string().optional(), // Inline Google service-account JSON with domain-wide delegation for the Gmail API mail provider
174175
GMAIL_SENDER: z.string().min(1).optional(), // Google Workspace user the Gmail service account impersonates when sending (e.g., noreply@yourdomain.com)
175176

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { resetEnvMock, resetUrlsMock, setEnv, urlsMockFns } from '@sim/testing'
5+
import { afterAll, beforeEach, describe, expect, it } from 'vitest'
6+
import { getSmtpEhloName } from '@/lib/messaging/email/ehlo'
7+
8+
afterAll(() => {
9+
resetEnvMock()
10+
resetUrlsMock()
11+
})
12+
13+
beforeEach(() => {
14+
resetEnvMock()
15+
setEnv({ SMTP_EHLO_NAME: undefined })
16+
urlsMockFns.mockGetEmailDomain.mockReturnValue('sim.example.com')
17+
})
18+
19+
describe('getSmtpEhloName', () => {
20+
it("falls back to the app's own domain so k8s pods never greet as [127.0.0.1]", () => {
21+
expect(getSmtpEhloName()).toBe('sim.example.com')
22+
})
23+
24+
it('prefers an explicitly configured SMTP_EHLO_NAME', () => {
25+
setEnv({ SMTP_EHLO_NAME: 'mail.yourdomain.com' })
26+
expect(getSmtpEhloName()).toBe('mail.yourdomain.com')
27+
})
28+
29+
it('trims surrounding whitespace', () => {
30+
setEnv({ SMTP_EHLO_NAME: ' mail.yourdomain.com ' })
31+
expect(getSmtpEhloName()).toBe('mail.yourdomain.com')
32+
})
33+
34+
it('accepts an RFC 5321 address literal', () => {
35+
setEnv({ SMTP_EHLO_NAME: '[203.0.113.5]' })
36+
expect(getSmtpEhloName()).toBe('[203.0.113.5]')
37+
})
38+
39+
it.each(['[IPv6:2001:db8::1]', '[ipv6:2001:db8::1]', '[IPV6:2001:db8::1]'])(
40+
'accepts the RFC 5321 IPv6 address literal %s, whose tag is case-insensitive',
41+
(literal) => {
42+
setEnv({ SMTP_EHLO_NAME: literal })
43+
expect(getSmtpEhloName()).toBe(literal)
44+
}
45+
)
46+
47+
it.each(['[::::]', '[13]', '[999.1.1.1]', '[2001:db8::1]', '[IPv6:203.0.113.5]'])(
48+
'ignores the malformed address literal %s rather than letting the relay refuse it',
49+
(literal) => {
50+
setEnv({ SMTP_EHLO_NAME: literal })
51+
expect(getSmtpEhloName()).toBe('sim.example.com')
52+
}
53+
)
54+
55+
it('ignores a dotless name, which strict relays reject just like the literal', () => {
56+
setEnv({ SMTP_EHLO_NAME: 'sim-app' })
57+
expect(getSmtpEhloName()).toBe('sim.example.com')
58+
})
59+
60+
it('ignores a name carrying CRLF rather than passing it into the EHLO command', () => {
61+
setEnv({ SMTP_EHLO_NAME: 'evil.com\r\nMAIL FROM:<attacker@evil.com>' })
62+
expect(getSmtpEhloName()).toBe('sim.example.com')
63+
})
64+
65+
it('strips a port from the app domain instead of falling back over it', () => {
66+
urlsMockFns.mockGetEmailDomain.mockReturnValue('sim.example.com:8443')
67+
expect(getSmtpEhloName()).toBe('sim.example.com')
68+
})
69+
70+
it("returns undefined for a dev app domain, leaving nodemailer's default", () => {
71+
urlsMockFns.mockGetEmailDomain.mockReturnValue('localhost:3000')
72+
expect(getSmtpEhloName()).toBeUndefined()
73+
})
74+
75+
it('returns undefined when neither source yields a qualified name', () => {
76+
setEnv({ SMTP_EHLO_NAME: 'localhost' })
77+
urlsMockFns.mockGetEmailDomain.mockReturnValue('localhost')
78+
expect(getSmtpEhloName()).toBeUndefined()
79+
})
80+
})
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { isIPv4, isIPv6 } from 'node:net'
2+
import { createLogger } from '@sim/logger'
3+
import { env } from '@/lib/core/config/env'
4+
import { getEmailDomain } from '@/lib/core/utils/urls'
5+
6+
const logger = createLogger('SmtpEhloName')
7+
8+
/**
9+
* A dotted FQDN built from RFC 1035 labels. A single dotless label is
10+
* deliberately rejected: strict relays treat it the same way they treat the
11+
* loopback literal this module exists to avoid.
12+
*/
13+
const FQDN_PATTERN =
14+
/^[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])?)+$/
15+
16+
/** RFC 5321 §4.1.3 tags the IPv6 form, case-insensitively per RFC 5234 §2.3. */
17+
const IPV6_TAG_PATTERN = /^IPv6:/i
18+
19+
/**
20+
* An RFC 5321 §4.1.3 address literal — `[192.0.2.1]` or `[IPv6:2001:db8::1]`.
21+
* The address itself is parsed rather than pattern-matched, so a bracketed
22+
* value that merely looks like one (`[::::]`, `[13]`) is refused here instead
23+
* of at the relay.
24+
*/
25+
function isAddressLiteral(value: string): boolean {
26+
if (!value.startsWith('[') || !value.endsWith(']')) return false
27+
const inner = value.slice(1, -1)
28+
return IPV6_TAG_PATTERN.test(inner) ? isIPv6(inner.slice(5)) : isIPv4(inner)
29+
}
30+
31+
function isValidEhloName(value: string): boolean {
32+
return value.length <= 255 && (FQDN_PATTERN.test(value) || isAddressLiteral(value))
33+
}
34+
35+
/**
36+
* Drops a trailing `:port`, leaving an IPv6 literal such as `[::1]` intact.
37+
* `getEmailDomain` reports a URL's `host`, so a deployment served on a
38+
* non-default port would otherwise carry one into the greeting, where it is
39+
* not a legal domain.
40+
*/
41+
function stripPort(host: string): string {
42+
const lastColon = host.lastIndexOf(':')
43+
return lastColon === -1 || host.indexOf(']') > lastColon ? host : host.slice(0, lastColon)
44+
}
45+
46+
let warnedInvalidName = false
47+
48+
/**
49+
* Resolves the hostname to send in the SMTP `EHLO` greeting, or `undefined` to
50+
* leave nodemailer's own default in place.
51+
*
52+
* Nodemailer derives its default from `os.hostname()` and substitutes the
53+
* address literal `[127.0.0.1]` whenever that name contains no dot. Kubernetes
54+
* pod hostnames never contain one, so on every k8s deployment Sim introduces
55+
* itself to the relay as loopback. Strict relays read that as a misconfigured
56+
* client and refuse the session before any mail moves — Google Workspace's
57+
* `smtp-relay.gmail.com` answers `421-4.7.0 Try again later, closing
58+
* connection`, which surfaces as "All email providers failed" on invitations
59+
* and verification mail.
60+
*
61+
* RFC 5321 §4.1.4 asks the client to greet with its own fully-qualified domain
62+
* name, so the deployment's own domain is the correct answer when the host
63+
* cannot supply one. `SMTP_EHLO_NAME` overrides it for relays that expect a
64+
* different identity than the app is served from.
65+
*/
66+
export function getSmtpEhloName(): string | undefined {
67+
const configured = env.SMTP_EHLO_NAME?.trim()
68+
if (configured) {
69+
if (isValidEhloName(configured)) return configured
70+
if (!warnedInvalidName) {
71+
warnedInvalidName = true
72+
logger.warn(
73+
'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.'
74+
)
75+
}
76+
}
77+
78+
const appDomain = stripPort(getEmailDomain())
79+
return isValidEhloName(appDomain) ? appDomain : undefined
80+
}

apps/sim/lib/messaging/email/providers/smtp.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createLogger } from '@sim/logger'
22
import nodemailer from 'nodemailer'
33
import { env, envBoolean, envNumber } from '@/lib/core/config/env'
4+
import { getSmtpEhloName } from '@/lib/messaging/email/ehlo'
45
import { sendViaNodemailer } from '@/lib/messaging/email/providers/_nodemailer'
56
import type { MailProvider } from '@/lib/messaging/email/types'
67

@@ -31,6 +32,7 @@ export function createSmtpProvider(): MailProvider | null {
3132
port,
3233
secure: envBoolean(env.SMTP_SECURE) ?? port === 465,
3334
auth: user && pass ? { user, pass } : undefined,
35+
name: getSmtpEhloName(),
3436
})
3537

3638
return {

helm/sim/Chart.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ apiVersion: v2
22
name: sim
33
description: A Helm chart for Sim - the open-source AI workspace where teams build, deploy, and manage AI agents
44
type: application
5-
version: 1.5.2
5+
version: 1.5.3
66
appVersion: "v0.7.44"
77
kubeVersion: ">=1.25.0-0"
88
home: https://sim.ai

helm/sim/values.schema.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,10 @@
212212
"type": "string",
213213
"description": "Set to 'true' to force TLS on connect. Defaults to true when SMTP_PORT=465."
214214
},
215+
"SMTP_EHLO_NAME": {
216+
"type": "string",
217+
"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."
218+
},
215219
"GMAIL_CREDENTIALS_JSON": {
216220
"type": "string",
217221
"description": "Inline Google service-account JSON with domain-wide delegation for the Gmail API mail provider."

0 commit comments

Comments
 (0)