docs(self-host): document Microsoft Exchange / SMTP relay modes and failure diagnostics (#3426)

GH#3405 / MUL-2768. Self-host docs already point at the SMTP path, but on-prem operators ran into two gaps:

- The Option B env block in auth-setup and self-host-quickstart only showed a 587 authenticated example, with no copy-pasteable block for the most common Exchange "anonymous internal relay on port 25" pattern, and no explicit mapping between port / auth / TLS / supported-or-not.
- troubleshooting "Emails not received" only covered Resend; SMTP failures (smtp dial / starttls / auth / MAIL FROM / RCPT TO / DATA) surface as wrapped errors in the backend logs, but operators had no doc telling them which Exchange-side fix maps to each.

Adds:

- A relay-mode table (anonymous 25 / authenticated 587 / 465 still unsupported) and two copy-pasteable env blocks in both auth-setup.mdx and self-host-quickstart.mdx (EN + ZH).
- Explicit note on the EmailService startup log line so operators can confirm SMTP is the active provider after restart, without leaking credentials.
- An SMTP failure-mode table in troubleshooting.mdx (EN + ZH) keyed on the exact wrapped error string, with the Exchange-side fix for each.

No code changes; env variable surface unchanged (still SMTP_HOST / SMTP_PORT / SMTP_USERNAME / SMTP_PASSWORD / SMTP_TLS_INSECURE). Port 465 stays "not supported" pending #3340.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Bohan Jiang
2026-05-28 13:59:10 +08:00
committed by GitHub
parent 5732b0dae8
commit fe2c990296
6 changed files with 180 additions and 26 deletions

View File

@@ -29,18 +29,39 @@ The user enters an email on the sign-in page → the server sends a 6-digit code
### Option B: SMTP relay (for self-hosted / on-premise deployments)
Use this when the deployment can't reach `api.resend.com` or you already have an internal mail relay (Exchange, Postfix, on-prem SendGrid, etc.). `SMTP_HOST` takes priority over `RESEND_API_KEY` when both are set.
Use this when the deployment can't reach `api.resend.com` or you already have an internal mail relay (Microsoft Exchange, Postfix, on-prem SendGrid, etc.). `SMTP_HOST` takes priority over `RESEND_API_KEY` when both are set — if `SMTP_HOST` is non-empty the server always goes through SMTP, even if `RESEND_API_KEY` is also configured, so verification and invite mail never leaves the internal network.
The SMTP path supports the three relay modes most on-premise mail servers (notably Microsoft Exchange's receive connectors) expose:
| Mode | Port | Auth | TLS |
|---|---|---|---|
| Anonymous internal relay | `25` | none — submission is trusted by IP / subnet | none on the wire (internal segment only) |
| Authenticated submission | `587` | `SMTP_USERNAME` + `SMTP_PASSWORD` | STARTTLS, upgraded automatically |
| Implicit TLS (SMTPS) | `465` | — | **not supported yet** — use port 25 or 587 |
**Anonymous Exchange relay on port 25** — the typical "internal SMTP relay" / Exchange anonymous receive connector that accepts mail from a trusted subnet without credentials:
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587 # default 25; use 587 for STARTTLS submission
SMTP_USERNAME=multica # leave empty for unauthenticated relay
SMTP_PASSWORD=...
SMTP_TLS_INSECURE=false # set true only for self-signed / private CA
SMTP_HOST=exchange.internal.example.com
SMTP_PORT=25
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_TLS_INSECURE=false
RESEND_FROM_EMAIL=noreply@yourdomain.com # reused as the From: header
```
STARTTLS is upgraded automatically when the server advertises it. Port 465 (SMTPS / implicit TLS) is **not** currently supported — use port 25 or 587.
**Authenticated submission on port 587** — for relays that require a service account; STARTTLS is upgraded automatically when the server advertises it:
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587
SMTP_USERNAME=multica
SMTP_PASSWORD=...
SMTP_TLS_INSECURE=false # set true only for self-signed / private CA
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
At startup the server prints which provider it picked — for example `EmailService: SMTP relay exchange.internal.example.com:25 from=noreply@example.com` (or `Resend API` / `DEV mode`). The password is never logged. If you don't see the SMTP line after restart, `SMTP_HOST` didn't reach the process — check the container env (`docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP`).
**What happens if you set neither**: the server doesn't error, but **every email that should have been sent is written to the server's stdout only**. Handy for local development (copy the code from the logs); in production it's a black hole.

View File

@@ -29,18 +29,39 @@ Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google
### Option BSMTP relay内网/自部署)
适合内网无法访问 `api.resend.com`或者已经有内部邮件中继Exchange、Postfix、自部署 SendGrid 等)的场景。同时设置时 `SMTP_HOST` 优先级高于 `RESEND_API_KEY`。
适合内网无法访问 `api.resend.com`,或者已经有内部邮件中继(Microsoft Exchange、Postfix、自部署 SendGrid 等)的场景。同时设置时 `SMTP_HOST` 优先级高于 `RESEND_API_KEY`:只要 `SMTP_HOST` 非空server 一律走 SMTP 路径,即便 `RESEND_API_KEY` 也配着,验证码和邀请邮件也不会经过 Resend 外发出内网
SMTP 路径覆盖大多数本地邮件服务器(特别是 Microsoft Exchange 的 receive connector暴露的三种 relay 模式:
| 模式 | 端口 | 认证 | TLS |
|---|---|---|---|
| 匿名内部 relay | `25` | 无 —— 按 IP / 子网信任 | 链路上无 TLS仅限内网段 |
| 认证提交submission | `587` | `SMTP_USERNAME` + `SMTP_PASSWORD` | STARTTLS自动升级 |
| 隐式 TLSSMTPS | `465` | —— | **暂不支持** —— 请用 25 或 587 |
**匿名 Exchange relay端口 25** —— 经典的 "internal SMTP relay" / Exchange 匿名 receive connector按可信子网放行不要求凭据
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587 # 默认 25STARTTLS 提交端口用 587
SMTP_USERNAME=multica # 留空则使用未认证 relay
SMTP_PASSWORD=...
SMTP_TLS_INSECURE=false # 仅在私有 CA / 自签证书时改成 true
SMTP_HOST=exchange.internal.example.com
SMTP_PORT=25
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_TLS_INSECURE=false
RESEND_FROM_EMAIL=noreply@yourdomain.com # 同时作为 SMTP From: 头
```
服务端 advertise STARTTLS 时会自动升级。**暂不支持** 465SMTPS / 隐式 TLS请使用 25 或 587。
**认证提交,端口 587** —— 需要 service account 的 relay服务端 advertise STARTTLS 时会自动升级:
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587
SMTP_USERNAME=multica
SMTP_PASSWORD=...
SMTP_TLS_INSECURE=false # 仅在私有 CA / 自签证书时改成 true
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
启动时 server 会打印当前选择的 provider比如 `EmailService: SMTP relay exchange.internal.example.com:25 from=noreply@example.com`(或 `Resend API` / `DEV mode`),密码不会出现在日志里。重启后没看到 SMTP 这行,说明 `SMTP_HOST` 没进到进程,确认下容器环境(`docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP`)。
**两种都不配**server 不报错,但所有本该发出去的邮件**只打到 server 的 stdout**。本地开发方便(你从日志抄验证码),生产环境等于黑洞。

View File

@@ -82,17 +82,31 @@ Two delivery backends are supported — pick whichever fits your network:
**Option B — SMTP relay (internal networks / on-premise):**
Use this when the deployment can't reach `api.resend.com`, or you already have an internal mail relay (Exchange, Postfix, on-prem SendGrid, etc.). `SMTP_HOST` takes priority over Resend when both are set.
Use this when the deployment can't reach `api.resend.com`, or you already have an internal mail relay (Microsoft Exchange, Postfix, on-prem SendGrid, etc.). `SMTP_HOST` takes priority over Resend when both are set, so verification and invite mail stays on the internal relay. Port 465 (SMTPS / implicit TLS) is not currently supported — use 25 or 587.
For **anonymous Exchange internal relay (port 25)** — the host is trusted by IP and submits without credentials:
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587 # default 25; use 587 for STARTTLS submission
SMTP_USERNAME=multica # leave empty for unauthenticated relay
SMTP_PASSWORD=...
SMTP_HOST=exchange.internal.example.com
SMTP_PORT=25
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_TLS_INSECURE=false
RESEND_FROM_EMAIL=noreply@yourdomain.com # reused as the From: header
```
Then restart: `docker compose -f docker-compose.selfhost.yml restart backend`.
For **authenticated submission (port 587, STARTTLS)** — the relay requires a service account; STARTTLS is upgraded automatically when advertised:
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587
SMTP_USERNAME=multica
SMTP_PASSWORD=...
SMTP_TLS_INSECURE=false # set true only for private CA / self-signed
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
Then restart: `docker compose -f docker-compose.selfhost.yml restart backend`. On restart, the backend prints which provider it picked (`EmailService: SMTP relay …` / `Resend API` / `DEV mode`) — credentials are never logged, so this line is safe to share when asking for help.
For more auth configuration (OAuth, signup allowlist) and the full SMTP variable reference, see [Auth setup](/auth-setup) and [Environment variables → Email](/environment-variables#email-configuration).

View File

@@ -81,17 +81,31 @@ make selfhost
**Option B — SMTP relay内网/自部署):**
适合内网无法访问 `api.resend.com`或已经有内部邮件中继Exchange、Postfix、自部署 SendGrid 等)的场景。同时设置时 `SMTP_HOST` 优先级高于 Resend。
适合内网无法访问 `api.resend.com`,或已经有内部邮件中继(Microsoft Exchange、Postfix、自部署 SendGrid 等)的场景。同时设置时 `SMTP_HOST` 优先级高于 Resend,验证码和邀请邮件不会走外部 provider。**暂不支持** 465SMTPS / 隐式 TLS请使用 25 或 587
**匿名 Exchange 内部 relay端口 25** —— 主机按 IP 被信任,不需要凭据:
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587 # 默认 25STARTTLS 提交端口用 587
SMTP_USERNAME=multica # 留空则使用未认证 relay
SMTP_PASSWORD=...
SMTP_HOST=exchange.internal.example.com
SMTP_PORT=25
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_TLS_INSECURE=false
RESEND_FROM_EMAIL=noreply@yourdomain.com # 同时作为 SMTP From: 头
```
之后重启:`docker compose -f docker-compose.selfhost.yml restart backend`。
**认证提交(端口 587STARTTLS** —— relay 需要 service account服务端 advertise STARTTLS 时自动升级:
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587
SMTP_USERNAME=multica
SMTP_PASSWORD=...
SMTP_TLS_INSECURE=false # 仅在私有 CA / 自签证书时改成 true
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
之后重启:`docker compose -f docker-compose.selfhost.yml restart backend`。重启时 backend 会打印当前选择的 provider`EmailService: SMTP relay …` / `Resend API` / `DEV mode`),密码不会被记录,所以这行截图给同事是安全的。
更多 auth 配置OAuth、注册白名单以及完整的 SMTP 变量说明见 [登录与注册配置](/auth-setup) 和 [环境变量](/environment-variables)。

View File

@@ -89,6 +89,20 @@ On the server side (self-host), grep for `"no_tasks"` / `"no_capacity"` to see t
**Symptom**: after submitting an email during sign-in or invite acceptance, neither the inbox nor the spam folder has the verification code.
**First, confirm which provider the server thinks is active.** At startup the backend prints one of:
- `EmailService: SMTP relay <host>:<port> from=<addr>` — using SMTP (`SMTP_HOST` non-empty wins over Resend)
- `EmailService: Resend API from=<addr>` — using Resend
- `EmailService: DEV mode — codes printed to stdout …` — no provider configured
```bash
docker compose -f docker-compose.selfhost.yml logs backend | grep "EmailService:"
```
If the line you expected isn't there, the environment didn't reach the process — check `.env` and `docker compose -f docker-compose.selfhost.yml exec backend env | grep -E 'RESEND_|SMTP_'`. Credentials are never logged on this startup line.
### When Resend is the active provider
**Likely causes**:
1. **`RESEND_API_KEY` not set** — the server silently falls back and **writes the code to its own stdout** without error. Easy to trip over in production
@@ -108,6 +122,34 @@ On the server side (self-host), grep for `"no_tasks"` / `"no_capacity"` to see t
- Domain not verified → run the DNS verification flow in the Resend console (add SPF / DKIM records)
- In an emergency (internal testing) → copy the code printed under `[DEV]` from the server logs
### When SMTP is the active provider
The SMTP path wraps every failure with the stage it failed at, so the server logs already tell you where the relay rejected the session. Grep for `"failed to send verification email"` / `"failed to send invitation email"` and check the wrapped error:
| Logged error | What it means | How to fix |
|---|---|---|
| `smtp dial <host>:<port>: dial tcp …: connect: connection refused` / `i/o timeout` | The backend container can't reach the relay — wrong host, wrong port, firewall, or the relay isn't listening | Verify `SMTP_HOST` / `SMTP_PORT` resolve from inside the container (`docker compose -f docker-compose.selfhost.yml exec backend nslookup <host>` and `nc -vz <host> <port>`); open the firewall from the host running Multica to the relay |
| `smtp starttls: x509: certificate signed by unknown authority` (or `certificate is not valid for any names`) | The relay uses a private CA / self-signed cert and the container's trust store rejects it | Either install the CA into the container, or set `SMTP_TLS_INSECURE=true` only after confirming the relay is reachable on a trusted segment |
| `smtp auth: 535 5.7.8 Authentication credentials invalid` (or `534`/`530`) | `SMTP_USERNAME` / `SMTP_PASSWORD` are wrong, or the relay requires a different auth mechanism than `PLAIN` | Re-confirm the service-account credentials with your mail admin; for Exchange anonymous internal relay leave both empty (`SMTP_USERNAME=`, `SMTP_PASSWORD=`) |
| `smtp MAIL FROM: 550 5.7.1 Client does not have permissions to send as this sender` | The relay won't accept `RESEND_FROM_EMAIL` as the envelope sender — typical Exchange "anonymous users not allowed" or DMARC alignment issue | Set `RESEND_FROM_EMAIL` to a domain the relay accepts; on Exchange, grant the source IP `ms-Exch-SMTP-Accept-Any-Sender` on the receive connector |
| `smtp RCPT TO <addr>: 550 5.7.1 Unable to relay` | The relay's receive connector doesn't allow your subnet to relay to external recipients (most common for anonymous internal relays talking to outside domains) | Either restrict invites to internal recipients, or add the Multica host's subnet to the Exchange "Anonymous Users → Relay" permission list |
| `smtp DATA` / `smtp write body` / `smtp end data` | Session was accepted but the relay dropped the body — usually message-size limits, content filtering, or a connection reset mid-stream | Check the relay's logs for the same `Message-ID` (logged as `<unixnano>@<host>`); raise the message size limit if needed |
`MAIL FROM`, `RCPT TO`, and `DATA` errors are always logged with the relay's response code so you can match them against Exchange / Postfix logs on the other side. Verification codes and invite tokens are **never** included in the wrapped error.
**How to diagnose**:
- Grep `"EmailService: SMTP relay"` once at startup, then `"failed to send"` for runtime failures
- From inside the backend container, sanity-check connectivity: `docker compose -f docker-compose.selfhost.yml exec backend sh -c 'nc -vz $SMTP_HOST $SMTP_PORT'`
- Confirm the env reached the process: `docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP_` (password will be in the output — only run on a trusted shell)
**How to fix**:
- Wrong host / port → adjust `SMTP_HOST` / `SMTP_PORT` and restart the backend; for the supported relay modes see [Auth setup → Option B: SMTP relay](/auth-setup)
- Cert mismatch → install the relay's CA into the container, or temporarily `SMTP_TLS_INSECURE=true` on a trusted segment
- Auth failure → re-check credentials; for anonymous internal relay leave `SMTP_USERNAME` and `SMTP_PASSWORD` empty
- `Unable to relay` → either restrict to internal recipients or grant the Multica host's IP relay permission on the Exchange receive connector
## Fixed local test code doesn't work
**Symptom**: on a self-hosted instance, you try to sign in with a fixed local test code such as `888888` and it's rejected with `invalid or expired code`.

View File

@@ -89,6 +89,20 @@ multica issue show <issue-id> # 看 task 历史
**症状**:登录或邀请时提交邮箱后,收件箱(和垃圾邮件)里都没有验证码邮件。
**先确认 server 自己认为在用哪个 provider。** 启动时 backend 会打印这三种之一:
- `EmailService: SMTP relay <host>:<port> from=<addr>` —— 走 SMTP`SMTP_HOST` 非空时优先级高于 Resend
- `EmailService: Resend API from=<addr>` —— 走 Resend
- `EmailService: DEV mode — codes printed to stdout …` —— 没配任何 provider
```bash
docker compose -f docker-compose.selfhost.yml logs backend | grep "EmailService:"
```
如果应该出现的那行没出现,说明环境变量没进到进程 —— 检查 `.env` 和 `docker compose -f docker-compose.selfhost.yml exec backend env | grep -E 'RESEND_|SMTP_'`。这行启动日志里**不会**打印任何密码。
### Resend 是当前 provider
**可能原因**
1. **`RESEND_API_KEY` 没配** —— server 会静默回落,**把验证码打到自己的 stdout 里**,不报错。生产部署很容易踩
@@ -108,6 +122,34 @@ multica issue show <issue-id> # 看 task 历史
- 域名没验证 → Resend console 里走 DNS 验证流程(加 SPF / DKIM 记录)
- 紧急情况下(如内部测试)→ 从 server 日志里抄 `[DEV]` 打印出的验证码
### SMTP 是当前 provider
SMTP 路径把每个失败都按阶段包装好,所以 server 日志已经告诉你 relay 在哪一步拒绝了会话。搜 `"failed to send verification email"` / `"failed to send invitation email"`,看里面包的具体错误:
| 错误日志 | 含义 | 怎么修 |
|---|---|---|
| `smtp dial <host>:<port>: dial tcp …: connect: connection refused` / `i/o timeout` | backend 容器连不上 relay —— host / port 错、防火墙挡了、或者 relay 没开 | 在容器里确认能解析和连通:`docker compose -f docker-compose.selfhost.yml exec backend nslookup <host>` 以及 `nc -vz <host> <port>`;放行从 Multica 主机到 relay 的网络 |
| `smtp starttls: x509: certificate signed by unknown authority`(或 `certificate is not valid for any names` | relay 用了私有 CA / 自签证书,容器的信任库不接受 | 要么把 CA 装进容器,要么在确认 relay 走的是可信网段后设 `SMTP_TLS_INSECURE=true` |
| `smtp auth: 535 5.7.8 Authentication credentials invalid`(或 `534`/`530` | `SMTP_USERNAME` / `SMTP_PASSWORD` 不对,或 relay 不接受 `PLAIN` 认证 | 找邮件管理员复核 service account 凭据Exchange 匿名内部 relay 应当把两者都留空 |
| `smtp MAIL FROM: 550 5.7.1 Client does not have permissions to send as this sender` | relay 不接受 `RESEND_FROM_EMAIL` 作为信封发件人 —— Exchange 常见 "anonymous users not allowed" 或 DMARC 对齐问题 | 把 `RESEND_FROM_EMAIL` 改成 relay 接受的域名Exchange 上给来源 IP 授 `ms-Exch-SMTP-Accept-Any-Sender` |
| `smtp RCPT TO <addr>: 550 5.7.1 Unable to relay` | relay 的 receive connector 不允许这个子网把邮件中继到外部收件人(匿名内部 relay 发给外部域时常见) | 邀请仅限内部域,或者把 Multica 主机的子网加进 Exchange "Anonymous Users → Relay" 权限列表 |
| `smtp DATA` / `smtp write body` / `smtp end data` | 会话被接受但 body 被丢 —— 通常是消息大小限制、内容过滤、或中途断连 | 在 relay 端按同一个 `Message-ID`(日志里是 `<unixnano>@<host>`)找上下文;必要时调大消息大小限制 |
`MAIL FROM` / `RCPT TO` / `DATA` 的错误日志里都带着 relay 返回的状态码,可以和 Exchange / Postfix 那边的日志对齐。验证码和邀请 token **不会**出现在这些包装的错误里。
**怎么查**
- 启动时搜 `"EmailService: SMTP relay"` 一次,运行时搜 `"failed to send"` 看具体阶段
- 在 backend 容器里测连通:`docker compose -f docker-compose.selfhost.yml exec backend sh -c 'nc -vz $SMTP_HOST $SMTP_PORT'`
- 确认环境变量真进到了进程:`docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP_`(这条会带出密码,仅在可信终端运行)
**怎么修**
- host / port 不对 → 改 `SMTP_HOST` / `SMTP_PORT` 后重启 backend支持的 relay 模式见 [登录与注册配置 → Option BSMTP relay](/auth-setup)
- 证书校验失败 → 把 relay 的 CA 装进容器,或在可信网段上临时 `SMTP_TLS_INSECURE=true`
- 认证失败 → 复核凭据;匿名内部 relay 应把 `SMTP_USERNAME` 和 `SMTP_PASSWORD` 都留空
- `Unable to relay` → 邀请仅限内部域,或在 Exchange receive connector 上给 Multica 主机授中继权限
## 固定本地测试验证码登不进去
**症状**:自部署实例,想用 `888888` 这类固定本地测试验证码登录,但被拒 `invalid or expired code`。