首页 / 资讯中心 / 文章详情

C#操作Exchange邮箱:EWS Managed API入门与实战指南

C#操作Exchange邮箱:EWS Managed API入门与实战指南 ★ FEATURED ARTICLE
简介这是一份面向C#开发者的英文技术文档系统讲解如何借助微软EWS Managed API访问与操作Exchange邮箱解决邮件收发、日历管理、联系人同步等场景的二次开发需求。文档从引用Microsoft.Exchange.WebServices.dll与相应命名空间开始逐步说明测试服务器自签名证书的验证回调处理、ExchangeService对象的构建与凭据设置以及手动指定URL或使用Autodiscover自动发现服务端点的两种连接方式并配有查找收件箱邮件、发送邮件、操作日历任务等实际代码示例。资源包以单个doc文档呈现共1个文件压缩包大小147KB内容聚焦核心API用法兼顾证书排错与端点选择思路适合具备基础C#知识、希望快速上手Exchange集成的开发者参考。该资源目前已有681人学习下载具有较强的实践参考价值。1. 直接用C#操作Exchange邮箱一套EWS Managed API就够当内部系统需要自动读收件箱、按模板回复邮件、把日历忙闲同步到业务页面时拿Exchange EWS来对接比走SMTP/IMAP更顺手因为邮件、日历、联系人、任务全在同一个对象模型里。这份英文版入门指南把EWS Managed API的用法拆成了可以直接编译的C#片段从引用一个dll开始到创建ExchangeService连接再到发信、建周期约会、查忙闲每一步都有对应的代码。适合正在给公司Exchange做自动化的.NET开发者也适合想快速评估EWS方案可行性的同学。2. 把EWS Managed API接入C#项目引用程序集与三种连接配置2.1 程序集引用与命名空间两个文件就够整个EWS Managed API实际只有一个核心程序集Microsoft.Exchange.WebServices.dll外加一个提供给Visual Studio做智能提示的配套XML文件。这两个文件放哪、怎么引用决定了项目能不能顺利编译。我习惯在解决方案里建一个lib目录把dll和xml按版本号分文件夹放好然后在项目里通过浏览方式添加引用。这样做比直接拖进bin目录清爽以后升级版本时只需要改引用路径不用动源码。引用成功后在代码文件顶部写入命名空间using Microsoft.Exchange.WebServices.Data;这行using要放在所有需要操作EWS的.cs文件顶部。dll和xml必须放在同一目录下否则Visual Studio的智能感知会失效代码还是能编译但写属性时没有自动补全效率会明显下降。xml文件只在设计期起作用发布上线时甚至可以不用拷贝到服务器。2.2 创建ExchangeService凭据、URL与Autodiscover的取舍ExchangeService是EWS所有操作的入口相当于一个封装好的HTTP客户端。创建它很简单但连接参数怎么配直接决定后面能不能连通。ExchangeService service new ExchangeService(); // 显式传域账号适用于服务账号运行或非域内机器 service.Credentials new WebCredentials(username, password, domain); // WebCredentials只是NetworkCredential的封装两种写法等价 service.Credentials new NetworkCredential(username, password, domain);在域内一台已登录的开发机上不设置凭据时EWS会沿用当前Windows身份调试阶段很省事。但后台服务跑的任务往往使用独立账号显式传凭据才是稳妥做法。WebCredentials是EWS包自带的类型内部就是对NetworkCredential的包装二者可以互换我统一用WebCredentials少引一个命名空间。URL的指定方式有两种取舍要看你的运行环境// 方式一手动指定EWS端点地址 service.Url new Uri(https://mail.example.com/EWS/Exchange.asmx); // 方式二通过Autodiscover自动发现 service.AutodiscoverUrl(someoneexample.com);手动指定适合已经知道端点地址、且Exchange拓扑不频繁变化的环境Autodiscover会根据邮箱地址自动解析出最合适的EWS端点管理员调整了前端服务器后URL变了也不用改代码。我一般把Autodiscover返回的URL缓存到配置文件里定期间隔重新探测一次兼顾启动速度和端点时效性。特别要注意手动设置Url和调用AutodiscoverUrl是互斥的两个都来会导致行为不确定代码里只保留一个分支。提示Autodiscover默认不跟随HTTP重定向一旦检测到重定向就会抛异常。需要跟随重定向时必须传入校验回调并且只允许https的地址。private static bool ValidateRedirectionUrlCallback(string url) { // 只放行https重定向http的一律拒绝 return url.StartsWith(https://); } service.AutodiscoverUrl(someoneexample.com, ValidateRedirectionUrlCallback);这个回调的返回值直接决定本次重定向是否被跟随。无条件return true是危险写法DNS劫持场景下Autodiscover请求可能被导到伪造的服务器凭据直接泄露。我在回调里还会加一层域名白名单URL同时满足https和内部域后缀才放行。ExchangeService上还有容易忽略的UserAgent属性。有些Exchange网关会按UserAgent做流量治理或日志分组默认的EWS标识有时会被误伤。项目初始化时顺手设置出问题后查服务端日志也方便定位。service.UserAgent InternalTools/1.0;这个值只影响请求头不影响认证逻辑。不设置的话多个程序共用同一个Exchange时服务端日志里很难区分请求来自哪个客户端。2.3 测试服务器自签名证书证书回调的正确写法测试环境通常不装正式CA证书而是用自签名证书部署Exchange前端。.NET默认的SSL校验不信任自签名证书直接连接会报“基础连接已经关闭无法为SSL/TLS安全通道建立信任关系”连Autodiscover阶段都过不去。解决思路是挂一个进程级的证书校验回调让自签名证书能完成握手。using System.Net.Security; using System.Security.Cryptography.X509Certificates; System.Net.ServicePointManager.ServerCertificateValidationCallback (obj, certificate, chain, errors) { // 测试环境临时放行生产环境不要无条件返回true return true; };这段代码挂的是进程级回调影响范围是整个应用程序里的所有HTTPS请求不只是EWS。我会把它收敛在一个初始化方法里用配置项控制只在测试环境生效。更严谨的做法是在回调里拿certificate对象对比测试服务器的证书指纹只有指纹匹配才放行。如果项目跑在.NET Framework 4.7.2之前的版本上还需要显式提升TLS版本否则新版Exchange会直接拒绝TLS 1.0的握手。System.Net.ServicePointManager.SecurityProtocol SecurityProtocolType.Tls12;这一行加在初始化最前面能避免一类“连接被服务器重置”的诡异报错。如果加了之后日志里仍是握手失败优先查服务端的TLS策略配置确认实际开放的协议版本。3. 操作邮件、日历与通讯组从Binding到发送的实战代码3.1 Item与Folder的类层次先搞懂对象模型再写代码EWS Managed API把Exchange里的每一种数据都映射成了C#类。EmailMessage代表邮件Appointment代表约会和会议Task、Contact、ContactGroup、PostItem各有对应。这些类都继承自ItemItem再往上继承自ServiceObject。会议相关的MeetingRequest、MeetingResponse、MeetingCancellation则都是EmailMessage的子类。这个继承关系决定了三个事情Bind方法能返回什么类型、FindItems返回集合时能用哪些属性、以及什么时候该做类型判断。处理混合类型的文件夹时用Item统一接收再按实际类型分支处理明确类型时直接用EmailMessage或Appointment声明变量代码更简洁。我先讲清楚这一层后面写起来会顺很多。3.2 绑定已知ID的邮件或文件夹拿到一条邮件的唯一标识后想读取它的完整内容Bind是最直接的方式。// 已知消息ID绑定为EmailMessage EmailMessage message EmailMessage.Bind(service, new ItemId(uniqueMessageId)); Console.WriteLine(message.Sender.Name);Bind会向服务器发起一次请求把所有第一类属性一次性加载到内存。之后直接访问Subject、Body、Sender这些属性就不会再产生网络调用。如果不确定这个ID对应的对象类型就用Item.Bind再逐个判断。Item item Item.Bind(service, new ItemId(uniqueMessageId)); if (item is EmailMessage email) { Console.WriteLine(email.Sender.Name); } else if (item is Appointment appointment) { Console.WriteLine(appointment.Start); } else { // 其他类型按业务需要处理 }这种多类型共存的写法在共享收件箱场景里很实用比如一个公共邮箱里既有普通邮件又有会议请求。文件夹的绑定方式几乎一样。// 绑定Inbox系统文件夹 Folder inbox Folder.Bind(service, WellKnownFolderName.Inbox); // 绑定已知ID的文件夹 Folder customFolder Folder.Bind(service, new FolderId(uniqueFolderId));WellKnownFolderName枚举覆盖Inbox、Calendar、Tasks、Contacts这些系统文件夹不需要知道ID就能直接取到。业务自己创建的文件夹通常先查ID再绑定。3.3 发送、回复、转发与删除邮件发送一封新邮件是这套API里最简单也最典型的操作。EmailMessage message new EmailMessage(service); message.Subject Hello world!; message.Body Sent using the EWS Managed API.; message.ToRecipients.Add(someoneexample.com); message.SendAndSaveCopy();SendAndSaveCopy会发送邮件同时在发件箱保存一份副本如果不留底改成调用Send()。ToRecipients是RecipientCollection类型除了Add邮箱地址还可以Add带显示名的重载这样收件箱里能看到姓名而不只是地址。回复邮件的标准做法是先用CreateReply再补内容。ResponseMessage responseMessage messageToReplyTo.CreateReply(true); responseMessage.BodyPrefix 根据你的问题我的回复如下\n; responseMessage.CcRecipients.Add(someoneexample.com); responseMessage.SendAndSaveCopy();CreateReply里的bool参数为true表示回复所有人false只回复发件人。BodyPrefix会追加到原邮件正文的上方原邮件正文保留在下方这是Exchange客户端回复时的常见格式。转发用Forward()或CreateForward()写法和回复一致区别只是收件人需要自己指定。删除操作只有一个Delete方法但模式要选对。message.Delete(DeleteMode.HardDelete);DeleteMode有三个值HardDelete直接从Exchange数据库中物理清除不可恢复SoftDelete进可恢复项管理员还能捞回来MoveToDeletedItems就是普通删除进回收站。业务代码里我默认用MoveToDeletedItems只有明确要求不留痕迹的场景才用HardDelete。3.4 创建周期约会与邀请与会者日历场景的难点不在创建单次约会而在周期规则。Appointment appointment new Appointment(service); appointment.Subject 每周技术评审; appointment.Body 例行评审请提前准备; appointment.Start new DateTime(2024, 1, 1, 18, 0, 0); appointment.End appointment.Start.AddHours(2); // 每两周的周二和周四重复 appointment.Recurrence new Recurrence.WeeklyPattern( new DateTime(2024, 1, 1), 2, DayOfWeek.Tuesday, DayOfWeek.Thursday); appointment.Save();Recurrence.WeeklyPattern第一个参数是模式开始日期第二个是间隔周数后面是变长的星期几参数传一个或多个DayOfWeek。模式开始日期最好与Start保持一致如果两边不一致Exchange会按模式开始日期推算后续发生实例结果可能和你预期的首次开会时间对不上。到这里约会还只是日历项没有参与者。加上与会者并更新一次它才变成会议。appointment.RequiredAttendees.Add(someoneexample.com); appointment.Update(ConflictResolutionMode.AutoResolve);RequiredAttendees中的地址会收到会议邀请。Update里的ConflictResolutionMode指定与会者时间冲突时如何处理AutoResolve让Exchange自动解决符合多数内部场景。想创建时就带参会者也可以在第一次Save前就Add进去一次保存完成。3.5 创建个人通讯组列表与文件夹个人通讯组列表存在当前用户的联系人文件夹里适合工具内部引用一组固定收件人。DistributionList distributionList new DistributionList(service); distributionList.DisplayName 项目团队; distributionList.Members.Add(new DistributionListMember(测试用户A, user.aexample.com)); distributionList.Save();它和Exchange全局通讯组是两回事创建、修改都只影响当前邮箱不产生AD对象。成员用DistributionListMember表示构造函数里可以同时填显示名和SMTP地址。新建文件夹的操作更简单指定父文件夹后保存。Folder folder new Folder(service); folder.DisplayName 自动归档; folder.Save(WellKnownFolderName.Inbox);保存后folder.Id会被赋上服务器返回的标识后续改名、移动、删除都靠这个ID。如果要在自定义文件夹下再建子文件夹把Save的参数换成该文件夹的FolderId即可。4. 搜索与分页从Inbox前十条到复杂筛选4.1 FindItems基础ItemView与分页搜索是EWS使用频率最高的功能。最简单的场景是取收件箱前十条。FindItemsResultsItem findResults service.FindItems( WellKnownFolderName.Inbox, new ItemView(10)); foreach (Item item in findResults.Items) { Console.WriteLine(item.Subject); }ItemView控制单次返回条数上面这个写法默认从第0条开始取。FindItems返回的集合里是Item基类Subject、DateTimeReceived这类公共属性可以直接访问需要读收件人这类邮件专属属性仍要类型判断后转成EmailMessage后面避坑章节会展开说。邮箱条目多了以后分页取全量是个绕不开的问题。ItemView view new ItemView(50); FindItemsResultsItem findResults; do { findResults service.FindItems(WellKnownFolderName.Inbox, view); foreach (Item item in findResults.Items) { // 逐条处理比如写入本地数据库 } view.Offset 50; } while (findResults.MoreAvailable);退出循环的唯一依据是MoreAvailable它由服务端告诉客户端还有没有下一页。Offset的含义是已跳过的条数每取完一批就加一个页面大小语义上和页码翻页等价但更好理解。不要拿TotalCount当循环条件它表示满足条件的总条数集合变化时用它做边界容易多取或漏取。4.2 SearchFilter组合多条件OR、排序与属性裁剪真正提效的是筛选、排序、裁剪一起上。ItemView view new ItemView(10); view.OrderBy.Add(ItemSchema.DateTimeReceived, SortDirection.Ascending); // 只取Id、主题和接收时间避免把正文和附件拉到本地 view.PropertySet new PropertySet( BasePropertySet.IdOnly, ItemSchema.Subject, ItemSchema.DateTimeReceived); FindItemsResultsItem findResults service.FindItems( WellKnownFolderName.Inbox, new SearchFilter.SearchFilterCollection( LogicalOperator.Or, new SearchFilter.ContainsSubstring(ItemSchema.Subject, EWS), new SearchFilter.ContainsSubstring(ItemSchema.Subject, API)), view); Console.WriteLine(Total number of items found: findResults.TotalCount);SearchFilterCollection是树形结构LogicalOperator.Or表示下面几个条件满足其一即命中改成And则需要同时满足。ContainsSubstring做的是子串匹配大小写不敏感不是全文搜索只在指定的属性内匹配。这个例子等价于SQL里的subject like %EWS% or subject like %API%。PropertySet裁剪很关键。默认的BasePropertySet.FirstClassProperties会把正文、附件元数据都拉回来一次查询几千条时带宽和内存都会被占满。只需要主题和时间时显式指定IdOnly加两个字段响应体明显变小速度也更快。排序字段加在view.OrderBy中支持多字段组合先按主字段排相同再按次字段排。4.3 日历搜索与模糊名称解析文件夹搜索和日历搜索各有专属API。FindFoldersResults folderResults service.FindFolders( WellKnownFolderName.Inbox, new FolderView(int.MaxValue)); foreach (Folder folder in folderResults.Folders) { Console.WriteLine(folder.DisplayName); }FolderView传int.MaxValue是一次性取完全部子文件夹适合脚本类管理工具。生产环境里文件夹数量同样可能很多稳妥做法是像ItemView一样分页别总想着一次load全量。日历搜索要查周期约会的具体发生实例情况有些特殊。CalendarFolder calendar CalendarFolder.Bind(service, WellKnownFolderName.Calendar); FindItemsResultsAppointment appointments calendar.FindAppointments( new CalendarView(DateTime.Now, DateTime.Now.AddDays(7)));CalendarView只接受起止时间传两个DateTime就能拿到时间窗内的所有约会包括周期约会被展开后的每一次occurrence。普通FindItems搜日历只会返回周期主项不会展开每次发生这里必须用CalendarFolder的FindAppointments。另外CalendarView是按时间窗口展开的天然适合查区间不需要手动翻页它也没有Offset属性可以设。按人名找邮箱用ResolveName适合做“输入姓氏自动补全收件人”的功能。NameResolutionCollection nameResolutions service.ResolveName( 张, NameResolutionSearchScope.ActiveDirectoryContacts, true); foreach (NameResolution nameResolution in nameResolutions) { Console.WriteLine(nameResolution.Mailbox.Name); Console.WriteLine(nameResolution.Mailbox.Address); }NameResolutionSearchScope.ActiveDirectoryContacts表示同时查AD和联系人文件夹顺序是先AD后联系人。第三个bool控制是否返回完整的联系人详情只要邮箱和显示名时传false能省一次详细查询的开销。5. 避坑指南EWS接入最常见的五个坑接入EWS的过程中文档里不会写但实际总会撞上的坑按出现频率排序集中在下面五条。5.1 Autodiscover遇到重定向直接抛异常现象调用service.AutodiscoverUrl(someoneexample.com)时邮箱地址确认无误程序却抛出与重定向相关的异常错误信息里通常带着“redirection response was not accepted”字样。原因EWS Managed API出于防DNS劫持的设计默认不跟随任何重定向。测试环境的Autodiscover往往从http跳到https于是这个保护机制被误触发把正常跳转也拦了下来。解决改用带校验回调的重载回调里只放行https开头的地址再做一层域名白名单不在白名单里的URL直接返回false。千万不要为了省事在回调里写return true。另一个连带问题是有些代码把AutodiscoverUrl放在try-catch里吞掉异常结果service.Url一直为空后续请求的报错信息更隐蔽排查方向直接跑偏。5.2 时区不对日历约会整体偏差数小时现象在服务器上创建一条18点的约会Exchange日历里显示的是凌晨或隔天某个时间每次都是整小时偏移。原因代码运行所在服务器时区和目标邮箱时区不一致时new DateTime(2024, 1, 1, 18, 0, 0)会被服务器解释成自己的本地时间Exchange再按邮箱时区做一次转换于是出现固定时差。EWS本身不做隐式时区换算它按你给的DateTime原样传输。解决创建约会前先确认服务器时区。常见做法是用TimeZoneInfo.ConvertTime把目标时区时间转成服务器本地时间后再赋值给Start和End如果所有机器都在同一时区最简单是让服务器时区与Exchange时区保持一致。DateTime.Kind也是一个变体Kind为Utc和Unspecified的DateTime传到Exchange后的处理逻辑不同赋值前可以先打印DateTime.Kind确认。5.3 证书回调无条件return true把安全防线拆了现象测试环境用ServerCertificateValidationCallback临时绕过了证书校验代码上线时忘了删生产环境里EWS连接被重定向到一个伪造的https站点程序照样握手成功。原因这个回调是进程级的return true等于告诉整个进程信任所有证书。它覆盖的不只是EWS连接还包括程序里所有HTTPS请求。测试时看着解决了问题上线后等于把证书链校验整个关掉。解决把证书回调放进只在测试环境执行的初始化方法用配置文件的开关控制。回调内部不要直接返回true而是校验certificate对象的指纹或颁发者匹配测试服务器的证书才放行。生产环境保持默认校验逻辑。另一种常见写法是把回调直接挂在Main函数开头上线时注释掉还好漏删就是隐患我习惯把证书策略单独抽成一个方法并用环境开关管理。5.4 FindItems返回的Item基类直接强转EmailMessage失败现象在foreach (Item item in findResults.Items)里写((EmailMessage)item).ToRecipients运行时抛InvalidCastException而且不是每次都抛取决于收件箱里有没有非邮件类型的条目。原因FindItems不知道返回项的静态类型统一按Item基类返回。收件箱里除了普通邮件还可能有会议请求、投递回执、通知消息等类型它们挂在EmailMessage的继承树下但直接转基类以外的类型会失败。解决不要无条件强转。先判断item is EmailMessage再进分支或者只读取Item基类上的公共属性。想确认实际类型可以在异常发生前先打印item.GetType().FullName同一封邮件在不同服务器上可能被识别成不同派生类型打印出来最直观。5.5 分页只取固定次数邮件超过容量就漏数据现象用ItemView(50)写固定取两页的循环测试收件箱只有80封邮件时看起来没问题上线后收件箱超过100封后面的邮件完全没被处理。原因把分页写成了“固定取前N批”没根据服务端返回结果决定是否继续。ItemView只是单次数量的上限不等于整个查询的结束条件。解决用do-while结构每取完一批就更新view.Offset循环条件用findResults.MoreAvailable。反面写法是while(pageCount 3)这类固定轮数循环测试环境数据量小看不出问题线上邮件一多就漏。循环轮数永远由服务端的MoreAvailable来决定不要自己拍脑袋。6. 进阶扩展属性与Availability服务两个值得深挖的能力基础功能跑通之后有两个能力对内部系统价值很高自定义扩展属性和Availability服务。前者让邮件对象能带上业务系统的字段后者让会议安排不再靠人工来回对时间。6.1 扩展属性给邮件打自定义标签扩展属性允许在标准属性之外给邮件加自定义字段业务系统可以把单据号、流程状态直接存在邮件上。Guid propertySetId new Guid({01638372-9F96-43b2-A403-B504ED14A910}); ExtendedPropertyDefinition extendedPropertyDefinition new ExtendedPropertyDefinition( propertySetId, MyProperty, MapiPropertyType.String); EmailMessage message new EmailMessage(service); message.SetExtendedProperty(extendedPropertyDefinition, MyValue); message.Save();propertySetId建议按应用为单位生成一个专用GUID并复用不要每个字段都新造一个避免和其他应用写入的扩展属性冲突。读取时要把同一个definition带进PropertySet否则绑定结果里不会加载它PropertySet propertySet new PropertySet( BasePropertySet.FirstClassProperties, extendedPropertyDefinition); EmailMessage boundMessage EmailMessage.Bind(service, new ItemId(uniqueMessageId), propertySet); foreach (ExtendedProperty extendedProperty in boundMessage.ExtendedProperties) { Console.WriteLine(extendedProperty.Value); }绑定后通过ExtendedProperties集合取回值。如果PropertySet里漏掉了extendedPropertyDefinition这个集合就是空的查不出任何自定义字段。6.2 Availability服务查忙闲与会议时间建议Availability服务可以查询一组用户在某时间窗内的忙闲状态并给出会议时间建议不需要对目标邮箱有访问权限。ListAttendeeInfo attendees new ListAttendeeInfo(); attendees.Add(someoneexample.com); attendees.Add(someoneelseexample.com); GetUserAvailabilityResults results service.GetUserAvailability( attendees, new TimeWindow(DateTime.Now, DateTime.Now.AddHours(12)), AvailabilityData.FreeBusyAndSuggestions); foreach (AttendeeAvailability availability in results.AttendeesAvailability) { if (availability.ErrorCode ServiceError.NoError) { foreach (CalendarEvent calendarEvent in availability.CalendarEvents) { Console.WriteLine(Start: calendarEvent.StartTime); Console.WriteLine(End: calendarEvent.EndTime); } } }GetUserAvailability第一个参数是与会者列表AttendeeInfo支持从字符串隐式转换直接Add邮箱地址即可。TimeWindow界定查询区间第三个参数决定返回忙闲数据、时间建议还是两者都要。每个与会者对应一个AttendeeAvailability先判断ErrorCode再读CalendarEvents因为不是所有人都有权限返回忙闲。会议建议直接读Suggestions集合按天分组foreach (Suggestion suggestion in results.Suggestions) { Console.WriteLine(Suggested day: suggestion.Date); foreach (TimeSuggestion timeSuggestion in suggestion.TimeSuggestions) { Console.WriteLine(Suggested time: timeSuggestion.MeetingTime); } }Suggestion按天给整体质量评分TimeSuggestion里是具体建议时段。实际项目里我拿这套接口做会议室预定辅助把参会人的忙闲和会议室日历放进同一个Availability查询一次性算出可预订时段比让用户来回对时间高效得多。以前我把扩展属性只当个标签用后来发现它还能存外部系统的单据号邮件系统和业务系统就靠这一个字段对账。从那以后我每次接EWS项目都会先花几分钟想清楚哪些数据要落进Exchange哪些只用它当通道。这套英文版指南加示例代码按第2章到第6章的顺序过一遍收信、发信、查忙闲三个场景基本就能搭起来剩下细节边用边补。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站