前情提要:之前寫程式存取 Exchange 收發信,我都是用 EWS API,用 AD 整合式驗證(企業環境)或應用程式密碼(Internet 線上版)執行作業,但 2022/10/1 起微軟停用了雲端 Exchange (Office 365 信箱)的 HTTP 基本驗證,後者已不能再用密碼存取 EWS,需改用 OAuth,本篇將介紹如何使用註冊應用程式、取得 Token 存取 EWS API。

OAuth 對我是陌生領域,所幸微軟有給說明:Authenticate an EWS application by using OAuth,就摸著石頭過河吧。

第一步是要在 Azure AD 註冊你的應用程式,用管理者身分登入Azure AD 管理中心,進入 Azure Active Dicretory 介面點選「應用程式註冊」:

有了上回設定 AzureAD 的經驗,這次倒沒茫然不知所措。IT 這行就是這樣,一點一點累積,路會愈走愈順... 直到下次發現他 X 的做法/版本/平台/工具怎麼又換了! (補聲暗)

新增應用程式時輸入名稱、帳戶類型、重導 URL 輸入 https://login.microsoftonline.com/common/oauth2/nativeclient

建立後有兩個識別碼要記下來,等下會用到。「應用程式(用戶端)識別碼」是 AppId、「目錄(租用戶)識別碼」是 TenantId:

OAuth 授權模式有兩種:

  1. Delegagted Permission
    需使用者或管理者在同意網頁完成授權,讓應用程式代表「登入中的使用者」呼叫 API 進行動作
  2. Application Permission
    即使使用者未登入也能執行動作,適用於排程或服務程式。我要跑排程收發信,要選這個

點開建立好的應用程式,點「資訊設定」(Manifest),找到 requiredResourceAccess,改成 Exchage 服務的 Application Permission 設定:

{
    "resourceAppId": "00000002-0000-0ff1-ce00-000000000000",
    "resourceAccess": [
        {
            "id": "dc890d15-9560-4a4c-9b7f-a736ec74ec40",
            "type": "Role"
        }
    ]
}

點「API 權限」,左側選單點「API 權限」,應可看到 Office 365 Exchange / full_access_as_app,點「代表XXX授與管理者同意」:

接著選「憑證與祕密」,按「新增用戶端密碼」以建立密碼:

建好密碼後,記得複製密碼值,它就是 Client Secret

到這裡設定就算完成了,呼! 再來要改程式,這部分反而簡單。

專案要參照 Microsoft.Identity.Client (MSAL.NET):

將 EWS 範例改寫如下,ConfidentialClientApplicationBuilder 輸入 AppId、TenantId、Client,執行 ExecuteAsyc() 取得 AccessToken,用來建立 OAuthCredentials 物件取代 WebCredentials,在 ImpersonatedUserId 指定存取信箱,程式終於又能收發信了。

using Microsoft.Exchange.WebServices.Data;
using Microsoft.Identity.Client;
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()
        {
            // 取得 Token
            var cca = ConfidentialClientApplicationBuilder
                .Create(AppConstants.AppId)
                .WithClientSecret(AppConstants.ClientSecret)
                .WithTenantId(AppConstants.TenantId)
                .Build();
            var ewsScopes = new string[] { "https://outlook.office365.com/.default" };
            var authResult = cca.AcquireTokenForClient(ewsScopes).ExecuteAsync().Result;

            var ews = new ExchangeService(ExchangeVersion.Exchange2013_SP1);
            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 OAuthCredentials(authResult.AccessToken);
            //ews.TraceEnabled = true;
            ews.ImpersonatedUserId = new ImpersonatedUserId(
                ConnectingIdType.SmtpAddress, AppConstants.Email);
            
            return ews;
        }

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

        static void CheckInbox(ExchangeService ews)
        {
            
            //https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/how-to-work-with-exchange-mailbox-items-by-using-ews-in-exchange
            // 繫結到收件匣
            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(AppConstants.TestRecipient);
            mail.Subject = $"發信測試: {DateTime.Now:HHmmss}";
            mail.Body = new MessageBody(BodyType.Text, "Sent from Office 365");
            mail.Send(); //或是 mail.SendAndSaveCopy();
        }
    }
}

提醒,以上做法應用程式可以存取組織的所有信箱,如要限制個別信箱存取權限,需使用 PowerShell 設定 ApplicationAccessPolicy,詳情可參考文件:Limiting application permissions to specific Exchange Online mailboxes

Tutorial of registering application and creating client script in AzureAD and use it to access EWS API.


Comments

# by Frank

大大您好: 我有看過您之前 基本驗證 使用 EWS 收發 Office 365 雲端 Exhange 信箱,https://blog.darkthread.net/blog/ews-office365/,幫助我滿多,最近 基本驗證 被棄用了,馬上來查就發現大大的文章了 收益良多。 想請問使用 本文這個oauth2.0 方法 會有另外的費用嗎 ,或是額度費用之類的? 謝謝 我有看到 另一個 microsoft graph api 是需要費用的,這兩個應該不一樣,只是想確認一下 謝謝 受益良多 再次謝謝

# by Jeffrey

to Frank,依我查到的結果,只是讀寫 Office 365 信箱內容,即使是用 Graph API 也是不收費的,它涵蓋在 O365 授權中。 Read/Write operations on O365 mailboxes using Graph APIs are not chargeable. 出處 https://learn.microsoft.com/en-us/answers/questions/993835/graph-api-pricing.html

# by Frank

Jeffrey 大好 了解 謝謝,看起來是。 抱歉 ,Graph API 的部分我不熟悉,想請問哪裡可以看到比較細節,或比較完整的收費方式嗎? 謝謝 我是查到這個,只有寫到 "每 1,000 個擷取的物件 " https://azure.microsoft.com/zh-tw/pricing/details/graph-data-connect/ 感謝回覆

# by 小胖

To 黑大: 感謝你在另一篇文章的回覆 我整個爬完官網的設定,實作完後才發現你這篇文章XD 如果早一點發現就好了XD

Post a comment