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.
This commit is contained in:
Waleed
2026-08-17 18:09:02 -07:00
committed by GitHub
parent d17a11f29b
commit 9dc828f36a
11 changed files with 180 additions and 2 deletions
@@ -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
@@ -164,6 +168,8 @@ 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={[
@@ -171,4 +177,5 @@ kubectl logs -n simstudio -l app.kubernetes.io/component=app --tail=100 | grep -
{ 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." },
]} />
@@ -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` |
+1
View File
@@ -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=
@@ -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'
@@ -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 }
+1
View File
@@ -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)
+80
View File
@@ -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
View File
@@ -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
}
@@ -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'
@@ -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 {
+1 -1
View File
@@ -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
+4
View File
@@ -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."
+1
View File
@@ -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)