diff --git a/apps/docs/content/docs/auth-setup.mdx b/apps/docs/content/docs/auth-setup.mdx index 6624d1c8f..bea151dab 100644 --- a/apps/docs/content/docs/auth-setup.mdx +++ b/apps/docs/content/docs/auth-setup.mdx @@ -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. diff --git a/apps/docs/content/docs/auth-setup.zh.mdx b/apps/docs/content/docs/auth-setup.zh.mdx index 2c5d1c0a6..0a5598c12 100644 --- a/apps/docs/content/docs/auth-setup.zh.mdx +++ b/apps/docs/content/docs/auth-setup.zh.mdx @@ -29,18 +29,39 @@ Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google ### Option B:SMTP 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,自动升级 | +| 隐式 TLS(SMTPS) | `465` | —— | **暂不支持** —— 请用 25 或 587 | + +**匿名 Exchange relay,端口 25** —— 经典的 "internal SMTP relay" / Exchange 匿名 receive connector,按可信子网放行,不要求凭据: ```bash -SMTP_HOST=smtp.internal.example.com -SMTP_PORT=587 # 默认 25;STARTTLS 提交端口用 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 时会自动升级。**暂不支持** 465(SMTPS / 隐式 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**。本地开发方便(你从日志抄验证码),生产环境等于黑洞。 diff --git a/apps/docs/content/docs/self-host-quickstart.mdx b/apps/docs/content/docs/self-host-quickstart.mdx index e07fa177c..9af35eac7 100644 --- a/apps/docs/content/docs/self-host-quickstart.mdx +++ b/apps/docs/content/docs/self-host-quickstart.mdx @@ -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). diff --git a/apps/docs/content/docs/self-host-quickstart.zh.mdx b/apps/docs/content/docs/self-host-quickstart.zh.mdx index 42b68e6f7..cfad51ec5 100644 --- a/apps/docs/content/docs/self-host-quickstart.zh.mdx +++ b/apps/docs/content/docs/self-host-quickstart.zh.mdx @@ -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。**暂不支持** 465(SMTPS / 隐式 TLS),请使用 25 或 587。 + +**匿名 Exchange 内部 relay(端口 25)** —— 主机按 IP 被信任,不需要凭据: ```bash -SMTP_HOST=smtp.internal.example.com -SMTP_PORT=587 # 默认 25;STARTTLS 提交端口用 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`。 +**认证提交(端口 587,STARTTLS)** —— 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)。 diff --git a/apps/docs/content/docs/troubleshooting.mdx b/apps/docs/content/docs/troubleshooting.mdx index 4699e4418..6ba6bfd20 100644 --- a/apps/docs/content/docs/troubleshooting.mdx +++ b/apps/docs/content/docs/troubleshooting.mdx @@ -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 : from=` — using SMTP (`SMTP_HOST` non-empty wins over Resend) +- `EmailService: Resend API from=` — 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 :: 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 ` and `nc -vz `); 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 : 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 `@`); 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`. diff --git a/apps/docs/content/docs/troubleshooting.zh.mdx b/apps/docs/content/docs/troubleshooting.zh.mdx index b99070443..be3eb1afd 100644 --- a/apps/docs/content/docs/troubleshooting.zh.mdx +++ b/apps/docs/content/docs/troubleshooting.zh.mdx @@ -89,6 +89,20 @@ multica issue show # 看 task 历史 **症状**:登录或邀请时提交邮箱后,收件箱(和垃圾邮件)里都没有验证码邮件。 +**先确认 server 自己认为在用哪个 provider。** 启动时 backend 会打印这三种之一: + +- `EmailService: SMTP relay : from=` —— 走 SMTP(`SMTP_HOST` 非空时优先级高于 Resend) +- `EmailService: Resend API from=` —— 走 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 # 看 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 :: dial tcp …: connect: connection refused` / `i/o timeout` | backend 容器连不上 relay —— host / port 错、防火墙挡了、或者 relay 没开 | 在容器里确认能解析和连通:`docker compose -f docker-compose.selfhost.yml exec backend nslookup ` 以及 `nc -vz `;放行从 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 : 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`(日志里是 `@`)找上下文;必要时调大消息大小限制 | + +`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 B:SMTP 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`。