microsoft-email.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. (function attachMicrosoftEmailHelpers(globalScope) {
  2. const OPENAI_SENDER_PATTERNS = [
  3. /openai\.com/i,
  4. /auth0\.openai\.com/i,
  5. ];
  6. const CODE_PATTERN = /\b(\d{6})\b/;
  7. const GRAPH_SCOPES = 'offline_access https://graph.microsoft.com/Mail.Read https://graph.microsoft.com/User.Read';
  8. const GRAPH_DEFAULT_SCOPE = 'https://graph.microsoft.com/.default';
  9. const TOKEN_STRATEGIES = [
  10. {
  11. name: 'entra-common-delegated',
  12. url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
  13. extraData: { scope: GRAPH_SCOPES },
  14. },
  15. {
  16. name: 'entra-consumers-delegated',
  17. url: 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token',
  18. extraData: { scope: GRAPH_SCOPES },
  19. },
  20. {
  21. name: 'entra-common-default',
  22. url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
  23. extraData: { scope: GRAPH_DEFAULT_SCOPE },
  24. },
  25. {
  26. name: 'entra-common-outlook',
  27. url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
  28. extraData: {},
  29. },
  30. ];
  31. const TRANSPORT_PLANS = [
  32. {
  33. transport: 'graph',
  34. strategyNames: ['entra-common-delegated', 'entra-consumers-delegated', 'entra-common-default'],
  35. },
  36. {
  37. transport: 'outlook',
  38. strategyNames: ['entra-common-outlook', 'entra-common-delegated', 'entra-consumers-delegated'],
  39. },
  40. ];
  41. const GRAPH_API_BASE = 'https://graph.microsoft.com/v1.0/me/mailFolders';
  42. const OUTLOOK_API_BASE = 'https://outlook.office.com/api/v2.0/me/mailfolders';
  43. function getFetchImpl(fetchImpl) {
  44. const resolved = fetchImpl || globalScope.fetch;
  45. if (typeof resolved !== 'function') {
  46. throw new Error('Microsoft email helper requires a fetch implementation.');
  47. }
  48. return resolved;
  49. }
  50. function resolveTokenStrategy(name) {
  51. return TOKEN_STRATEGIES.find((item) => item.name === name) || TOKEN_STRATEGIES[0];
  52. }
  53. function normalizeMailboxLabel(mailbox = 'INBOX') {
  54. return /^junk(?:\s*e-?mail|\s*email)?$/i.test(String(mailbox || '').trim()) ? 'Junk' : 'INBOX';
  55. }
  56. function normalizeMailboxId(mailbox = 'INBOX') {
  57. return normalizeMailboxLabel(mailbox) === 'Junk' ? 'junkemail' : 'inbox';
  58. }
  59. function normalizeMailboxList(mailboxes) {
  60. const list = Array.isArray(mailboxes) && mailboxes.length ? mailboxes : ['INBOX'];
  61. return [...new Set(list.map((mailbox) => normalizeMailboxLabel(mailbox)))];
  62. }
  63. async function getResponseErrorText(response) {
  64. const text = await response.text().catch(() => '');
  65. if (!text) {
  66. return response.statusText || `HTTP ${response.status}`;
  67. }
  68. try {
  69. const parsed = JSON.parse(text);
  70. return parsed.error_description || parsed.error?.message || parsed.error || parsed.message || text;
  71. } catch {
  72. return text;
  73. }
  74. }
  75. async function exchangeRefreshToken(clientId, refreshToken, options = {}) {
  76. const fetchImpl = getFetchImpl(options.fetchImpl);
  77. const strategy = resolveTokenStrategy(options.strategyName);
  78. const body = new URLSearchParams({
  79. client_id: clientId,
  80. grant_type: 'refresh_token',
  81. refresh_token: refreshToken,
  82. ...(strategy.extraData || {}),
  83. });
  84. const response = await fetchImpl(strategy.url, {
  85. method: 'POST',
  86. headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  87. body: body.toString(),
  88. signal: options.signal,
  89. });
  90. if (!response.ok) {
  91. throw new Error(`${strategy.name}: ${await getResponseErrorText(response)}`);
  92. }
  93. const data = await response.json();
  94. if (!data.access_token) {
  95. throw new Error(`${strategy.name}: token response missing access_token`);
  96. }
  97. return {
  98. ...data,
  99. tokenStrategy: strategy.name,
  100. };
  101. }
  102. async function fetchGraphMessages(accessToken, options = {}) {
  103. const fetchImpl = getFetchImpl(options.fetchImpl);
  104. const mailbox = normalizeMailboxLabel(options.mailbox);
  105. const top = Math.max(1, Math.min(Number(options.top) || 5, 30));
  106. const url = `${GRAPH_API_BASE}/${normalizeMailboxId(mailbox)}/messages?$top=${encodeURIComponent(top)}&$select=id,internetMessageId,subject,from,bodyPreview,receivedDateTime&$orderby=receivedDateTime desc`;
  107. const response = await fetchImpl(url, {
  108. method: 'GET',
  109. headers: {
  110. Accept: 'application/json',
  111. Authorization: `Bearer ${accessToken}`,
  112. },
  113. signal: options.signal,
  114. });
  115. if (!response.ok) {
  116. throw new Error(`graph: ${await getResponseErrorText(response)}`);
  117. }
  118. const payload = await response.json();
  119. return Array.isArray(payload?.value) ? payload.value : [];
  120. }
  121. async function fetchOutlookMessages(accessToken, options = {}) {
  122. const fetchImpl = getFetchImpl(options.fetchImpl);
  123. const mailbox = normalizeMailboxLabel(options.mailbox);
  124. const top = Math.max(1, Math.min(Number(options.top) || 5, 30));
  125. const url = `${OUTLOOK_API_BASE}/${normalizeMailboxId(mailbox)}/messages?$top=${encodeURIComponent(top)}&$select=Id,Subject,From,BodyPreview,Body,ReceivedDateTime&$orderby=ReceivedDateTime desc`;
  126. const response = await fetchImpl(url, {
  127. method: 'GET',
  128. headers: {
  129. Accept: 'application/json',
  130. Authorization: `Bearer ${accessToken}`,
  131. },
  132. signal: options.signal,
  133. });
  134. if (!response.ok) {
  135. throw new Error(`outlook: ${await getResponseErrorText(response)}`);
  136. }
  137. const payload = await response.json();
  138. return Array.isArray(payload?.value) ? payload.value : [];
  139. }
  140. function normalizeMessage(message, mailbox = 'INBOX') {
  141. const sender = message?.From || message?.from || {};
  142. const emailAddress = sender?.EmailAddress || sender?.emailAddress || {};
  143. return {
  144. mailbox: normalizeMailboxLabel(mailbox || message?.mailbox),
  145. from: {
  146. emailAddress: {
  147. address: String(emailAddress?.Address || emailAddress?.address || '').trim(),
  148. name: String(emailAddress?.Name || emailAddress?.name || '').trim(),
  149. },
  150. },
  151. subject: String(message?.Subject || message?.subject || '').trim(),
  152. receivedDateTime: String(message?.ReceivedDateTime || message?.receivedDateTime || '').trim(),
  153. bodyPreview: String(message?.BodyPreview || message?.bodyPreview || '').trim(),
  154. body: {
  155. content: String(message?.Body?.Content || message?.body?.content || '').trim(),
  156. },
  157. id: String(message?.Id || message?.id || message?.internetMessageId || '').trim(),
  158. };
  159. }
  160. function normalizeFilterValue(value) {
  161. return String(value || '').trim().toLowerCase();
  162. }
  163. function getMessageSender(message) {
  164. return String(
  165. message?.from?.emailAddress?.address
  166. || message?.sender?.emailAddress?.address
  167. || ''
  168. ).trim();
  169. }
  170. function getMessageTimestamp(message) {
  171. const value = Date.parse(message?.receivedDateTime || message?.createdDateTime || '');
  172. return Number.isFinite(value) ? value : 0;
  173. }
  174. function getMessageSearchText(message) {
  175. return [
  176. message?.subject,
  177. message?.bodyPreview,
  178. message?.body?.content,
  179. getMessageSender(message),
  180. ]
  181. .map((value) => String(value || ''))
  182. .join('\n');
  183. }
  184. function isOpenAiMessage(message) {
  185. const sender = getMessageSender(message);
  186. if (OPENAI_SENDER_PATTERNS.some((pattern) => pattern.test(sender))) {
  187. return true;
  188. }
  189. const searchText = getMessageSearchText(message);
  190. return OPENAI_SENDER_PATTERNS.some((pattern) => pattern.test(searchText));
  191. }
  192. function extractVerificationCodeFromMessages(messages, options = {}) {
  193. const filterAfterTimestamp = Number(options.filterAfterTimestamp || 0) || 0;
  194. const senderFilters = (options.senderFilters || []).map(normalizeFilterValue).filter(Boolean);
  195. const subjectFilters = (options.subjectFilters || []).map(normalizeFilterValue).filter(Boolean);
  196. const excludedCodes = new Set((options.excludeCodes || []).map((value) => String(value || '').trim()).filter(Boolean));
  197. const hasExplicitFilters = senderFilters.length > 0 || subjectFilters.length > 0;
  198. const sortedMessages = (Array.isArray(messages) ? messages : [])
  199. .map((raw) => normalizeMessage(raw, raw?.mailbox))
  200. .sort((left, right) => getMessageTimestamp(right) - getMessageTimestamp(left));
  201. for (const message of sortedMessages) {
  202. const receivedAt = getMessageTimestamp(message);
  203. if (receivedAt && receivedAt < filterAfterTimestamp) {
  204. continue;
  205. }
  206. const sender = normalizeFilterValue(getMessageSender(message));
  207. const subject = normalizeFilterValue(message?.subject);
  208. const preview = normalizeFilterValue(message?.bodyPreview);
  209. const searchText = normalizeFilterValue(getMessageSearchText(message));
  210. const codeMatch = getMessageSearchText(message).match(CODE_PATTERN);
  211. const code = codeMatch?.[1] || '';
  212. if (!code || excludedCodes.has(code)) {
  213. continue;
  214. }
  215. if (!hasExplicitFilters && !isOpenAiMessage(message)) {
  216. continue;
  217. }
  218. const senderMatched = senderFilters.length === 0
  219. ? true
  220. : senderFilters.some((filter) => sender.includes(filter) || preview.includes(filter) || searchText.includes(filter));
  221. const subjectMatched = subjectFilters.length === 0
  222. ? true
  223. : subjectFilters.some((filter) => subject.includes(filter) || preview.includes(filter) || searchText.includes(filter));
  224. if (!senderMatched && !subjectMatched) {
  225. continue;
  226. }
  227. return {
  228. code,
  229. emailTimestamp: receivedAt || Date.now(),
  230. messageId: message?.id || null,
  231. sender: getMessageSender(message),
  232. subject: String(message?.subject || ''),
  233. mailbox: message?.mailbox || 'INBOX',
  234. message,
  235. };
  236. }
  237. return null;
  238. }
  239. async function fetchMicrosoftMailboxMessages(options = {}) {
  240. const {
  241. clientId,
  242. refreshToken,
  243. mailbox = 'INBOX',
  244. top = 5,
  245. fetchImpl,
  246. signal,
  247. log = null,
  248. } = options;
  249. if (!refreshToken) {
  250. throw new Error('Microsoft refresh token is empty.');
  251. }
  252. if (!clientId) {
  253. throw new Error('Microsoft client_id is empty.');
  254. }
  255. const errors = [];
  256. for (const plan of TRANSPORT_PLANS) {
  257. for (const strategyName of plan.strategyNames) {
  258. try {
  259. const tokenData = await exchangeRefreshToken(clientId, refreshToken, {
  260. fetchImpl,
  261. signal,
  262. strategyName,
  263. });
  264. const rawMessages = plan.transport === 'graph'
  265. ? await fetchGraphMessages(tokenData.access_token, { mailbox, top, fetchImpl, signal })
  266. : await fetchOutlookMessages(tokenData.access_token, { mailbox, top, fetchImpl, signal });
  267. return {
  268. tokenData,
  269. nextRefreshToken: String(tokenData?.refresh_token || '').trim(),
  270. tokenStrategy: strategyName,
  271. transport: plan.transport,
  272. mailbox: normalizeMailboxLabel(mailbox),
  273. messages: rawMessages.map((message) => normalizeMessage(message, mailbox)),
  274. };
  275. } catch (error) {
  276. const message = error?.message || String(error);
  277. errors.push(`${plan.transport}/${strategyName}: ${message}`);
  278. if (typeof log === 'function') {
  279. log(`mailbox=${normalizeMailboxLabel(mailbox)} ${plan.transport}/${strategyName} failed: ${message}`);
  280. }
  281. }
  282. }
  283. }
  284. throw new Error(`Microsoft mailbox request failed: ${errors.join(' | ')}`);
  285. }
  286. function delay(timeoutMs, signal) {
  287. if (timeoutMs <= 0) {
  288. return Promise.resolve();
  289. }
  290. return new Promise((resolve, reject) => {
  291. const timer = setTimeout(() => {
  292. cleanup();
  293. resolve();
  294. }, timeoutMs);
  295. const onAbort = () => {
  296. cleanup();
  297. reject(signal.reason || new Error('Aborted'));
  298. };
  299. const cleanup = () => {
  300. clearTimeout(timer);
  301. signal?.removeEventListener('abort', onAbort);
  302. };
  303. if (signal?.aborted) {
  304. cleanup();
  305. reject(signal.reason || new Error('Aborted'));
  306. return;
  307. }
  308. signal?.addEventListener('abort', onAbort, { once: true });
  309. });
  310. }
  311. async function fetchMicrosoftVerificationCode(options = {}) {
  312. const {
  313. token,
  314. refreshToken,
  315. clientId,
  316. maxRetries = 3,
  317. retryDelayMs = 10000,
  318. top = 5,
  319. log = null,
  320. filterAfterTimestamp = 0,
  321. senderFilters = [],
  322. subjectFilters = [],
  323. excludeCodes = [],
  324. mailboxes = ['INBOX'],
  325. fetchImpl,
  326. signal,
  327. } = options;
  328. let workingRefreshToken = String(refreshToken || token || '').trim();
  329. if (!workingRefreshToken) {
  330. throw new Error('Microsoft refresh token is empty.');
  331. }
  332. if (!clientId) {
  333. throw new Error('Microsoft client_id is empty.');
  334. }
  335. const normalizedMailboxes = normalizeMailboxList(mailboxes);
  336. let lastError = null;
  337. for (let attempt = 1; attempt <= maxRetries; attempt += 1) {
  338. try {
  339. const collectedMessages = [];
  340. for (const mailbox of normalizedMailboxes) {
  341. const result = await fetchMicrosoftMailboxMessages({
  342. clientId,
  343. refreshToken: workingRefreshToken,
  344. mailbox,
  345. top,
  346. fetchImpl,
  347. signal,
  348. log,
  349. });
  350. if (result.nextRefreshToken) {
  351. workingRefreshToken = result.nextRefreshToken;
  352. }
  353. collectedMessages.push(...result.messages);
  354. }
  355. const match = extractVerificationCodeFromMessages(collectedMessages, {
  356. filterAfterTimestamp,
  357. senderFilters,
  358. subjectFilters,
  359. excludeCodes,
  360. });
  361. if (match) {
  362. return {
  363. ...match,
  364. nextRefreshToken: workingRefreshToken,
  365. messages: collectedMessages,
  366. };
  367. }
  368. lastError = new Error('No matching Microsoft verification email found.');
  369. } catch (error) {
  370. lastError = error;
  371. }
  372. if (attempt < maxRetries) {
  373. if (typeof log === 'function') {
  374. log(`attempt ${attempt}/${maxRetries} found no matching Microsoft mail, retrying...`);
  375. }
  376. await delay(retryDelayMs, signal);
  377. }
  378. }
  379. throw lastError || new Error('No matching Microsoft verification email found.');
  380. }
  381. const api = {
  382. CODE_PATTERN,
  383. exchangeRefreshToken,
  384. extractVerificationCodeFromMessages,
  385. fetchGraphMessages,
  386. fetchMicrosoftMailboxMessages,
  387. fetchMicrosoftVerificationCode,
  388. fetchOutlookMessages,
  389. getMessageSender,
  390. getMessageTimestamp,
  391. isOpenAiMessage,
  392. normalizeMailboxId,
  393. normalizeMailboxLabel,
  394. normalizeMessage,
  395. };
  396. globalScope.MultiPageMicrosoftEmail = api;
  397. if (typeof module !== 'undefined' && module.exports) {
  398. module.exports = api;
  399. }
  400. })(typeof globalThis !== 'undefined' ? globalThis : this);