@aglyn/plugins-outreach 1.0.0-beta.224 → 1.0.0-beta.225

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +10 -4
  2. package/package.json +14 -14
  3. package/src/lib/components/enrollment-detail.d.ts +8 -0
  4. package/src/lib/components/enrollment-detail.js +17 -5
  5. package/src/lib/components/enrollment-detail.js.map +1 -1
  6. package/src/lib/components/mailbox-card.d.ts +3 -3
  7. package/src/lib/components/mailbox-card.js +11 -5
  8. package/src/lib/components/mailbox-card.js.map +1 -1
  9. package/src/lib/components/mailboxes-section.d.ts +6 -5
  10. package/src/lib/components/mailboxes-section.js +61 -26
  11. package/src/lib/components/mailboxes-section.js.map +1 -1
  12. package/src/lib/components/sequence-detail.js +1 -1
  13. package/src/lib/components/sequence-detail.js.map +1 -1
  14. package/src/lib/components/sequence-editor.js +29 -4
  15. package/src/lib/components/sequence-editor.js.map +1 -1
  16. package/src/lib/components/sequence-mailbox-picker.d.ts +21 -0
  17. package/src/lib/components/sequence-mailbox-picker.js +74 -13
  18. package/src/lib/components/sequence-mailbox-picker.js.map +1 -1
  19. package/src/lib/components/use-outreach-mailbox-api.d.ts +10 -2
  20. package/src/lib/components/use-outreach-mailbox-api.js +7 -2
  21. package/src/lib/components/use-outreach-mailbox-api.js.map +1 -1
  22. package/src/lib/engine/enrollment-state.d.ts +6 -0
  23. package/src/lib/engine/enrollment-state.js +1 -1
  24. package/src/lib/engine/enrollment-state.js.map +1 -1
  25. package/src/lib/engine/index.d.ts +1 -0
  26. package/src/lib/engine/index.js +1 -0
  27. package/src/lib/engine/index.js.map +1 -1
  28. package/src/lib/engine/mailbox-notice.d.ts +5 -2
  29. package/src/lib/engine/mailbox-notice.js +6 -2
  30. package/src/lib/engine/mailbox-notice.js.map +1 -1
  31. package/src/lib/engine/mailbox-rotation.d.ts +41 -0
  32. package/src/lib/engine/mailbox-rotation.js +71 -0
  33. package/src/lib/engine/mailbox-rotation.js.map +1 -0
  34. package/src/lib/mailboxes/mailbox-api.d.ts +30 -11
  35. package/src/lib/mailboxes/mailbox-api.js +7 -3
  36. package/src/lib/mailboxes/mailbox-api.js.map +1 -1
  37. package/src/lib/mailboxes/mailbox-credentials.d.ts +15 -12
  38. package/src/lib/mailboxes/mailbox-credentials.js +12 -8
  39. package/src/lib/mailboxes/mailbox-credentials.js.map +1 -1
  40. package/src/lib/mailboxes/mailbox-erasure.js +3 -0
  41. package/src/lib/mailboxes/mailbox-erasure.js.map +1 -1
  42. package/src/lib/mailboxes/mailbox-revoke.d.ts +10 -5
  43. package/src/lib/mailboxes/mailbox-revoke.js +1 -0
  44. package/src/lib/mailboxes/mailbox-revoke.js.map +1 -1
  45. package/src/lib/mailboxes/mailbox-routes.d.ts +11 -3
  46. package/src/lib/mailboxes/mailbox-routes.js +218 -35
  47. package/src/lib/mailboxes/mailbox-routes.js.map +1 -1
  48. package/src/lib/mailboxes/mailbox-settings.d.ts +6 -2
  49. package/src/lib/mailboxes/mailbox-settings.js +5 -1
  50. package/src/lib/mailboxes/mailbox-settings.js.map +1 -1
  51. package/src/lib/mailboxes/mailbox-transport.d.ts +15 -9
  52. package/src/lib/mailboxes/mailbox-transport.js +47 -17
  53. package/src/lib/mailboxes/mailbox-transport.js.map +1 -1
  54. package/src/lib/mailboxes/oauth-state.d.ts +11 -3
  55. package/src/lib/mailboxes/oauth-state.js +17 -8
  56. package/src/lib/mailboxes/oauth-state.js.map +1 -1
  57. package/src/lib/mailboxes/outreach-config.d.ts +30 -0
  58. package/src/lib/mailboxes/outreach-config.js +53 -10
  59. package/src/lib/mailboxes/outreach-config.js.map +1 -1
  60. package/src/lib/mailboxes/register-mailbox-routes.js +2 -1
  61. package/src/lib/mailboxes/register-mailbox-routes.js.map +1 -1
  62. package/src/lib/model/outreach.types.d.ts +13 -2
  63. package/src/lib/model/outreach.types.js +7 -2
  64. package/src/lib/model/outreach.types.js.map +1 -1
  65. package/src/lib/model/sequence-draft.d.ts +6 -1
  66. package/src/lib/model/sequence-draft.js +16 -4
  67. package/src/lib/model/sequence-draft.js.map +1 -1
  68. package/src/lib/routes/enroll-routes.js +52 -1
  69. package/src/lib/routes/enroll-routes.js.map +1 -1
  70. package/src/lib/routes/sequence-routes.js +11 -3
  71. package/src/lib/routes/sequence-routes.js.map +1 -1
  72. package/src/lib/runtime/fixtures/fake-gmail.d.ts +18 -3
  73. package/src/lib/runtime/fixtures/fake-gmail.js +21 -1
  74. package/src/lib/runtime/fixtures/fake-gmail.js.map +1 -1
  75. package/src/lib/runtime/mailbox-notices.js +2 -2
  76. package/src/lib/runtime/mailbox-notices.js.map +1 -1
  77. package/src/lib/runtime/send-job.js +2 -8
  78. package/src/lib/runtime/send-job.js.map +1 -1
  79. package/src/lib/runtime/sync-job.js +24 -11
  80. package/src/lib/runtime/sync-job.js.map +1 -1
  81. package/src/lib/subprocessors.d.ts +12 -1
  82. package/src/lib/subprocessors.js +25 -2
  83. package/src/lib/subprocessors.js.map +1 -1
  84. package/src/lib/transfer/sequences-package.js +7 -2
  85. package/src/lib/transfer/sequences-package.js.map +1 -1
  86. package/src/lib/transport/gmail-client.d.ts +4 -11
  87. package/src/lib/transport/gmail-client.js +42 -17
  88. package/src/lib/transport/gmail-client.js.map +1 -1
  89. package/src/lib/transport/graph-client.d.ts +87 -0
  90. package/src/lib/transport/graph-client.js +359 -0
  91. package/src/lib/transport/graph-client.js.map +1 -0
  92. package/src/lib/transport/mail-client.d.ts +138 -0
  93. package/src/lib/transport/mail-client.js +34 -0
  94. package/src/lib/transport/mail-client.js.map +1 -0
  95. package/src/lib/transport/microsoft-errors.d.ts +32 -0
  96. package/src/lib/transport/microsoft-errors.js +94 -0
  97. package/src/lib/transport/microsoft-errors.js.map +1 -0
  98. package/src/lib/transport/microsoft-oauth.d.ts +140 -0
  99. package/src/lib/transport/microsoft-oauth.js +225 -0
  100. package/src/lib/transport/microsoft-oauth.js.map +1 -0
  101. package/src/lib/transport/mime-message.d.ts +30 -0
  102. package/src/lib/transport/mime-message.js +235 -0
  103. package/src/lib/transport/mime-message.js.map +1 -0
  104. package/src/lib/transport/send-message.d.ts +15 -8
  105. package/src/lib/transport/send-message.js +4 -3
  106. package/src/lib/transport/send-message.js.map +1 -1
@@ -17,6 +17,7 @@
17
17
  import { GmailTransportError, gmailApiError } from "./gmail-errors.js";
18
18
  import { refreshGoogleAccessToken, revokeGoogleToken } from "./google-oauth.js";
19
19
  import { fetchWithBackoff, retryAfterMs } from "./http.js";
20
+ import { bareMessageId, gmailSearchQuery } from "./mail-client.js";
20
21
  /**
21
22
  * THE GMAIL REST TRANSPORT (AGL-2978): one connected mailbox, as the calls
22
23
  * Outreach makes against it.
@@ -142,7 +143,29 @@ function metadataQuery(headers) {
142
143
  });
143
144
  }
144
145
  };
146
+ const listMessages = async (listOptions)=>{
147
+ const query = new URLSearchParams({
148
+ q: listOptions.q
149
+ });
150
+ if (listOptions.maxResults) query.set('maxResults', String(listOptions.maxResults));
151
+ if (listOptions.pageToken) query.set('pageToken', listOptions.pageToken);
152
+ if (listOptions.includeSpamTrash) query.set('includeSpamTrash', 'true');
153
+ const body = await call(`/messages?${query.toString()}`);
154
+ const messages = Array.isArray(body['messages']) ? body['messages'] : [];
155
+ return {
156
+ messages: messages.map((entry)=>{
157
+ const record = entry != null ? entry : {};
158
+ return {
159
+ id: text(record['id']),
160
+ threadId: text(record['threadId'])
161
+ };
162
+ }),
163
+ nextPageToken: text(body['nextPageToken']) || null,
164
+ resultSizeEstimate: Number(body['resultSizeEstimate']) || 0
165
+ };
166
+ };
145
167
  return {
168
+ provider: 'google',
146
169
  getAccessToken,
147
170
  async getProfile () {
148
171
  const body = await call('/profile');
@@ -199,27 +222,29 @@ function metadataQuery(headers) {
199
222
  async getFullMessage (messageId) {
200
223
  return readFullMessage(await call(`/messages/${encodeURIComponent(messageId)}?format=full`));
201
224
  },
202
- async listMessages (listOptions) {
203
- const query = new URLSearchParams({
204
- q: listOptions.q
225
+ listMessages,
226
+ async searchMessages (search, page) {
227
+ var _ref, _ref1;
228
+ const list = await listMessages({
229
+ q: gmailSearchQuery(search),
230
+ maxResults: (_ref = page == null ? void 0 : page.maxResults) != null ? _ref : 100,
231
+ pageToken: (_ref1 = page == null ? void 0 : page.pageToken) != null ? _ref1 : null,
232
+ includeSpamTrash: true
205
233
  });
206
- if (listOptions.maxResults) query.set('maxResults', String(listOptions.maxResults));
207
- if (listOptions.pageToken) query.set('pageToken', listOptions.pageToken);
208
- if (listOptions.includeSpamTrash) query.set('includeSpamTrash', 'true');
209
- const body = await call(`/messages?${query.toString()}`);
210
- const messages = Array.isArray(body['messages']) ? body['messages'] : [];
211
234
  return {
212
- messages: messages.map((entry)=>{
213
- const record = entry != null ? entry : {};
214
- return {
215
- id: text(record['id']),
216
- threadId: text(record['threadId'])
217
- };
218
- }),
219
- nextPageToken: text(body['nextPageToken']) || null,
220
- resultSizeEstimate: Number(body['resultSizeEstimate']) || 0
235
+ messages: list.messages.filter((message)=>message.id),
236
+ nextPageToken: list.nextPageToken
221
237
  };
222
238
  },
239
+ async findMessageByMessageId (messageId) {
240
+ var _found_messages_find;
241
+ const found = await listMessages({
242
+ q: `rfc822msgid:${bareMessageId(messageId)}`,
243
+ maxResults: 1,
244
+ includeSpamTrash: true
245
+ });
246
+ return (_found_messages_find = found.messages.find((message)=>message.id)) != null ? _found_messages_find : null;
247
+ },
223
248
  revoke () {
224
249
  return revokeGoogleToken(options.refreshToken, deps);
225
250
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/transport/gmail-client.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { GmailApiMessage } from '../engine/thread-message'\nimport { GmailTransportError, gmailApiError } from './gmail-errors'\nimport {\n refreshGoogleAccessToken,\n revokeGoogleToken,\n} from './google-oauth'\nimport { fetchWithBackoff, retryAfterMs, type TransportDeps } from './http'\n\n/**\n * THE GMAIL REST TRANSPORT (AGL-2978): one connected mailbox, as the calls\n * Outreach makes against it.\n *\n * Built per mailbox, per invocation, from the mailbox's refresh token. The\n * access token is minted on first use and reused until a minute before it\n * expires, so one cron pass over one mailbox — a send, a thread read, a\n * bounce search — refreshes once. Concurrent callers share the one refresh\n * in flight.\n *\n * A 401 from Gmail drops the cached token and retries the call once with a\n * fresh one: an access token can be refused before its stated expiry (a\n * password change, an admin action), and that is not yet a dead grant. If\n * the REFRESH is refused, it is — `invalid_grant`, `reconnectRequired`.\n *\n * Every failure is a `GmailTransportError`; see `gmail-errors.ts` for how a\n * caller decides between reconnecting, retrying later and giving up.\n */\n\nexport const GMAIL_API_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me'\n\nexport interface GmailClientOptions extends TransportDeps {\n clientId: string\n clientSecret: string\n refreshToken: string\n}\n\n/** `users.getProfile`. */\nexport interface GmailProfile {\n emailAddress: string\n messagesTotal: number\n threadsTotal: number\n historyId: string\n}\n\n/** One entry of `users.settings.sendAs.list`. */\nexport interface GmailSendAs {\n sendAsEmail: string\n displayName: string\n replyToAddress: string | null\n isPrimary: boolean\n isDefault: boolean\n treatAsAlias: boolean\n /**\n * `accepted` once Gmail verified the address; absent on the primary\n * address, which needs no verification.\n */\n verificationStatus: string | null\n}\n\nexport interface GmailMessageHeader {\n name: string\n value: string\n}\n\n/** A message in `format=metadata`: ids, labels, snippet and the headers asked for. */\nexport interface GmailMessageMetadata {\n id: string\n threadId: string\n labelIds: string[]\n snippet: string\n internalDateMs: number | null\n headers: GmailMessageHeader[]\n}\n\nexport interface GmailThread {\n id: string\n historyId: string | null\n messages: GmailMessageMetadata[]\n}\n\n/**\n * A thread in `format=full`: every message as Gmail returns it — headers,\n * MIME tree and base64url bodies — which is what the engine's\n * `outreachThreadMessageFromGmail` reads replies and bounces from.\n */\nexport interface GmailFullThread {\n id: string\n historyId: string | null\n messages: GmailApiMessage[]\n}\n\nexport interface GmailMessageList {\n messages: Array<{ id: string; threadId: string }>\n nextPageToken: string | null\n resultSizeEstimate: number\n}\n\nexport interface GmailSentMessage {\n id: string\n threadId: string\n labelIds: string[]\n}\n\nexport interface GmailClient {\n /** A live access token, minted or reused. */\n getAccessToken(): Promise<string>\n getProfile(): Promise<GmailProfile>\n listSendAs(): Promise<GmailSendAs[]>\n /**\n * `messages.send`: `raw` is the base64url RFC 5322 message\n * (`encodeGmailRawMessage`), and `threadId` files a follow-up into the\n * thread its first step started.\n */\n sendMessage(input: { raw: string; threadId?: string | null }): Promise<GmailSentMessage>\n /** `threads.get` with `format=metadata` and only the headers named. */\n getThread(threadId: string, options?: { metadataHeaders?: readonly string[] }): Promise<GmailThread>\n /**\n * `threads.get` with `format=full`: every message whole, unflattened, for\n * the engine's classifier — a reply's text, a bounce's delivery report.\n */\n getFullThread(threadId: string): Promise<GmailFullThread>\n /** `messages.get` with `format=metadata` and only the headers named. */\n getMessage(messageId: string, options?: { metadataHeaders?: readonly string[] }): Promise<GmailMessageMetadata>\n /** `messages.get` with `format=full` — a message a search found, read whole. */\n getFullMessage(messageId: string): Promise<GmailApiMessage>\n /** `messages.list` with a Gmail search `q`. */\n listMessages(options: {\n q: string\n maxResults?: number\n pageToken?: string | null\n includeSpamTrash?: boolean\n }): Promise<GmailMessageList>\n /** Revokes the grant at Google. `already-invalid` when it was dead. */\n revoke(): Promise<'revoked' | 'already-invalid'>\n}\n\n/** Refresh this long before the stated expiry, so a slow call cannot cross it. */\nconst EXPIRY_MARGIN_MS = 60_000\n\nconst text = (value: unknown): string => (typeof value === 'string' ? value : '')\n\nfunction readMessage(value: unknown): GmailMessageMetadata {\n const record = (value ?? {}) as Record<string, unknown>\n const payload = (record['payload'] ?? {}) as { headers?: unknown }\n const headers = Array.isArray(payload.headers) ? payload.headers : []\n const internalDate = Number(record['internalDate'])\n return {\n id: text(record['id']),\n threadId: text(record['threadId']),\n labelIds: Array.isArray(record['labelIds']) ? record['labelIds'].map(String) : [],\n snippet: text(record['snippet']),\n internalDateMs: Number.isFinite(internalDate) ? internalDate : null,\n headers: headers\n .map((header) => header as { name?: unknown; value?: unknown })\n .filter((header) => typeof header.name === 'string')\n .map((header) => ({ name: String(header.name), value: text(header.value) })),\n }\n}\n\nfunction readSendAs(value: unknown): GmailSendAs {\n const record = (value ?? {}) as Record<string, unknown>\n return {\n sendAsEmail: text(record['sendAsEmail']).trim().toLowerCase(),\n displayName: text(record['displayName']),\n replyToAddress: text(record['replyToAddress']) || null,\n isPrimary: record['isPrimary'] === true,\n isDefault: record['isDefault'] === true,\n treatAsAlias: record['treatAsAlias'] === true,\n verificationStatus: text(record['verificationStatus']) || null,\n }\n}\n\n/**\n * A `format=full` message, as Gmail sent it. Only the id is checked here: the\n * engine's reader decodes the rest defensively and never throws.\n */\nfunction readFullMessage(value: unknown): GmailApiMessage {\n if (!value || typeof value !== 'object' || Array.isArray(value)) {\n throw new GmailTransportError('unexpected', 'Gmail answered with a message that is not an object.')\n }\n const record = value as Record<string, unknown>\n return { ...(record as unknown as GmailApiMessage), id: text(record['id']) }\n}\n\nfunction metadataQuery(headers: readonly string[] | undefined): string {\n const query = new URLSearchParams({ format: 'metadata' })\n for (const header of headers ?? []) query.append('metadataHeaders', header)\n return query.toString()\n}\n\n/** A Gmail client for one mailbox. See the module comment. */\nexport function createGmailClient(options: GmailClientOptions): GmailClient {\n const deps: TransportDeps = options\n const now = options.now ?? Date.now\n let cached: { token: string; expiresAtMs: number } | null = null\n let refreshing: Promise<string> | null = null\n\n const mint = (): Promise<string> => {\n refreshing ??= refreshGoogleAccessToken(\n {\n clientId: options.clientId,\n clientSecret: options.clientSecret,\n refreshToken: options.refreshToken,\n },\n deps,\n )\n .then((minted) => {\n cached = {\n token: minted.accessToken,\n expiresAtMs: now() + minted.expiresInSeconds * 1000,\n }\n return minted.accessToken\n })\n .finally(() => {\n refreshing = null\n })\n return refreshing\n }\n\n const getAccessToken = async (): Promise<string> => {\n if (cached && cached.expiresAtMs - EXPIRY_MARGIN_MS > now()) return cached.token\n return mint()\n }\n\n /** One Gmail API call: authorized, retried per policy, 401 once refreshed. */\n const call = async <T>(path: string, init: RequestInit = {}): Promise<T> => {\n for (let tries = 0; ; tries += 1) {\n const token = await getAccessToken()\n const response = await fetchWithBackoff(\n `${GMAIL_API_BASE}${path}`,\n {\n ...init,\n headers: {\n Accept: 'application/json',\n ...(init.body ? { 'Content-Type': 'application/json' } : {}),\n ...(init.headers as Record<string, string> | undefined),\n Authorization: `Bearer ${token}`,\n },\n },\n deps,\n )\n if (response.status === 401 && tries === 0) {\n cached = null\n continue\n }\n const body = await response.json().catch(() => null)\n if (response.ok) {\n if (body === null) {\n throw new GmailTransportError('unexpected', 'Gmail answered with a body that is not JSON.', {\n status: response.status,\n })\n }\n return body as T\n }\n throw gmailApiError(response.status, body, { retryAfterMs: retryAfterMs(response, now()) })\n }\n }\n\n return {\n getAccessToken,\n\n async getProfile() {\n const body = await call<Record<string, unknown>>('/profile')\n return {\n emailAddress: text(body['emailAddress']).trim().toLowerCase(),\n messagesTotal: Number(body['messagesTotal']) || 0,\n threadsTotal: Number(body['threadsTotal']) || 0,\n historyId: text(body['historyId']),\n }\n },\n\n async listSendAs() {\n const body = await call<{ sendAs?: unknown }>('/settings/sendAs')\n return (Array.isArray(body.sendAs) ? body.sendAs : []).map(readSendAs)\n },\n\n async sendMessage(input) {\n if (!input?.raw) {\n throw new GmailTransportError('invalid_request', 'A message to send needs its raw content.')\n }\n const body = await call<Record<string, unknown>>('/messages/send', {\n method: 'POST',\n body: JSON.stringify({ raw: input.raw, ...(input.threadId ? { threadId: input.threadId } : {}) }),\n })\n return {\n id: text(body['id']),\n threadId: text(body['threadId']),\n labelIds: Array.isArray(body['labelIds']) ? body['labelIds'].map(String) : [],\n }\n },\n\n async getThread(threadId, threadOptions) {\n const body = await call<Record<string, unknown>>(\n `/threads/${encodeURIComponent(threadId)}?${metadataQuery(threadOptions?.metadataHeaders)}`,\n )\n return {\n id: text(body['id']),\n historyId: text(body['historyId']) || null,\n messages: (Array.isArray(body['messages']) ? body['messages'] : []).map(readMessage),\n }\n },\n\n async getFullThread(threadId) {\n const body = await call<Record<string, unknown>>(\n `/threads/${encodeURIComponent(threadId)}?format=full`,\n )\n return {\n id: text(body['id']),\n historyId: text(body['historyId']) || null,\n // An entry that is not a message object is not one to classify.\n messages: (Array.isArray(body['messages']) ? body['messages'] : [])\n .filter((entry) => Boolean(entry) && typeof entry === 'object' && !Array.isArray(entry))\n .map(readFullMessage),\n }\n },\n\n async getMessage(messageId, messageOptions) {\n const body = await call<unknown>(\n `/messages/${encodeURIComponent(messageId)}?${metadataQuery(messageOptions?.metadataHeaders)}`,\n )\n return readMessage(body)\n },\n\n async getFullMessage(messageId) {\n return readFullMessage(await call<unknown>(`/messages/${encodeURIComponent(messageId)}?format=full`))\n },\n\n async listMessages(listOptions) {\n const query = new URLSearchParams({ q: listOptions.q })\n if (listOptions.maxResults) query.set('maxResults', String(listOptions.maxResults))\n if (listOptions.pageToken) query.set('pageToken', listOptions.pageToken)\n if (listOptions.includeSpamTrash) query.set('includeSpamTrash', 'true')\n const body = await call<Record<string, unknown>>(`/messages?${query.toString()}`)\n const messages = Array.isArray(body['messages']) ? body['messages'] : []\n return {\n messages: messages.map((entry) => {\n const record = (entry ?? {}) as Record<string, unknown>\n return { id: text(record['id']), threadId: text(record['threadId']) }\n }),\n nextPageToken: text(body['nextPageToken']) || null,\n resultSizeEstimate: Number(body['resultSizeEstimate']) || 0,\n }\n },\n\n revoke() {\n return revokeGoogleToken(options.refreshToken, deps)\n },\n }\n}\n"],"names":["GmailTransportError","gmailApiError","refreshGoogleAccessToken","revokeGoogleToken","fetchWithBackoff","retryAfterMs","GMAIL_API_BASE","EXPIRY_MARGIN_MS","text","value","readMessage","record","payload","headers","Array","isArray","internalDate","Number","id","threadId","labelIds","map","String","snippet","internalDateMs","isFinite","header","filter","name","readSendAs","sendAsEmail","trim","toLowerCase","displayName","replyToAddress","isPrimary","isDefault","treatAsAlias","verificationStatus","readFullMessage","metadataQuery","query","URLSearchParams","format","append","toString","createGmailClient","options","deps","now","Date","cached","refreshing","mint","clientId","clientSecret","refreshToken","then","minted","token","accessToken","expiresAtMs","expiresInSeconds","finally","getAccessToken","call","path","init","tries","response","Accept","body","Authorization","status","json","catch","ok","getProfile","emailAddress","messagesTotal","threadsTotal","historyId","listSendAs","sendAs","sendMessage","input","raw","method","JSON","stringify","getThread","threadOptions","encodeURIComponent","metadataHeaders","messages","getFullThread","entry","Boolean","getMessage","messageId","messageOptions","getFullMessage","listMessages","listOptions","q","maxResults","set","pageToken","includeSpamTrash","nextPageToken","resultSizeEstimate","revoke"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/transport/gmail-client.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { GmailApiMessage } from '../engine/thread-message'\nimport { GmailTransportError, gmailApiError } from './gmail-errors'\nimport {\n refreshGoogleAccessToken,\n revokeGoogleToken,\n} from './google-oauth'\nimport { fetchWithBackoff, retryAfterMs, type TransportDeps } from './http'\nimport {\n bareMessageId,\n gmailSearchQuery,\n type OutreachMailClient,\n type OutreachMailMetadata,\n} from './mail-client'\n\n/**\n * THE GMAIL REST TRANSPORT (AGL-2978): one connected mailbox, as the calls\n * Outreach makes against it.\n *\n * Built per mailbox, per invocation, from the mailbox's refresh token. The\n * access token is minted on first use and reused until a minute before it\n * expires, so one cron pass over one mailbox — a send, a thread read, a\n * bounce search — refreshes once. Concurrent callers share the one refresh\n * in flight.\n *\n * A 401 from Gmail drops the cached token and retries the call once with a\n * fresh one: an access token can be refused before its stated expiry (a\n * password change, an admin action), and that is not yet a dead grant. If\n * the REFRESH is refused, it is — `invalid_grant`, `reconnectRequired`.\n *\n * Every failure is a `GmailTransportError`; see `gmail-errors.ts` for how a\n * caller decides between reconnecting, retrying later and giving up.\n */\n\nexport const GMAIL_API_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me'\n\nexport interface GmailClientOptions extends TransportDeps {\n clientId: string\n clientSecret: string\n refreshToken: string\n}\n\n/** `users.getProfile`. */\nexport interface GmailProfile {\n emailAddress: string\n messagesTotal: number\n threadsTotal: number\n historyId: string\n}\n\n/** One entry of `users.settings.sendAs.list`. */\nexport interface GmailSendAs {\n sendAsEmail: string\n displayName: string\n replyToAddress: string | null\n isPrimary: boolean\n isDefault: boolean\n treatAsAlias: boolean\n /**\n * `accepted` once Gmail verified the address; absent on the primary\n * address, which needs no verification.\n */\n verificationStatus: string | null\n}\n\nexport interface GmailMessageHeader {\n name: string\n value: string\n}\n\n/** A message in `format=metadata`: ids, labels, snippet and the headers asked for. */\nexport type GmailMessageMetadata = OutreachMailMetadata\n\nexport interface GmailThread {\n id: string\n historyId: string | null\n messages: GmailMessageMetadata[]\n}\n\n/**\n * A thread in `format=full`: every message as Gmail returns it — headers,\n * MIME tree and base64url bodies — which is what the engine's\n * `outreachThreadMessageFromGmail` reads replies and bounces from.\n */\nexport interface GmailFullThread {\n id: string\n historyId: string | null\n messages: GmailApiMessage[]\n}\n\nexport interface GmailMessageList {\n messages: Array<{ id: string; threadId: string }>\n nextPageToken: string | null\n resultSizeEstimate: number\n}\n\nexport interface GmailSentMessage {\n id: string\n threadId: string\n labelIds: string[]\n}\n\nexport interface GmailClient extends OutreachMailClient {\n readonly provider: 'google'\n getProfile(): Promise<GmailProfile>\n listSendAs(): Promise<GmailSendAs[]>\n /**\n * `messages.send`: `raw` is the base64url RFC 5322 message\n * (`encodeGmailRawMessage`), and `threadId` files a follow-up into the\n * thread its first step started.\n */\n sendMessage(input: { raw: string; threadId?: string | null }): Promise<GmailSentMessage>\n /** `threads.get` with `format=metadata` and only the headers named. */\n getThread(threadId: string, options?: { metadataHeaders?: readonly string[] }): Promise<GmailThread>\n /**\n * `threads.get` with `format=full`: every message whole, unflattened, for\n * the engine's classifier — a reply's text, a bounce's delivery report.\n */\n getFullThread(threadId: string): Promise<GmailFullThread>\n /** `messages.get` with `format=metadata` and only the headers named. */\n getMessage(messageId: string, options?: { metadataHeaders?: readonly string[] }): Promise<GmailMessageMetadata>\n /** `messages.get` with `format=full` — a message a search found, read whole. */\n getFullMessage(messageId: string): Promise<GmailApiMessage>\n /** `messages.list` with a Gmail search `q`. */\n listMessages(options: {\n q: string\n maxResults?: number\n pageToken?: string | null\n includeSpamTrash?: boolean\n }): Promise<GmailMessageList>\n /** Revokes the grant at Google. `already-invalid` when it was dead. */\n revoke(): Promise<'revoked' | 'already-invalid'>\n}\n\n/** Refresh this long before the stated expiry, so a slow call cannot cross it. */\nconst EXPIRY_MARGIN_MS = 60_000\n\nconst text = (value: unknown): string => (typeof value === 'string' ? value : '')\n\nfunction readMessage(value: unknown): GmailMessageMetadata {\n const record = (value ?? {}) as Record<string, unknown>\n const payload = (record['payload'] ?? {}) as { headers?: unknown }\n const headers = Array.isArray(payload.headers) ? payload.headers : []\n const internalDate = Number(record['internalDate'])\n return {\n id: text(record['id']),\n threadId: text(record['threadId']),\n labelIds: Array.isArray(record['labelIds']) ? record['labelIds'].map(String) : [],\n snippet: text(record['snippet']),\n internalDateMs: Number.isFinite(internalDate) ? internalDate : null,\n headers: headers\n .map((header) => header as { name?: unknown; value?: unknown })\n .filter((header) => typeof header.name === 'string')\n .map((header) => ({ name: String(header.name), value: text(header.value) })),\n }\n}\n\nfunction readSendAs(value: unknown): GmailSendAs {\n const record = (value ?? {}) as Record<string, unknown>\n return {\n sendAsEmail: text(record['sendAsEmail']).trim().toLowerCase(),\n displayName: text(record['displayName']),\n replyToAddress: text(record['replyToAddress']) || null,\n isPrimary: record['isPrimary'] === true,\n isDefault: record['isDefault'] === true,\n treatAsAlias: record['treatAsAlias'] === true,\n verificationStatus: text(record['verificationStatus']) || null,\n }\n}\n\n/**\n * A `format=full` message, as Gmail sent it. Only the id is checked here: the\n * engine's reader decodes the rest defensively and never throws.\n */\nfunction readFullMessage(value: unknown): GmailApiMessage {\n if (!value || typeof value !== 'object' || Array.isArray(value)) {\n throw new GmailTransportError('unexpected', 'Gmail answered with a message that is not an object.')\n }\n const record = value as Record<string, unknown>\n return { ...(record as unknown as GmailApiMessage), id: text(record['id']) }\n}\n\nfunction metadataQuery(headers: readonly string[] | undefined): string {\n const query = new URLSearchParams({ format: 'metadata' })\n for (const header of headers ?? []) query.append('metadataHeaders', header)\n return query.toString()\n}\n\n/** A Gmail client for one mailbox. See the module comment. */\nexport function createGmailClient(options: GmailClientOptions): GmailClient {\n const deps: TransportDeps = options\n const now = options.now ?? Date.now\n let cached: { token: string; expiresAtMs: number } | null = null\n let refreshing: Promise<string> | null = null\n\n const mint = (): Promise<string> => {\n refreshing ??= refreshGoogleAccessToken(\n {\n clientId: options.clientId,\n clientSecret: options.clientSecret,\n refreshToken: options.refreshToken,\n },\n deps,\n )\n .then((minted) => {\n cached = {\n token: minted.accessToken,\n expiresAtMs: now() + minted.expiresInSeconds * 1000,\n }\n return minted.accessToken\n })\n .finally(() => {\n refreshing = null\n })\n return refreshing\n }\n\n const getAccessToken = async (): Promise<string> => {\n if (cached && cached.expiresAtMs - EXPIRY_MARGIN_MS > now()) return cached.token\n return mint()\n }\n\n /** One Gmail API call: authorized, retried per policy, 401 once refreshed. */\n const call = async <T>(path: string, init: RequestInit = {}): Promise<T> => {\n for (let tries = 0; ; tries += 1) {\n const token = await getAccessToken()\n const response = await fetchWithBackoff(\n `${GMAIL_API_BASE}${path}`,\n {\n ...init,\n headers: {\n Accept: 'application/json',\n ...(init.body ? { 'Content-Type': 'application/json' } : {}),\n ...(init.headers as Record<string, string> | undefined),\n Authorization: `Bearer ${token}`,\n },\n },\n deps,\n )\n if (response.status === 401 && tries === 0) {\n cached = null\n continue\n }\n const body = await response.json().catch(() => null)\n if (response.ok) {\n if (body === null) {\n throw new GmailTransportError('unexpected', 'Gmail answered with a body that is not JSON.', {\n status: response.status,\n })\n }\n return body as T\n }\n throw gmailApiError(response.status, body, { retryAfterMs: retryAfterMs(response, now()) })\n }\n }\n\n const listMessages: GmailClient['listMessages'] = async (listOptions) => {\n const query = new URLSearchParams({ q: listOptions.q })\n if (listOptions.maxResults) query.set('maxResults', String(listOptions.maxResults))\n if (listOptions.pageToken) query.set('pageToken', listOptions.pageToken)\n if (listOptions.includeSpamTrash) query.set('includeSpamTrash', 'true')\n const body = await call<Record<string, unknown>>(`/messages?${query.toString()}`)\n const messages = Array.isArray(body['messages']) ? body['messages'] : []\n return {\n messages: messages.map((entry) => {\n const record = (entry ?? {}) as Record<string, unknown>\n return { id: text(record['id']), threadId: text(record['threadId']) }\n }),\n nextPageToken: text(body['nextPageToken']) || null,\n resultSizeEstimate: Number(body['resultSizeEstimate']) || 0,\n }\n }\n\n return {\n provider: 'google',\n getAccessToken,\n\n async getProfile() {\n const body = await call<Record<string, unknown>>('/profile')\n return {\n emailAddress: text(body['emailAddress']).trim().toLowerCase(),\n messagesTotal: Number(body['messagesTotal']) || 0,\n threadsTotal: Number(body['threadsTotal']) || 0,\n historyId: text(body['historyId']),\n }\n },\n\n async listSendAs() {\n const body = await call<{ sendAs?: unknown }>('/settings/sendAs')\n return (Array.isArray(body.sendAs) ? body.sendAs : []).map(readSendAs)\n },\n\n async sendMessage(input) {\n if (!input?.raw) {\n throw new GmailTransportError('invalid_request', 'A message to send needs its raw content.')\n }\n const body = await call<Record<string, unknown>>('/messages/send', {\n method: 'POST',\n body: JSON.stringify({ raw: input.raw, ...(input.threadId ? { threadId: input.threadId } : {}) }),\n })\n return {\n id: text(body['id']),\n threadId: text(body['threadId']),\n labelIds: Array.isArray(body['labelIds']) ? body['labelIds'].map(String) : [],\n }\n },\n\n async getThread(threadId, threadOptions) {\n const body = await call<Record<string, unknown>>(\n `/threads/${encodeURIComponent(threadId)}?${metadataQuery(threadOptions?.metadataHeaders)}`,\n )\n return {\n id: text(body['id']),\n historyId: text(body['historyId']) || null,\n messages: (Array.isArray(body['messages']) ? body['messages'] : []).map(readMessage),\n }\n },\n\n async getFullThread(threadId) {\n const body = await call<Record<string, unknown>>(\n `/threads/${encodeURIComponent(threadId)}?format=full`,\n )\n return {\n id: text(body['id']),\n historyId: text(body['historyId']) || null,\n // An entry that is not a message object is not one to classify.\n messages: (Array.isArray(body['messages']) ? body['messages'] : [])\n .filter((entry) => Boolean(entry) && typeof entry === 'object' && !Array.isArray(entry))\n .map(readFullMessage),\n }\n },\n\n async getMessage(messageId, messageOptions) {\n const body = await call<unknown>(\n `/messages/${encodeURIComponent(messageId)}?${metadataQuery(messageOptions?.metadataHeaders)}`,\n )\n return readMessage(body)\n },\n\n async getFullMessage(messageId) {\n return readFullMessage(await call<unknown>(`/messages/${encodeURIComponent(messageId)}?format=full`))\n },\n\n listMessages,\n\n async searchMessages(search, page) {\n const list = await listMessages({\n q: gmailSearchQuery(search),\n maxResults: page?.maxResults ?? 100,\n pageToken: page?.pageToken ?? null,\n includeSpamTrash: true,\n })\n return { messages: list.messages.filter((message) => message.id), nextPageToken: list.nextPageToken }\n },\n\n async findMessageByMessageId(messageId) {\n const found = await listMessages({\n q: `rfc822msgid:${bareMessageId(messageId)}`,\n maxResults: 1,\n includeSpamTrash: true,\n })\n return found.messages.find((message) => message.id) ?? null\n },\n\n revoke() {\n return revokeGoogleToken(options.refreshToken, deps)\n },\n }\n}\n"],"names":["GmailTransportError","gmailApiError","refreshGoogleAccessToken","revokeGoogleToken","fetchWithBackoff","retryAfterMs","bareMessageId","gmailSearchQuery","GMAIL_API_BASE","EXPIRY_MARGIN_MS","text","value","readMessage","record","payload","headers","Array","isArray","internalDate","Number","id","threadId","labelIds","map","String","snippet","internalDateMs","isFinite","header","filter","name","readSendAs","sendAsEmail","trim","toLowerCase","displayName","replyToAddress","isPrimary","isDefault","treatAsAlias","verificationStatus","readFullMessage","metadataQuery","query","URLSearchParams","format","append","toString","createGmailClient","options","deps","now","Date","cached","refreshing","mint","clientId","clientSecret","refreshToken","then","minted","token","accessToken","expiresAtMs","expiresInSeconds","finally","getAccessToken","call","path","init","tries","response","Accept","body","Authorization","status","json","catch","ok","listMessages","listOptions","q","maxResults","set","pageToken","includeSpamTrash","messages","entry","nextPageToken","resultSizeEstimate","provider","getProfile","emailAddress","messagesTotal","threadsTotal","historyId","listSendAs","sendAs","sendMessage","input","raw","method","JSON","stringify","getThread","threadOptions","encodeURIComponent","metadataHeaders","getFullThread","Boolean","getMessage","messageId","messageOptions","getFullMessage","searchMessages","search","page","list","message","findMessageByMessageId","found","find","revoke"],"mappings":"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"}
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type TransportDeps } from './http';
18
+ import type { OutreachMailClient, OutreachMailSearch } from './mail-client';
19
+ /**
20
+ * THE MICROSOFT GRAPH TRANSPORT (AGL-3489): one connected Microsoft 365
21
+ * mailbox, behind the same seam as Gmail (`mail-client.ts`).
22
+ *
23
+ * ## Sending
24
+ *
25
+ * The message is the one the RFC 5322 writer built for Gmail, headers and
26
+ * all — `List-Unsubscribe`, `In-Reply-To`, `References`, the `Message-ID`
27
+ * the runtime minted. It is created as a draft from that MIME and the draft
28
+ * is sent: creating it first is what answers the message's id and
29
+ * `conversationId` (Graph's `sendMail` answers nothing), and the
30
+ * conversation id is what a follow-up step and the sync know the thread by.
31
+ * A follow-up threads by its own `In-Reply-To` and `References`, which is
32
+ * how Exchange and every receiver group it, so `threadId` is not sent.
33
+ *
34
+ * Every call asks for immutable ids (`Prefer: IdType="ImmutableId"`), so the
35
+ * draft's id is still the message's id once sending has moved it to Sent
36
+ * Items.
37
+ *
38
+ * ## Reading
39
+ *
40
+ * A message read whole is its MIME (`/$value`) read into the Gmail API
41
+ * shape the classifier takes. A search lists the window's messages across
42
+ * every folder (`/me/messages`, junk and deleted included) with the few
43
+ * properties a search needs, and filters them here: Graph's `$filter` cannot
44
+ * combine a received-time range with a sender list or a recipient, and the
45
+ * window a sync pass reads is short.
46
+ *
47
+ * ## Tokens
48
+ *
49
+ * As the Gmail client: minted on first use, reused until a minute before
50
+ * expiry, one refresh in flight, a 401 retried once with a fresh token.
51
+ * Microsoft rotates refresh tokens, so a refresh that answers a new one
52
+ * hands it to `onRefreshTokenRotated` for the caller to store, and uses it
53
+ * from then on.
54
+ */
55
+ export declare const GRAPH_API_BASE = "https://graph.microsoft.com/v1.0";
56
+ export interface GraphClientOptions extends TransportDeps {
57
+ clientId: string;
58
+ clientSecret: string;
59
+ tenant: string;
60
+ refreshToken: string;
61
+ /** The mailbox's own addresses: what a search's `notFromSelf` leaves out. */
62
+ selfAddresses?: readonly string[];
63
+ /** An access token already in hand — a connect's own — used until it expires. */
64
+ accessToken?: {
65
+ token: string;
66
+ expiresInSeconds: number;
67
+ } | null;
68
+ /** Stores a refresh token Microsoft rotated in. Its failure is logged, not thrown. */
69
+ onRefreshTokenRotated?: (refreshToken: string) => Promise<void> | void;
70
+ }
71
+ /** `GET /me`: the account a grant is for. */
72
+ export interface GraphProfile {
73
+ /** The directory object id — the ID token's `oid`. */
74
+ id: string;
75
+ /** The mailbox's address: `mail`, or the sign-in name when it has none. */
76
+ emailAddress: string;
77
+ displayName: string;
78
+ }
79
+ export interface GraphClient extends OutreachMailClient {
80
+ readonly provider: 'microsoft';
81
+ getProfile(): Promise<GraphProfile>;
82
+ revoke(): Promise<'unsupported'>;
83
+ }
84
+ /** Whether a listed message answers a search. */
85
+ export declare function graphMessageMatches(record: Record<string, unknown>, search: OutreachMailSearch, self: ReadonlySet<string>): boolean;
86
+ /** A Graph client for one mailbox. See the module comment. */
87
+ export declare function createGraphClient(options: GraphClientOptions): GraphClient;
@@ -0,0 +1,359 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
+ import { GmailTransportError } from "./gmail-errors.js";
18
+ import { fetchWithBackoff, retryAfterMs } from "./http.js";
19
+ import { graphApiError } from "./microsoft-errors.js";
20
+ import { refreshMicrosoftAccessToken } from "./microsoft-oauth.js";
21
+ import { gmailApiMessageFromMime } from "./mime-message.js";
22
+ /**
23
+ * THE MICROSOFT GRAPH TRANSPORT (AGL-3489): one connected Microsoft 365
24
+ * mailbox, behind the same seam as Gmail (`mail-client.ts`).
25
+ *
26
+ * ## Sending
27
+ *
28
+ * The message is the one the RFC 5322 writer built for Gmail, headers and
29
+ * all — `List-Unsubscribe`, `In-Reply-To`, `References`, the `Message-ID`
30
+ * the runtime minted. It is created as a draft from that MIME and the draft
31
+ * is sent: creating it first is what answers the message's id and
32
+ * `conversationId` (Graph's `sendMail` answers nothing), and the
33
+ * conversation id is what a follow-up step and the sync know the thread by.
34
+ * A follow-up threads by its own `In-Reply-To` and `References`, which is
35
+ * how Exchange and every receiver group it, so `threadId` is not sent.
36
+ *
37
+ * Every call asks for immutable ids (`Prefer: IdType="ImmutableId"`), so the
38
+ * draft's id is still the message's id once sending has moved it to Sent
39
+ * Items.
40
+ *
41
+ * ## Reading
42
+ *
43
+ * A message read whole is its MIME (`/$value`) read into the Gmail API
44
+ * shape the classifier takes. A search lists the window's messages across
45
+ * every folder (`/me/messages`, junk and deleted included) with the few
46
+ * properties a search needs, and filters them here: Graph's `$filter` cannot
47
+ * combine a received-time range with a sender list or a recipient, and the
48
+ * window a sync pass reads is short.
49
+ *
50
+ * ## Tokens
51
+ *
52
+ * As the Gmail client: minted on first use, reused until a minute before
53
+ * expiry, one refresh in flight, a 401 retried once with a fresh token.
54
+ * Microsoft rotates refresh tokens, so a refresh that answers a new one
55
+ * hands it to `onRefreshTokenRotated` for the caller to store, and uses it
56
+ * from then on.
57
+ */ export const GRAPH_API_BASE = 'https://graph.microsoft.com/v1.0';
58
+ const ME = `${GRAPH_API_BASE}/me`;
59
+ /** Refresh this long before the stated expiry. */ const EXPIRY_MARGIN_MS = 60000;
60
+ /** Messages one search page lists; Graph allows up to 1000. */ const SEARCH_PAGE_SIZE = 250;
61
+ /** Messages of one conversation read whole. */ const THREAD_MAX = 50;
62
+ const text = (value)=>typeof value === 'string' ? value : '';
63
+ /** An OData string literal: single quotes doubled. */ const literal = (value)=>`'${value.replace(/'/g, "''")}'`;
64
+ /** A query string Graph reads: `%20`, never `+`, for a space. */ function query(params) {
65
+ return Object.entries(params).map(([key, value])=>`${key}=${encodeURIComponent(value)}`).join('&');
66
+ }
67
+ const addressOf = (recipient)=>{
68
+ var _recipient_emailAddress;
69
+ return text(recipient == null ? void 0 : (_recipient_emailAddress = recipient.emailAddress) == null ? void 0 : _recipient_emailAddress.address).trim().toLowerCase();
70
+ };
71
+ const addressesOf = (list)=>Array.isArray(list) ? list.map(addressOf).filter(Boolean) : [];
72
+ const receivedMs = (record)=>{
73
+ const at = Date.parse(text(record['receivedDateTime']));
74
+ return Number.isFinite(at) ? at : null;
75
+ };
76
+ /** Whether an address answers a `from` term: a whole address, or a local part. */ function fromMatches(address, term) {
77
+ const wanted = term.trim().toLowerCase();
78
+ if (!wanted || !address) return false;
79
+ if (wanted.includes('@')) return address === wanted;
80
+ return address.slice(0, address.indexOf('@')) === wanted;
81
+ }
82
+ /** Whether a listed message answers a search. */ export function graphMessageMatches(record, search, self) {
83
+ var _search_from;
84
+ const at = receivedMs(record);
85
+ if (at !== null && at < search.afterMs) return false;
86
+ const from = addressOf(record['from']);
87
+ if (search.notFromSelf && self.has(from)) return false;
88
+ if (((_search_from = search.from) == null ? void 0 : _search_from.length) && !search.from.some((term)=>fromMatches(from, term))) return false;
89
+ if (search.to) {
90
+ const to = search.to.trim().toLowerCase();
91
+ const recipients = [
92
+ ...addressesOf(record['toRecipients']),
93
+ ...addressesOf(record['ccRecipients'])
94
+ ];
95
+ if (!recipients.includes(to)) return false;
96
+ }
97
+ return true;
98
+ }
99
+ /** The header list a metadata read answers, the named ones only. */ function metadataHeaders(record, wanted) {
100
+ var _from_emailAddress, _from_emailAddress1;
101
+ const listed = (Array.isArray(record['internetMessageHeaders']) ? record['internetMessageHeaders'] : []).map((entry)=>entry).filter((entry)=>typeof entry.name === 'string').map((entry)=>({
102
+ name: String(entry.name),
103
+ value: text(entry.value)
104
+ }));
105
+ const has = (name)=>listed.some((header)=>header.name.toLowerCase() === name.toLowerCase());
106
+ const from = record['from'];
107
+ const fromName = text(from == null ? void 0 : (_from_emailAddress = from.emailAddress) == null ? void 0 : _from_emailAddress.name);
108
+ const fromAddress = text(from == null ? void 0 : (_from_emailAddress1 = from.emailAddress) == null ? void 0 : _from_emailAddress1.address);
109
+ // A sent message carries no internet headers in Graph; its properties say the same.
110
+ const synthesized = [
111
+ {
112
+ name: 'Subject',
113
+ value: text(record['subject'])
114
+ },
115
+ {
116
+ name: 'From',
117
+ value: fromName && fromAddress ? `${fromName} <${fromAddress}>` : fromAddress
118
+ },
119
+ {
120
+ name: 'To',
121
+ value: addressesOf(record['toRecipients']).join(', ')
122
+ },
123
+ {
124
+ name: 'Message-ID',
125
+ value: text(record['internetMessageId'])
126
+ }
127
+ ].filter((header)=>header.value && !has(header.name));
128
+ const all = [
129
+ ...listed,
130
+ ...synthesized
131
+ ];
132
+ if (!(wanted == null ? void 0 : wanted.length)) return all;
133
+ const names = new Set(wanted.map((name)=>name.toLowerCase()));
134
+ return all.filter((header)=>names.has(header.name.toLowerCase()));
135
+ }
136
+ /** A Graph client for one mailbox. See the module comment. */ export function createGraphClient(options) {
137
+ var _options_now, _options_selfAddresses;
138
+ var _options_accessToken;
139
+ const deps = options;
140
+ const now = (_options_now = options.now) != null ? _options_now : Date.now;
141
+ const self = new Set(((_options_selfAddresses = options.selfAddresses) != null ? _options_selfAddresses : []).map((address)=>address.trim().toLowerCase()).filter(Boolean));
142
+ let refreshToken = options.refreshToken;
143
+ let cached = ((_options_accessToken = options.accessToken) == null ? void 0 : _options_accessToken.token) ? {
144
+ token: options.accessToken.token,
145
+ expiresAtMs: now() + options.accessToken.expiresInSeconds * 1000
146
+ } : null;
147
+ let refreshing = null;
148
+ const mint = ()=>{
149
+ refreshing != null ? refreshing : refreshing = refreshMicrosoftAccessToken({
150
+ clientId: options.clientId,
151
+ clientSecret: options.clientSecret,
152
+ tenant: options.tenant,
153
+ refreshToken
154
+ }, deps).then(async (minted)=>{
155
+ cached = {
156
+ token: minted.accessToken,
157
+ expiresAtMs: now() + minted.expiresInSeconds * 1000
158
+ };
159
+ if (minted.refreshToken && minted.refreshToken !== refreshToken) {
160
+ refreshToken = minted.refreshToken;
161
+ try {
162
+ await (options.onRefreshTokenRotated == null ? void 0 : options.onRefreshTokenRotated.call(options, minted.refreshToken));
163
+ } catch (error) {
164
+ console.error('[outreach] storing a rotated Microsoft refresh token failed', error);
165
+ }
166
+ }
167
+ return minted.accessToken;
168
+ }).finally(()=>{
169
+ refreshing = null;
170
+ });
171
+ return refreshing;
172
+ };
173
+ const getAccessToken = async ()=>{
174
+ if (cached && cached.expiresAtMs - EXPIRY_MARGIN_MS > now()) return cached.token;
175
+ return mint();
176
+ };
177
+ /**
178
+ * One Graph call: authorized, retried per policy, a 401 once refreshed.
179
+ * Answers the response, already judged ok.
180
+ */ const send = async (url, init = {})=>{
181
+ for(let tries = 0;; tries += 1){
182
+ const token = await getAccessToken();
183
+ const response = await fetchWithBackoff(url, _extends({}, init, {
184
+ headers: _extends({
185
+ Accept: 'application/json',
186
+ Prefer: 'IdType="ImmutableId"'
187
+ }, init.headers, {
188
+ Authorization: `Bearer ${token}`
189
+ })
190
+ }), deps);
191
+ if (response.status === 401 && tries === 0) {
192
+ cached = null;
193
+ continue;
194
+ }
195
+ if (response.ok) return response;
196
+ const body = await response.json().catch(()=>null);
197
+ throw graphApiError(response.status, body, {
198
+ retryAfterMs: retryAfterMs(response, now())
199
+ });
200
+ }
201
+ };
202
+ const json = async (url, init = {})=>{
203
+ const response = await send(url, init);
204
+ const body = await response.json().catch(()=>null);
205
+ if (body === null || typeof body !== 'object') {
206
+ throw new GmailTransportError('unexpected', 'Microsoft Graph answered with a body that is not JSON.', {
207
+ status: response.status
208
+ });
209
+ }
210
+ return body;
211
+ };
212
+ const messagePath = (id)=>{
213
+ if (!id) throw new GmailTransportError('invalid_request', 'A message to read needs its id.');
214
+ return `${ME}/messages/${encodeURIComponent(id)}`;
215
+ };
216
+ const getFullMessage = async (messageId)=>{
217
+ const path = messagePath(messageId);
218
+ const [record, raw] = await Promise.all([
219
+ json(`${path}?${query({
220
+ $select: 'id,conversationId,receivedDateTime'
221
+ })}`),
222
+ send(`${path}/$value`, {
223
+ headers: {
224
+ Accept: 'message/rfc822, text/plain, */*'
225
+ }
226
+ }).then((response)=>response.arrayBuffer())
227
+ ]);
228
+ return gmailApiMessageFromMime(new Uint8Array(raw), {
229
+ id: text(record['id']) || messageId,
230
+ threadId: text(record['conversationId']),
231
+ internalDateMs: receivedMs(record)
232
+ });
233
+ };
234
+ return {
235
+ provider: 'microsoft',
236
+ getAccessToken,
237
+ async getProfile () {
238
+ const body = await json(`${ME}?${query({
239
+ $select: 'id,mail,userPrincipalName,displayName'
240
+ })}`);
241
+ return {
242
+ id: text(body['id']).toLowerCase(),
243
+ emailAddress: (text(body['mail']) || text(body['userPrincipalName'])).trim().toLowerCase(),
244
+ displayName: text(body['displayName']).trim()
245
+ };
246
+ },
247
+ async sendMessage (input) {
248
+ if (!(input == null ? void 0 : input.raw)) {
249
+ throw new GmailTransportError('invalid_request', 'A message to send needs its raw content.');
250
+ }
251
+ // Graph takes MIME as standard base64 in a text/plain body.
252
+ const mime = Buffer.from(input.raw, 'base64url').toString('base64');
253
+ const draft = await json(`${ME}/messages`, {
254
+ method: 'POST',
255
+ headers: {
256
+ 'Content-Type': 'text/plain'
257
+ },
258
+ body: mime
259
+ });
260
+ const id = text(draft['id']);
261
+ if (!id) throw new GmailTransportError('unexpected', 'Microsoft Graph created the message without an id.');
262
+ try {
263
+ await send(`${messagePath(id)}/send`, {
264
+ method: 'POST'
265
+ });
266
+ } catch (error) {
267
+ // The draft would otherwise sit in Drafts, unsent, looking like work.
268
+ await send(messagePath(id), {
269
+ method: 'DELETE'
270
+ }).catch(()=>undefined);
271
+ throw error;
272
+ }
273
+ return {
274
+ id,
275
+ threadId: text(draft['conversationId']),
276
+ internetMessageId: text(draft['internetMessageId']) || null
277
+ };
278
+ },
279
+ async getFullThread (threadId) {
280
+ const list = await json(`${ME}/messages?${query({
281
+ $filter: `conversationId eq ${literal(threadId)}`,
282
+ $select: 'id,conversationId,receivedDateTime',
283
+ $top: String(THREAD_MAX)
284
+ })}`);
285
+ const records = (Array.isArray(list.value) ? list.value : []).map((entry)=>entry != null ? entry : {}).filter((record)=>text(record['id'])).sort((a, b)=>{
286
+ var _receivedMs, _receivedMs1;
287
+ return ((_receivedMs = receivedMs(a)) != null ? _receivedMs : 0) - ((_receivedMs1 = receivedMs(b)) != null ? _receivedMs1 : 0);
288
+ });
289
+ const messages = [];
290
+ for (const record of records)messages.push(await getFullMessage(text(record['id'])));
291
+ return {
292
+ id: threadId,
293
+ historyId: null,
294
+ messages
295
+ };
296
+ },
297
+ getFullMessage,
298
+ async getMessage (messageId, messageOptions) {
299
+ const record = await json(`${messagePath(messageId)}?${query({
300
+ $select: 'id,conversationId,subject,from,toRecipients,receivedDateTime,bodyPreview,internetMessageId,internetMessageHeaders'
301
+ })}`);
302
+ return {
303
+ id: text(record['id']) || messageId,
304
+ threadId: text(record['conversationId']),
305
+ labelIds: [],
306
+ snippet: text(record['bodyPreview']),
307
+ internalDateMs: receivedMs(record),
308
+ headers: metadataHeaders(record, messageOptions == null ? void 0 : messageOptions.metadataHeaders)
309
+ };
310
+ },
311
+ async searchMessages (search, page) {
312
+ let url;
313
+ if (page == null ? void 0 : page.pageToken) {
314
+ // A next link is Graph's own address; anything else is not followed
315
+ // with this mailbox's token.
316
+ if (!page.pageToken.startsWith(`${ME}/messages?`)) {
317
+ throw new GmailTransportError('invalid_request', 'That page token is not a Microsoft Graph page.');
318
+ }
319
+ url = page.pageToken;
320
+ } else {
321
+ var _ref;
322
+ url = `${ME}/messages?${query({
323
+ $filter: `receivedDateTime ge ${new Date(Math.max(0, search.afterMs)).toISOString()}`,
324
+ $select: 'id,conversationId,from,toRecipients,ccRecipients,receivedDateTime',
325
+ $top: String(Math.max((_ref = page == null ? void 0 : page.maxResults) != null ? _ref : 0, SEARCH_PAGE_SIZE))
326
+ })}`;
327
+ }
328
+ const body = await json(url);
329
+ const messages = (Array.isArray(body['value']) ? body['value'] : []).map((entry)=>entry != null ? entry : {}).filter((record)=>text(record['id']) && graphMessageMatches(record, search, self)).map((record)=>({
330
+ id: text(record['id']),
331
+ threadId: text(record['conversationId'])
332
+ }));
333
+ return {
334
+ messages,
335
+ nextPageToken: text(body['@odata.nextLink']) || null
336
+ };
337
+ },
338
+ async findMessageByMessageId (messageId) {
339
+ const wanted = String(messageId != null ? messageId : '').trim();
340
+ if (!wanted) return null;
341
+ const id = wanted.startsWith('<') ? wanted : `<${wanted}>`;
342
+ const body = await json(`${ME}/messages?${query({
343
+ $filter: `internetMessageId eq ${literal(id)}`,
344
+ $select: 'id,conversationId',
345
+ $top: '1'
346
+ })}`);
347
+ const record = Array.isArray(body.value) ? body.value[0] : null;
348
+ return record && text(record['id']) ? {
349
+ id: text(record['id']),
350
+ threadId: text(record['conversationId'])
351
+ } : null;
352
+ },
353
+ async revoke () {
354
+ return 'unsupported';
355
+ }
356
+ };
357
+ }
358
+
359
+ //# sourceMappingURL=graph-client.js.map