EnterpriseMail 域统一了 5 家企业邮箱供应商的邮件发送、读取、文件夹管理和附件操作能力。它遵循标准三层包模式,但有一个显著的结构性差异:网易企业邮箱基于 MailKit 的 IMAP/SMTP 长连接,注册为 Singleton 并用信号量串行化访问,和其他 4 家走 Typed HttpClient 的无状态调用完全不同。
域结构
Bitzsoft.Integrations.EnterpriseMail ← 抽象层├── Bitzsoft.Integrations.EnterpriseMail.Alibaba ← 阿里邮箱(Typed HttpClient)├── Bitzsoft.Integrations.EnterpriseMail.Feishu ← 飞书邮箱(Typed HttpClient)├── Bitzsoft.Integrations.EnterpriseMail.Microsoft365 ← 微软 365(Microsoft Graph)├── Bitzsoft.Integrations.EnterpriseMail.NetEase ← 网易企业邮箱(MailKit,net8+)├── Bitzsoft.Integrations.EnterpriseMail.Tencent ← 腾讯企业邮箱(Typed HttpClient)└── Bitzsoft.Integrations.EnterpriseMail.All ← 聚合包统一接口(11 方法,三组)
IEnterpriseMailProvider 把邮件能力分成三组:邮件操作、文件夹操作、附件操作。
public interface IEnterpriseMailProvider{ string ProviderName { get; }
// ① 邮件操作(6) Task<SendMailResult> SendAsync(SendMailRequest request, CancellationToken ct = default); Task<MailListResult> ListMessagesAsync(string folderId, int pageIndex = 1, int pageSize = 20, CancellationToken ct = default); Task<MailMessage> GetMessageAsync(string messageId, CancellationToken ct = default); Task<MailListResult> SearchAsync(MailSearchRequest request, CancellationToken ct = default); Task DeleteMessageAsync(string messageId, CancellationToken ct = default); Task MoveMessageAsync(string messageId, string targetFolderId, CancellationToken ct = default);
// ② 文件夹操作(3) Task<IReadOnlyList<MailFolder>> ListFoldersAsync(CancellationToken ct = default); Task<MailFolder> CreateFolderAsync(string name, string? parentFolderId = null, CancellationToken ct = default); Task DeleteFolderAsync(string folderId, CancellationToken ct = default);
// ③ 附件操作(2) Task<IReadOnlyList<MailAttachment>> ListAttachmentsAsync(string messageId, CancellationToken ct = default); Task<Stream> DownloadAttachmentAsync(string messageId, string attachmentId, CancellationToken ct = default);}接口 XML 文档明确说明:受限于供应商 API 能力,部分操作可能抛 NotSupportedException,调用方应在使用前确认供应商支持所需操作。
错误处理:无 Result 包装
EnterpriseMail 域没有 Result 包装——接口直接返回模型对象(SendMailResult / MailListResult / MailMessage),失败时抛 EnterpriseMailException。SendMailResult 用工厂方法 Ok(messageId) / Fail(errorMessage) 构造,但它只表示发送层面的成功与否,不是通用结果包装。
public sealed class EnterpriseMailException : IntegrationException{ public string ProviderName => Provider ?? string.Empty; public new string? ErrorCode => base.ErrorCode; public new string? ProviderMessage => base.ProviderMessage;
public EnterpriseMailException(string providerName, string message, string? errorCode = null, string? providerMessage = null, Exception? innerException = null) : base(domain: "EnterpriseMail", message: $"[{providerName}] {message}", ...) { }}发送邮件
SendMailRequest 支持多收件人、抄送、密送、纯文本/HTML 双正文、附件和优先级:
public sealed class SendMailRequest{ public MailAddress? From { get; init; } // 可选,部分供应商从配置读默认发件人 public IReadOnlyList<MailAddress> To { get; init; } = []; public IReadOnlyList<MailAddress> Cc { get; init; } = []; public IReadOnlyList<MailAddress> Bcc { get; init; } = []; public string Subject { get; init; } = string.Empty; public string? TextBody { get; init; } // 纯文本正文 public string? HtmlBody { get; init; } // HTML 正文 public IReadOnlyList<MailAttachmentContent> Attachments { get; init; } = []; public MailPriority Priority { get; init; } = MailPriority.Normal;}TextBody 和 HtmlBody 至少传一个,部分供应商同时支持时以 HTML 优先、纯文本作降级。
供应商清单
| 供应商 | Provider ID | 接入方式 | 生命周期 | DI 方法 |
|---|---|---|---|---|
| 阿里邮箱 | Alibaba | Typed HttpClient | Transient | AddBitzsoftAlibabaMail() |
| 飞书邮箱 | Feishu | Typed HttpClient | Transient | AddBitzsoftFeishuMail() |
| 微软 365 | Microsoft365 | Microsoft Graph | Transient | AddBitzsoftMicrosoft365Mail() |
| 网易企业邮箱 | NetEase | MailKit(IMAP + SMTP) | Singleton | AddBitzsoftNetEaseMail() |
| 腾讯企业邮箱 | Tencent | Typed HttpClient | Transient | AddBitzsoftTencentMail() |
4 家 HTTP 类供应商每次调用无状态,Typed HttpClient 管理连接池。网易是唯一的例外。
网易长连接单例
其他 4 家供应商通过 HTTP 发请求,每次调用无状态。网易基于 MailKit 的 IMAP/SMTP 协议,需要持有持久连接,因此 NetEaseMailClient 注册为 Singleton,整个进程共享一个连接管理器:
internal sealed class NetEaseMailClient : IDisposable{ private readonly ImapClient _imapClient = new(); private readonly SmtpClient _smtpClient = new(); // 两把信号量分别串行化 IMAP 和 SMTP 访问 private readonly SemaphoreSlim _imapLock = new(1, 1); private readonly SemaphoreSlim _smtpLock = new(1, 1);}
// DI 注册为 Singletonservices.TryAddSingleton<NetEaseMailClient>();连接管理的设计要点:
- 信号量串行化:
_imapLock/_smtpLock把对共享 IMAP/SMTP 客户端的并发操作串行化,因为单个 MailKit 客户端不是线程安全的。 - 双重检查连接:
ConnectAndAuthenticateAsync进入临界区后做双重检查——等待锁期间可能已有其他线程完成连接,避免重复登录。 - 异常过滤:
ex is not OperationCanceledException,确保取消不变成连接失败。 - 安全释放:
Dispose里安全断开连接并释放信号量,忽略断开过程中的异常。
注册
单厂商
// ① 阿里邮箱(Typed HttpClient)builder.Services.AddBitzsoftAlibabaMail(builder.Configuration.GetSection("EnterpriseMail:Alibaba"));
// ② 微软 365(Microsoft Graph)builder.Services.AddBitzsoftMicrosoft365Mail(builder.Configuration.GetSection("EnterpriseMail:Microsoft365"));
// ③ 网易企业邮箱(MailKit 长连接,仅 net8+)builder.Services.AddBitzsoftNetEaseMail(builder.Configuration.GetSection("EnterpriseMail:NetEase"));全量聚合
// 注意:聚合方法名是 AddBitzsoftEnterpriseMail,不带 All 后缀builder.Services.AddBitzsoftEnterpriseMail(builder.Configuration, "EnterpriseMail");聚合包按配置节存在性自动注册,appsettings.json 只需写出启用的供应商:
{ "EnterpriseMail": { "Alibaba": { "AccessKeyId": "...", "AccessKeySecret": "...", "Account": "..." }, "NetEase": { "ImapHost": "hwimap.qiye.163.com", "Username": "...", "Password": "..." } }}消费
发送邮件
public class MailSender(IEnterpriseMailProvider mail){ public async Task SendWelcomeAsync(string to, string name) { var result = await mail.SendAsync(new SendMailRequest { To = [new MailAddress(to)], Subject = $"欢迎,{name}", HtmlBody = $"<p>{name},感谢注册。</p>", Attachments = [new MailAttachmentContent { FileName = "新手指南.pdf", Content = pdfBytes }], });
if (!result.Success) throw new InvalidOperationException($"邮件发送失败: {result.ErrorMessage}"); }}读取与文件夹操作
public class MailReader(IEnterpriseMailProvider mail){ public async Task<IReadOnlyList<MailFolder>> GetFoldersAsync() => await mail.ListFoldersAsync();
public async Task<MailListResult> GetInboxAsync(int page = 1) => await mail.ListMessagesAsync("INBOX", pageIndex: page, pageSize: 20);
public async Task<Stream> DownloadAttachmentAsync(string messageId, string attachmentId) => await mail.DownloadAttachmentAsync(messageId, attachmentId);}多厂商路由
public class MailRouter(IIntegrationProviderResolver<IEnterpriseMailProvider> providers){ public async Task<SendMailResult> SendAsync(string vendor, SendMailRequest request) { var mail = providers.GetRequired(vendor); // "Alibaba" / "NetEase" / "Microsoft365" ... return await mail.SendAsync(request); }}