邮件发送
邮件发送
邮件能力已从"一个 MailHelper 类"升级为一条完整链路:配置 → 模板 → 落库 → 队列/同步发送 → 失败重试 → 审计。
| 层 | 位置 |
|---|---|
| 发送抽象 | ZR.Common/Email/EmailAbstractions.cs(IEmailSender / EmailMessage) |
| SMTP 实现 | ZR.ServiceCore/Email/SmtpEmailSender.cs + ZR.Common/Email/MailKitTransport.cs |
| 业务编排 | ZR.ServiceCore/Email/EmailSendService.cs(IEmailSendService) |
| 内存队列 | ZR.ServiceCore/Email/EmailQueue.cs(容量 10000) |
| 后台派发 | ZR.ServiceCore/Email/EmailDispatchService.cs(HostedService) |
| 模板渲染 | ZR.Common/Email/EmailTemplateRenderer.cs |
| 数据表 | email_log(发送记录)、emailTpl(模板),均落系统库 |
一、配置
MailOptions 是数组(旧文档写成对象,会导致绑定失败),支持多个发件账号,按 FromName 选取:
"MailOptions": [
{
"FromName": "system", // 唯一标识,发送时按它选配置;未匹配取第一项
"FromEmail": "noreply@example.com",
"Password": "", // 留空!生产由环境变量 MailOptions__0__Password 覆盖(见下文)
"Smtp": "smtp.qq.com",
"Port": 587, // 587=STARTTLS,465=SSL 直连
"UseSsl": true,
"Signature": "系统邮件,请勿回复!",
"Enabled": true, // false=模拟发送(只落库/记日志,不连 SMTP)
"Security": "", // 留空按 UseSsl 自动映射,见下表
"TimeoutSeconds": 30, // 连接/读写超时,最大 600
"IgnoreCertificateErrors": false, // 仅内网自签证书时开
"MaxAttachmentMB": 20, // 单封附件总大小上限,<=0 不限制
"MaxRetryCount": 3 // 失败自动重试次数(不含首次)
},
{
"FromName": "alert",
"FromEmail": "alert@example.com",
"Password": "", // 对应 MailOptions__1__Password
"Smtp": "smtp.exmail.qq.com",
"Port": 465,
"UseSsl": true
}
]
关键字段
| 字段 | 说明 |
|---|---|
FromName | 发送时用来选配置(EmailMessage.FromName),未匹配时回退数组第一项 |
Enabled | false 为模拟发送:正常落库、结果标记 simulated,但不连 SMTP,适合开发/预发环境 |
Security | Auto / StartTls / StartTlsWhenAvailable / SslOnConnect / None;留空按 UseSsl 映射为 Auto(true)或 None(false) |
Password | QQ、163、企业邮箱都要填授权码,填登录密码会返回 AUTH_FAILED |
IgnoreCertificateErrors | 默认 false;打开会跳过证书校验,有中间人风险,只在内网自签证书场景用 |
端口选择
| 端口 | 推荐配置 |
|---|---|
| 587 | Port: 587、UseSsl: true(自动 STARTTLS) |
| 465 | Port: 465、UseSsl: true、Security: SslOnConnect(自动识别 465 为 SSL 直连) |
| 25 | 多数云厂商已封禁,且明文传输,不建议 |
密码不写明文(生产必读)
仓库里的 appsettings.json 会被提交,Password 必须留空,值由环境变量 / UserSecrets / 部署配置提供。配置源合并优先级(后者覆盖前者,全部由框架启动时自动完成):
appsettings.json
< appsettings.{Environment}.json
< UserSecrets(仅 Development 加载)
< 环境变量
< 命令行参数
方式一:环境变量(推荐)
环境变量由
Program.cs的AddEnvironmentVariables()在启动时自动合并进配置,MailOptions也由IOptions<OptionsSetting>自动绑定——不需要写任何读取代码,设置好重启进程即可。但骨架字段仍要写在 appsettings 里(
FromName/FromEmail/Smtp/Port等),环境变量只覆盖字段值。纯靠环境变量提供整个数组项会因为缺字段绑定出半成品(FromEmail为空 →NO_CONFIG)。
多账号用数组下标,且下标即顺序,增删账号时要同步改:
# Linux / systemd
# /etc/zradmin/admin.env(chmod 600,属主为运行账号)
MailOptions__0__Password=QQ邮箱授权码
MailOptions__1__Password=告警邮箱授权码
# zradmin.service
[Service]
EnvironmentFile=-/etc/zradmin/admin.env
# docker compose
services:
zradmin:
env_file:
- /etc/zradmin/admin.env
# 或者直接写(注意别提交到仓库)
# environment:
# - MailOptions__0__Password=xxx
<!-- IIS:web.config -->
<aspNetCore processPath="dotnet" arguments=".\ZR.Admin.WebApi.dll" ...>
<environmentVariables>
<environmentVariable name="MailOptions__0__Password" value="授权码" />
</environmentVariables>
</aspNetCore>
# 本地调试:只对当前会话生效
$env:MailOptions__0__Password = "授权码"
dotnet run --project ZR.Admin.WebApi
环境变量不会热更新,改完必须重启进程。
方式二:appsettings.Production.json(次选)
该文件已被 gitignore,确认没被误提交也可以放这里,但注意:
- 服务器上要限制权限:
chmod 600 appsettings.Production.json; - 容器化部署不要把它打进镜像(用
docker build时排除,或运行时挂载); - 备份/分发配置时容易泄露,能力上不如环境变量 + 密钥管理系统。
方式三:开发环境用 UserSecrets
cd ZR.Admin.WebApi
dotnet user-secrets set "MailOptions:0:Password" "授权码"
dotnet user-secrets list
UserSecrets 只在 Development 环境加载,生产不生效;密钥存在用户目录(~/.microsoft/usersecrets),不进仓库。
方式四:内网 SMTP 中继,不配密码
传输层在 Password 为空时会跳过认证(if (!string.IsNullOrWhiteSpace(options.Password))),因此可以直接指向内网的 Postfix / Exchange 中继 / MailHog:
{
"FromName": "system",
"FromEmail": "noreply@corp.local",
"Password": "",
"Smtp": "192.168.1.20",
"Port": 25,
"UseSsl": false
}
前提是中继本身允许该网段匿名发信;公网邮箱(QQ/163/企业微信邮箱)都必须认证,不能这么配。
不建议的做法
| 做法 | 原因 |
|---|---|
| 把密码"加密"后写进 appsettings | 代码读取 Password 时不做解密,密文会直接被当作密码去认证 |
| 依赖 OAuth2 / XOAUTH2 | 当前只支持「用户名 + 密码」认证(用户名固定取 FromEmail) |
用 FromName 之外的手段选账号 | 只能按 FromName 匹配,匹配不到回退数组第一项 |
| 多个账号共用同一个授权码 | 一个账号被风控会连带所有通知失败,且无法定位是哪个账号 |
自测:确认密码已生效
配置是框架自动合并 + 自动绑定的,没有单独的"注入"步骤;要确认的是"改的值有没有真的被用上",直接发一封最快:
- 先把该发件配置的
Enabled置false发一封,确认链路(落库、队列、状态流转)没问题 —— 此时不连 SMTP,SendResult=simulated; - 改回
true再发一封(后台「邮件日志 → 发送」或POST /common/SendEmail); - 看结果:
- 成功:
email_log.Status=1、SendResult=ok; NO_CONFIG:MailOptions数组为空或FromEmail为空 → 不是密码问题,先补骨架配置;AUTH_FAILED:密码/授权码不对,或改完环境变量没重启;TIMEOUT:网络/端口不通(云厂商常封 25 端口)。
- 成功:
想确认"值有没有从环境变量覆盖上去",别去打印配置(会泄露密码):临时把
Smtp或FromName也改用环境变量覆盖,改完能连上就说明环境变量链路是通的。
轮换建议
- 用专用发信账号,只开 SMTP 权限,不用于人工收发;
- 授权码定期轮换:改环境变量文件 → 重启服务(无需改代码、无需重新发版);
- 每套账号一个
FromName,出问题时能从email_log.FromName直接定位。
二、代码里怎么发
业务代码不要直接 new MailHelper(),统一注入 IEmailSendService:
public class DemoService
{
private readonly IEmailSendService _emailSend;
public DemoService(IEmailSendService emailSend) => _emailSend = emailSend;
public async Task SendWelcomeAsync(string to)
{
// 1) 同步发送:调用方等待真实结果,失败不自动重试(避免用户重复点击重复投递)
var result = await _emailSend.SendNowAsync(new EmailMessage
{
FromName = "system", // 对应 MailOptions 的 FromName,可空
To = new List<string> { to },
Cc = new List<string> { "boss@example.com" },
Subject = "欢迎使用 ZRAdmin",
HtmlBody = "<p>你好,账号已开通。</p>",
Priority = EmailPriority.High,
Attachments = new List<EmailAttachment>
{
new() { FilePath = "/data/zradmin/uploads/guide.pdf" } // 只有 FilePath 能跨重启重发
}
}, emailType: "manual");
if (!result.Success)
{
// ErrorCode: NO_CONFIG / AUTH_FAILED / TIMEOUT / SMTP_xxx / ATTACHMENT_TOO_LARGE ...
}
}
public void NotifyAsync(string to, string title, string content)
{
// 2) 异步通知:落库为「待发送」并入队,立即返回,不阻塞业务
_emailSend.Enqueue(new EmailMessage
{
To = new List<string> { to },
Subject = title,
TextBody = content
}, emailType: "notify");
}
public async Task SendByTemplateAsync(string to)
{
// 3) 模板发送(同步)
var result = await _emailSend.SendTemplateNowAsync("security.alert", new[] { to },
new Dictionary<string, object>
{
["subject"] = "检测到新设备登录",
["content"] = "登录地点:杭州;如非本人操作请立即改密。"
});
// 模板不存在会返回 TEMPLATE_NOT_FOUND
}
}
| 方法 | 场景 |
|---|---|
SendNowAsync(message, emailType) | 交互式发送,等结果、失败不自动重试 |
Enqueue(message, emailType) | 通知类,落库 + 入队后立即返回 |
SendTemplateNowAsync(code, to, data, emailType, fromName) | 按模板编码同步发送 |
EnqueueTemplate(code, to, data, emailType, fromName) | 按模板编码异步发送 |
CreateDraft(dto) | 只落库为草稿(IsSend=false) |
Resend(ids) | 从发送记录批量重发 |
emailType 取值会影响后台重试与统计口径:manual(手动)/ template(模板)/ notify(通知)/ workflow(流程)/ security(安全提醒)。
历史兼容:
ZR.Common.MailHelper仍然保留(内部已统一走MailKitTransport,修掉了多附件文件名错乱、流不释放、证书无条件信任等问题),但新代码请用上面的接口,以拿到 Cc/Bcc、模板、发送记录与重试能力。
三、邮件模板
后台「邮件模板」页维护,落 emailTpl 表(系统库,所有租户共用):
| 字段 | 说明 |
|---|---|
Name | 展示名称 |
Code | 业务编码,代码按它取模板(如 workflow.notify) |
Subject | 主题模板,支持 {{key}};为空时用 Name |
Content | 正文模板,长文本 |
IsHtml | true 按 HTML 正文发送;false 按纯文本发送(可空,NULL 视为 true) |
Enabled | 停用后按 Code 取模板会跳过,回退内置默认模板(可空,NULL 视为启用) |
渲染规则
- 占位符格式
{{key}},key 区分大小写,允许字母/数字/下划线/点/短横线; - HTML 正文会对值做 HTML 编码(防昵称、流程标题注入标签),并把换行转成
<br/>; - 未提供的占位符原样保留,便于发现漏传参数,而不是静默变成空串。
内置兜底模板
数据库没配对应 Code 时用内置模板,保证通知始终能发:
| Code | 用途 |
|---|---|
workflow.notify | 工作流待办/审批等行动类通知 |
security.alert | 2FA 连续失败、新设备登录、恢复码使用等安全提醒 |
内置模板只有 {{subject}} 与 {{content}} 两个占位符;需要品牌化样式时在后台按同一 Code 建一条启用模板即可覆盖。
四、发送记录与失败重试
每次发送都会写一条 email_log(系统库,IMainDbEntity + [Tenant("0")]),后台「邮件日志」页可查、可重发。
状态机
Status | 含义 | 说明 |
|---|---|---|
0 | 待发送 | 已落库、已入队(或队列满等待扫描补发) |
1 | 成功 | SendResult=ok 或 simulated |
2 | 失败 | 可按 NextRetryTime 重试 |
3 | 发送中 | 后台已原子抢占,用于多实例防重复投递 |
4 | 草稿 | 只落库不发送,不参与自动重试 |
IsSend 是历史字段(等价于 Status==1),SendTime 未发送时写哨兵值 1900-01-01。
后台派发(EmailDispatchService)
- 宿主启动时注册:
services.AddEmailDispatch()(ZR.ServiceCore/Email/EmailServiceSetup.cs)。当前只有ZR.Admin.WebApi注册了;ZR.Front.WebApi若也承担发信(例如只部署公网宿主、或 C 端通知需要独立投递),需自行在它的Program.cs补这一行——否则该宿主入队的记录会一直停在「待发送」,要等 Admin 宿主的补偿扫描捞起来; - 启动恢复:把最近 1 天内、
EmailType非空的「待发送」记录重新入队(升级前的历史IsSend=0数据不会被误补发); - 僵死重置:
Status=3且LastAttemptTime超过 15 分钟 → 视为进程异常退出残留,重置为待发送; - 定时补偿:每 60 秒扫一次,单轮最多 200 条,处理"队列满未入队、失败到期重试、僵死记录"三类;
- 多实例安全:发送前用
状态 待发送/失败 → 发送中的原子更新抢占,影响行数为 0 说明已被别的实例处理,直接跳过。
重试策略
| 项 | 规则 |
|---|---|
| 重试次数 | 取该发件配置的 MaxRetryCount(默认 3,不含首次) |
| 退避 | 指数退避:1 分钟起,每次翻倍,最长 30 分钟 |
| 不重试的错误 | NO_CONFIG / NO_RECIPIENT / INVALID_ADDRESS / INVALID_PARAM / AUTH_FAILED / ATTACHMENT_NOT_FOUND / ATTACHMENT_TOO_LARGE / TEMPLATE_NOT_FOUND |
| SMTP 错误 | SMTP_4xx 视为临时错误会重试,SMTP_5xx 为永久错误不重试 |
| 同步发送 | 不安排自动重试(用户已看到结果,避免重复投递) |
附件注意:只有
FilePath形式的附件会写入email_log.FileUrl,进程重启后重发才能恢复;Content(字节)/Stream形式的附件仅当次有效。
落库失败时的降级
写 email_log 失败(典型原因:表结构未同步)不会阻断业务:Enqueue 会降级为后台直接发送(无记录但邮件不丢)。若日志里反复出现"邮件发送记录落库失败",执行 --initdb 或后台「数据库同步」补 email_log / emailTpl 表。
五、接口
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| GET | /system/EmailLog/list | emaillog:list | 发送记录列表 |
| GET | /system/EmailLog/{Id} | emaillog:query | 记录详情 |
| POST | /system/EmailLog/sendEmail | tool:email:send | 批量重发(IdArr),入队后由后台发送 |
| DELETE | /system/EmailLog/{ids} | emaillog:delete | 删除记录 |
| POST | /common/SendEmail | tool:email:send | 同步发送;IsSend=false 时只存草稿 |
| GET | /common/SendEmail/options | tool:email:send | 发件人下拉(只返回 FromName/FromEmail) |
| GET | /system/EmailTpl/list | tool:emailtpl:list | 模板列表 |
| GET | /system/EmailTpl/{Id} | 登录即可 | 模板详情 |
| POST | /system/EmailTpl | tool:emailtpl:add | 新增模板 |
| PUT | /system/EmailTpl | tool:emailtpl:edit | 修改模板 |
| DELETE | /system/EmailTpl/{ids} | tool:emailtpl:delete | 删除模板 |
注意:/system/EmailLog/sendEmail 是重发已有记录(不是新发邮件),新邮件请用 /common/SendEmail 或业务接口。
六、已有业务接入
| 场景 | 行为 |
|---|---|
| 双因子安全提醒 | 2FA 连续失败锁定、新设备登录、恢复码使用 → security.alert 模板,走 Security.alert 发件人(AlertMailFromName,默认 system) |
| 工作流通知 | 待办/催办/转办/委托/加签/改派 → workflow.notify,需 Workflow:Notify:EmailEnabled=true,发件人取 MailFromName |
| 租户到期提醒 | 到期阶梯提醒/停服/注销 → 租户模块的邮件通道,需先配好发件邮箱 |
| 站内信同步 | 站内信不依赖邮件;邮件未配置时静默跳过,不影响主流程 |
七、常见问题
发送失败 NO_CONFIG / 提示"请配置邮箱信息"?
MailOptions 数组为空,或数组里的 FromEmail 为空。至少配一条完整配置;FromName 对不上时会回退第一项,但 FromEmail 必须非空。
AUTH_FAILED(535 认证失败)
- 密码用了邮箱登录密码——QQ/163/企业邮箱必须用「授权码」;
- 邮箱服务端未开启 SMTP 服务(QQ 邮箱:设置 → 账号 → 开启 POP3/SMTP);
- 端口与加密方式不匹配:587 配
UseSsl=true(STARTTLS),465 配SslOnConnect。
一直"待发送"不动?
说明后台派发没跑起来:Admin 宿主已内置注册 services.AddEmailDispatch(),Front 宿主需自行确认是否补了这一行;再看日志里有没有"邮件后台派发服务已启动"。若日志提示表不存在,先补 email_log 表。
多实例会不会重复发同一封?
不会。发送前用 待发送/失败 → 发送中 的原子更新抢占,抢不到的实例直接跳过。但前提是多实例共用同一个系统库(email_log 落系统库,不是各租户库)。
附件发送成功但收件方没有附件?
- 优先用
FilePath形式,Content/Stream只在当次发送有效(异步队列重发时拿不到原内容); - 单个附件或总大小超过
MaxAttachmentMB(默认 20MB)会返回ATTACHMENT_TOO_LARGE; - 路径必须是服务器上可读的绝对路径,容器部署注意挂载。
生产环境想先不真发
把对应发件配置的 Enabled 置 false:仍会落库、仍会走队列与状态流转,只是不连 SMTP,SendResult 记为 simulated。上线前记得改回 true。
