MapleCheng

在浩瀚的網路世界中無限潛水欸少年郎!

0%

Microsoft Graph API 郵件回覆實戰:如何正確保持 Email Thread 連貫

最近在做一個自動化郵件處理系統,需要用 Microsoft Graph API 回覆客戶的來信。聽起來很簡單對吧?就是收到信、處理、回覆,標準流程。

結果踩了一個讓我懷疑人生的坑:sendMail API 回覆,收件人看到的居然是一封全新的信,不在原本的 thread 裡面。

問題現象

假設客戶寄了一封信給我,主旨是「專案進度確認」。我用 Graph API 的 sendMail 端點回覆:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
POST /users/{userId}/sendMail
{
"message": {
"subject": "RE: 專案進度確認",
"body": {
"contentType": "HTML",
"content": "<p>您好,以下是最新進度...</p>"
},
"toRecipients": [
{
"emailAddress": {
"address": "client@example.com"
}
}
]
}
}

看起來沒問題吧?主旨有 RE:,收件人也對。

但客戶收到的是一封獨立的新信。

在他的信箱裡,這封「回覆」跟原本的詢問信是分開的,不在同一個 thread。如果後續還有來回,就會變成一堆散落的獨立郵件,完全失去 conversation 的脈絡。

根本原因:Email Thread 不是靠主旨判斷的

這是我搞錯的第一個觀念。

Email 的 thread(或稱 conversation)是靠 Message-IDIn-Reply-To / References header 來串連的,不是靠主旨的 RE:FW:

當你用 sendMail API 發信時:

  • Graph API 會產生一個全新的 Message-ID
  • 不會自動帶上 In-Reply-To header
  • 就算主旨寫 RE: xxx,郵件系統還是認定這是新信

所以收件人的信箱會把它當成獨立郵件處理。

正確做法:用 reply API

Graph API 有專門的回覆端點:

1
POST /users/{userId}/messages/{messageId}/reply

或者如果要回覆給所有人(包括 CC):

1
POST /users/{userId}/messages/{messageId}/replyAll

用這個端點,Graph API 會自動:

  1. 設定正確的 In-Reply-To header,指向原始郵件的 Message-ID
  2. 維護 References header chain
  3. 保持 conversationId 一致

實際呼叫長這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
POST /users/{userId}/messages/{originalMessageId}/reply
{
"message": {
"toRecipients": [
{
"emailAddress": {
"address": "client@example.com"
}
}
]
},
"comment": "<p>您好,以下是最新進度...</p>"
}

注意幾個差異:

  • 端點是 /messages/{messageId}/reply,需要原始郵件的 ID
  • 回覆內容放在 comment 欄位,不是 message.body
  • 不需要指定 subject,會自動帶上 RE: + 原主旨

conversationId 的妙用

reply API 之後,會發現一個很棒的特性:同一個 email thread 裡的所有郵件,conversationId 都是一樣的。

1
2
3
4
5
6
{
"id": "AAMkAGI2TG93AAA=",
"conversationId": "AAQkAGI2TG93BBB=",
"subject": "RE: 專案進度確認",
...
}

這個 conversationId 可以拿來做很多事:

  1. 追蹤完整對話:用 conversationId 查詢,可以撈出整個 thread 的所有郵件
  2. 關聯內部系統:把 conversationId 存起來,跟 CRM、工單系統對應
  3. 避免重複處理:收到新信時,先查 conversationId 是否已存在

我的做法是建一個 mapping table:

1
2
3
4
5
6
7
8
// thread-map.json
{
"AAQkAGI2TG93BBB=": {
"internalThreadId": "12345",
"firstReceivedAt": "2026-02-05T10:30:00Z",
"lastUpdatedAt": "2026-02-06T09:15:00Z"
}
}

每次收到新信,先查這個 mapping。如果 conversationId 存在,就是既有對話的後續;如果不存在,就是新的詢問。

完整流程範例

來看一個完整的自動回覆流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import requests
from msal import ConfidentialClientApplication

# 1. 取得 access token
app = ConfidentialClientApplication(
client_id,
authority=f"https://login.microsoftonline.com/{tenant_id}",
client_credential=client_secret
)
token = app.acquire_token_for_client(
scopes=["https://graph.microsoft.com/.default"]
)
access_token = token["access_token"]

# 2. 讀取收件匣的新郵件
headers = {"Authorization": f"Bearer {access_token}"}
response = requests.get(
f"https://graph.microsoft.com/v1.0/users/{user_id}/messages"
"?$filter=isRead eq false&$orderby=receivedDateTime desc",
headers=headers
)
messages = response.json()["value"]

for msg in messages:
message_id = msg["id"]
conversation_id = msg["conversationId"]
sender = msg["from"]["emailAddress"]["address"]
subject = msg["subject"]
body = msg["body"]["content"]

# 3. 處理郵件內容(這邊放你的業務邏輯)
reply_content = process_and_generate_reply(body)

# 4. 用 reply API 回覆
reply_response = requests.post(
f"https://graph.microsoft.com/v1.0/users/{user_id}"
f"/messages/{message_id}/reply",
headers=headers,
json={
"message": {
"toRecipients": [
{"emailAddress": {"address": sender}}
]
},
"comment": reply_content
}
)

if reply_response.status_code == 202:
print(f"✅ 已回覆: {subject}")

# 5. 記錄 conversationId mapping
save_thread_mapping(conversation_id, message_id)

# 6. 標記為已讀
requests.patch(
f"https://graph.microsoft.com/v1.0/users/{user_id}"
f"/messages/{message_id}",
headers=headers,
json={"isRead": True}
)

幾個注意事項

1. reply vs replyAll

  • reply:只回給寄件人
  • replyAll:回給寄件人 + 所有 CC

如果原信有 CC 其他人,要考慮清楚用哪個。自動化系統通常用 reply 比較安全,避免不小心 spam 到不相關的人。

2. comment 欄位是 HTML

comment 欄位支援 HTML,但 Graph API 會自動在回覆內容上方加上原始郵件的引用。如果你想完全控制格式,可以改用:

1
2
3
4
5
6
7
8
{
"message": {
"body": {
"contentType": "HTML",
"content": "<p>完全自訂的內容</p>"
}
}
}

但這樣就不會有自動引用原文的效果。

3. 權限需求

用 Application Permission(daemon app)呼叫 reply API 需要 Mail.Send 權限。如果是 Delegated Permission,則需要 Mail.ReadWrite

4. 處理已刪除的原始郵件

如果原始郵件被刪了,reply API 會回 404。這時候只能 fallback 到 sendMail,但要有心理準備 thread 會斷掉。

延伸應用:整合到其他系統

我後來把這套機制跟 Discord 整合了。流程大概是:

  1. 收到客戶來信 → 建立 Discord Forum thread
  2. 在 Discord 討論、決定回覆內容
  3. 觸發 bot → 用 Graph API reply 發回給客戶
  4. 客戶再回 → 更新 Discord thread

因為有 conversationId mapping,所以可以精準對應每個 email thread 到哪個 Discord thread。客戶的後續回覆會自動歸到正確的討論串,不會搞混。

總結

用 Graph API 處理郵件回覆,記住這幾點:

  • ❌ 不要用 sendMail 來「回覆」,那只是發新信
  • ✅ 用 /messages/{id}/reply 端點
  • ✅ 善用 conversationId 追蹤整個對話
  • ✅ 記得處理原始郵件被刪除的 edge case

這個坑我踩了大概半天才搞清楚,希望這篇能幫到有類似需求的人。

如果你也在做 Office 365 / Microsoft 365 的整合,歡迎留言交流。這領域的文件說實話不算友善,很多細節要自己試才知道。