From 5b57a8ebcae6decda639d75f91c7eb6040710eb4 Mon Sep 17 00:00:00 2001 From: QuanHengzhuang Date: Tue, 14 Jul 2026 14:26:16 +0800 Subject: [PATCH] MUL-4460: fix(email): add SMTP_FROM_EMAIL for SMTP sender Closes MUL-4460 --- .env.example | 3 + .../content/docs/environment-variables.ja.mdx | 3 +- .../content/docs/environment-variables.ko.mdx | 3 +- .../content/docs/environment-variables.mdx | 3 +- .../content/docs/environment-variables.zh.mdx | 3 +- server/internal/service/email.go | 24 ++++-- server/internal/service/email_test.go | 76 ++++++++++++++++++- 7 files changed, 104 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index f8645877f..feb36d6a3 100644 --- a/.env.example +++ b/.env.example @@ -128,6 +128,8 @@ RESEND_FROM_EMAIL=noreply@multica.ai # Option B: SMTP relay (for self-hosted / on-premise deployments) # Takes priority over Resend when SMTP_HOST is set. # Supports unauthenticated relay (leave SMTP_USERNAME empty) and authenticated SMTP. +# SMTP_FROM_EMAIL is the envelope/header sender for SMTP. If unset, SMTP +# falls back to RESEND_FROM_EMAIL for backwards compatibility. # Set SMTP_TLS_INSECURE=true only for private CA or self-signed certificates. # SMTP_TLS controls the TLS mode: # - unset / "starttls" (default): plaintext connect, upgrade via STARTTLS. @@ -142,6 +144,7 @@ SMTP_HOST= SMTP_PORT=25 SMTP_USERNAME= SMTP_PASSWORD= +SMTP_FROM_EMAIL= SMTP_TLS_INSECURE=false SMTP_TLS= SMTP_EHLO_NAME= diff --git a/apps/docs/content/docs/environment-variables.ja.mdx b/apps/docs/content/docs/environment-variables.ja.mdx index 56f59782a..7fe230b3c 100644 --- a/apps/docs/content/docs/environment-variables.ja.mdx +++ b/apps/docs/content/docs/environment-variables.ja.mdx @@ -42,7 +42,7 @@ Multica は 2 つの配信バックエンドをサポートします — クラ | 変数 | デフォルト | 説明 | |---|---|---| | `RESEND_API_KEY` | 空 | Resend API key | -| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | 送信元アドレス(Resend アカウントで検証済みのドメインである必要があり、SMTP を使用する場合も `From:` ヘッダーとして再利用されます) | +| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | Resend の送信元アドレス(Resend アカウントで検証済みのドメインである必要があります)。`SMTP_FROM_EMAIL` が未設定の場合、SMTP でも使われます | ### SMTP relay @@ -52,6 +52,7 @@ Multica は 2 つの配信バックエンドをサポートします — クラ | `SMTP_PORT` | `25` | SMTP ポート。STARTTLS サブミッションには `587` を、SMTPS(暗黙的 TLS、自動有効化)には `465` を使用します | | `SMTP_USERNAME` | 空 | SMTP ユーザー名。認証なしの relay の場合は空のままにしてください | | `SMTP_PASSWORD` | 空 | SMTP パスワード | +| `SMTP_FROM_EMAIL` | 空 | SMTP の `MAIL FROM` と `From:` に使う送信元アドレス。未設定の場合は `RESEND_FROM_EMAIL` にフォールバックします | | `SMTP_TLS` | `starttls` | TLS モード。`implicit`(別名 `smtps`、`ssl`)は接続時に即座に TLS ハンドシェイクを行います(SMTPS)。`465` ポートでは自動的に有効になります。未設定 / `starttls` の場合は接続後に STARTTLS でアップグレードします | | `SMTP_TLS_INSECURE` | `false` | TLS 証明書の検証をスキップするには `true` に設定(プライベート CA / 自己署名証明書のみ) | | `SMTP_EHLO_NAME` | マシンのホスト名 | relay に通知する EHLO/HELO 名。厳格な relay(例: Google Workspace `smtp-relay.gmail.com`)が公開 IP からのデフォルトの挨拶を拒否する場合は、実際の FQDN を設定してください — そうしないと relay が接続を切断し、後続のコマンドで不明瞭な `EOF` として表面化します | diff --git a/apps/docs/content/docs/environment-variables.ko.mdx b/apps/docs/content/docs/environment-variables.ko.mdx index 67427f31e..c06222177 100644 --- a/apps/docs/content/docs/environment-variables.ko.mdx +++ b/apps/docs/content/docs/environment-variables.ko.mdx @@ -42,7 +42,7 @@ Multica는 두 가지 전송 백엔드를 지원합니다 — 클라우드 배 | 변수 | 기본값 | 설명 | |---|---|---| | `RESEND_API_KEY` | 비어 있음 | Resend API key | -| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | 발신 주소 (Resend 계정에서 검증된 도메인이어야 하며, SMTP를 사용할 때도 `From:` 헤더로 재사용됨) | +| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | Resend 발신 주소(Resend 계정에서 검증된 도메인이어야 함). `SMTP_FROM_EMAIL`이 비어 있으면 SMTP에서도 사용됩니다 | ### SMTP relay @@ -52,6 +52,7 @@ Multica는 두 가지 전송 백엔드를 지원합니다 — 클라우드 배 | `SMTP_PORT` | `25` | SMTP 포트. STARTTLS 제출에는 `587`을, SMTPS(암묵적 TLS, 자동 활성화)에는 `465`를 사용하세요 | | `SMTP_USERNAME` | 비어 있음 | SMTP 사용자명. 인증 없는 relay의 경우 비워 두세요 | | `SMTP_PASSWORD` | 비어 있음 | SMTP 비밀번호 | +| `SMTP_FROM_EMAIL` | 비어 있음 | SMTP `MAIL FROM` 및 `From:`에 사용할 발신 주소. 설정하지 않으면 `RESEND_FROM_EMAIL`로 대체됩니다 | | `SMTP_TLS` | `starttls` | TLS 모드. `implicit`(별칭 `smtps`, `ssl`)은 연결 시 즉시 TLS 핸드셰이크를 수행합니다(SMTPS). `465` 포트에서는 자동으로 활성화됩니다. 미설정 / `starttls`는 연결 후 STARTTLS로 업그레이드합니다 | | `SMTP_TLS_INSECURE` | `false` | TLS 인증서 검증을 건너뛰려면 `true`로 설정 (사설 CA / 자체 서명 인증서만 해당) | | `SMTP_EHLO_NAME` | 머신 호스트명 | relay에 알리는 EHLO/HELO 이름. 엄격한 relay(예: Google Workspace `smtp-relay.gmail.com`)가 공개 IP에서 보내는 기본 greeting을 거부하는 경우 실제 FQDN을 설정하세요 — 그렇지 않으면 relay가 연결을 끊고, 이는 이후 명령에서 불투명한 `EOF`로 나타납니다 | diff --git a/apps/docs/content/docs/environment-variables.mdx b/apps/docs/content/docs/environment-variables.mdx index 527c7fdc5..2ad1e02ab 100644 --- a/apps/docs/content/docs/environment-variables.mdx +++ b/apps/docs/content/docs/environment-variables.mdx @@ -42,7 +42,7 @@ Multica supports two delivery backends — [Resend](https://resend.com/) for clo | Variable | Default | Description | |---|---|---| | `RESEND_API_KEY` | empty | Resend API key | -| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | Sender address (must be a domain verified in your Resend account; also reused as the `From:` header when SMTP is in use) | +| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | Sender address for Resend (must be a domain verified in your Resend account). SMTP also uses it when `SMTP_FROM_EMAIL` is unset | ### SMTP relay @@ -52,6 +52,7 @@ Multica supports two delivery backends — [Resend](https://resend.com/) for clo | `SMTP_PORT` | `25` | SMTP port. Use `587` for STARTTLS submission, or `465` for SMTPS (implicit TLS, auto-enabled) | | `SMTP_USERNAME` | empty | SMTP username. Leave empty for unauthenticated relay | | `SMTP_PASSWORD` | empty | SMTP password | +| `SMTP_FROM_EMAIL` | empty | Sender address for SMTP `MAIL FROM` and `From:`. Falls back to `RESEND_FROM_EMAIL` when unset | | `SMTP_TLS` | `starttls` | TLS mode. `implicit` (aliases `smtps`, `ssl`) forces an immediate TLS handshake on connect (SMTPS); port `465` auto-enables it. Unset / `starttls` upgrades via STARTTLS after connect | | `SMTP_TLS_INSECURE` | `false` | Set `true` to skip TLS certificate verification (private CA / self-signed only) | | `SMTP_EHLO_NAME` | machine hostname | EHLO/HELO name announced to the relay. Set a real FQDN when a strict relay (e.g. Google Workspace `smtp-relay.gmail.com`) rejects the default greeting from a public IP — otherwise the relay drops the connection and it surfaces as an opaque `EOF` on a later command | diff --git a/apps/docs/content/docs/environment-variables.zh.mdx b/apps/docs/content/docs/environment-variables.zh.mdx index 3a58a67e4..2bd5998d2 100644 --- a/apps/docs/content/docs/environment-variables.zh.mdx +++ b/apps/docs/content/docs/environment-variables.zh.mdx @@ -42,7 +42,7 @@ Multica 支持两种邮件发送通道——[Resend](https://resend.com/) 适合 | 环境变量 | 默认值 | 说明 | |---|---|---| | `RESEND_API_KEY` | 空 | Resend API key | -| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | 发件地址(必须是 Resend 账号已验证的域名;走 SMTP 时同时作为 `From:` 头)| +| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | Resend 发件地址(必须是 Resend 账号已验证的域名;`SMTP_FROM_EMAIL` 未设置时,SMTP 也会使用它)| ### SMTP relay @@ -52,6 +52,7 @@ Multica 支持两种邮件发送通道——[Resend](https://resend.com/) 适合 | `SMTP_PORT` | `25` | SMTP 端口。STARTTLS 提交端口用 `587`;SMTPS(隐式 TLS,自动启用)用 `465` | | `SMTP_USERNAME` | 空 | SMTP 用户名。留空表示未认证 relay | | `SMTP_PASSWORD` | 空 | SMTP 密码 | +| `SMTP_FROM_EMAIL` | 空 | SMTP 的 `MAIL FROM` 和 `From:` 发件地址。未设置时回退到 `RESEND_FROM_EMAIL` | | `SMTP_TLS` | `starttls` | TLS 模式。`implicit`(别名 `smtps`、`ssl`)在连接时立即进行 TLS 握手(SMTPS);`465` 端口会自动启用。未设置 / `starttls` 则在连接后通过 STARTTLS 升级 | | `SMTP_TLS_INSECURE` | `false` | 设为 `true` 跳过 TLS 证书校验(仅限私有 CA / 自签证书)| | `SMTP_EHLO_NAME` | 机器主机名 | 向 relay 通告的 EHLO/HELO 名称。当严格的 relay(例如 Google Workspace `smtp-relay.gmail.com`)拒绝来自公网 IP 的默认问候时,填一个真实的 FQDN——否则 relay 会直接断开连接,并在后续某条命令上表现为一个不知所云的 `EOF` | diff --git a/server/internal/service/email.go b/server/internal/service/email.go index 756461182..e03750daf 100644 --- a/server/internal/service/email.go +++ b/server/internal/service/email.go @@ -111,6 +111,20 @@ func smtpAuthWithFallback(c smtpAuthClient, host, username, password string) (bo return true, plainErr } +func resolveFromEmail(smtpHost string) string { + resendFrom := strings.TrimSpace(os.Getenv("RESEND_FROM_EMAIL")) + if smtpHost == "" { + if resendFrom != "" { + return resendFrom + } + return "noreply@multica.ai" + } + if smtpFrom := strings.TrimSpace(os.Getenv("SMTP_FROM_EMAIL")); smtpFrom != "" { + return smtpFrom + } + return resendFrom +} + func (s *EmailService) openSMTPClient() (*smtp.Client, error) { addr := net.JoinHostPort(s.smtpHost, s.smtpPort) @@ -162,11 +176,6 @@ func (s *EmailService) openSMTPClient() (*smtp.Client, error) { func NewEmailService() *EmailService { apiKey := os.Getenv("RESEND_API_KEY") - from := strings.TrimSpace(os.Getenv("RESEND_FROM_EMAIL")) - if from == "" { - from = "noreply@multica.ai" - } - smtpHost := strings.TrimSpace(os.Getenv("SMTP_HOST")) smtpPort := strings.TrimSpace(os.Getenv("SMTP_PORT")) if smtpPort == "" { @@ -175,6 +184,7 @@ func NewEmailService() *EmailService { smtpUsername := os.Getenv("SMTP_USERNAME") smtpPassword := os.Getenv("SMTP_PASSWORD") smtpTLSInsecure := os.Getenv("SMTP_TLS_INSECURE") == "true" + from := resolveFromEmail(smtpHost) // EHLO/HELO name, only relevant on the SMTP relay send path. net/smtp defaults // to "localhost", which strict relays (e.g. smtp-relay.gmail.com) reject from a @@ -245,6 +255,10 @@ func NewEmailService() *EmailService { // Upgrades to STARTTLS when advertised by the server. // Set SMTP_TLS_INSECURE=true for self-signed or private CA certificates. func (s *EmailService) sendSMTP(to, subject, htmlBody string) error { + if strings.TrimSpace(s.fromEmail) == "" { + return fmt.Errorf("SMTP_FROM_EMAIL or RESEND_FROM_EMAIL is required when SMTP_HOST is set") + } + c, err := s.openSMTPClient() if err != nil { return err diff --git a/server/internal/service/email_test.go b/server/internal/service/email_test.go index 4ebb392d9..0fb73b994 100644 --- a/server/internal/service/email_test.go +++ b/server/internal/service/email_test.go @@ -199,6 +199,74 @@ func TestNewEmailService_EHLOName(t *testing.T) { } } +func TestNewEmailService_FromEmailResolution(t *testing.T) { + tests := []struct { + name string + smtpHost string + smtpUsername string + smtpFromEmail string + resendFrom string + want string + }{ + { + name: "resend mode uses resend from", + resendFrom: "resend@example.com", + want: "resend@example.com", + }, + { + name: "smtp mode prefers smtp from", + smtpHost: "smtp.example.com", + smtpUsername: "auth@example.com", + smtpFromEmail: "sender@example.com", + resendFrom: "resend@example.com", + want: "sender@example.com", + }, + { + name: "smtp mode falls back to resend from", + smtpHost: "smtp.example.com", + smtpUsername: "auth@example.com", + resendFrom: "resend@example.com", + want: "resend@example.com", + }, + { + name: "default", + want: "noreply@multica.ai", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("RESEND_API_KEY", "") + t.Setenv("SMTP_HOST", tt.smtpHost) + t.Setenv("SMTP_USERNAME", tt.smtpUsername) + t.Setenv("SMTP_FROM_EMAIL", tt.smtpFromEmail) + t.Setenv("RESEND_FROM_EMAIL", tt.resendFrom) + + s := NewEmailService() + if s.fromEmail != tt.want { + t.Fatalf("fromEmail = %q, want %q", s.fromEmail, tt.want) + } + }) + } +} + +func TestSendSMTPRequiresConfiguredFromEmail(t *testing.T) { + s := &EmailService{ + smtpHost: "127.0.0.1", + smtpPort: "1", + smtpUsername: "auth@example.com", + smtpPassword: "testpass", + } + + err := s.sendSMTP("to@example.com", "Test Subject", "

Hello

") + if err == nil { + t.Fatal("expected missing from email error") + } + if got := err.Error(); got != "SMTP_FROM_EMAIL or RESEND_FROM_EMAIL is required when SMTP_HOST is set" { + t.Fatalf("error = %q, want missing from email error", got) + } +} + func TestBuildInvitationParams_EscapesHTMLInBody(t *testing.T) { tests := []struct { name string @@ -413,6 +481,7 @@ func TestLoginAuth_Start_AllowsLoopbackIPs(t *testing.T) { func TestSendSMTP_OpenClientFailureNoPanic(t *testing.T) { s := &EmailService{ + fromEmail: "from@example.com", smtpHost: "255.255.255.255", // unroutable, will time out or fail smtpPort: "25", smtpUsername: "user", @@ -579,6 +648,7 @@ func TestSendSMTP_FallbackReconnectsAndAuthsWithLOGIN(t *testing.T) { host, port, _ := net.SplitHostPort(srv.Addr) s := &EmailService{ + fromEmail: "from@example.com", smtpHost: host, smtpPort: port, smtpUsername: "testuser", @@ -605,6 +675,7 @@ func TestSendSMTP_PlainAuthSucceedsWithoutFallback(t *testing.T) { host, port, _ := net.SplitHostPort(srv.Addr) s := &EmailService{ + fromEmail: "from@example.com", smtpHost: host, smtpPort: port, smtpUsername: "testuser", @@ -625,8 +696,9 @@ func TestSendSMTP_NoAuthWhenUsernameEmpty(t *testing.T) { host, port, _ := net.SplitHostPort(srv.Addr) s := &EmailService{ - smtpHost: host, - smtpPort: port, + fromEmail: "from@example.com", + smtpHost: host, + smtpPort: port, // smtpUsername is empty → unauthenticated relay }