fix: align Hotmail API integration with current dev

- merge the latest dev changes into PR #69 before continuing the review flow
- keep Microsoft API mode while preserving mailbox-by-mailbox polling and verification filters
- update the Hotmail side panel copy and helper coverage for the repaired API path
This commit is contained in:
QLHazyCoder
2026-04-15 01:36:40 +08:00
30 changed files with 8560 additions and 854 deletions
+284 -84
View File
@@ -4,21 +4,91 @@
/auth0\.openai\.com/i,
];
const CODE_PATTERN = /\b(\d{6})\b/;
const TOKEN_ENDPOINT = 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token';
const OUTLOOK_API_BASE = 'https://outlook.office.com/api/v2.0/me/messages';
const GRAPH_SCOPES = 'offline_access https://graph.microsoft.com/Mail.Read https://graph.microsoft.com/User.Read';
const GRAPH_DEFAULT_SCOPE = 'https://graph.microsoft.com/.default';
const TOKEN_STRATEGIES = [
{
name: 'entra-common-delegated',
url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
extraData: { scope: GRAPH_SCOPES },
},
{
name: 'entra-consumers-delegated',
url: 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token',
extraData: { scope: GRAPH_SCOPES },
},
{
name: 'entra-common-default',
url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
extraData: { scope: GRAPH_DEFAULT_SCOPE },
},
{
name: 'entra-common-outlook',
url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
extraData: {},
},
];
const TRANSPORT_PLANS = [
{
transport: 'graph',
strategyNames: ['entra-common-delegated', 'entra-consumers-delegated', 'entra-common-default'],
},
{
transport: 'outlook',
strategyNames: ['entra-common-outlook', 'entra-common-delegated', 'entra-consumers-delegated'],
},
];
const GRAPH_API_BASE = 'https://graph.microsoft.com/v1.0/me/mailFolders';
const OUTLOOK_API_BASE = 'https://outlook.office.com/api/v2.0/me/mailfolders';
function getFetchImpl(fetchImpl) {
const resolved = fetchImpl || globalScope.fetch;
if (typeof resolved !== 'function') {
throw new Error('Microsoft email helper requires a fetch implementation.');
}
return resolved;
}
function resolveTokenStrategy(name) {
return TOKEN_STRATEGIES.find((item) => item.name === name) || TOKEN_STRATEGIES[0];
}
function normalizeMailboxLabel(mailbox = 'INBOX') {
return /^junk(?:\s*e-?mail|\s*email)?$/i.test(String(mailbox || '').trim()) ? 'Junk' : 'INBOX';
}
function normalizeMailboxId(mailbox = 'INBOX') {
return normalizeMailboxLabel(mailbox) === 'Junk' ? 'junkemail' : 'inbox';
}
function normalizeMailboxList(mailboxes) {
const list = Array.isArray(mailboxes) && mailboxes.length ? mailboxes : ['INBOX'];
return [...new Set(list.map((mailbox) => normalizeMailboxLabel(mailbox)))];
}
async function getResponseErrorText(response) {
const text = await response.text().catch(() => '');
if (!text) {
return response.statusText || `HTTP ${response.status}`;
}
try {
const parsed = JSON.parse(text);
return parsed.error_description || parsed.error?.message || parsed.error || parsed.message || text;
} catch {
return text;
}
}
async function exchangeRefreshToken(clientId, refreshToken, options = {}) {
const fetchImpl = options.fetchImpl || globalScope.fetch;
if (typeof fetchImpl !== 'function') {
throw new Error('Microsoft 邮箱 helper 缺少 fetch 实现。');
}
const fetchImpl = getFetchImpl(options.fetchImpl);
const strategy = resolveTokenStrategy(options.strategyName);
const body = new URLSearchParams({
client_id: clientId,
grant_type: 'refresh_token',
refresh_token: refreshToken,
...(strategy.extraData || {}),
});
const response = await fetchImpl(TOKEN_ENDPOINT, {
const response = await fetchImpl(strategy.url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
@@ -26,75 +96,89 @@
});
if (!response.ok) {
const text = await response.text().catch(() => '');
const errorInfo = (() => {
try {
return JSON.parse(text);
} catch {
return {};
}
})();
throw new Error(
errorInfo.error_description
|| `Token exchange failed (${response.status}): ${text.slice(0, 200)}`
);
throw new Error(`${strategy.name}: ${await getResponseErrorText(response)}`);
}
const data = await response.json();
if (!data.access_token) {
throw new Error('Token exchange response missing access_token.');
throw new Error(`${strategy.name}: token response missing access_token`);
}
return data;
return {
...data,
tokenStrategy: strategy.name,
};
}
async function fetchOutlookMessages(accessToken, options = {}) {
const { top = 5, signal } = options;
const fetchImpl = options.fetchImpl || globalScope.fetch;
if (typeof fetchImpl !== 'function') {
throw new Error('Microsoft 邮箱 helper 缺少 fetch 实现。');
}
const response = await fetchImpl(
`${OUTLOOK_API_BASE}?$top=${encodeURIComponent(top)}&$orderby=ReceivedDateTime desc&$select=From,Subject,ReceivedDateTime,BodyPreview,Body`,
{
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
signal,
}
);
if (response.status === 401 || response.status === 403) {
throw new Error('Microsoft Graph token invalid or expired.');
}
async function fetchGraphMessages(accessToken, options = {}) {
const fetchImpl = getFetchImpl(options.fetchImpl);
const mailbox = normalizeMailboxLabel(options.mailbox);
const top = Math.max(1, Math.min(Number(options.top) || 5, 30));
const url = `${GRAPH_API_BASE}/${normalizeMailboxId(mailbox)}/messages?$top=${encodeURIComponent(top)}&$select=id,internetMessageId,subject,from,bodyPreview,receivedDateTime&$orderby=receivedDateTime desc`;
const response = await fetchImpl(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
signal: options.signal,
});
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new Error(`Outlook API request failed (${response.status}): ${body || response.statusText}`);
throw new Error(`graph: ${await getResponseErrorText(response)}`);
}
const payload = await response.json();
return Array.isArray(payload?.value) ? payload.value : [];
}
function normalizeMessage(message) {
async function fetchOutlookMessages(accessToken, options = {}) {
const fetchImpl = getFetchImpl(options.fetchImpl);
const mailbox = normalizeMailboxLabel(options.mailbox);
const top = Math.max(1, Math.min(Number(options.top) || 5, 30));
const url = `${OUTLOOK_API_BASE}/${normalizeMailboxId(mailbox)}/messages?$top=${encodeURIComponent(top)}&$select=Id,Subject,From,BodyPreview,Body,ReceivedDateTime&$orderby=ReceivedDateTime desc`;
const response = await fetchImpl(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
signal: options.signal,
});
if (!response.ok) {
throw new Error(`outlook: ${await getResponseErrorText(response)}`);
}
const payload = await response.json();
return Array.isArray(payload?.value) ? payload.value : [];
}
function normalizeMessage(message, mailbox = 'INBOX') {
const sender = message?.From || message?.from || {};
const emailAddress = sender?.EmailAddress || sender?.emailAddress || {};
return {
mailbox: normalizeMailboxLabel(mailbox || message?.mailbox),
from: {
emailAddress: {
address: message?.From?.EmailAddress?.Address
|| message?.from?.emailAddress?.address
|| '',
address: String(emailAddress?.Address || emailAddress?.address || '').trim(),
name: String(emailAddress?.Name || emailAddress?.name || '').trim(),
},
},
subject: message?.Subject || message?.subject || '',
receivedDateTime: message?.ReceivedDateTime || message?.receivedDateTime || '',
bodyPreview: message?.BodyPreview || message?.bodyPreview || '',
subject: String(message?.Subject || message?.subject || '').trim(),
receivedDateTime: String(message?.ReceivedDateTime || message?.receivedDateTime || '').trim(),
bodyPreview: String(message?.BodyPreview || message?.bodyPreview || '').trim(),
body: {
content: message?.Body?.Content || message?.body?.content || '',
content: String(message?.Body?.Content || message?.body?.content || '').trim(),
},
id: message?.Id || message?.id || '',
id: String(message?.Id || message?.id || message?.internetMessageId || '').trim(),
};
}
function normalizeFilterValue(value) {
return String(value || '').trim().toLowerCase();
}
function getMessageSender(message) {
return String(
message?.from?.emailAddress?.address
@@ -130,29 +214,54 @@
}
function extractVerificationCodeFromMessages(messages, options = {}) {
const { filterAfterTimestamp = 0 } = options;
const filterAfterTimestamp = Number(options.filterAfterTimestamp || 0) || 0;
const senderFilters = (options.senderFilters || []).map(normalizeFilterValue).filter(Boolean);
const subjectFilters = (options.subjectFilters || []).map(normalizeFilterValue).filter(Boolean);
const excludedCodes = new Set((options.excludeCodes || []).map((value) => String(value || '').trim()).filter(Boolean));
const hasExplicitFilters = senderFilters.length > 0 || subjectFilters.length > 0;
for (const raw of messages) {
const message = normalizeMessage(raw);
const sortedMessages = (Array.isArray(messages) ? messages : [])
.map((raw) => normalizeMessage(raw, raw?.mailbox))
.sort((left, right) => getMessageTimestamp(right) - getMessageTimestamp(left));
for (const message of sortedMessages) {
const receivedAt = getMessageTimestamp(message);
if (receivedAt && receivedAt < Number(filterAfterTimestamp || 0)) {
continue;
}
if (!isOpenAiMessage(message)) {
if (receivedAt && receivedAt < filterAfterTimestamp) {
continue;
}
const match = getMessageSearchText(message).match(CODE_PATTERN);
if (!match) {
const sender = normalizeFilterValue(getMessageSender(message));
const subject = normalizeFilterValue(message?.subject);
const preview = normalizeFilterValue(message?.bodyPreview);
const searchText = normalizeFilterValue(getMessageSearchText(message));
const codeMatch = getMessageSearchText(message).match(CODE_PATTERN);
const code = codeMatch?.[1] || '';
if (!code || excludedCodes.has(code)) {
continue;
}
if (!hasExplicitFilters && !isOpenAiMessage(message)) {
continue;
}
const senderMatched = senderFilters.length === 0
? true
: senderFilters.some((filter) => sender.includes(filter) || preview.includes(filter) || searchText.includes(filter));
const subjectMatched = subjectFilters.length === 0
? true
: subjectFilters.some((filter) => subject.includes(filter) || preview.includes(filter) || searchText.includes(filter));
if (!senderMatched && !subjectMatched) {
continue;
}
return {
code: match[1],
code,
emailTimestamp: receivedAt || Date.now(),
messageId: message?.id || null,
sender: getMessageSender(message),
subject: String(message?.subject || ''),
mailbox: message?.mailbox || 'INBOX',
message,
};
}
@@ -163,9 +272,11 @@
const {
clientId,
refreshToken,
mailbox = 'INBOX',
top = 5,
fetchImpl,
signal,
log = null,
} = options;
if (!refreshToken) {
@@ -175,71 +286,160 @@
throw new Error('Microsoft client_id is empty.');
}
const tokenData = await exchangeRefreshToken(clientId, refreshToken, { fetchImpl, signal });
const rawMessages = await fetchOutlookMessages(tokenData.access_token, { top, signal, fetchImpl });
const errors = [];
for (const plan of TRANSPORT_PLANS) {
for (const strategyName of plan.strategyNames) {
try {
const tokenData = await exchangeRefreshToken(clientId, refreshToken, {
fetchImpl,
signal,
strategyName,
});
const rawMessages = plan.transport === 'graph'
? await fetchGraphMessages(tokenData.access_token, { mailbox, top, fetchImpl, signal })
: await fetchOutlookMessages(tokenData.access_token, { mailbox, top, fetchImpl, signal });
return {
tokenData,
nextRefreshToken: String(tokenData?.refresh_token || '').trim(),
messages: rawMessages.map((message) => normalizeMessage(message)),
};
return {
tokenData,
nextRefreshToken: String(tokenData?.refresh_token || '').trim(),
tokenStrategy: strategyName,
transport: plan.transport,
mailbox: normalizeMailboxLabel(mailbox),
messages: rawMessages.map((message) => normalizeMessage(message, mailbox)),
};
} catch (error) {
const message = error?.message || String(error);
errors.push(`${plan.transport}/${strategyName}: ${message}`);
if (typeof log === 'function') {
log(`mailbox=${normalizeMailboxLabel(mailbox)} ${plan.transport}/${strategyName} failed: ${message}`);
}
}
}
}
throw new Error(`Microsoft mailbox request failed: ${errors.join(' | ')}`);
}
function delay(timeoutMs, signal) {
if (timeoutMs <= 0) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
resolve();
}, timeoutMs);
const onAbort = () => {
cleanup();
reject(signal.reason || new Error('Aborted'));
};
const cleanup = () => {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
};
if (signal?.aborted) {
cleanup();
reject(signal.reason || new Error('Aborted'));
return;
}
signal?.addEventListener('abort', onAbort, { once: true });
});
}
async function fetchMicrosoftVerificationCode(options = {}) {
const {
token,
refreshToken,
clientId,
maxRetries = 3,
retryDelayMs = 10000,
top = 5,
log = null,
filterAfterTimestamp = 0,
senderFilters = [],
subjectFilters = [],
excludeCodes = [],
mailboxes = ['INBOX'],
fetchImpl,
signal,
} = options;
if (!token) {
let workingRefreshToken = String(refreshToken || token || '').trim();
if (!workingRefreshToken) {
throw new Error('Microsoft refresh token is empty.');
}
if (!clientId) {
throw new Error('Microsoft client_id is empty.');
}
const tokenData = await exchangeRefreshToken(clientId, token, { fetchImpl, signal });
const accessToken = tokenData.access_token;
const nextRefreshToken = String(tokenData?.refresh_token || '').trim();
const normalizedMailboxes = normalizeMailboxList(mailboxes);
let lastError = null;
for (let attempt = 1; attempt <= maxRetries; attempt += 1) {
const messages = await fetchOutlookMessages(accessToken, { top: 5, signal, fetchImpl });
const result = extractVerificationCodeFromMessages(messages, { filterAfterTimestamp });
if (result) {
return {
...result,
nextRefreshToken,
messages: messages.map((message) => normalizeMessage(message)),
};
try {
const collectedMessages = [];
for (const mailbox of normalizedMailboxes) {
const result = await fetchMicrosoftMailboxMessages({
clientId,
refreshToken: workingRefreshToken,
mailbox,
top,
fetchImpl,
signal,
log,
});
if (result.nextRefreshToken) {
workingRefreshToken = result.nextRefreshToken;
}
collectedMessages.push(...result.messages);
}
const match = extractVerificationCodeFromMessages(collectedMessages, {
filterAfterTimestamp,
senderFilters,
subjectFilters,
excludeCodes,
});
if (match) {
return {
...match,
nextRefreshToken: workingRefreshToken,
messages: collectedMessages,
};
}
lastError = new Error('No matching Microsoft verification email found.');
} catch (error) {
lastError = error;
}
if (attempt < maxRetries) {
if (typeof log === 'function') {
log(`Outlook API: attempt ${attempt}/${maxRetries} found no OpenAI verification mail, retrying...`);
log(`attempt ${attempt}/${maxRetries} found no matching Microsoft mail, retrying...`);
}
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
await delay(retryDelayMs, signal);
}
}
throw new Error('No matching OpenAI verification email found.');
throw lastError || new Error('No matching Microsoft verification email found.');
}
const api = {
CODE_PATTERN,
exchangeRefreshToken,
extractVerificationCodeFromMessages,
fetchGraphMessages,
fetchMicrosoftMailboxMessages,
fetchMicrosoftVerificationCode,
fetchOutlookMessages,
getMessageSender,
getMessageTimestamp,
isOpenAiMessage,
normalizeMailboxId,
normalizeMailboxLabel,
normalizeMessage,
};