From 78a54fc46087ce3d970357299d7497e38dfab9c0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 17 Aug 2026 17:24:36 -0700 Subject: [PATCH 1/3] 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. --- .../docs/en/platform/self-hosting/email.mdx | 7 +++ .../self-hosting/environment-variables.mdx | 2 +- apps/sim/.env.example | 1 + apps/sim/app/api/tools/smtp/send/route.ts | 2 + apps/sim/lib/core/config/env-capabilities.ts | 2 +- apps/sim/lib/core/config/env.ts | 1 + apps/sim/lib/messaging/email/ehlo.test.ts | 59 +++++++++++++++++++ apps/sim/lib/messaging/email/ehlo.ts | 54 +++++++++++++++++ .../sim/lib/messaging/email/providers/smtp.ts | 2 + helm/sim/Chart.yaml | 2 +- helm/sim/values.schema.json | 4 ++ helm/sim/values.yaml | 1 + 12 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 apps/sim/lib/messaging/email/ehlo.test.ts create mode 100644 apps/sim/lib/messaging/email/ehlo.ts diff --git a/apps/docs/content/docs/en/platform/self-hosting/email.mdx b/apps/docs/content/docs/en/platform/self-hosting/email.mdx index 535397458ac..aade5fa1cb3 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/email.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/email.mdx @@ -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 " ``` +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. diff --git a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx index 7e52d9225b7..555f2c7eec8 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx @@ -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` | diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 927e437bad4..54451772873 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -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= diff --git a/apps/sim/app/api/tools/smtp/send/route.ts b/apps/sim/app/api/tools/smtp/send/route.ts index be1c4371fb6..920f6925784 100644 --- a/apps/sim/app/api/tools/smtp/send/route.ts +++ b/apps/sim/app/api/tools/smtp/send/route.ts @@ -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 } diff --git a/apps/sim/lib/core/config/env-capabilities.ts b/apps/sim/lib/core/config/env-capabilities.ts index 830838fecf3..406224884bb 100644 --- a/apps/sim/lib/core/config/env-capabilities.ts +++ b/apps/sim/lib/core/config/env-capabilities.ts @@ -940,7 +940,7 @@ export const EMAIL_CAPABILITY = defineCapability({ }, }) ), - optionalFields: [envField('SMTP_USER'), envField('SMTP_PASS')], + optionalFields: [envField('SMTP_USER'), envField('SMTP_PASS'), envField('SMTP_EHLO_NAME')], }, { id: 'azure', diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 5a8fed1bb40..315a9bb4349 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -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) diff --git a/apps/sim/lib/messaging/email/ehlo.test.ts b/apps/sim/lib/messaging/email/ehlo.test.ts new file mode 100644 index 00000000000..2e3c8eba4bd --- /dev/null +++ b/apps/sim/lib/messaging/email/ehlo.test.ts @@ -0,0 +1,59 @@ +/** + * @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('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:' }) + expect(getSmtpEhloName()).toBe('sim.example.com') + }) + + it("returns undefined when the app domain carries a port, 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() + }) +}) diff --git a/apps/sim/lib/messaging/email/ehlo.ts b/apps/sim/lib/messaging/email/ehlo.ts new file mode 100644 index 00000000000..601f7124990 --- /dev/null +++ b/apps/sim/lib/messaging/email/ehlo.ts @@ -0,0 +1,54 @@ +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, or an RFC 5321 address literal such + * as `[203.0.113.5]`. 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 EHLO_NAME_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])?)+|\[[0-9A-Fa-f:.]{1,45}\])$/ + +function isValidEhloName(value: string): boolean { + return value.length <= 255 && EHLO_NAME_PATTERN.test(value) +} + +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 = getEmailDomain() + return isValidEhloName(appDomain) ? appDomain : undefined +} diff --git a/apps/sim/lib/messaging/email/providers/smtp.ts b/apps/sim/lib/messaging/email/providers/smtp.ts index ef90e6046c8..af23e1468cd 100644 --- a/apps/sim/lib/messaging/email/providers/smtp.ts +++ b/apps/sim/lib/messaging/email/providers/smtp.ts @@ -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 { diff --git a/helm/sim/Chart.yaml b/helm/sim/Chart.yaml index 23853ce9607..464def36213 100644 --- a/helm/sim/Chart.yaml +++ b/helm/sim/Chart.yaml @@ -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 diff --git a/helm/sim/values.schema.json b/helm/sim/values.schema.json index ad22d97bf8e..920918cd422 100644 --- a/helm/sim/values.schema.json +++ b/helm/sim/values.schema.json @@ -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." diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index e19224f983e..2c91b63ef64 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -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) From 43b2c7019615541ecb4b8f5d0d4335c26500ec15 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 17 Aug 2026 17:33:11 -0700 Subject: [PATCH 2/3] fix(email): parse EHLO address literals and drop a port from the app domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/sim/lib/messaging/email/ehlo.test.ts | 20 +++++++++++- apps/sim/lib/messaging/email/ehlo.ts | 39 ++++++++++++++++++----- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/apps/sim/lib/messaging/email/ehlo.test.ts b/apps/sim/lib/messaging/email/ehlo.test.ts index 2e3c8eba4bd..9116c86b59f 100644 --- a/apps/sim/lib/messaging/email/ehlo.test.ts +++ b/apps/sim/lib/messaging/email/ehlo.test.ts @@ -36,6 +36,19 @@ describe('getSmtpEhloName', () => { expect(getSmtpEhloName()).toBe('[203.0.113.5]') }) + it('accepts an RFC 5321 IPv6 address literal', () => { + setEnv({ SMTP_EHLO_NAME: '[IPv6:2001:db8::1]' }) + expect(getSmtpEhloName()).toBe('[IPv6:2001:db8::1]') + }) + + 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') @@ -46,7 +59,12 @@ describe('getSmtpEhloName', () => { expect(getSmtpEhloName()).toBe('sim.example.com') }) - it("returns undefined when the app domain carries a port, leaving nodemailer's default", () => { + 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() }) diff --git a/apps/sim/lib/messaging/email/ehlo.ts b/apps/sim/lib/messaging/email/ehlo.ts index 601f7124990..3fe926ad8f8 100644 --- a/apps/sim/lib/messaging/email/ehlo.ts +++ b/apps/sim/lib/messaging/email/ehlo.ts @@ -1,3 +1,4 @@ +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' @@ -5,16 +6,38 @@ import { getEmailDomain } from '@/lib/core/utils/urls' const logger = createLogger('SmtpEhloName') /** - * A dotted FQDN built from RFC 1035 labels, or an RFC 5321 address literal such - * as `[203.0.113.5]`. A single dotless label is deliberately rejected: strict - * relays treat it the same way they treat the loopback literal this module - * exists to avoid. + * 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 EHLO_NAME_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])?)+|\[[0-9A-Fa-f:.]{1,45}\])$/ +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])?)+$/ + +/** + * 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 inner.startsWith('IPv6:') ? isIPv6(inner.slice(5)) : isIPv4(inner) +} function isValidEhloName(value: string): boolean { - return value.length <= 255 && EHLO_NAME_PATTERN.test(value) + 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 @@ -49,6 +72,6 @@ export function getSmtpEhloName(): string | undefined { } } - const appDomain = getEmailDomain() + const appDomain = stripPort(getEmailDomain()) return isValidEhloName(appDomain) ? appDomain : undefined } From 3334de8f9ad11712845ae5960a44ff527953a885 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 17 Aug 2026 17:48:19 -0700 Subject: [PATCH 3/3] fix(email): accept any casing of the IPv6 literal tag, and stop owning SMTP_EHLO_NAME in setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/sim/lib/core/config/env-capabilities.ts | 2 +- apps/sim/lib/messaging/email/ehlo.test.ts | 11 +++++++---- apps/sim/lib/messaging/email/ehlo.ts | 5 ++++- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/sim/lib/core/config/env-capabilities.ts b/apps/sim/lib/core/config/env-capabilities.ts index 406224884bb..830838fecf3 100644 --- a/apps/sim/lib/core/config/env-capabilities.ts +++ b/apps/sim/lib/core/config/env-capabilities.ts @@ -940,7 +940,7 @@ export const EMAIL_CAPABILITY = defineCapability({ }, }) ), - optionalFields: [envField('SMTP_USER'), envField('SMTP_PASS'), envField('SMTP_EHLO_NAME')], + optionalFields: [envField('SMTP_USER'), envField('SMTP_PASS')], }, { id: 'azure', diff --git a/apps/sim/lib/messaging/email/ehlo.test.ts b/apps/sim/lib/messaging/email/ehlo.test.ts index 9116c86b59f..11423937539 100644 --- a/apps/sim/lib/messaging/email/ehlo.test.ts +++ b/apps/sim/lib/messaging/email/ehlo.test.ts @@ -36,10 +36,13 @@ describe('getSmtpEhloName', () => { expect(getSmtpEhloName()).toBe('[203.0.113.5]') }) - it('accepts an RFC 5321 IPv6 address literal', () => { - setEnv({ SMTP_EHLO_NAME: '[IPv6:2001:db8::1]' }) - expect(getSmtpEhloName()).toBe('[IPv6:2001:db8::1]') - }) + 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', diff --git a/apps/sim/lib/messaging/email/ehlo.ts b/apps/sim/lib/messaging/email/ehlo.ts index 3fe926ad8f8..27c4d0e1db4 100644 --- a/apps/sim/lib/messaging/email/ehlo.ts +++ b/apps/sim/lib/messaging/email/ehlo.ts @@ -13,6 +13,9 @@ const logger = createLogger('SmtpEhloName') 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 @@ -22,7 +25,7 @@ const FQDN_PATTERN = function isAddressLiteral(value: string): boolean { if (!value.startsWith('[') || !value.endsWith(']')) return false const inner = value.slice(1, -1) - return inner.startsWith('IPv6:') ? isIPv6(inner.slice(5)) : isIPv4(inner) + return IPV6_TAG_PATTERN.test(inner) ? isIPv6(inner.slice(5)) : isIPv4(inner) } function isValidEhloName(value: string): boolean {