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.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..11423937539 --- /dev/null +++ b/apps/sim/lib/messaging/email/ehlo.test.ts @@ -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:' }) + 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() + }) +}) diff --git a/apps/sim/lib/messaging/email/ehlo.ts b/apps/sim/lib/messaging/email/ehlo.ts new file mode 100644 index 00000000000..27c4d0e1db4 --- /dev/null +++ b/apps/sim/lib/messaging/email/ehlo.ts @@ -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 +} 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)