C# 通过 EWS Managed API 读写 Exchange 邮箱与日历
简介:面向使用 C# 对接 Exchange 邮箱的开发者与运维人员,这份英文文档围绕微软 EWS Managed API 展开,重点解决服务引用、自签名证书校验、ExchangeService 初始化、凭据与 URL 配置等接入问题,并延伸到邮件读取、发送以及日历、任务、联系人管理等常见操作。资源包共 1 个 doc 文件,大小约 147KB,内容以英文技术说明和代码示例为主,适合作为 EWS 入门查阅与接口联调时的速查材料。目前已有 682 人学习,读者可从中获得从添加程序集引用、处理测试服务器证书回调,到通过 Autodiscover 或手动设置服务地址的完整思路,也能对照示例理解 FindItems、EmailMessage 发送等核心用法,为后续构建邮件管理应用打基础。
1. 一份英文手册的定位:用 C# 把 Exchange 邮箱变成可编程数据源
内部办公系统做到一定深度,邮箱集成几乎躲不开:审批流要读会议邀约,CRM 要把往来邮件挂到客户单据上,值班系统要判断某个人下午有没有空。运维通常只给一个 Exchange Web Services 端点,不给 IMAP。这时候用 C# 引用 EWS Managed API,是打通 Exchange 邮箱读写最省事的一条路。这份资料是英文的,价值在于它把「引用两个文件 → 建 ExchangeService → 绑定 Item/Folder → FindItems 分页 → 扩展属性与可用性服务」压成了一条完整链路,缺点是编排顺序不太照顾新手,很多人卡在前两页就放弃了。
适合两类人:一类是手上有个 .NET 项目,需要在现有体系里加邮件、日历、联系人同步能力;另一类是要接手别人写了一半的邮箱模块,代码里到处是FindItems、Bind、ExtendedProperty,却没有配套文档。前者照着链路走能跑通,后者更适合把这份手册当字典,遇到哪个类就翻哪一节。
2. 环境与初始化:从两个文件到一个能连通的 ExchangeService
2.1 程序集引用:dll 管运行,xml 管提示
这份资料给的其实只有两样东西:Microsoft.Exchange.WebServices.dll和同名的.xml。很多人只把 dll 拖进项目,然后抱怨写代码时一个提示都没有——.xml里存的是 XML 注释,IDE 靠它在写service.FindItems(的时候弹参数说明。两个文件必须放在同一个目录下,而且必须一起跟着 dll 走。
我把 dll 和 xml 放在项目的lib目录下,用项目文件里的Reference引入,而不是直接双击引用,这样换机器、拉代码都不会出现路径失效:
<ItemGroup> <!-- HintPath 指向仓库内路径,别用绝对路径 --> <Reference Include="Microsoft.Exchange.WebServices"> <HintPath>lib\Microsoft.Exchange.WebServices.dll</HintPath> <!-- Private=True 表示复制到输出目录,否则运行时找不到程序集 --> <Private>True</Private> </Reference> </ItemGroup>逻辑说明:Include里的名字要和程序集简单名称一致,HintPath是相对项目文件的路径,Private决定生成时是否把 dll 拷进bin。如果项目是按 NuGet 方式管理依赖,那这一步可以跳过,但仍然要确认目标框架版本与 dll 一致——老的 EWS Managed API 程序集在 .NET Core / .NET 5+ 上直接引用会因为依赖System.Web之类的程序集而报警告,常见做法是留在.NET Framework工程里跑,或者在迁移时用兼容包。踩过的坑是:在.NET Framework 4.5工程里引用了编译目标更高的 dll,编译能过,运行时抛FileLoadException,报错信息还特别含糊。
然后在代码文件顶部加命名空间,这是后面所有类型能认出来的前提:
using Microsoft.Exchange.WebServices.Data;后面用到的ExchangeService、EmailMessage、ItemView、SearchFilter、PropertySet全部来自这个命名空间,不需要额外using。
2.2 ExchangeService 实例化:凭据、版本、时区三件套
整个 API 的入口只有一个类:ExchangeService。它是个有状态的客户端对象,内部维护着 Cookie、请求序列化器、当前时区等一堆东西。我一般把它当成「一次会话」,在 Web 项目里按请求创建,在桌面或后台服务里按线程创建,不建议全局单例乱用。
using Microsoft.Exchange.WebServices.Data; using System.Diagnostics; // 指定目标 Exchange 版本,决定能用到哪一层 schema ExchangeService service = new ExchangeService(ExchangeVersion.Exchange2013_SP1); // WebCredentials 是 NetworkCredential 的包装,第三个参数是域 service.Credentials = new WebCredentials("app.user", "P@ssw0rd", "corp"); // 时区决定 Appointment.Start/End 怎么被解释,不设会跟着服务器走 service.TimeZone = TimeZoneInfo.Local; // 打开 SOAP 追踪,排查阶段必开,上线后关掉 service.TraceEnabled = true; service.TraceListener = new ConsoleTraceListener();参数说明与取舍:
ExchangeVersion这个构造函数参数不是可有可无的装饰。写低了(比如Exchange2007_SP1),有些属性拿不到,PropertySet里写了也返回空;写高了而服务器版本老,第一次请求就会抛版本不匹配。常见做法是选一个略低于生产环境的版本,比如目标环境是 Exchange 2013 就写Exchange2013_SP1,兼容余量足够。
Credentials有三种写法。域内机器上跑的服务,可以完全省略不设,让它走当前登录身份(CredentialCache.DefaultCredentials);跨域或独立账号,用WebCredentials传账号、密码、域;也可以直接用NetworkCredential,两者等价,WebCredentials只是带了个域参数的便捷包装。密码别硬编码在源码里,配置文件读取或密钥管理服务是基本操作。
TimeZone这一行经常被忽略,然后出现「日历里明明是下午三点,程序读出来是早上七点」这类玄学问题。Appointment.Start返回的是DateTime,它的Kind和这里设置的时区强相关,涉及跨时区会议时尤其明显。
TraceEnabled是我最推荐先打开的东西。打开后,每一次 EWS 调用都会把完整的 SOAP 请求体和响应体打出来,ServiceResponseException里那些看不懂的ErrorCode,对着 SOAP 报文一看基本就明白了。线上一定要关,一个带大量附件日期的响应体能打出几十 KB 日志。
如果要代表另一个用户操作邮箱,还需要一行模拟声明:
// 以目标用户身份操作邮箱,服务账号需要被授予 ApplicationImpersonation 角色 service.ImpersonatedUserId = new ImpersonatedUserId( ConnectingIdType.SmtpAddress, "target.user@example.com");这是批量处理多个邮箱的标准做法,比给每个用户存一套密码安全得多,前提是管理员在服务端把权限配好。
2.3 端点定位:Autodiscover 的回调别写成无条件返回 true
服务对象建好之后还差一个Url。这份资料给了两条路:手动写 URL,或者走 Autodiscover 自动发现。推荐自动发现,理由是同一个邮箱在不同部署阶段可能指向不同的 Client Access 服务器,硬编码的 URL 早晚会失效。
自动发现有一个必须理解的细节:它内部会做一系列 DNS 查询和 HTTP 探测,过程中可能出现重定向。默认情况下,AutodiscoverUrl一旦发现有重定向就直接抛异常。要允许跟随重定向,必须传一个 URL 校验回调。
// 校验回调:只放行 https 且属于受信域的地址 private static bool RedirectionUrlValidationCallback(string redirectionUrl) { if (!Uri.TryCreate(redirectionUrl, UriKind.Absolute, out Uri uri)) return false; return uri.Scheme == Uri.UriSchemeHttps && (uri.Host.Equals("example.com", StringComparison.OrdinalIgnoreCase) || uri.Host.EndsWith(".example.com", StringComparison.OrdinalIgnoreCase)); } // 第二个参数就是上面的回调 service.AutodiscoverUrl("user@example.com", RedirectionUrlValidationCallback);逻辑说明:回调收到的是待跳转的 URL 字符串,返回true表示允许跟随,false表示拒绝并抛异常。判断逻辑必须是白名单式的——校验协议是不是 https,校验域名是不是自己的域。原资料里明确写了「永远不要无条件返回 true」,这不是客套话:无条件放行等于把「你信任哪个服务器」这件事交给 DNS 的去决定,一旦解析被污染,客户端会老老实实把凭据发到一个陌生地址。
另一个要点是「二选一」:要么手动设service.Url,要么调AutodiscoverUrl,两个都做没有意义,后者会覆盖前者。手动方式适合内网部署明确、不希望产生额外 DNS 探测的场景:
// 已知 EWS 地址时的直连写法 service.Url = new Uri("https://mail.example.com/ews/exchange.asmx");自动发现的结果可以缓存。首次发现成功后把 URL 存进配置或内存缓存,后续复用;等出现连接失败,再重新发现一次。这样既避免了每次请求都做一次发现握手,又保留了端点变更时的自愈能力。
3. 对象模型拆解:Item/Folder 类层级与 Bind 的两种绑定姿势
3.1 类层级:EmailMessage、Appointment、Contact 各管哪摊事
EWS Managed API 把 Exchange 里的东西抽象成两棵树:Item 树和 Folder 树。Item 树的根是Item,下面是EmailMessage、Appointment、Contact、Task、PostItem、ContactGroup,以及会议相关的MeetingMessage家族(MeetingRequest、MeetingResponse、MeetingCancellation)。文件夹树的根是Folder,常见子类是CalendarFolder、ContactsFolder、SearchFolder。
这层继承不是摆设,它决定了你拿到的对象上有哪些属性可用:
| 类型 | 典型属性 | 对应 Exchange 对象 |
|---|---|---|
EmailMessage | Subject、Body、ToRecipients、Sender、IsRead | 邮件 |
Appointment | Start、End、Location、RequiredAttendees、Recurrence | 日历事件 / 会议 |
Contact | DisplayName、EmailAddresses、PhoneNumbers、Birthday | 联系人 |
Task | Subject、Status、DueDate、PercentComplete | 待办 |
MeetingRequest | Start、End、Organizer | 收到的会议邀请 |
基类Item上有一批通用属性:Id、ParentFolderId、ItemClass、Subject、DateTimeReceived、HasAttachments、Categories、Size、WebClientReadFormQueryString。最后这个属性挺实用,能直接拼出一个可在浏览器里打开该邮件的链接,做通知类功能时省事。
3.2 Bind:用 Id 换回一个完整对象
EWS 里几乎所有操作都是「先绑定,再操作」。绑定就是拿着唯一标识去服务器把对象拉回来。如果已经知道类型,直接EmailMessage.Bind:
// 用唯一标识绑定一封已知类型的邮件 EmailMessage message = EmailMessage.Bind(service, new ItemId(uniqueId)); // 绑定后属性是按需加载的,直接读 Sender 会触发一次额外请求 Console.WriteLine(message.Sender.Name);如果手里只有 Id、不知道它对应什么类型,就绑到基类Item上再做类型判断:
// 绑定到基类,再由运行时类型决定怎么处理 Item item = Item.Bind(service, new ItemId(uniqueId)); if (item is EmailMessage mail) { Console.WriteLine(mail.Sender.Name); } else if (item is Appointment appointment) { Console.WriteLine(appointment.Start); } else { Console.WriteLine($"未处理的类型:{item.ItemClass}"); }逻辑说明:ItemId是 EWS 对条目唯一标识的封装,字符串形式通常是 base64 编码的一长串。Bind内部会发一次GetItem请求,默认只返回第一类属性(FirstClassProperties),正文、附件这类重字段是按需加载的。这一点在循环里特别关键——如果绑一万封邮件还每封都读Body,那就是一万次额外往返。
文件夹同理,两种绑法:
// 按已知 Id 绑定文件夹 Folder folder = Folder.Bind(service, new FolderId(folderId)); // 按众所周知的名称绑定,不需要知道 Id Folder inbox = Folder.Bind(service, WellKnownFolderName.Inbox);WellKnownFolderName这个枚举覆盖了Inbox、Calendar、Contacts、Tasks、SentItems、DeletedItems、Drafts、JunkEmail、ArchiveRoot等标准文件夹,日常开发用它的频率远高于拼 Id。
3.3 最小收发闭环:发信、回复、转发、删除、建文件夹
发一封邮件最短只要五行。SendAndSaveCopy表示「发送并保存到已发送」:
EmailMessage message = new EmailMessage(service); message.Subject = "Hello world!"; message.Body = "Sent using the EWS Managed API."; message.ToRecipients.Add("someone@example.com"); // 发送并存入 Sent Items;只想发送不存档就用 Send() message.SendAndSaveCopy();回复有两种粒度。第一种是一句话直接回,第二个参数控制是否回复全部:
public void ReplyToMessage(EmailMessage messageToReplyTo, string replyText) { // 简单回复:正文追加在原文上方,replyAll=true 表示回复全部 messageToReplyTo.Reply(replyText, true); // 需要精细控制时,先创建回复草稿,改完再发 ResponseMessage response = messageToReplyTo.CreateReply(true); response.BodyPrefix = replyText; response.CcRecipients.Add("someone@example.com"); response.SendAndSaveCopy(); }转发对应Forward/CreateForward,用法与回复完全对称。区别在于Forward必须传收件人,因为原邮件没有「转发目标」这个概念。
删除的关键在DeleteMode。SoftDelete进「可恢复项目」,用户能在 Outlook 里找回来;HardDelete是直接抹掉,不进回收站;还有一个MoveToDeletedItems是移到已删除文件夹:
// 彻底删除,不进回收站,谨慎使用 message.Delete(DeleteMode.HardDelete);新建文件夹也是「实例化 → 设名 → 保存到父文件夹」:
Folder folder = new Folder(service); folder.DisplayName = "归档 2024"; // 保存时指定父文件夹,这里是建在收件箱下面 folder.Save(WellKnownFolderName.Inbox);3.4 Appointment 到 Meeting:一次保存和两次保存
这是资料里讲得比较细、但新手最容易搞混的地方。Appointment本身只是个日程条目,只有当它带上参会人之后才变成会议。
Appointment appointment = new Appointment(service); appointment.Subject = "周会"; appointment.Body = "每周同步"; appointment.Start = new DateTime(2024, 1, 2, 18, 0, 0); appointment.End = appointment.Start.AddHours(2); // 每周二、四重复,间隔 2 周 appointment.Recurrence = new Recurrence.WeeklyPattern( new DateTime(2024, 1, 2), 2, DayOfWeek.Tuesday, DayOfWeek.Thursday); appointment.Save(); // 此时再添加参会人,把它升级成会议 appointment.RequiredAttendees.Add("someone@example.com"); appointment.Update(ConflictResolutionMode.AutoResolve);WeeklyPattern的参数依次是:起始日期、周间隔(每几周一次)、主星期几、以及可变参数的多个星期几。ConflictResolutionMode.AutoResolve告诉服务器遇到并发修改时自动合并,避免抛冲突异常——多人日历系统里这个参数基本必加。
参会人也可以在创建时一次性加上,省掉第二次Update。分两步的原因通常业务上是「先占位,再确认参会人」,技术上都可行。需要注意的是:一旦有了参会人,再改时间或地点就会触发会议邀请的重新发送,用户手机上会出现「会议已更新」的通知,做批量脚本时这一点很重要。
4. 查询与分页:FindItems、SearchFilter、CalendarView 的组合用法
4.1 ItemView 分页与 PropertySet 瘦身
FindItems返回的是FindItemsResults<Item>,里面有两个关键成员:Items是当前页数据,MoreAvailable表示后面还有没有。分页靠ItemView.Offset推进:
public void PageThroughEntireInbox(ExchangeService service) { const int pageSize = 50; ItemView view = new ItemView(pageSize) { // 只取 Id 和标题,别把正文一起拉回来 PropertySet = new PropertySet(BasePropertySet.IdOnly, ItemSchema.Subject) }; FindItemsResults<Item> results; int pageIndex = 0; do { results = service.FindItems(WellKnownFolderName.Inbox, view); Console.WriteLine($"第 {++pageIndex} 页,{results.Items.Count} 条"); foreach (Item item in results.Items) { // 这里的 item 只有 Id 和 Subject 两个字段可用 Console.WriteLine(item.Subject); } view.Offset += pageSize; } while (results.MoreAvailable); }逻辑说明:Offset是「跳过多少条」,不是页码,所以每次递增必须是pageSize本身。PropertySet里的BasePropertySet.IdOnly表示只取标识,再显式加需要的那几个属性,这能显著减少响应体大小。如果写成BasePropertySet.Default,返回的是第一类属性全集,一封带长正文的邮件能量级能达到几十 KB,五千封就是灾难。
MoreAvailable是判断终止的唯一可靠依据。不要用results.TotalCount去推导页数——有些服务器版本下TotalCount返回 0,有些受查询复杂度限制会截断,用它做循环条件会翻车。
4.2 SearchFilterCollection:把条件拼成 WHERE
FindItems的第二个参数是搜索过滤器,可以单个,也可以组合。组合时用SearchFilterCollection加逻辑运算符:
public void FindItems(ExchangeService service) { ItemView view = new ItemView(10); // 按接收时间升序 view.OrderBy.Add(ItemSchema.DateTimeReceived, SortDirection.Ascending); // 只返回 Id、标题、接收时间三个字段 view.PropertySet = new PropertySet( BasePropertySet.IdOnly, ItemSchema.Subject, ItemSchema.DateTimeReceived); // 标题包含 EWS 或 API,两个条件取或 var filter = new SearchFilter.SearchFilterCollection( LogicalOperator.Or, new SearchFilter.ContainsSubstring(ItemSchema.Subject, "EWS"), new SearchFilter.ContainsSubstring(ItemSchema.Subject, "API")); FindItemsResults<Item> results = service.FindItems( WellKnownFolderName.Inbox, filter, view); Console.WriteLine($"命中总数:{results.TotalCount}"); foreach (Item item in results) { Console.WriteLine($"{item.DateTimeReceived:yyyy-MM-dd HH:mm} {item.Subject}"); } }逻辑说明:SearchFilterCollection的第一个参数是LogicalOperator.Or或And,后面跟任意多个SearchFilter子类实例。常用的几种:IsEqualTo、IsGreaterThan、IsLessThan、ContainsSubstring、Exists、Not。OrderBy最多可以加几个字段,服务器会按顺序排序。
有个容易忽略的点:ContainsSubstring匹配的是「包含子串」,不是全文检索,它对邮件正文无效,只对指定属性做匹配。想搜正文得用另一种方式(SearchFilter本身不支持全文,需要走service.FindItems之外的查询接口)。另外TotalCount在复杂过滤条件下可能不准,只用来打日志,不要拿来做业务判断。
4.3 CalendarView:查日历不能直接套 ItemView
查日历和查邮件是两套视图。日历必须用CalendarView,因为它要做周期展开——一个「每周二重复」的会议在查询区间内会出现多次,服务器负责把这些实例展开成独立条目:
public void FindAppointments(ExchangeService service, DateTime start, DateTime end) { // 先绑定到日历文件夹 CalendarFolder calendar = CalendarFolder.Bind(service, WellKnownFolderName.Calendar); // CalendarView 的时间跨度不宜过大,按月切分更稳 FindItemsResults<Appointment> results = calendar.FindAppointments(new CalendarView(start, end)); foreach (Appointment appointment in results.Items) { Console.WriteLine($"{appointment.Start} - {appointment.End} {appointment.Subject}"); } }CalendarView的两个参数是查询区间的起止。这里的区间建议按周或按月切分,一次拉一整年会返回海量实例,还容易触发服务器端的查询复杂度限制,表现是返回不完整但不报错——这种静默丢数据最难查。
CalendarFolder不能通过service.FindItems直接查,必须先Bind成CalendarFolder再调FindAppointments,这是类型层面的约束。
可用性服务是另一个独立入口,用来查别人忙不忙、以及系统推荐什么时间开会:
List<AttendeeInfo> attendees = new List<AttendeeInfo> { new AttendeeInfo("someone@example.com"), new AttendeeInfo("someoneelse@example.com") }; // 查未来 12 小时的忙闲和会议时间建议 GetUserAvailabilityResults results = service.GetUserAvailability( attendees, new TimeWindow(DateTime.Now, DateTime.Now.AddHours(12)), AvailabilityData.FreeBusyAndSuggestions); foreach (Suggestion suggestion in results.Suggestions) { Console.WriteLine($"建议日期:{suggestion.Date},质量:{suggestion.Quality}"); foreach (TimeSuggestion timeSuggestion in suggestion.TimeSuggestions) { Console.WriteLine($" 建议时间:{timeSuggestion.MeetingTime}"); } }这个接口的好处是不需要目标用户授权,只要服务账号有相应权限就能查——比自己遍历每个人的日历去算空档靠谱得多。
4.4 ExtendedProperty:给邮件打业务标签
业务系统经常需要在邮件上挂自定义字段,比如「关联工单号」。EWS 的解法是扩展属性。定义一次,复用到处:
// GUID 必须自己生成,绝不能照抄示例里的值 Guid propertySetId = new Guid("8f1c2d3e-4a5b-6c7d-8e9f-0a1b2c3d4e5f"); ExtendedPropertyDefinition ticketDef = new ExtendedPropertyDefinition(propertySetId, "TicketNo", MapiPropertyType.String); EmailMessage message = new EmailMessage(service); message.Subject = "带扩展属性的邮件"; message.SetExtendedProperty(ticketDef, "WO-20240102-001"); message.Save();读取时要把这个定义塞进PropertySet,否则服务器不会返回它:
PropertySet propertySet = new PropertySet( BasePropertySet.FirstClassProperties, ticketDef); EmailMessage bound = EmailMessage.Bind(service, new ItemId(uniqueId), propertySet); foreach (ExtendedProperty ext in bound.ExtendedProperties) { Console.WriteLine($"{ext.PropertyDefinition.Name} = {ext.Value}"); }这里的血泪经验是 GUID:资料里那串示例 GUID 是公开的,所有照抄的人都会撞在同一个命名空间里,最后不同系统的扩展属性互相污染。每个应用必须生成自己的 GUID,且一旦上线就不能改,改了等于历史数据全部读不出来。这属于典型的「上线前不设后悔药,上线后只能背锅」的设计点。
5. 避坑与排查:证书、自动发现、分页相关的五个翻车现场
5.1 连接层:证书和自动发现最容易出问题
现象一:连测试环境时报证书不受信任。
第一次连测试服务器,请求直接抛异常,提示底层连接已关闭,或者 SSL/TLS 信任关系无法建立。原因很明确:测试环境用的是自签名证书,.NET Framework 默认不信任。
using System.Net.Security; using System.Security.Cryptography.X509Certificates; System.Net.ServicePointManager.ServerCertificateValidationCallback = delegate (object obj, X509Certificate certificate, X509Chain chain, SslPolicyErrors errors) { // 只放过预期的那张证书的指纹,别写死 return true var cert2 = certificate as X509Certificate2; return cert2 != null && cert2.Thumbprint == "测试证书指纹"; };注意:网上流传的写法是回调里直接return true,本地调试没问题,一旦这段代码跟着发布包进了生产,整个 HTTPS 校验就形同虚设。我一般会在编译期加个条件编译符号,让这段代码只在 Debug 下生效,Release 下压根不注册回调。
现象二:AutodiscoverUrl抛异常说检测到重定向。
原因是没传校验回调,而服务器返回了一个跳转。解决就是 2.3 节那个白名单回调。这里还有一个派生问题:回调写得过于严格(比如只允许固定主机名),在服务器做负载切换后全部失败。合理的粒度是按域后缀放行,而不是按完整主机名。
现象三:模拟身份不生效,读写到的还是服务账号自己的邮箱。
ImpersonatedUserId设了,但结果不对。八成是服务端没给这个账号授予 ApplicationImpersonation 角色,或者授予的范围没覆盖目标用户所在的数据库。判断方法很简单:把TraceEnabled打开,看 SOAP 头里有没有带上 impersonation 元素——带上了说明客户端没问题,那就是服务端权限没配。
5.2 数据层:分页、字段和时区
现象四:全量遍历收件箱,跑一段时间卡死或漏数据。
分页循环里view.Offset += 1写错是最常见的,那会变成逐条翻页,几万封邮件要跑一整天。另一个更隐蔽的问题是:遍历期间有新人往收件箱投递邮件,Offset分页会整体偏移,导致某些邮件被跳过或者重复处理。解决办法是记录已处理的最大DateTimeReceived,用时间过滤代替纯 Offset 翻页,或者干脆把处理做成幂等。
现象五:Appointment.Start读出来和 Outlook 里看到的时间差了几个小时。
DateTime的Kind属性在 EWS 里非常关键。服务器返回的时间可能带时区偏移也可能不带,客户端TimeZone设错的话,界面上就会差几个小时。经验做法是:ExchangeService.TimeZone明确设成本地时区,所有Appointment时间在入库前统一转成 UTC,显示时再转回本地。永远不要在业务层直接比较两个来源不同的DateTime。
现象六(补充):绑定后读Body得到 null。
Bind默认只取第一类属性,正文、附件集合属于需要显式请求的内容。要么在Bind时传一个带EmailMessageSchema.Body的PropertySet,要么调message.Load(new PropertySet(EmailMessageSchema.Body))单独加载。这个坑在循环里最致命,每一条都在偷偷多打一次请求。
6. 二次开发:把散落的示例代码收成一个可复用服务层
资料里的代码全是碎片:一段发信、一段查询、一段建文件夹。真正落到项目里,我会先把它们收进一个服务类,把「创建连接」「重试」「异常分类」三件事管住,业务代码只负责传参和接结果。
public sealed class MailboxClient : IDisposable { private readonly ExchangeService _service; public MailboxClient(string user, string pwd, string domain) { _service = new ExchangeService(ExchangeVersion.Exchange2013_SP1) { Credentials = new WebCredentials(user, pwd, domain), TimeZone = TimeZoneInfo.Local, KeepAlive = false }; } // 统一入口:自动发现一次并缓存 URL public void Connect(string smtpAddress, bool allowRedirectCallback = false) { if (allowRedirectCallback) _service.AutodiscoverUrl(smtpAddress, RedirectionUrlValidationCallback); else _service.AutodiscoverUrl(smtpAddress); } // 把"绑定+加载正文"的重复写法收口 public EmailMessage GetMessage(string itemId, bool withBody = false) { PropertySet ps = withBody ? new PropertySet(BasePropertySet.FirstClassProperties, EmailMessageSchema.Body) : new PropertySet(BasePropertySet.FirstClassProperties); return EmailMessage.Bind(_service, new ItemId(itemId), ps); } public void Dispose() => _service?.Dispose(); }异常处理是二次开发里最该做的一件事。EWS 抛出的异常分三类:ServiceResponseException是服务器明确拒绝(权限、找不到对象、属性非法),重试没用,要按ErrorCode做映射;ServiceRequestException是请求层面的失败(超时、连接断开),值得退避重试;ServiceLocalException是客户端用法错了,比如没设 URL 就调用,属于代码 bug。三者的处理策略完全不同,混在一起catch (Exception)只会让排查变成黑匣子。
验证封装是否正确,我通常用三个小动作:先用ListFirstTenItems拉一封标题,确认连接和权限都对;再用一次带SearchFilter的查询确认过滤生效;最后用一次GetUserAvailability确认跨用户的场景没被权限挡住。三步都过,这个服务层基本可以交给业务同事用了。
从那以后我每次封装这类客户端,都强制先打开TraceEnabled跑一遍冒烟三步,再关掉上线。希望帮到你。
本文还有配套的精品资源,点击获取