mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 20:45:37 +02:00
MUL-4460: fix(email): add SMTP_FROM_EMAIL for SMTP sender
Closes MUL-4460
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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` として表面化します |
|
||||
|
||||
@@ -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`로 나타납니다 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user