[2022-10-30 更新] 微軟已於 2022-10-1 起停用 Exchange Online 服務之 HTTP 基本驗證功能,本文範例已無法執行,會得到 HTTP 401 回應,替代做法為使用 OAuth 認證存取 Office 365 雲端 Exhange 收發信

我想透過 Email 觸發某些批次作業,構想是定期跑程式從 Exchange 或 Office 365 收信,由主旨跟內容決定作業內容,執行完成再透過 Email 回報結果,這篇將示範使用 C# + Managed EWS API 的程式寫法。

開始前先提一下,用程式整合 Exchange 的方法不只 EWS 一種,微軟有篇完整介紹:Exchange Online and Exchange development,包含 Graph REST APIs (Office 365/Exchange Online)、EWS、Outlook VBA、Exchange PowerShell 指令... 等。若連線對象為 Exchange Online,微軟強烈建議改用 Microsoft Graph,EWS 雖然還是可存取,但已不會再發展擴充新功能,至於 EWS 對企業 Exchange 的支援策略則會不變。考量我想通吃 Exchage Service 跟 Office 365,故仍選擇使用 EWS。

之前試過用 EWS 連 Exchange Server 發送紅色主旨信件讀取共用資料夾,這篇將著重整合 Office 365 跟收信的部分。

使用 Managed EWS 程式庫收發信的範例我主要是參考 MSDocs 文件:

廢話少說,放碼過來!

using Microsoft.Exchange.WebServices.Data;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading.Tasks;

namespace RcvAndSendMail
{
    class Program
    {
        static ExchangeService InitEws()
        {
            var ews = new ExchangeService(ExchangeVersion.Exchange2010_SP2);

            //公司內部Exchange比較簡單,可慱入帳密登入
            //ews.Credentials  = new NetworkCredential(userName, password, domain);
            //或使用執行程式的AD帳號自動登入
            //ews.UseDefaultCredentials = true;

            //Office 365 登入方式
            //AppContants.Account 即為 Email 地址,如 "email@xxx.onmicrosoft.com"
            ews.Credentials = new WebCredentials(AppContants.Account, AppContants.Password);
            var sw = new Stopwatch();
            sw.Start();
            ews.AutodiscoverUrl(AppContants.Account, RedirectUrlVaidationCallback);
            sw.Stop();
            Console.WriteLine($"AutodiscoverUrl() in {sw.ElapsedMilliseconds:n0}ms");

            return ews;
        }

        private static bool RedirectUrlVaidationCallback(string redirectionUrl)
        {
            return (new Uri(redirectionUrl).Scheme == "https");
        }

        static void Main(string[] args)
        {
            var ews = InitEws();
            CheckInbox(ews);
            SendEmail(ews);
        }

        static void CheckInbox(ExchangeService ews)
        {
            // 繫結到收件匣
            Folder inbox = Folder.Bind(ews, WellKnownFolderName.Inbox);
            // 篩選未讀信件
            SearchFilter sf = new SearchFilter.SearchFilterCollection(LogicalOperator.And, 
                new SearchFilter.IsEqualTo(EmailMessageSchema.IsRead, false));

            ItemView view = new ItemView(10);
            // Fire the query for the unread items.
            // This method call results in a FindItem call to EWS.
            FindItemsResults<Item> findResults = ews.FindItems(WellKnownFolderName.Inbox, sf, view);
            foreach (var item in findResults.Items)
            {
                string subject = item.Subject;
                if (item is EmailMessage && subject.StartsWith("#BOT"))
                {
                    //信件內容及附件要 Load() 才會載入
                    item.Load();
                    var mail = item as EmailMessage;
                    var text = mail.Body.Text;
                    Console.WriteLine($"收件時間:{item.DateTimeReceived:yyyy/MM/dd HH:mm}");
                    Console.WriteLine($"寄件者:{mail.From}");
                    Console.WriteLine($"主旨:{item.Subject}");
                    Console.WriteLine($"內容:{text}");
                    //TODO 執行特定作業
                    //刪除有 HardDelete/SoftDelete/MoveToDeletedItems 三種模式
                    item.Delete(DeleteMode.HardDelete);
                }
            }

        }

        static void SendEmail(ExchangeService ews)
        {
            var mail = new EmailMessage(ews);
            mail.ToRecipients.Add(AppContants.TestRecipient);
            mail.Subject = $"發信測試: {DateTime.Now:HHmmss}";
            mail.Body = new MessageBody(BodyType.Text, "Sent from Office 365");
            mail.Send(); //或是 mail.SendAndSaveCopy();
        }
    }
}

登入部分 Exchange Server 或 Office 365 做法有點不同,Exchange Server 用 AD 帳號,通常設 UseDefaultCredentials = true 就好;使用 Office 365 信箱登入的話要用 WebCredential。AutodiscoverUrl() 可依據 Email 自動找到對應的 EWS 服務,使用時需提供一個 RedirectUrlVaidationCallback 檢查憑證,一般直接只檢查回傳網址為 https 並 return true 即可。參考

實測收信成功:

發信也沒問題:

但有個地方不理想。由收信擷圖第一行顯示,AutodiscoverUrl() 花了 14 秒才完成。關於這點,MSDocs 有篇 - Improving performance when using Autodiscover for Exchange 提到:Autodisover 背後的動作蠻複雜,涉及與 AD Domain Service 溝通、尋找 SCP (Service Connection Point)、嘗試多個 EndPoint... 等程序。文件提到 EnableScpLookup = false 可加速,經實測差異不大。回到根本問題,AutodiscoverUrl() 原本的設計想法是呼叫取得設定資訊後 Cache 起來重複利用,不該頻繁執行,我想到一個解法是將收發信功能寫成服務取代定期執行 EXE,如此服務啟動時執行一次即可。

不過進一步研究,我發現如果已知 EWS 服務網址,直接指定 ews.Url 就好了,大可省略耗時的 AutodiscoverUrl() 程序。所以,開發時執行一次 AutodiscoverUrl() 找出 Url 存成設定檔,未來直接讀取設定就好了。

還有一個確認 EWS Url 的做法是從網頁版 Outlook (https://outlook.office365.com/mail) 找右上角齒輪的設定 / 檢視所有 Outlook 設定:

再找郵件 / 同步電子郵件,查到 outlook.office365.com:

加上 ews/exchange.asmx 變成 https://outlook.office365.com/ews/exchange.asmx 就是 EWS 位址。

故簡化版如下,實測可省下十幾秒。

static ExchangeService InitEws()
{
    var ews = new ExchangeService(ExchangeVersion.Exchange2010_SP2);
    //實際運用時建議由設定檔讀取,需調整時才不用改程式
    ews.Url = new Uri("https://outlook.office365.com/ews/exchange.asmx");

    //公司內部Exchange比較簡單,可慱入帳密登入
    //ews.Credentials  = new NetworkCredential(userName, password, domain);
    //或使用執行程式的AD帳號自動登入
    //ews.UseDefaultCredentials = true;

    //Office 365 登入方式
    ews.Credentials = new WebCredentials(AppContants.Account, AppContants.Password);

    return ews;
}

寫死 EWS Url 有個風險是當 EWS Url 變更時程式會壞掉,需人工校正。EWS 服務修改網址的頻率不高,事前應該也會通知,到時再手工調整就好。若想做到無懈可擊,可以加入一段 try catch 呼叫簡單 EWS,若發生錯誤再補上 AutodiscoverUrl()。

Example of using Managed EWS API to receive and send Emails for Exchange Online.


Comments

# by Ike

想問… EWS 可以取得 Email Group 的 成員 有哪些嗎?

# by Jeffrey

tp lke, 像這樣嗎?https://docs.microsoft.com/en-us/previous-versions/office/developer/exchange-server-2010/hh532557(v%3Dexchg.80)

# by Ike

有像,來試試… 感謝~

# by Ike

傷心 竟然 Exchange 2007 以上才能用

# by Samuel

不好意思, 問個很菜的問題, AppContants 和 RedirectUrlVaidationCallback 不存在, 我使用Microsoft.Exchange.WebServices 2.2版本, 是否缺了什麼?

# by Samuel

抱歉, 看懂了, 原來AppContants 和 RedirectUrlVaidationCallback 另有定義

# by Max

不好意思, 想請教一個問題,我依照上述的範例程式嘗試發送一封信件,但是都一直收到此訊息 : Microsoft.Exchange.WebServices.Data.ServiceRequestException: 'The request failed. 遠端伺服器傳回一個錯誤: (401) 未經授權。' 我已經確認帳號與密碼沒有錯誤,可能是哪一個部分錯了。

# by Jeffrey

to Max, 會是因為帳號啟用了多重因素認證?

# by Eddie

我也遇到Max的問題,請問是什麼問題呢

# by 小胖

Hi 黑大: 我們最近有個抓信小程式在2023/1/23前還好好,但2023/1/24就掛了 症狀跟Max大大的一樣是401 目前也是用365的服務 還沒找到解決方法lol

# by Jeffey

to 小胖,會是這個嗎?微軟 2023 一月開始停用 Exchange 線上服務的基本驗證功能 In early January 2023, we will permanently turn off Basic auth for multiple protocols for many Exchange Online tenants. Soon after basic auth is permanently disabled, any clients or apps connecting using Basic auth to one of the affected protocols will receive a bad username/password/HTTP 401 error. https://techcommunity.microsoft.com/t5/exchange-team-blog/basic-authentication-deprecation-in-exchange-online-time-s-up/ba-p/3695312 解法是改用 OAuth,已在文章開頭加上連結。

# by Johnson

請問用EWS如何確認寄信的狀態?若開啟寄信回執功能,能夠在寄信失敗的時候就知道嗎?如郵件地址錯誤(查無此人)的狀況。

Post a comment