@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
@@ -0,0 +1,71 @@
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
+ /*==========================================
18
+ * MAILBOX ROTATION (AGL-3489).
19
+ *
20
+ * A sequence sends from its own mailbox and, when it names more, from those
21
+ * too: each person enrolled is given ONE of them, and every step of theirs —
22
+ * the first email and each follow-up in its thread — goes from that one.
23
+ * Several cold-email inboxes can so share one sequence, each sending its own
24
+ * daily cap in its own window, pausing itself on its own bounces.
25
+ *
26
+ * A new enrollment goes to the connected mailbox with the fewest active
27
+ * enrollments, the earliest in the sequence's order when two are level. The
28
+ * count is the mailbox's across every sequence, since it is the mailbox's
29
+ * day the enrollments share. A mailbox that is paused, waiting to be
30
+ * reconnected or disconnected is passed over; when none of the rotation is
31
+ * connected, the person goes to the sequence's own mailbox, to wait there as
32
+ * they always have.
33
+ *==========================================*/ /** The most mailboxes one sequence sends from, its own included. */ export const OUTREACH_SEQUENCE_MAX_MAILBOXES = 20;
34
+ /**
35
+ * The mailboxes a sequence sends from: its own first, then the ones it
36
+ * rotates through, each once, `''` dropped.
37
+ */ export function outreachSequenceMailboxIds(sequence) {
38
+ var _sequence_mailboxIds;
39
+ const ids = [];
40
+ for (const id of [
41
+ sequence.mailboxId,
42
+ ...(_sequence_mailboxIds = sequence.mailboxIds) != null ? _sequence_mailboxIds : []
43
+ ]){
44
+ const clean = String(id != null ? id : '').trim();
45
+ if (clean && !ids.includes(clean)) ids.push(clean);
46
+ }
47
+ return ids.slice(0, OUTREACH_SEQUENCE_MAX_MAILBOXES);
48
+ }
49
+ /**
50
+ * Assigns mailboxes to new enrollments one at a time, each call the next
51
+ * person's, counting the ones it has handed out. `null` when no candidate
52
+ * is connected and schedulable — the caller then uses the sequence's own.
53
+ */ export function createOutreachMailboxRotation(candidates) {
54
+ const open = candidates.map((candidate, order)=>_extends({}, candidate, {
55
+ order,
56
+ load: Math.max(0, candidate.activeEnrollments || 0)
57
+ })).filter((candidate)=>candidate.status === 'connected' && candidate.schedulable);
58
+ return ()=>{
59
+ let best = null;
60
+ for (const candidate of open){
61
+ if (!best || candidate.load < best.load || candidate.load === best.load && candidate.order < best.order) {
62
+ best = candidate;
63
+ }
64
+ }
65
+ if (!best) return null;
66
+ best.load += 1;
67
+ return best.id;
68
+ };
69
+ }
70
+
71
+ //# sourceMappingURL=mailbox-rotation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/mailbox-rotation.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 { OutreachMailboxStatus, OutreachSequence } from '../model/outreach.types'\n\n/*==========================================\n * MAILBOX ROTATION (AGL-3489).\n *\n * A sequence sends from its own mailbox and, when it names more, from those\n * too: each person enrolled is given ONE of them, and every step of theirs —\n * the first email and each follow-up in its thread — goes from that one.\n * Several cold-email inboxes can so share one sequence, each sending its own\n * daily cap in its own window, pausing itself on its own bounces.\n *\n * A new enrollment goes to the connected mailbox with the fewest active\n * enrollments, the earliest in the sequence's order when two are level. The\n * count is the mailbox's across every sequence, since it is the mailbox's\n * day the enrollments share. A mailbox that is paused, waiting to be\n * reconnected or disconnected is passed over; when none of the rotation is\n * connected, the person goes to the sequence's own mailbox, to wait there as\n * they always have.\n *==========================================*/\n\n/** The most mailboxes one sequence sends from, its own included. */\nexport const OUTREACH_SEQUENCE_MAX_MAILBOXES = 20\n\n/**\n * The mailboxes a sequence sends from: its own first, then the ones it\n * rotates through, each once, `''` dropped.\n */\nexport function outreachSequenceMailboxIds(\n sequence: Pick<OutreachSequence, 'mailboxId'> & { mailboxIds?: readonly string[] | null },\n): string[] {\n const ids: string[] = []\n for (const id of [sequence.mailboxId, ...(sequence.mailboxIds ?? [])]) {\n const clean = String(id ?? '').trim()\n if (clean && !ids.includes(clean)) ids.push(clean)\n }\n return ids.slice(0, OUTREACH_SEQUENCE_MAX_MAILBOXES)\n}\n\n/** One mailbox of a rotation, as the enroll door read it. */\nexport interface OutreachRotationCandidate {\n id: string\n status: OutreachMailboxStatus\n /** Active enrollments on it now, across every sequence. */\n activeEnrollments: number\n /** Whether the sequence's hours ever open in the mailbox's timezone. */\n schedulable: boolean\n}\n\n/**\n * Assigns mailboxes to new enrollments one at a time, each call the next\n * person's, counting the ones it has handed out. `null` when no candidate\n * is connected and schedulable — the caller then uses the sequence's own.\n */\nexport function createOutreachMailboxRotation(\n candidates: readonly OutreachRotationCandidate[],\n): () => string | null {\n const open = candidates\n .map((candidate, order) => ({ ...candidate, order, load: Math.max(0, candidate.activeEnrollments || 0) }))\n .filter((candidate) => candidate.status === 'connected' && candidate.schedulable)\n return () => {\n let best: (typeof open)[number] | null = null\n for (const candidate of open) {\n if (!best || candidate.load < best.load || (candidate.load === best.load && candidate.order < best.order)) {\n best = candidate\n }\n }\n if (!best) return null\n best.load += 1\n return best.id\n }\n}\n"],"names":["OUTREACH_SEQUENCE_MAX_MAILBOXES","outreachSequenceMailboxIds","sequence","ids","id","mailboxId","mailboxIds","clean","String","trim","includes","push","slice","createOutreachMailboxRotation","candidates","open","map","candidate","order","load","Math","max","activeEnrollments","filter","status","schedulable","best"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAID;;;;;;;;;;;;;;;;4CAgB4C,GAE5C,kEAAkE,GAClE,OAAO,MAAMA,kCAAkC,GAAE;AAEjD;;;CAGC,GACD,OAAO,SAASC,2BACdC,QAAyF;QAG/CA;IAD1C,MAAMC,MAAgB,EAAE;IACxB,KAAK,MAAMC,MAAM;QAACF,SAASG,SAAS;YAAMH,uBAAAA,SAASI,UAAU,YAAnBJ,uBAAuB,EAAE;KAAE,CAAE;QACrE,MAAMK,QAAQC,OAAOJ,aAAAA,KAAM,IAAIK,IAAI;QACnC,IAAIF,SAAS,CAACJ,IAAIO,QAAQ,CAACH,QAAQJ,IAAIQ,IAAI,CAACJ;IAC9C;IACA,OAAOJ,IAAIS,KAAK,CAAC,GAAGZ;AACtB;AAYA;;;;CAIC,GACD,OAAO,SAASa,8BACdC,UAAgD;IAEhD,MAAMC,OAAOD,WACVE,GAAG,CAAC,CAACC,WAAWC,QAAW,aAAKD;YAAWC;YAAOC,MAAMC,KAAKC,GAAG,CAAC,GAAGJ,UAAUK,iBAAiB,IAAI;YACnGC,MAAM,CAAC,CAACN,YAAcA,UAAUO,MAAM,KAAK,eAAeP,UAAUQ,WAAW;IAClF,OAAO;QACL,IAAIC,OAAqC;QACzC,KAAK,MAAMT,aAAaF,KAAM;YAC5B,IAAI,CAACW,QAAQT,UAAUE,IAAI,GAAGO,KAAKP,IAAI,IAAKF,UAAUE,IAAI,KAAKO,KAAKP,IAAI,IAAIF,UAAUC,KAAK,GAAGQ,KAAKR,KAAK,EAAG;gBACzGQ,OAAOT;YACT;QACF;QACA,IAAI,CAACS,MAAM,OAAO;QAClBA,KAAKP,IAAI,IAAI;QACb,OAAOO,KAAKtB,EAAE;IAChB;AACF"}
@@ -15,7 +15,7 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  import type { SenderReadiness } from '@aglyn/shared-util-email';
18
- import type { OutreachMailbox, OutreachSendWindow } from '../model/outreach.types';
18
+ import type { OutreachMailbox, OutreachMailboxProvider, OutreachSendWindow } from '../model/outreach.types';
19
19
  /**
20
20
  * THE MAILBOX ROUTES' CONTRACT (AGL-2978): what the Mailboxes panel sends,
21
21
  * what the routes answer, and the fragment a connect comes back through.
@@ -35,6 +35,9 @@ export interface OutreachApiRefusal {
35
35
  export type OutreachMailboxAvailabilityGate = {
36
36
  gate: 'google';
37
37
  missing: string[];
38
+ } | {
39
+ gate: 'microsoft';
40
+ missing: string[];
38
41
  } | {
39
42
  gate: 'state';
40
43
  } | {
@@ -42,12 +45,20 @@ export type OutreachMailboxAvailabilityGate = {
42
45
  };
43
46
  /** `GET outreach/mailboxes/availability` */
44
47
  export interface OutreachMailboxAvailability {
48
+ /** Whether a Google mailbox can be connected: `providers.google`. */
45
49
  configured: boolean;
46
50
  /**
47
- * When not configured, which gates refused, so an operator reading the
48
- * response knows what to set: the Google client (`google`, with the names
49
- * of the missing variables), the state signing secret (`state`), or the
50
- * console origin to register (`redirect`). Absent when configured.
51
+ * Whether each provider's mailboxes can be connected here (AGL-3489): its
52
+ * client configured, and the state secret and redirect every connect
53
+ * needs. Absent from an older deployment, which connects Google only.
54
+ */
55
+ providers?: Record<OutreachMailboxProvider, boolean>;
56
+ /**
57
+ * Which gates refused, so an operator reading the response knows what to
58
+ * set: the Google client (`google`) or the Microsoft app registration
59
+ * (`microsoft`), each with the names of its missing variables, the state
60
+ * signing secret (`state`), or the console origin to register
61
+ * (`redirect`). Absent when every gate is open.
51
62
  */
52
63
  missing?: OutreachMailboxAvailabilityGate[];
53
64
  /**
@@ -60,9 +71,13 @@ export interface OutreachMailboxAvailability {
60
71
  /** `POST outreach/mailboxes/connect` */
61
72
  export interface OutreachConnectRequest {
62
73
  orgId: string;
74
+ /** Whose consent screen the connect goes to; Google when omitted. */
75
+ provider?: OutreachMailboxProvider;
76
+ /** The account to suggest on Microsoft's sign-in, such as a mailbox being reconnected. */
77
+ loginHint?: string;
63
78
  }
64
79
  export interface OutreachConnectResponse {
65
- /** Google's consent address; the browser goes there. */
80
+ /** The provider's consent address; the browser goes there. */
66
81
  url: string;
67
82
  }
68
83
  /** `POST outreach/mailboxes/connect/complete` */
@@ -78,7 +93,7 @@ export interface OutreachConnectCompleteResponse {
78
93
  mailbox: OutreachMailbox;
79
94
  /** False when an existing mailbox for the account was reconnected. */
80
95
  created: boolean;
81
- /** Pending addresses of the member's that Gmail's send-as list confirmed. */
96
+ /** Pending addresses of the member's that the provider's send-as list confirmed. */
82
97
  confirmedAliases: string[];
83
98
  }
84
99
  /** `POST outreach/mailboxes/settings` — every field but the ids is optional. */
@@ -122,12 +137,13 @@ export interface OutreachMailboxTestResponse {
122
137
  export interface OutreachMailboxDisconnectResponse {
123
138
  ok: true;
124
139
  /**
125
- * What became of the grant at Google: `revoked`, `already-invalid`,
140
+ * What became of the grant at the provider: `revoked`, `already-invalid`,
126
141
  * `kept-for-other-mailbox` when another mailbox still uses the account,
127
- * or `failed` when Google could not be told — the stored grant is deleted
142
+ * `unsupported` for a Microsoft grant, which no app can revoke itself, or
143
+ * `failed` when Google could not be told — the stored grant is deleted
128
144
  * either way.
129
145
  */
130
- revocation: 'revoked' | 'already-invalid' | 'kept-for-other-mailbox' | 'failed';
146
+ revocation: 'revoked' | 'already-invalid' | 'kept-for-other-mailbox' | 'unsupported' | 'failed';
131
147
  }
132
148
  /**
133
149
  * `POST outreach/mailboxes/readiness` — `{ mailboxId, fresh? }`. How
@@ -158,9 +174,12 @@ export type OutreachConnectReturn = {
158
174
  kind: 'code';
159
175
  code: string;
160
176
  state: string;
161
- } | {
177
+ }
178
+ /** `provider` names a Microsoft connect; an error without it was Google's. */
179
+ | {
162
180
  kind: 'error';
163
181
  reason: OutreachConnectReturnError;
182
+ provider?: OutreachMailboxProvider;
164
183
  };
165
184
  /** The fragment for a connect's return, without the leading `#`. */
166
185
  export declare function buildConnectReturnFragment(value: OutreachConnectReturn): string;
@@ -13,7 +13,8 @@
13
13
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
- */ /**
16
+ */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
+ /**
17
18
  * The fragment key a connect returns through (AGL-2978).
18
19
  *
19
20
  * Google redirects to the callback route with the authorization code in the
@@ -32,6 +33,7 @@
32
33
  } else {
33
34
  params.set(OUTREACH_CONNECT_FRAGMENT_KEY, 'error');
34
35
  params.set('reason', value.reason);
36
+ if (value.provider === 'microsoft') params.set('provider', value.provider);
35
37
  }
36
38
  return params.toString();
37
39
  }
@@ -55,10 +57,12 @@ const RETURN_ERRORS = [
55
57
  }
56
58
  if (kind === 'error') {
57
59
  const reason = params.get('reason');
58
- return {
60
+ return _extends({
59
61
  kind: 'error',
60
62
  reason: reason && RETURN_ERRORS.includes(reason) ? reason : 'google_error'
61
- };
63
+ }, params.get('provider') === 'microsoft' ? {
64
+ provider: 'microsoft'
65
+ } : {});
62
66
  }
63
67
  return null;
64
68
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/mailboxes/mailbox-api.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 { SenderReadiness } from '@aglyn/shared-util-email'\nimport type { OutreachMailbox, OutreachSendWindow } from '../model/outreach.types'\n\n/**\n * THE MAILBOX ROUTES' CONTRACT (AGL-2978): what the Mailboxes panel sends,\n * what the routes answer, and the fragment a connect comes back through.\n * Client-safe — types, constants and two string functions.\n */\n\n/** Every reason a mailbox route refuses with, for a caller to branch on. */\nexport type OutreachApiRefusalReason =\n | 'unauthenticated'\n | 'email-unverified'\n | 'org-required'\n | 'not-a-member'\n | 'not-org-wide'\n | 'permission'\n | 'entitlement'\n | 'not-configured'\n | 'method-not-allowed'\n | 'invalid-request'\n | 'mailbox-not-found'\n | 'not-your-mailbox'\n | 'mailbox-limit'\n | 'state-invalid'\n | 'state-expired'\n | 'state-user-mismatch'\n | 'state-org-mismatch'\n | 'state-replayed'\n | 'state-superseded'\n | 'code-rejected'\n | 'refresh-token-missing'\n | 'scopes-missing'\n | 'identity-unverified'\n | 'account-mismatch'\n | 'reconnect-required'\n | 'invalid-settings'\n | 'rate-limited'\n /** The Google account has no Gmail service: off for it, or not yet provisioned. */\n | 'mail-service-unavailable'\n /** Google refused the account itself, such as an administrator barring Gmail API access. */\n | 'google-refused'\n | 'google-unavailable'\n\nexport interface OutreachApiRefusal {\n error: string\n reason: OutreachApiRefusalReason\n}\n\n/** A reason `GET outreach/mailboxes/availability` answers `configured: false`. */\nexport type OutreachMailboxAvailabilityGate =\n | { gate: 'google'; missing: string[] }\n | { gate: 'state' }\n | { gate: 'redirect' }\n\n/** `GET outreach/mailboxes/availability` */\nexport interface OutreachMailboxAvailability {\n configured: boolean\n /**\n * When not configured, which gates refused, so an operator reading the\n * response knows what to set: the Google client (`google`, with the names\n * of the missing variables), the state signing secret (`state`), or the\n * console origin to register (`redirect`). Absent when configured.\n */\n missing?: OutreachMailboxAvailabilityGate[]\n /**\n * Whether the viewer is an organization owner or admin, who may change,\n * pause and disconnect any member's mailbox. The routes decide this on\n * every call; the panel reads it to offer only what will be allowed.\n */\n canManageAll: boolean\n}\n\n/** `POST outreach/mailboxes/connect` */\nexport interface OutreachConnectRequest {\n orgId: string\n}\nexport interface OutreachConnectResponse {\n /** Google's consent address; the browser goes there. */\n url: string\n}\n\n/** `POST outreach/mailboxes/connect/complete` */\nexport interface OutreachConnectCompleteRequest {\n orgId: string\n code: string\n state: string\n /** The browser's IANA timezone, for a new mailbox's sending window. */\n timezone?: string\n}\nexport interface OutreachConnectCompleteResponse {\n ok: true\n mailbox: OutreachMailbox\n /** False when an existing mailbox for the account was reconnected. */\n created: boolean\n /** Pending addresses of the member's that Gmail's send-as list confirmed. */\n confirmedAliases: string[]\n}\n\n/** `POST outreach/mailboxes/settings` — every field but the ids is optional. */\nexport interface OutreachMailboxSettingsRequest {\n orgId: string\n mailboxId: string\n sendAs?: string\n displayName?: string\n dailyCap?: number\n window?: OutreachSendWindow\n timezone?: string\n /**\n * Whether the warm-up ramp applies (AGL-3228). `false` clears it, so an\n * established mailbox sends at its cap from today; `true` starts one now\n * when none is running.\n */\n warmUp?: boolean\n}\n\n/** `POST outreach/mailboxes/status` */\nexport interface OutreachMailboxStatusRequest {\n orgId: string\n mailboxId: string\n paused: boolean\n}\n\nexport interface OutreachMailboxResponse {\n ok: true\n mailbox: OutreachMailbox\n}\n\n/**\n * `POST outreach/mailboxes/test` — `{ mailboxId, to? }`. `to` is the address\n * the test goes to; left off, the account's own (AGL-3228).\n */\nexport interface OutreachMailboxTestResponse {\n ok: true\n /** The address the test went to. */\n sentTo: string\n gmailMessageId: string\n sentAtMs: number\n}\n\n/** `POST outreach/mailboxes/disconnect` */\nexport interface OutreachMailboxDisconnectResponse {\n ok: true\n /**\n * What became of the grant at Google: `revoked`, `already-invalid`,\n * `kept-for-other-mailbox` when another mailbox still uses the account,\n * or `failed` when Google could not be told — the stored grant is deleted\n * either way.\n */\n revocation: 'revoked' | 'already-invalid' | 'kept-for-other-mailbox' | 'failed'\n}\n\n/**\n * `POST outreach/mailboxes/readiness` — `{ mailboxId, fresh? }`. How\n * receivers judge mail from the address the mailbox sends as (AGL-3328):\n * `readiness` is null for a consumer Gmail address, whose records are\n * Google's own and have nothing for its owner to publish.\n */\nexport interface OutreachMailboxReadinessResponse {\n ok: true\n /** The address whose domain was read. */\n sendAs: string\n readiness: SenderReadiness | null\n}\n\n/**\n * The fragment key a connect returns through (AGL-2978).\n *\n * Google redirects to the callback route with the authorization code in the\n * query; the callback moves it into the FRAGMENT of the Mailboxes page, which\n * no server receives — not in a request line, not in a `Referer` — and the\n * page takes it out of the address bar before it finishes the connect with\n * the member's own session. The cross-domain session handoff uses the\n * fragment for the same reason.\n */\nexport const OUTREACH_CONNECT_FRAGMENT_KEY = 'outreachConnect'\n\n/** Why a connect came back without a code. */\nexport type OutreachConnectReturnError = 'access_denied' | 'expired' | 'google_error'\n\nexport type OutreachConnectReturn =\n | { kind: 'code'; code: string; state: string }\n | { kind: 'error'; reason: OutreachConnectReturnError }\n\n/** The fragment for a connect's return, without the leading `#`. */\nexport function buildConnectReturnFragment(value: OutreachConnectReturn): string {\n const params = new URLSearchParams()\n if (value.kind === 'code') {\n params.set(OUTREACH_CONNECT_FRAGMENT_KEY, 'code')\n params.set('code', value.code)\n params.set('state', value.state)\n } else {\n params.set(OUTREACH_CONNECT_FRAGMENT_KEY, 'error')\n params.set('reason', value.reason)\n }\n return params.toString()\n}\n\nconst RETURN_ERRORS: readonly OutreachConnectReturnError[] = ['access_denied', 'expired', 'google_error']\n\n/** A connect's return read off a location hash, or `null` when it carries none. */\nexport function parseConnectReturnFragment(hash: string | null | undefined): OutreachConnectReturn | null {\n const params = new URLSearchParams(String(hash ?? '').replace(/^#/, ''))\n const kind = params.get(OUTREACH_CONNECT_FRAGMENT_KEY)\n if (kind === 'code') {\n const code = params.get('code') ?? ''\n const state = params.get('state') ?? ''\n return code && state ? { kind: 'code', code, state } : null\n }\n if (kind === 'error') {\n const reason = params.get('reason') as OutreachConnectReturnError | null\n return { kind: 'error', reason: reason && RETURN_ERRORS.includes(reason) ? reason : 'google_error' }\n }\n return null\n}\n"],"names":["OUTREACH_CONNECT_FRAGMENT_KEY","buildConnectReturnFragment","value","params","URLSearchParams","kind","set","code","state","reason","toString","RETURN_ERRORS","parseConnectReturnFragment","hash","String","replace","get","includes"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAuKD;;;;;;;;;CASC,GACD,OAAO,MAAMA,gCAAgC,kBAAiB;AAS9D,kEAAkE,GAClE,OAAO,SAASC,2BAA2BC,KAA4B;IACrE,MAAMC,SAAS,IAAIC;IACnB,IAAIF,MAAMG,IAAI,KAAK,QAAQ;QACzBF,OAAOG,GAAG,CAACN,+BAA+B;QAC1CG,OAAOG,GAAG,CAAC,QAAQJ,MAAMK,IAAI;QAC7BJ,OAAOG,GAAG,CAAC,SAASJ,MAAMM,KAAK;IACjC,OAAO;QACLL,OAAOG,GAAG,CAACN,+BAA+B;QAC1CG,OAAOG,GAAG,CAAC,UAAUJ,MAAMO,MAAM;IACnC;IACA,OAAON,OAAOO,QAAQ;AACxB;AAEA,MAAMC,gBAAuD;IAAC;IAAiB;IAAW;CAAe;AAEzG,iFAAiF,GACjF,OAAO,SAASC,2BAA2BC,IAA+B;IACxE,MAAMV,SAAS,IAAIC,gBAAgBU,OAAOD,eAAAA,OAAQ,IAAIE,OAAO,CAAC,MAAM;IACpE,MAAMV,OAAOF,OAAOa,GAAG,CAAChB;IACxB,IAAIK,SAAS,QAAQ;YACNF,aACCA;QADd,MAAMI,QAAOJ,cAAAA,OAAOa,GAAG,CAAC,mBAAXb,cAAsB;QACnC,MAAMK,SAAQL,eAAAA,OAAOa,GAAG,CAAC,oBAAXb,eAAuB;QACrC,OAAOI,QAAQC,QAAQ;YAAEH,MAAM;YAAQE;YAAMC;QAAM,IAAI;IACzD;IACA,IAAIH,SAAS,SAAS;QACpB,MAAMI,SAASN,OAAOa,GAAG,CAAC;QAC1B,OAAO;YAAEX,MAAM;YAASI,QAAQA,UAAUE,cAAcM,QAAQ,CAACR,UAAUA,SAAS;QAAe;IACrG;IACA,OAAO;AACT"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/mailboxes/mailbox-api.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 { SenderReadiness } from '@aglyn/shared-util-email'\nimport type { OutreachMailbox, OutreachMailboxProvider, OutreachSendWindow } from '../model/outreach.types'\n\n/**\n * THE MAILBOX ROUTES' CONTRACT (AGL-2978): what the Mailboxes panel sends,\n * what the routes answer, and the fragment a connect comes back through.\n * Client-safe — types, constants and two string functions.\n */\n\n/** Every reason a mailbox route refuses with, for a caller to branch on. */\nexport type OutreachApiRefusalReason =\n | 'unauthenticated'\n | 'email-unverified'\n | 'org-required'\n | 'not-a-member'\n | 'not-org-wide'\n | 'permission'\n | 'entitlement'\n | 'not-configured'\n | 'method-not-allowed'\n | 'invalid-request'\n | 'mailbox-not-found'\n | 'not-your-mailbox'\n | 'mailbox-limit'\n | 'state-invalid'\n | 'state-expired'\n | 'state-user-mismatch'\n | 'state-org-mismatch'\n | 'state-replayed'\n | 'state-superseded'\n | 'code-rejected'\n | 'refresh-token-missing'\n | 'scopes-missing'\n | 'identity-unverified'\n | 'account-mismatch'\n | 'reconnect-required'\n | 'invalid-settings'\n | 'rate-limited'\n /** The Google account has no Gmail service: off for it, or not yet provisioned. */\n | 'mail-service-unavailable'\n /** Google refused the account itself, such as an administrator barring Gmail API access. */\n | 'google-refused'\n | 'google-unavailable'\n\nexport interface OutreachApiRefusal {\n error: string\n reason: OutreachApiRefusalReason\n}\n\n/** A reason `GET outreach/mailboxes/availability` answers `configured: false`. */\nexport type OutreachMailboxAvailabilityGate =\n | { gate: 'google'; missing: string[] }\n | { gate: 'microsoft'; missing: string[] }\n | { gate: 'state' }\n | { gate: 'redirect' }\n\n/** `GET outreach/mailboxes/availability` */\nexport interface OutreachMailboxAvailability {\n /** Whether a Google mailbox can be connected: `providers.google`. */\n configured: boolean\n /**\n * Whether each provider's mailboxes can be connected here (AGL-3489): its\n * client configured, and the state secret and redirect every connect\n * needs. Absent from an older deployment, which connects Google only.\n */\n providers?: Record<OutreachMailboxProvider, boolean>\n /**\n * Which gates refused, so an operator reading the response knows what to\n * set: the Google client (`google`) or the Microsoft app registration\n * (`microsoft`), each with the names of its missing variables, the state\n * signing secret (`state`), or the console origin to register\n * (`redirect`). Absent when every gate is open.\n */\n missing?: OutreachMailboxAvailabilityGate[]\n /**\n * Whether the viewer is an organization owner or admin, who may change,\n * pause and disconnect any member's mailbox. The routes decide this on\n * every call; the panel reads it to offer only what will be allowed.\n */\n canManageAll: boolean\n}\n\n/** `POST outreach/mailboxes/connect` */\nexport interface OutreachConnectRequest {\n orgId: string\n /** Whose consent screen the connect goes to; Google when omitted. */\n provider?: OutreachMailboxProvider\n /** The account to suggest on Microsoft's sign-in, such as a mailbox being reconnected. */\n loginHint?: string\n}\nexport interface OutreachConnectResponse {\n /** The provider's consent address; the browser goes there. */\n url: string\n}\n\n/** `POST outreach/mailboxes/connect/complete` */\nexport interface OutreachConnectCompleteRequest {\n orgId: string\n code: string\n state: string\n /** The browser's IANA timezone, for a new mailbox's sending window. */\n timezone?: string\n}\nexport interface OutreachConnectCompleteResponse {\n ok: true\n mailbox: OutreachMailbox\n /** False when an existing mailbox for the account was reconnected. */\n created: boolean\n /** Pending addresses of the member's that the provider's send-as list confirmed. */\n confirmedAliases: string[]\n}\n\n/** `POST outreach/mailboxes/settings` — every field but the ids is optional. */\nexport interface OutreachMailboxSettingsRequest {\n orgId: string\n mailboxId: string\n sendAs?: string\n displayName?: string\n dailyCap?: number\n window?: OutreachSendWindow\n timezone?: string\n /**\n * Whether the warm-up ramp applies (AGL-3228). `false` clears it, so an\n * established mailbox sends at its cap from today; `true` starts one now\n * when none is running.\n */\n warmUp?: boolean\n}\n\n/** `POST outreach/mailboxes/status` */\nexport interface OutreachMailboxStatusRequest {\n orgId: string\n mailboxId: string\n paused: boolean\n}\n\nexport interface OutreachMailboxResponse {\n ok: true\n mailbox: OutreachMailbox\n}\n\n/**\n * `POST outreach/mailboxes/test` — `{ mailboxId, to? }`. `to` is the address\n * the test goes to; left off, the account's own (AGL-3228).\n */\nexport interface OutreachMailboxTestResponse {\n ok: true\n /** The address the test went to. */\n sentTo: string\n gmailMessageId: string\n sentAtMs: number\n}\n\n/** `POST outreach/mailboxes/disconnect` */\nexport interface OutreachMailboxDisconnectResponse {\n ok: true\n /**\n * What became of the grant at the provider: `revoked`, `already-invalid`,\n * `kept-for-other-mailbox` when another mailbox still uses the account,\n * `unsupported` for a Microsoft grant, which no app can revoke itself, or\n * `failed` when Google could not be told — the stored grant is deleted\n * either way.\n */\n revocation: 'revoked' | 'already-invalid' | 'kept-for-other-mailbox' | 'unsupported' | 'failed'\n}\n\n/**\n * `POST outreach/mailboxes/readiness` — `{ mailboxId, fresh? }`. How\n * receivers judge mail from the address the mailbox sends as (AGL-3328):\n * `readiness` is null for a consumer Gmail address, whose records are\n * Google's own and have nothing for its owner to publish.\n */\nexport interface OutreachMailboxReadinessResponse {\n ok: true\n /** The address whose domain was read. */\n sendAs: string\n readiness: SenderReadiness | null\n}\n\n/**\n * The fragment key a connect returns through (AGL-2978).\n *\n * Google redirects to the callback route with the authorization code in the\n * query; the callback moves it into the FRAGMENT of the Mailboxes page, which\n * no server receives — not in a request line, not in a `Referer` — and the\n * page takes it out of the address bar before it finishes the connect with\n * the member's own session. The cross-domain session handoff uses the\n * fragment for the same reason.\n */\nexport const OUTREACH_CONNECT_FRAGMENT_KEY = 'outreachConnect'\n\n/** Why a connect came back without a code. */\nexport type OutreachConnectReturnError = 'access_denied' | 'expired' | 'google_error'\n\nexport type OutreachConnectReturn =\n | { kind: 'code'; code: string; state: string }\n /** `provider` names a Microsoft connect; an error without it was Google's. */\n | { kind: 'error'; reason: OutreachConnectReturnError; provider?: OutreachMailboxProvider }\n\n/** The fragment for a connect's return, without the leading `#`. */\nexport function buildConnectReturnFragment(value: OutreachConnectReturn): string {\n const params = new URLSearchParams()\n if (value.kind === 'code') {\n params.set(OUTREACH_CONNECT_FRAGMENT_KEY, 'code')\n params.set('code', value.code)\n params.set('state', value.state)\n } else {\n params.set(OUTREACH_CONNECT_FRAGMENT_KEY, 'error')\n params.set('reason', value.reason)\n if (value.provider === 'microsoft') params.set('provider', value.provider)\n }\n return params.toString()\n}\n\nconst RETURN_ERRORS: readonly OutreachConnectReturnError[] = ['access_denied', 'expired', 'google_error']\n\n/** A connect's return read off a location hash, or `null` when it carries none. */\nexport function parseConnectReturnFragment(hash: string | null | undefined): OutreachConnectReturn | null {\n const params = new URLSearchParams(String(hash ?? '').replace(/^#/, ''))\n const kind = params.get(OUTREACH_CONNECT_FRAGMENT_KEY)\n if (kind === 'code') {\n const code = params.get('code') ?? ''\n const state = params.get('state') ?? ''\n return code && state ? { kind: 'code', code, state } : null\n }\n if (kind === 'error') {\n const reason = params.get('reason') as OutreachConnectReturnError | null\n return {\n kind: 'error',\n reason: reason && RETURN_ERRORS.includes(reason) ? reason : 'google_error',\n ...(params.get('provider') === 'microsoft' ? { provider: 'microsoft' as const } : {}),\n }\n }\n return null\n}\n"],"names":["OUTREACH_CONNECT_FRAGMENT_KEY","buildConnectReturnFragment","value","params","URLSearchParams","kind","set","code","state","reason","provider","toString","RETURN_ERRORS","parseConnectReturnFragment","hash","String","replace","get","includes"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAqLD;;;;;;;;;CASC,GACD,OAAO,MAAMA,gCAAgC,kBAAiB;AAU9D,kEAAkE,GAClE,OAAO,SAASC,2BAA2BC,KAA4B;IACrE,MAAMC,SAAS,IAAIC;IACnB,IAAIF,MAAMG,IAAI,KAAK,QAAQ;QACzBF,OAAOG,GAAG,CAACN,+BAA+B;QAC1CG,OAAOG,GAAG,CAAC,QAAQJ,MAAMK,IAAI;QAC7BJ,OAAOG,GAAG,CAAC,SAASJ,MAAMM,KAAK;IACjC,OAAO;QACLL,OAAOG,GAAG,CAACN,+BAA+B;QAC1CG,OAAOG,GAAG,CAAC,UAAUJ,MAAMO,MAAM;QACjC,IAAIP,MAAMQ,QAAQ,KAAK,aAAaP,OAAOG,GAAG,CAAC,YAAYJ,MAAMQ,QAAQ;IAC3E;IACA,OAAOP,OAAOQ,QAAQ;AACxB;AAEA,MAAMC,gBAAuD;IAAC;IAAiB;IAAW;CAAe;AAEzG,iFAAiF,GACjF,OAAO,SAASC,2BAA2BC,IAA+B;IACxE,MAAMX,SAAS,IAAIC,gBAAgBW,OAAOD,eAAAA,OAAQ,IAAIE,OAAO,CAAC,MAAM;IACpE,MAAMX,OAAOF,OAAOc,GAAG,CAACjB;IACxB,IAAIK,SAAS,QAAQ;YACNF,aACCA;QADd,MAAMI,QAAOJ,cAAAA,OAAOc,GAAG,CAAC,mBAAXd,cAAsB;QACnC,MAAMK,SAAQL,eAAAA,OAAOc,GAAG,CAAC,oBAAXd,eAAuB;QACrC,OAAOI,QAAQC,QAAQ;YAAEH,MAAM;YAAQE;YAAMC;QAAM,IAAI;IACzD;IACA,IAAIH,SAAS,SAAS;QACpB,MAAMI,SAASN,OAAOc,GAAG,CAAC;QAC1B,OAAO;YACLZ,MAAM;YACNI,QAAQA,UAAUG,cAAcM,QAAQ,CAACT,UAAUA,SAAS;WACxDN,OAAOc,GAAG,CAAC,gBAAgB,cAAc;YAAEP,UAAU;QAAqB,IAAI,CAAC;IAEvF;IACA,OAAO;AACT"}
@@ -15,7 +15,7 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  import { type SecretBoxKeyring } from '@aglyn/shared-util-tools/secret-box';
18
- import { type OutreachMailboxCredentials } from '../model/outreach.types';
18
+ import { type OutreachMailboxCredentials, type OutreachMailboxProvider } from '../model/outreach.types';
19
19
  /**
20
20
  * A MAILBOX'S GRANT AT REST (AGL-2978): `outreachMailboxCredentials/{mailboxId}`.
21
21
  *
@@ -30,16 +30,19 @@ import { type OutreachMailboxCredentials } from '../model/outreach.types';
30
30
  * redacts by field name first, and a neutral name would be judged by its
31
31
  * value's shape alone.
32
32
  */
33
- /** A connected Google mailbox's stored credential. */
34
- export interface OutreachGoogleMailboxCredentials extends OutreachMailboxCredentials {
35
- provider: 'google';
33
+ /** A connected mailbox's stored credential, Google's or Microsoft's. */
34
+ export interface OutreachStoredMailboxCredentials extends OutreachMailboxCredentials {
35
+ provider: OutreachMailboxProvider;
36
36
  /** The member who connected the mailbox. */
37
37
  connectedByUid: string;
38
- /** Google's stable account id (`sub`), so one account's grants can be found. */
38
+ /**
39
+ * The provider's stable account id, so one account's grants can be found:
40
+ * Google's `sub`, or Microsoft's `<tid>:<oid>`.
41
+ */
39
42
  providerAccountId: string;
40
- /** The account's address, as Google verified it. */
43
+ /** The account's address, as the provider verified it. */
41
44
  email: string;
42
- /** The scopes Google granted, as it listed them. */
45
+ /** The scopes the provider granted, as it listed them. */
43
46
  scopes: string[];
44
47
  /** The refresh token, sealed — see the module comment. */
45
48
  sealedRefreshToken: string;
@@ -57,12 +60,12 @@ export declare function sealMailboxRefreshToken(refreshToken: string, mailboxId:
57
60
  * Opens a stored credential's refresh token. Throws `SecretBoxError` when it
58
61
  * cannot be opened — a missing or rotated-away key, or a tampered value.
59
62
  */
60
- export declare function openMailboxRefreshToken(credential: Pick<OutreachGoogleMailboxCredentials, 'mailboxId' | 'sealedRefreshToken'>, keyring: SecretBoxKeyring): {
63
+ export declare function openMailboxRefreshToken(credential: Pick<OutreachStoredMailboxCredentials, 'mailboxId' | 'sealedRefreshToken'>, keyring: SecretBoxKeyring): {
61
64
  refreshToken: string;
62
65
  needsReseal: boolean;
63
66
  };
64
67
  /**
65
- * The id a member's mailbox for one Google account has in one organization.
68
+ * The id a member's mailbox for one provider account has in one organization.
66
69
  *
67
70
  * Derived rather than random, so connecting the same account again updates
68
71
  * the same mailbox instead of adding a second one, and two members — or two
@@ -70,13 +73,13 @@ export declare function openMailboxRefreshToken(credential: Pick<OutreachGoogleM
70
73
  * collection is top-level, keyed by this id, so it must be unique across
71
74
  * every organization, which is why the org is part of it.
72
75
  */
73
- export declare function outreachMailboxId(orgId: string, uid: string, providerAccountId: string): string;
76
+ export declare function outreachMailboxId(orgId: string, uid: string, providerAccountId: string, provider?: OutreachMailboxProvider): string;
74
77
  export declare function mailboxCredentialsRef(firestore: FirebaseFirestore.Firestore, mailboxId: string): FirebaseFirestore.DocumentReference<FirebaseFirestore.DocumentData, FirebaseFirestore.DocumentData>;
75
78
  export declare function mailboxRef(firestore: FirebaseFirestore.Firestore, orgId: string, mailboxId: string): FirebaseFirestore.DocumentReference<FirebaseFirestore.DocumentData, FirebaseFirestore.DocumentData>;
76
79
  /** A stored credential read defensively, or `null` when it is not one. */
77
- export declare function readMailboxCredentials(data: unknown): OutreachGoogleMailboxCredentials | null;
80
+ export declare function readMailboxCredentials(data: unknown): OutreachStoredMailboxCredentials | null;
78
81
  /**
79
- * How many OTHER stored credentials hold a grant for the same Google account.
82
+ * How many OTHER stored credentials hold a grant for the same provider account.
80
83
  *
81
84
  * Google's revocation ends the whole grant this client holds for an account,
82
85
  * not one token, so revoking while another mailbox still uses the account —
@@ -15,7 +15,7 @@
15
15
  * limitations under the License.
16
16
  */ import { needsReseal, openSecret, sealSecret } from "@aglyn/shared-util-tools/secret-box";
17
17
  import { createHash } from "node:crypto";
18
- import { OUTREACH_COLLECTIONS } from "../model/outreach.types.js";
18
+ import { OUTREACH_COLLECTIONS, OUTREACH_MAILBOX_PROVIDERS } from "../model/outreach.types.js";
19
19
  /** The context a mailbox's refresh token is sealed under. */ export function refreshTokenSealContext(mailboxId) {
20
20
  return `${OUTREACH_COLLECTIONS.mailboxCredentials}/${mailboxId}#refreshToken`;
21
21
  }
@@ -39,17 +39,21 @@ import { OUTREACH_COLLECTIONS } from "../model/outreach.types.js";
39
39
  needsReseal: needsReseal(opened, keyring)
40
40
  };
41
41
  }
42
+ /** The prefix a mailbox id carries for its provider. */ const MAILBOX_ID_PREFIX = {
43
+ google: 'gm',
44
+ microsoft: 'ms'
45
+ };
42
46
  /**
43
- * The id a member's mailbox for one Google account has in one organization.
47
+ * The id a member's mailbox for one provider account has in one organization.
44
48
  *
45
49
  * Derived rather than random, so connecting the same account again updates
46
50
  * the same mailbox instead of adding a second one, and two members — or two
47
51
  * organizations — connecting one account never share an id. The credential
48
52
  * collection is top-level, keyed by this id, so it must be unique across
49
53
  * every organization, which is why the org is part of it.
50
- */ export function outreachMailboxId(orgId, uid, providerAccountId) {
51
- const hash = createHash('sha256').update(`${orgId}\n${uid}\ngoogle\n${providerAccountId}`).digest('base64url');
52
- return `gm_${hash.slice(0, 24)}`;
54
+ */ export function outreachMailboxId(orgId, uid, providerAccountId, provider = 'google') {
55
+ const hash = createHash('sha256').update(`${orgId}\n${uid}\n${provider}\n${providerAccountId}`).digest('base64url');
56
+ return `${MAILBOX_ID_PREFIX[provider]}_${hash.slice(0, 24)}`;
53
57
  }
54
58
  export function mailboxCredentialsRef(firestore, mailboxId) {
55
59
  return firestore.collection(OUTREACH_COLLECTIONS.mailboxCredentials).doc(mailboxId);
@@ -60,14 +64,14 @@ export function mailboxRef(firestore, orgId, mailboxId) {
60
64
  /** A stored credential read defensively, or `null` when it is not one. */ export function readMailboxCredentials(data) {
61
65
  var _record_id, _record_connectedByUid, _record_providerAccountId, _record_email, _record_tokenKeyId;
62
66
  const record = data != null ? data : null;
63
- if (!record || record.provider !== 'google' || typeof record.mailboxId !== 'string' || typeof record.orgId !== 'string' || typeof record.sealedRefreshToken !== 'string' || !record.sealedRefreshToken) {
67
+ if (!record || !OUTREACH_MAILBOX_PROVIDERS.includes(record.provider) || typeof record.mailboxId !== 'string' || typeof record.orgId !== 'string' || typeof record.sealedRefreshToken !== 'string' || !record.sealedRefreshToken) {
64
68
  return null;
65
69
  }
66
70
  return {
67
71
  id: String((_record_id = record.id) != null ? _record_id : record.mailboxId),
68
72
  orgId: record.orgId,
69
73
  mailboxId: record.mailboxId,
70
- provider: 'google',
74
+ provider: record.provider,
71
75
  connectedByUid: String((_record_connectedByUid = record.connectedByUid) != null ? _record_connectedByUid : ''),
72
76
  providerAccountId: String((_record_providerAccountId = record.providerAccountId) != null ? _record_providerAccountId : ''),
73
77
  email: String((_record_email = record.email) != null ? _record_email : ''),
@@ -79,7 +83,7 @@ export function mailboxRef(firestore, orgId, mailboxId) {
79
83
  };
80
84
  }
81
85
  /**
82
- * How many OTHER stored credentials hold a grant for the same Google account.
86
+ * How many OTHER stored credentials hold a grant for the same provider account.
83
87
  *
84
88
  * Google's revocation ends the whole grant this client holds for an account,
85
89
  * not one token, so revoking while another mailbox still uses the account —
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/mailboxes/mailbox-credentials.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 {\n needsReseal,\n openSecret,\n sealSecret,\n type SecretBoxKeyring,\n} from '@aglyn/shared-util-tools/secret-box'\nimport { createHash } from 'node:crypto'\nimport {\n OUTREACH_COLLECTIONS,\n type OutreachMailboxCredentials,\n} from '../model/outreach.types'\n\n/**\n * A MAILBOX'S GRANT AT REST (AGL-2978): `outreachMailboxCredentials/{mailboxId}`.\n *\n * The refresh token is the only secret, and it is stored only sealed —\n * AES-256-GCM under `OUTREACH_TOKEN_KEY` through the shared secret box, bound\n * to this document by the seal's context, so a sealed token copied into\n * another mailbox's credential refuses to open there. Every other field is\n * bookkeeping a reader needs without the key: which account, which scopes,\n * which member, which key sealed it.\n *\n * Sealed fields carry `token` in their names. The personal-data export\n * redacts by field name first, and a neutral name would be judged by its\n * value's shape alone.\n */\n\n/** A connected Google mailbox's stored credential. */\nexport interface OutreachGoogleMailboxCredentials extends OutreachMailboxCredentials {\n provider: 'google'\n /** The member who connected the mailbox. */\n connectedByUid: string\n /** Google's stable account id (`sub`), so one account's grants can be found. */\n providerAccountId: string\n /** The account's address, as Google verified it. */\n email: string\n /** The scopes Google granted, as it listed them. */\n scopes: string[]\n /** The refresh token, sealed — see the module comment. */\n sealedRefreshToken: string\n /** The id of the key that sealed it, for rotation. */\n tokenKeyId: string\n}\n\n/** The context a mailbox's refresh token is sealed under. */\nexport function refreshTokenSealContext(mailboxId: string): string {\n return `${OUTREACH_COLLECTIONS.mailboxCredentials}/${mailboxId}#refreshToken`\n}\n\n/** Seals a refresh token for one mailbox under the keyring's current key. */\nexport function sealMailboxRefreshToken(\n refreshToken: string,\n mailboxId: string,\n keyring: SecretBoxKeyring,\n): { sealedRefreshToken: string; tokenKeyId: string } {\n return {\n sealedRefreshToken: sealSecret(refreshToken, keyring.current, {\n context: refreshTokenSealContext(mailboxId),\n }),\n tokenKeyId: keyring.current.id,\n }\n}\n\n/**\n * Opens a stored credential's refresh token. Throws `SecretBoxError` when it\n * cannot be opened — a missing or rotated-away key, or a tampered value.\n */\nexport function openMailboxRefreshToken(\n credential: Pick<OutreachGoogleMailboxCredentials, 'mailboxId' | 'sealedRefreshToken'>,\n keyring: SecretBoxKeyring,\n): { refreshToken: string; needsReseal: boolean } {\n const opened = openSecret(credential.sealedRefreshToken, keyring, {\n context: refreshTokenSealContext(credential.mailboxId),\n })\n return { refreshToken: opened.plaintext, needsReseal: needsReseal(opened, keyring) }\n}\n\n/**\n * The id a member's mailbox for one Google account has in one organization.\n *\n * Derived rather than random, so connecting the same account again updates\n * the same mailbox instead of adding a second one, and two members — or two\n * organizations — connecting one account never share an id. The credential\n * collection is top-level, keyed by this id, so it must be unique across\n * every organization, which is why the org is part of it.\n */\nexport function outreachMailboxId(orgId: string, uid: string, providerAccountId: string): string {\n const hash = createHash('sha256').update(`${orgId}\\n${uid}\\ngoogle\\n${providerAccountId}`).digest('base64url')\n return `gm_${hash.slice(0, 24)}`\n}\n\nexport function mailboxCredentialsRef(firestore: FirebaseFirestore.Firestore, mailboxId: string) {\n return firestore.collection(OUTREACH_COLLECTIONS.mailboxCredentials).doc(mailboxId)\n}\n\nexport function mailboxRef(firestore: FirebaseFirestore.Firestore, orgId: string, mailboxId: string) {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(OUTREACH_COLLECTIONS.mailboxes)\n .doc(mailboxId)\n}\n\n/** A stored credential read defensively, or `null` when it is not one. */\nexport function readMailboxCredentials(data: unknown): OutreachGoogleMailboxCredentials | null {\n const record = (data ?? null) as Partial<OutreachGoogleMailboxCredentials> | null\n if (\n !record ||\n record.provider !== 'google' ||\n typeof record.mailboxId !== 'string' ||\n typeof record.orgId !== 'string' ||\n typeof record.sealedRefreshToken !== 'string' ||\n !record.sealedRefreshToken\n ) {\n return null\n }\n return {\n id: String(record.id ?? record.mailboxId),\n orgId: record.orgId,\n mailboxId: record.mailboxId,\n provider: 'google',\n connectedByUid: String(record.connectedByUid ?? ''),\n providerAccountId: String(record.providerAccountId ?? ''),\n email: String(record.email ?? ''),\n scopes: Array.isArray(record.scopes) ? record.scopes.map(String) : [],\n sealedRefreshToken: record.sealedRefreshToken,\n tokenKeyId: String(record.tokenKeyId ?? ''),\n createdAtMs: Number(record.createdAtMs) || 0,\n updatedAtMs: Number(record.updatedAtMs) || 0,\n }\n}\n\n/**\n * How many OTHER stored credentials hold a grant for the same Google account.\n *\n * Google's revocation ends the whole grant this client holds for an account,\n * not one token, so revoking while another mailbox still uses the account —\n * the same shared inbox connected by two members, or one account connected\n * in two organizations — would silently break that mailbox. A disconnect or\n * an erasure asks this first and leaves the grant alone while it is shared.\n *\n * An erasure names what it is ending anyway — a whole organization's\n * credentials, or every one of a person's — and those are not counted.\n */\nexport async function countOtherCredentialsForAccount(\n firestore: FirebaseFirestore.Firestore,\n input: {\n providerAccountId: string\n mailboxId: string\n excludeOrgId?: string\n excludeMailboxIds?: ReadonlySet<string>\n },\n): Promise<number> {\n if (!input.providerAccountId) return 0\n const snapshot = await firestore\n .collection(OUTREACH_COLLECTIONS.mailboxCredentials)\n .where('providerAccountId', '==', input.providerAccountId)\n .limit(20)\n .get()\n return snapshot.docs.filter((doc) => {\n if (doc.id === input.mailboxId || input.excludeMailboxIds?.has(doc.id)) return false\n return !input.excludeOrgId || doc.get('orgId') !== input.excludeOrgId\n }).length\n}\n"],"names":["needsReseal","openSecret","sealSecret","createHash","OUTREACH_COLLECTIONS","refreshTokenSealContext","mailboxId","mailboxCredentials","sealMailboxRefreshToken","refreshToken","keyring","sealedRefreshToken","current","context","tokenKeyId","id","openMailboxRefreshToken","credential","opened","plaintext","outreachMailboxId","orgId","uid","providerAccountId","hash","update","digest","slice","mailboxCredentialsRef","firestore","collection","doc","mailboxRef","mailboxes","readMailboxCredentials","data","record","provider","String","connectedByUid","email","scopes","Array","isArray","map","createdAtMs","Number","updatedAtMs","countOtherCredentialsForAccount","input","snapshot","where","limit","get","docs","filter","excludeMailboxIds","has","excludeOrgId","length"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/mailboxes/mailbox-credentials.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 {\n needsReseal,\n openSecret,\n sealSecret,\n type SecretBoxKeyring,\n} from '@aglyn/shared-util-tools/secret-box'\nimport { createHash } from 'node:crypto'\nimport {\n OUTREACH_COLLECTIONS,\n OUTREACH_MAILBOX_PROVIDERS,\n type OutreachMailboxCredentials,\n type OutreachMailboxProvider,\n} from '../model/outreach.types'\n\n/**\n * A MAILBOX'S GRANT AT REST (AGL-2978): `outreachMailboxCredentials/{mailboxId}`.\n *\n * The refresh token is the only secret, and it is stored only sealed —\n * AES-256-GCM under `OUTREACH_TOKEN_KEY` through the shared secret box, bound\n * to this document by the seal's context, so a sealed token copied into\n * another mailbox's credential refuses to open there. Every other field is\n * bookkeeping a reader needs without the key: which account, which scopes,\n * which member, which key sealed it.\n *\n * Sealed fields carry `token` in their names. The personal-data export\n * redacts by field name first, and a neutral name would be judged by its\n * value's shape alone.\n */\n\n/** A connected mailbox's stored credential, Google's or Microsoft's. */\nexport interface OutreachStoredMailboxCredentials extends OutreachMailboxCredentials {\n provider: OutreachMailboxProvider\n /** The member who connected the mailbox. */\n connectedByUid: string\n /**\n * The provider's stable account id, so one account's grants can be found:\n * Google's `sub`, or Microsoft's `<tid>:<oid>`.\n */\n providerAccountId: string\n /** The account's address, as the provider verified it. */\n email: string\n /** The scopes the provider granted, as it listed them. */\n scopes: string[]\n /** The refresh token, sealed — see the module comment. */\n sealedRefreshToken: string\n /** The id of the key that sealed it, for rotation. */\n tokenKeyId: string\n}\n\n/** The context a mailbox's refresh token is sealed under. */\nexport function refreshTokenSealContext(mailboxId: string): string {\n return `${OUTREACH_COLLECTIONS.mailboxCredentials}/${mailboxId}#refreshToken`\n}\n\n/** Seals a refresh token for one mailbox under the keyring's current key. */\nexport function sealMailboxRefreshToken(\n refreshToken: string,\n mailboxId: string,\n keyring: SecretBoxKeyring,\n): { sealedRefreshToken: string; tokenKeyId: string } {\n return {\n sealedRefreshToken: sealSecret(refreshToken, keyring.current, {\n context: refreshTokenSealContext(mailboxId),\n }),\n tokenKeyId: keyring.current.id,\n }\n}\n\n/**\n * Opens a stored credential's refresh token. Throws `SecretBoxError` when it\n * cannot be opened — a missing or rotated-away key, or a tampered value.\n */\nexport function openMailboxRefreshToken(\n credential: Pick<OutreachStoredMailboxCredentials, 'mailboxId' | 'sealedRefreshToken'>,\n keyring: SecretBoxKeyring,\n): { refreshToken: string; needsReseal: boolean } {\n const opened = openSecret(credential.sealedRefreshToken, keyring, {\n context: refreshTokenSealContext(credential.mailboxId),\n })\n return { refreshToken: opened.plaintext, needsReseal: needsReseal(opened, keyring) }\n}\n\n/** The prefix a mailbox id carries for its provider. */\nconst MAILBOX_ID_PREFIX: Record<OutreachMailboxProvider, string> = { google: 'gm', microsoft: 'ms' }\n\n/**\n * The id a member's mailbox for one provider account has in one organization.\n *\n * Derived rather than random, so connecting the same account again updates\n * the same mailbox instead of adding a second one, and two members — or two\n * organizations — connecting one account never share an id. The credential\n * collection is top-level, keyed by this id, so it must be unique across\n * every organization, which is why the org is part of it.\n */\nexport function outreachMailboxId(\n orgId: string,\n uid: string,\n providerAccountId: string,\n provider: OutreachMailboxProvider = 'google',\n): string {\n const hash = createHash('sha256').update(`${orgId}\\n${uid}\\n${provider}\\n${providerAccountId}`).digest('base64url')\n return `${MAILBOX_ID_PREFIX[provider]}_${hash.slice(0, 24)}`\n}\n\nexport function mailboxCredentialsRef(firestore: FirebaseFirestore.Firestore, mailboxId: string) {\n return firestore.collection(OUTREACH_COLLECTIONS.mailboxCredentials).doc(mailboxId)\n}\n\nexport function mailboxRef(firestore: FirebaseFirestore.Firestore, orgId: string, mailboxId: string) {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(OUTREACH_COLLECTIONS.mailboxes)\n .doc(mailboxId)\n}\n\n/** A stored credential read defensively, or `null` when it is not one. */\nexport function readMailboxCredentials(data: unknown): OutreachStoredMailboxCredentials | null {\n const record = (data ?? null) as Partial<OutreachStoredMailboxCredentials> | null\n if (\n !record ||\n !(OUTREACH_MAILBOX_PROVIDERS as readonly unknown[]).includes(record.provider) ||\n typeof record.mailboxId !== 'string' ||\n typeof record.orgId !== 'string' ||\n typeof record.sealedRefreshToken !== 'string' ||\n !record.sealedRefreshToken\n ) {\n return null\n }\n return {\n id: String(record.id ?? record.mailboxId),\n orgId: record.orgId,\n mailboxId: record.mailboxId,\n provider: record.provider as OutreachMailboxProvider,\n connectedByUid: String(record.connectedByUid ?? ''),\n providerAccountId: String(record.providerAccountId ?? ''),\n email: String(record.email ?? ''),\n scopes: Array.isArray(record.scopes) ? record.scopes.map(String) : [],\n sealedRefreshToken: record.sealedRefreshToken,\n tokenKeyId: String(record.tokenKeyId ?? ''),\n createdAtMs: Number(record.createdAtMs) || 0,\n updatedAtMs: Number(record.updatedAtMs) || 0,\n }\n}\n\n/**\n * How many OTHER stored credentials hold a grant for the same provider account.\n *\n * Google's revocation ends the whole grant this client holds for an account,\n * not one token, so revoking while another mailbox still uses the account —\n * the same shared inbox connected by two members, or one account connected\n * in two organizations — would silently break that mailbox. A disconnect or\n * an erasure asks this first and leaves the grant alone while it is shared.\n *\n * An erasure names what it is ending anyway — a whole organization's\n * credentials, or every one of a person's — and those are not counted.\n */\nexport async function countOtherCredentialsForAccount(\n firestore: FirebaseFirestore.Firestore,\n input: {\n providerAccountId: string\n mailboxId: string\n excludeOrgId?: string\n excludeMailboxIds?: ReadonlySet<string>\n },\n): Promise<number> {\n if (!input.providerAccountId) return 0\n const snapshot = await firestore\n .collection(OUTREACH_COLLECTIONS.mailboxCredentials)\n .where('providerAccountId', '==', input.providerAccountId)\n .limit(20)\n .get()\n return snapshot.docs.filter((doc) => {\n if (doc.id === input.mailboxId || input.excludeMailboxIds?.has(doc.id)) return false\n return !input.excludeOrgId || doc.get('orgId') !== input.excludeOrgId\n }).length\n}\n"],"names":["needsReseal","openSecret","sealSecret","createHash","OUTREACH_COLLECTIONS","OUTREACH_MAILBOX_PROVIDERS","refreshTokenSealContext","mailboxId","mailboxCredentials","sealMailboxRefreshToken","refreshToken","keyring","sealedRefreshToken","current","context","tokenKeyId","id","openMailboxRefreshToken","credential","opened","plaintext","MAILBOX_ID_PREFIX","google","microsoft","outreachMailboxId","orgId","uid","providerAccountId","provider","hash","update","digest","slice","mailboxCredentialsRef","firestore","collection","doc","mailboxRef","mailboxes","readMailboxCredentials","data","record","includes","String","connectedByUid","email","scopes","Array","isArray","map","createdAtMs","Number","updatedAtMs","countOtherCredentialsForAccount","input","snapshot","where","limit","get","docs","filter","excludeMailboxIds","has","excludeOrgId","length"],"mappings":"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"}
@@ -31,12 +31,14 @@ const emptyTally = ()=>({
31
31
  revoked: 0,
32
32
  alreadyInvalid: 0,
33
33
  kept: 0,
34
+ unsupported: 0,
34
35
  failed: 0
35
36
  });
36
37
  function count(tally, outcome) {
37
38
  if (outcome === 'revoked') tally.revoked += 1;
38
39
  else if (outcome === 'already-invalid') tally.alreadyInvalid += 1;
39
40
  else if (outcome === 'kept-for-other-mailbox') tally.kept += 1;
41
+ else if (outcome === 'unsupported') tally.unsupported += 1;
40
42
  else tally.failed += 1;
41
43
  }
42
44
  /** Firestore refuses a batch of more than 500 writes. */ const BATCH_LIMIT = 450;
@@ -63,6 +65,7 @@ function count(tally, outcome) {
63
65
  revoked: null,
64
66
  alreadyInvalid: null,
65
67
  kept: null,
68
+ unsupported: null,
66
69
  failed: null
67
70
  };
68
71
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/mailboxes/mailbox-erasure.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 {\n PluginOrgEraser,\n PluginOrgErasureReport,\n} from '@aglyn/aglyn/plugin-manager/plugin-org-erasure'\nimport type {\n PluginUserEraser,\n PluginUserErasureReport,\n} from '@aglyn/aglyn/plugin-manager/plugin-user-erasure'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { OUTREACH_COLLECTIONS } from '../model/outreach.types'\nimport {\n mailboxCredentialsRef,\n mailboxRef,\n readMailboxCredentials,\n type OutreachGoogleMailboxCredentials,\n} from './mailbox-credentials'\nimport {\n revokeMailboxGrant,\n type OutreachGrantRevocation,\n type OutreachRevokeDeps,\n} from './mailbox-revoke'\nimport { outreachOAuthStateRef } from './oauth-state'\nimport { readOutreachGoogleConfig } from './outreach-config'\n\n/**\n * OUTREACH'S SHARE OF AN ERASURE (AGL-2978).\n *\n * The workspace erasure deletes every stored Outreach grant on its own —\n * `outreachMailboxCredentials` by its `orgId` field, and the mailboxes and\n * pending connects with the organization's tree — in any process, whether\n * or not this plugin is loaded. Deleting the platform's copy leaves the\n * grant alive at Google, though: listed in the rep's account as an app with\n * access to their mail until they remove it by hand. So Outreach registers a\n * workspace eraser (`plugin-org-erasure`) that revokes each grant first,\n * while the stored credential still exists to revoke it with. It deletes\n * nothing itself; the erasure's own sweep does that right after.\n *\n * An ACCOUNT erasure (`plugin-user-erasure`, AGL-3106) reaches none of it on\n * its own: a person's mailbox lives under the organization and their grant\n * in a top-level collection, and the erasure removes the person, not the\n * organizations they belonged to. So the account eraser below revokes every\n * grant the person connected and deletes it, with their mailboxes and their\n * pending connects — their Gmail address and a token that sends as them are\n * about the person, and must not outlive them.\n *\n * Both erasers are registered from the console-only declarations entry:\n * revoking opens a sealed token with `OUTREACH_TOKEN_KEY`, which only the\n * console holds.\n */\n\nexport interface OutreachErasureDeps extends OutreachRevokeDeps {\n firestore(): FirebaseFirestore.Firestore\n}\n\n/** The platform's own dependencies. Specs build their own. */\nexport function defaultOutreachErasureDeps(): OutreachErasureDeps {\n return {\n firestore: () => firebaseAdmin.app().firestore(),\n readConfig: readOutreachGoogleConfig,\n transport: {},\n }\n}\n\n/** How many grants came to each end, for an erasure's audit record. */\ninterface RevocationTally {\n revoked: number\n alreadyInvalid: number\n kept: number\n failed: number\n}\n\nconst emptyTally = (): RevocationTally => ({ revoked: 0, alreadyInvalid: 0, kept: 0, failed: 0 })\n\nfunction count(tally: RevocationTally, outcome: OutreachGrantRevocation): void {\n if (outcome === 'revoked') tally.revoked += 1\n else if (outcome === 'already-invalid') tally.alreadyInvalid += 1\n else if (outcome === 'kept-for-other-mailbox') tally.kept += 1\n else tally.failed += 1\n}\n\n/** Firestore refuses a batch of more than 500 writes. */\nconst BATCH_LIMIT = 450\n\n/** Deletes every reference, in batches under Firestore's limit. */\nasync function deleteAll(\n firestore: FirebaseFirestore.Firestore,\n refs: readonly FirebaseFirestore.DocumentReference[],\n): Promise<void> {\n for (let start = 0; start < refs.length; start += BATCH_LIMIT) {\n const batch = firestore.batch()\n for (const ref of refs.slice(start, start + BATCH_LIMIT)) batch.delete(ref)\n await batch.commit()\n }\n}\n\n/**\n * The workspace eraser: revoke, at Google, every grant the organization\n * holds. A grant another organization still uses for the same Google\n * account is kept (`kept`), because revoking it would cut that\n * organization's mailbox off too. A plan counts the grants and touches\n * nothing, so its revocation figures are `null` — not measured, not zero.\n */\nexport function createOutreachOrgEraser(deps: OutreachErasureDeps): PluginOrgEraser {\n return async ({ orgId, dryRun }): Promise<PluginOrgErasureReport> => {\n const firestore = deps.firestore()\n const rows = await firestore\n .collection(OUTREACH_COLLECTIONS.mailboxCredentials)\n .where('orgId', '==', orgId)\n .get()\n if (dryRun) {\n return { grants: rows.size, revoked: null, alreadyInvalid: null, kept: null, failed: null }\n }\n const tally = emptyTally()\n for (const doc of rows.docs) {\n const credential = readMailboxCredentials(doc.data())\n count(\n tally,\n credential\n ? await revokeMailboxGrant(firestore, credential, deps, { excludeOrgId: orgId })\n : 'failed',\n )\n }\n return { grants: rows.size, ...tally }\n }\n}\n\n/**\n * The account eraser (AGL-3106): every mailbox the person connected, in the\n * organizations they belonged to and in any they had already left, revoked\n * at Google and deleted with its stored grant; and their pending connects.\n *\n * A grant is kept at Google when a TEAMMATE still uses the same Google\n * account — a shared inbox two members connected — because revoking it would\n * cut the teammate's mailbox off; the erased person's own copy is deleted\n * either way. Every other copy the person holds is being erased too, so it\n * does not count as a use.\n */\nexport function createOutreachUserEraser(deps: OutreachErasureDeps): PluginUserEraser {\n return async ({ uid, orgIds }): Promise<PluginUserErasureReport> => {\n const firestore = deps.firestore()\n // By the person, across every organization — including one they have\n // already left, which `orgIds` no longer names.\n const credentials = await firestore\n .collection(OUTREACH_COLLECTIONS.mailboxCredentials)\n .where('connectedByUid', '==', uid)\n .get()\n const mailboxes = new Map<string, { orgId: string; mailboxId: string }>()\n const stored: OutreachGoogleMailboxCredentials[] = []\n for (const doc of credentials.docs) {\n const credential = readMailboxCredentials(doc.data())\n const orgId = credential?.orgId ?? String(doc.get('orgId') ?? '')\n if (credential) stored.push(credential)\n if (orgId) mailboxes.set(`${orgId}/${doc.id}`, { orgId, mailboxId: doc.id })\n }\n // A mailbox whose grant is already gone still names the person's\n // address; find those under each organization the person was in.\n for (const orgId of orgIds) {\n const connected = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection(OUTREACH_COLLECTIONS.mailboxes)\n .where('connectedByUid', '==', uid)\n .get()\n for (const doc of connected.docs) mailboxes.set(`${orgId}/${doc.id}`, { orgId, mailboxId: doc.id })\n }\n\n const theirs = new Set(credentials.docs.map((doc) => doc.id))\n const tally = emptyTally()\n for (const credential of stored) {\n count(tally, await revokeMailboxGrant(firestore, credential, deps, { excludeMailboxIds: theirs }))\n }\n tally.failed += credentials.size - stored.length\n\n const orgsWithPending = new Set([...orgIds, ...[...mailboxes.values()].map((entry) => entry.orgId)])\n await deleteAll(firestore, [\n ...credentials.docs.map((doc) => mailboxCredentialsRef(firestore, doc.id)),\n ...[...mailboxes.values()].map(({ orgId, mailboxId }) => mailboxRef(firestore, orgId, mailboxId)),\n ...[...orgsWithPending].map((orgId) => outreachOAuthStateRef(firestore, orgId, uid)),\n ])\n\n return {\n mailboxes: mailboxes.size,\n grants: credentials.size,\n ...tally,\n }\n }\n}\n"],"names":["firebaseAdmin","OUTREACH_COLLECTIONS","mailboxCredentialsRef","mailboxRef","readMailboxCredentials","revokeMailboxGrant","outreachOAuthStateRef","readOutreachGoogleConfig","defaultOutreachErasureDeps","firestore","app","readConfig","transport","emptyTally","revoked","alreadyInvalid","kept","failed","count","tally","outcome","BATCH_LIMIT","deleteAll","refs","start","length","batch","ref","slice","delete","commit","createOutreachOrgEraser","deps","orgId","dryRun","rows","collection","mailboxCredentials","where","get","grants","size","doc","docs","credential","data","excludeOrgId","createOutreachUserEraser","uid","orgIds","credentials","mailboxes","Map","stored","String","push","set","id","mailboxId","connected","theirs","Set","map","excludeMailboxIds","orgsWithPending","values","entry"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/mailboxes/mailbox-erasure.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 {\n PluginOrgEraser,\n PluginOrgErasureReport,\n} from '@aglyn/aglyn/plugin-manager/plugin-org-erasure'\nimport type {\n PluginUserEraser,\n PluginUserErasureReport,\n} from '@aglyn/aglyn/plugin-manager/plugin-user-erasure'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { OUTREACH_COLLECTIONS } from '../model/outreach.types'\nimport {\n mailboxCredentialsRef,\n mailboxRef,\n readMailboxCredentials,\n type OutreachStoredMailboxCredentials,\n} from './mailbox-credentials'\nimport {\n revokeMailboxGrant,\n type OutreachGrantRevocation,\n type OutreachRevokeDeps,\n} from './mailbox-revoke'\nimport { outreachOAuthStateRef } from './oauth-state'\nimport { readOutreachGoogleConfig } from './outreach-config'\n\n/**\n * OUTREACH'S SHARE OF AN ERASURE (AGL-2978).\n *\n * The workspace erasure deletes every stored Outreach grant on its own —\n * `outreachMailboxCredentials` by its `orgId` field, and the mailboxes and\n * pending connects with the organization's tree — in any process, whether\n * or not this plugin is loaded. Deleting the platform's copy leaves the\n * grant alive at Google, though: listed in the rep's account as an app with\n * access to their mail until they remove it by hand. So Outreach registers a\n * workspace eraser (`plugin-org-erasure`) that revokes each grant first,\n * while the stored credential still exists to revoke it with. It deletes\n * nothing itself; the erasure's own sweep does that right after.\n *\n * An ACCOUNT erasure (`plugin-user-erasure`, AGL-3106) reaches none of it on\n * its own: a person's mailbox lives under the organization and their grant\n * in a top-level collection, and the erasure removes the person, not the\n * organizations they belonged to. So the account eraser below revokes every\n * grant the person connected and deletes it, with their mailboxes and their\n * pending connects — their Gmail address and a token that sends as them are\n * about the person, and must not outlive them.\n *\n * Both erasers are registered from the console-only declarations entry:\n * revoking opens a sealed token with `OUTREACH_TOKEN_KEY`, which only the\n * console holds.\n */\n\nexport interface OutreachErasureDeps extends OutreachRevokeDeps {\n firestore(): FirebaseFirestore.Firestore\n}\n\n/** The platform's own dependencies. Specs build their own. */\nexport function defaultOutreachErasureDeps(): OutreachErasureDeps {\n return {\n firestore: () => firebaseAdmin.app().firestore(),\n readConfig: readOutreachGoogleConfig,\n transport: {},\n }\n}\n\n/** How many grants came to each end, for an erasure's audit record. */\ninterface RevocationTally {\n revoked: number\n alreadyInvalid: number\n kept: number\n /** Microsoft grants, which no app can revoke: deleting them ends them here. */\n unsupported: number\n failed: number\n}\n\nconst emptyTally = (): RevocationTally => ({ revoked: 0, alreadyInvalid: 0, kept: 0, unsupported: 0, failed: 0 })\n\nfunction count(tally: RevocationTally, outcome: OutreachGrantRevocation): void {\n if (outcome === 'revoked') tally.revoked += 1\n else if (outcome === 'already-invalid') tally.alreadyInvalid += 1\n else if (outcome === 'kept-for-other-mailbox') tally.kept += 1\n else if (outcome === 'unsupported') tally.unsupported += 1\n else tally.failed += 1\n}\n\n/** Firestore refuses a batch of more than 500 writes. */\nconst BATCH_LIMIT = 450\n\n/** Deletes every reference, in batches under Firestore's limit. */\nasync function deleteAll(\n firestore: FirebaseFirestore.Firestore,\n refs: readonly FirebaseFirestore.DocumentReference[],\n): Promise<void> {\n for (let start = 0; start < refs.length; start += BATCH_LIMIT) {\n const batch = firestore.batch()\n for (const ref of refs.slice(start, start + BATCH_LIMIT)) batch.delete(ref)\n await batch.commit()\n }\n}\n\n/**\n * The workspace eraser: revoke, at Google, every grant the organization\n * holds. A grant another organization still uses for the same Google\n * account is kept (`kept`), because revoking it would cut that\n * organization's mailbox off too. A plan counts the grants and touches\n * nothing, so its revocation figures are `null` — not measured, not zero.\n */\nexport function createOutreachOrgEraser(deps: OutreachErasureDeps): PluginOrgEraser {\n return async ({ orgId, dryRun }): Promise<PluginOrgErasureReport> => {\n const firestore = deps.firestore()\n const rows = await firestore\n .collection(OUTREACH_COLLECTIONS.mailboxCredentials)\n .where('orgId', '==', orgId)\n .get()\n if (dryRun) {\n return { grants: rows.size, revoked: null, alreadyInvalid: null, kept: null, unsupported: null, failed: null }\n }\n const tally = emptyTally()\n for (const doc of rows.docs) {\n const credential = readMailboxCredentials(doc.data())\n count(\n tally,\n credential\n ? await revokeMailboxGrant(firestore, credential, deps, { excludeOrgId: orgId })\n : 'failed',\n )\n }\n return { grants: rows.size, ...tally }\n }\n}\n\n/**\n * The account eraser (AGL-3106): every mailbox the person connected, in the\n * organizations they belonged to and in any they had already left, revoked\n * at Google and deleted with its stored grant; and their pending connects.\n *\n * A grant is kept at Google when a TEAMMATE still uses the same Google\n * account — a shared inbox two members connected — because revoking it would\n * cut the teammate's mailbox off; the erased person's own copy is deleted\n * either way. Every other copy the person holds is being erased too, so it\n * does not count as a use.\n */\nexport function createOutreachUserEraser(deps: OutreachErasureDeps): PluginUserEraser {\n return async ({ uid, orgIds }): Promise<PluginUserErasureReport> => {\n const firestore = deps.firestore()\n // By the person, across every organization — including one they have\n // already left, which `orgIds` no longer names.\n const credentials = await firestore\n .collection(OUTREACH_COLLECTIONS.mailboxCredentials)\n .where('connectedByUid', '==', uid)\n .get()\n const mailboxes = new Map<string, { orgId: string; mailboxId: string }>()\n const stored: OutreachStoredMailboxCredentials[] = []\n for (const doc of credentials.docs) {\n const credential = readMailboxCredentials(doc.data())\n const orgId = credential?.orgId ?? String(doc.get('orgId') ?? '')\n if (credential) stored.push(credential)\n if (orgId) mailboxes.set(`${orgId}/${doc.id}`, { orgId, mailboxId: doc.id })\n }\n // A mailbox whose grant is already gone still names the person's\n // address; find those under each organization the person was in.\n for (const orgId of orgIds) {\n const connected = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection(OUTREACH_COLLECTIONS.mailboxes)\n .where('connectedByUid', '==', uid)\n .get()\n for (const doc of connected.docs) mailboxes.set(`${orgId}/${doc.id}`, { orgId, mailboxId: doc.id })\n }\n\n const theirs = new Set(credentials.docs.map((doc) => doc.id))\n const tally = emptyTally()\n for (const credential of stored) {\n count(tally, await revokeMailboxGrant(firestore, credential, deps, { excludeMailboxIds: theirs }))\n }\n tally.failed += credentials.size - stored.length\n\n const orgsWithPending = new Set([...orgIds, ...[...mailboxes.values()].map((entry) => entry.orgId)])\n await deleteAll(firestore, [\n ...credentials.docs.map((doc) => mailboxCredentialsRef(firestore, doc.id)),\n ...[...mailboxes.values()].map(({ orgId, mailboxId }) => mailboxRef(firestore, orgId, mailboxId)),\n ...[...orgsWithPending].map((orgId) => outreachOAuthStateRef(firestore, orgId, uid)),\n ])\n\n return {\n mailboxes: mailboxes.size,\n grants: credentials.size,\n ...tally,\n }\n }\n}\n"],"names":["firebaseAdmin","OUTREACH_COLLECTIONS","mailboxCredentialsRef","mailboxRef","readMailboxCredentials","revokeMailboxGrant","outreachOAuthStateRef","readOutreachGoogleConfig","defaultOutreachErasureDeps","firestore","app","readConfig","transport","emptyTally","revoked","alreadyInvalid","kept","unsupported","failed","count","tally","outcome","BATCH_LIMIT","deleteAll","refs","start","length","batch","ref","slice","delete","commit","createOutreachOrgEraser","deps","orgId","dryRun","rows","collection","mailboxCredentials","where","get","grants","size","doc","docs","credential","data","excludeOrgId","createOutreachUserEraser","uid","orgIds","credentials","mailboxes","Map","stored","String","push","set","id","mailboxId","connected","theirs","Set","map","excludeMailboxIds","orgsWithPending","values","entry"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAUD,SAASA,aAAa,QAAQ,iDAAgD;AAC9E,SAASC,oBAAoB,QAAQ,6BAAyB;AAC9D,SACEC,qBAAqB,EACrBC,UAAU,EACVC,sBAAsB,QAEjB,2BAAuB;AAC9B,SACEC,kBAAkB,QAGb,sBAAkB;AACzB,SAASC,qBAAqB,QAAQ,mBAAe;AACrD,SAASC,wBAAwB,QAAQ,uBAAmB;AAgC5D,4DAA4D,GAC5D,OAAO,SAASC;IACd,OAAO;QACLC,WAAW,IAAMT,cAAcU,GAAG,GAAGD,SAAS;QAC9CE,YAAYJ;QACZK,WAAW,CAAC;IACd;AACF;AAYA,MAAMC,aAAa,IAAwB,CAAA;QAAEC,SAAS;QAAGC,gBAAgB;QAAGC,MAAM;QAAGC,aAAa;QAAGC,QAAQ;IAAE,CAAA;AAE/G,SAASC,MAAMC,KAAsB,EAAEC,OAAgC;IACrE,IAAIA,YAAY,WAAWD,MAAMN,OAAO,IAAI;SACvC,IAAIO,YAAY,mBAAmBD,MAAML,cAAc,IAAI;SAC3D,IAAIM,YAAY,0BAA0BD,MAAMJ,IAAI,IAAI;SACxD,IAAIK,YAAY,eAAeD,MAAMH,WAAW,IAAI;SACpDG,MAAMF,MAAM,IAAI;AACvB;AAEA,uDAAuD,GACvD,MAAMI,cAAc;AAEpB,iEAAiE,GACjE,eAAeC,UACbd,SAAsC,EACtCe,IAAoD;IAEpD,IAAK,IAAIC,QAAQ,GAAGA,QAAQD,KAAKE,MAAM,EAAED,SAASH,YAAa;QAC7D,MAAMK,QAAQlB,UAAUkB,KAAK;QAC7B,KAAK,MAAMC,OAAOJ,KAAKK,KAAK,CAACJ,OAAOA,QAAQH,aAAcK,MAAMG,MAAM,CAACF;QACvE,MAAMD,MAAMI,MAAM;IACpB;AACF;AAEA;;;;;;CAMC,GACD,OAAO,SAASC,wBAAwBC,IAAyB;IAC/D,OAAO,OAAO,EAAEC,KAAK,EAAEC,MAAM,EAAE;QAC7B,MAAM1B,YAAYwB,KAAKxB,SAAS;QAChC,MAAM2B,OAAO,MAAM3B,UAChB4B,UAAU,CAACpC,qBAAqBqC,kBAAkB,EAClDC,KAAK,CAAC,SAAS,MAAML,OACrBM,GAAG;QACN,IAAIL,QAAQ;YACV,OAAO;gBAAEM,QAAQL,KAAKM,IAAI;gBAAE5B,SAAS;gBAAMC,gBAAgB;gBAAMC,MAAM;gBAAMC,aAAa;gBAAMC,QAAQ;YAAK;QAC/G;QACA,MAAME,QAAQP;QACd,KAAK,MAAM8B,OAAOP,KAAKQ,IAAI,CAAE;YAC3B,MAAMC,aAAazC,uBAAuBuC,IAAIG,IAAI;YAClD3B,MACEC,OACAyB,aACI,MAAMxC,mBAAmBI,WAAWoC,YAAYZ,MAAM;gBAAEc,cAAcb;YAAM,KAC5E;QAER;QACA,OAAO;YAAEO,QAAQL,KAAKM,IAAI;WAAKtB;IACjC;AACF;AAEA;;;;;;;;;;CAUC,GACD,OAAO,SAAS4B,yBAAyBf,IAAyB;IAChE,OAAO,OAAO,EAAEgB,GAAG,EAAEC,MAAM,EAAE;QAC3B,MAAMzC,YAAYwB,KAAKxB,SAAS;QAChC,qEAAqE;QACrE,gDAAgD;QAChD,MAAM0C,cAAc,MAAM1C,UACvB4B,UAAU,CAACpC,qBAAqBqC,kBAAkB,EAClDC,KAAK,CAAC,kBAAkB,MAAMU,KAC9BT,GAAG;QACN,MAAMY,YAAY,IAAIC;QACtB,MAAMC,SAA6C,EAAE;QACrD,KAAK,MAAMX,OAAOQ,YAAYP,IAAI,CAAE;sBAEQD;YAD1C,MAAME,aAAazC,uBAAuBuC,IAAIG,IAAI;YAClD,MAAMZ,gBAAQW,8BAAAA,WAAYX,KAAK,mBAAIqB,QAAOZ,WAAAA,IAAIH,GAAG,CAAC,oBAARG,WAAoB;YAC9D,IAAIE,YAAYS,OAAOE,IAAI,CAACX;YAC5B,IAAIX,OAAOkB,UAAUK,GAAG,CAAC,GAAGvB,MAAM,CAAC,EAAES,IAAIe,EAAE,EAAE,EAAE;gBAAExB;gBAAOyB,WAAWhB,IAAIe,EAAE;YAAC;QAC5E;QACA,iEAAiE;QACjE,iEAAiE;QACjE,KAAK,MAAMxB,SAASgB,OAAQ;YAC1B,MAAMU,YAAY,MAAMnD,UACrB4B,UAAU,CAAC,QACXM,GAAG,CAACT,OACJG,UAAU,CAACpC,qBAAqBmD,SAAS,EACzCb,KAAK,CAAC,kBAAkB,MAAMU,KAC9BT,GAAG;YACN,KAAK,MAAMG,OAAOiB,UAAUhB,IAAI,CAAEQ,UAAUK,GAAG,CAAC,GAAGvB,MAAM,CAAC,EAAES,IAAIe,EAAE,EAAE,EAAE;gBAAExB;gBAAOyB,WAAWhB,IAAIe,EAAE;YAAC;QACnG;QAEA,MAAMG,SAAS,IAAIC,IAAIX,YAAYP,IAAI,CAACmB,GAAG,CAAC,CAACpB,MAAQA,IAAIe,EAAE;QAC3D,MAAMtC,QAAQP;QACd,KAAK,MAAMgC,cAAcS,OAAQ;YAC/BnC,MAAMC,OAAO,MAAMf,mBAAmBI,WAAWoC,YAAYZ,MAAM;gBAAE+B,mBAAmBH;YAAO;QACjG;QACAzC,MAAMF,MAAM,IAAIiC,YAAYT,IAAI,GAAGY,OAAO5B,MAAM;QAEhD,MAAMuC,kBAAkB,IAAIH,IAAI;eAAIZ;eAAW;mBAAIE,UAAUc,MAAM;aAAG,CAACH,GAAG,CAAC,CAACI,QAAUA,MAAMjC,KAAK;SAAE;QACnG,MAAMX,UAAUd,WAAW;eACtB0C,YAAYP,IAAI,CAACmB,GAAG,CAAC,CAACpB,MAAQzC,sBAAsBO,WAAWkC,IAAIe,EAAE;eACrE;mBAAIN,UAAUc,MAAM;aAAG,CAACH,GAAG,CAAC,CAAC,EAAE7B,KAAK,EAAEyB,SAAS,EAAE,GAAKxD,WAAWM,WAAWyB,OAAOyB;eACnF;mBAAIM;aAAgB,CAACF,GAAG,CAAC,CAAC7B,QAAU5B,sBAAsBG,WAAWyB,OAAOe;SAChF;QAED,OAAO;YACLG,WAAWA,UAAUV,IAAI;YACzBD,QAAQU,YAAYT,IAAI;WACrBtB;IAEP;AACF"}
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import type { TransportDeps } from '../transport/http';
18
18
  import type { OutreachMailboxDisconnectResponse } from './mailbox-api';
19
- import { type OutreachGoogleMailboxCredentials } from './mailbox-credentials';
19
+ import { type OutreachStoredMailboxCredentials } from './mailbox-credentials';
20
20
  import type { OutreachGoogleConfigResult } from './outreach-config';
21
21
  /**
22
22
  * TELLING GOOGLE A STORED GRANT IS OVER (AGL-2978).
@@ -28,14 +28,19 @@ import type { OutreachGoogleConfigResult } from './outreach-config';
28
28
  * revocation ends the whole grant this client holds for the account, and
29
29
  * revoking it would silently break that other mailbox.
30
30
  *
31
+ * A Microsoft grant (AGL-3489) has no such endpoint: Microsoft offers no
32
+ * way for an app to revoke one refresh token, so the outcome is
33
+ * `unsupported`, and deleting the stored grant is what ends it here.
34
+ *
31
35
  * Never throws. A grant that cannot be revoked is still deleted by whoever
32
36
  * called this; the outcome says which happened.
33
37
  */
34
38
  /**
35
- * What became of a grant at Google: `revoked`, `already-invalid`,
39
+ * What became of a grant at the provider: `revoked`, `already-invalid`,
36
40
  * `kept-for-other-mailbox` while another credential still uses the account,
37
- * or `failed` when Google could not be told — the token would not open, the
38
- * deployment is not configured, or Google did not answer.
41
+ * `unsupported` for a provider with no revocation, or `failed` when Google
42
+ * could not be told — the token would not open, the deployment is not
43
+ * configured, or Google did not answer.
39
44
  */
40
45
  export type OutreachGrantRevocation = OutreachMailboxDisconnectResponse['revocation'];
41
46
  export interface OutreachRevokeDeps {
@@ -56,4 +61,4 @@ export interface RevokeMailboxGrantOptions {
56
61
  excludeMailboxIds?: ReadonlySet<string>;
57
62
  }
58
63
  /** Revokes one stored grant at Google, unless it is still shared. Never throws. */
59
- export declare function revokeMailboxGrant(firestore: FirebaseFirestore.Firestore, credential: OutreachGoogleMailboxCredentials, deps: OutreachRevokeDeps, options?: RevokeMailboxGrantOptions): Promise<OutreachGrantRevocation>;
64
+ export declare function revokeMailboxGrant(firestore: FirebaseFirestore.Firestore, credential: OutreachStoredMailboxCredentials, deps: OutreachRevokeDeps, options?: RevokeMailboxGrantOptions): Promise<OutreachGrantRevocation>;
@@ -18,6 +18,7 @@ import { GmailTransportError } from "../transport/gmail-errors.js";
18
18
  import { revokeGoogleToken } from "../transport/google-oauth.js";
19
19
  import { countOtherCredentialsForAccount, openMailboxRefreshToken } from "./mailbox-credentials.js";
20
20
  /** Revokes one stored grant at Google, unless it is still shared. Never throws. */ export async function revokeMailboxGrant(firestore, credential, deps, options = {}) {
21
+ if (credential.provider === 'microsoft') return 'unsupported';
21
22
  try {
22
23
  const others = await countOtherCredentialsForAccount(firestore, {
23
24
  providerAccountId: credential.providerAccountId,