MUL-4460: fix(email): add SMTP_FROM_EMAIL for SMTP sender

Closes MUL-4460
This commit is contained in:
QuanHengzhuang
2026-07-14 14:26:16 +08:00
committed by GitHub
parent 6c6143e8fc
commit 5b57a8ebca
7 changed files with 104 additions and 11 deletions

View File

@@ -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=

View File

@@ -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` として表面化します |

View File

@@ -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`로 나타납니다 |

View File

@@ -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 |

View File

@@ -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` |

View File

@@ -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

View File

@@ -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", "<p>Hello</p>")
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
}