@aglyn/plugins-outreach 1.0.0-beta.143

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 (282) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +93 -0
  3. package/package.json +52 -0
  4. package/src/index.d.ts +22 -0
  5. package/src/index.js +29 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/compliance-section.d.ts +20 -0
  8. package/src/lib/components/compliance-section.js +291 -0
  9. package/src/lib/components/compliance-section.js.map +1 -0
  10. package/src/lib/components/enroll-dialog.d.ts +36 -0
  11. package/src/lib/components/enroll-dialog.js +656 -0
  12. package/src/lib/components/enroll-dialog.js.map +1 -0
  13. package/src/lib/components/enrollments-table.d.ts +28 -0
  14. package/src/lib/components/enrollments-table.js +375 -0
  15. package/src/lib/components/enrollments-table.js.map +1 -0
  16. package/src/lib/components/mailbox-card.d.ts +46 -0
  17. package/src/lib/components/mailbox-card.js +517 -0
  18. package/src/lib/components/mailbox-card.js.map +1 -0
  19. package/src/lib/components/mailboxes-section.d.ts +24 -0
  20. package/src/lib/components/mailboxes-section.js +258 -0
  21. package/src/lib/components/mailboxes-section.js.map +1 -0
  22. package/src/lib/components/outreach-console-page.d.ts +17 -0
  23. package/src/lib/components/outreach-console-page.js +81 -0
  24. package/src/lib/components/outreach-console-page.js.map +1 -0
  25. package/src/lib/components/outreach-console-sections.d.ts +43 -0
  26. package/src/lib/components/outreach-console-sections.js +55 -0
  27. package/src/lib/components/outreach-console-sections.js.map +1 -0
  28. package/src/lib/components/outreach-ui.d.ts +51 -0
  29. package/src/lib/components/outreach-ui.js +171 -0
  30. package/src/lib/components/outreach-ui.js.map +1 -0
  31. package/src/lib/components/send-window-fields.d.ts +25 -0
  32. package/src/lib/components/send-window-fields.js +178 -0
  33. package/src/lib/components/send-window-fields.js.map +1 -0
  34. package/src/lib/components/sequence-detail.d.ts +54 -0
  35. package/src/lib/components/sequence-detail.js +435 -0
  36. package/src/lib/components/sequence-detail.js.map +1 -0
  37. package/src/lib/components/sequence-editor.d.ts +37 -0
  38. package/src/lib/components/sequence-editor.js +485 -0
  39. package/src/lib/components/sequence-editor.js.map +1 -0
  40. package/src/lib/components/sequence-mailbox-picker.d.ts +44 -0
  41. package/src/lib/components/sequence-mailbox-picker.js +137 -0
  42. package/src/lib/components/sequence-mailbox-picker.js.map +1 -0
  43. package/src/lib/components/sequence-preview.d.ts +27 -0
  44. package/src/lib/components/sequence-preview.js +146 -0
  45. package/src/lib/components/sequence-preview.js.map +1 -0
  46. package/src/lib/components/sequence-step-card.d.ts +31 -0
  47. package/src/lib/components/sequence-step-card.js +326 -0
  48. package/src/lib/components/sequence-step-card.js.map +1 -0
  49. package/src/lib/components/sequences-section.d.ts +40 -0
  50. package/src/lib/components/sequences-section.js +310 -0
  51. package/src/lib/components/sequences-section.js.map +1 -0
  52. package/src/lib/components/use-outreach-api.d.ts +38 -0
  53. package/src/lib/components/use-outreach-api.js +139 -0
  54. package/src/lib/components/use-outreach-api.js.map +1 -0
  55. package/src/lib/components/use-outreach-crm.d.ts +50 -0
  56. package/src/lib/components/use-outreach-crm.js +200 -0
  57. package/src/lib/components/use-outreach-crm.js.map +1 -0
  58. package/src/lib/components/use-outreach-data.d.ts +55 -0
  59. package/src/lib/components/use-outreach-data.js +199 -0
  60. package/src/lib/components/use-outreach-data.js.map +1 -0
  61. package/src/lib/components/use-outreach-mailbox-api.d.ts +30 -0
  62. package/src/lib/components/use-outreach-mailbox-api.js +101 -0
  63. package/src/lib/components/use-outreach-mailbox-api.js.map +1 -0
  64. package/src/lib/components/use-outreach-mailboxes.d.ts +22 -0
  65. package/src/lib/components/use-outreach-mailboxes.js +68 -0
  66. package/src/lib/components/use-outreach-mailboxes.js.map +1 -0
  67. package/src/lib/components/use-outreach-settings.d.ts +17 -0
  68. package/src/lib/components/use-outreach-settings.js +64 -0
  69. package/src/lib/components/use-outreach-settings.js.map +1 -0
  70. package/src/lib/constants/api-routes.d.ts +68 -0
  71. package/src/lib/constants/api-routes.js +57 -0
  72. package/src/lib/constants/api-routes.js.map +1 -0
  73. package/src/lib/constants/bundle-common.d.ts +46 -0
  74. package/src/lib/constants/bundle-common.js +55 -0
  75. package/src/lib/constants/bundle-common.js.map +1 -0
  76. package/src/lib/constants/runtime-jobs.d.ts +24 -0
  77. package/src/lib/constants/runtime-jobs.js +24 -0
  78. package/src/lib/constants/runtime-jobs.js.map +1 -0
  79. package/src/lib/declarations.console-server.d.ts +38 -0
  80. package/src/lib/declarations.console-server.js +99 -0
  81. package/src/lib/declarations.console-server.js.map +1 -0
  82. package/src/lib/engine/compose.d.ts +86 -0
  83. package/src/lib/engine/compose.js +212 -0
  84. package/src/lib/engine/compose.js.map +1 -0
  85. package/src/lib/engine/delivery-status.d.ts +53 -0
  86. package/src/lib/engine/delivery-status.js +184 -0
  87. package/src/lib/engine/delivery-status.js.map +1 -0
  88. package/src/lib/engine/do-not-contact.d.ts +18 -0
  89. package/src/lib/engine/do-not-contact.js +34 -0
  90. package/src/lib/engine/do-not-contact.js.map +1 -0
  91. package/src/lib/engine/enrollment-state.d.ts +156 -0
  92. package/src/lib/engine/enrollment-state.js +303 -0
  93. package/src/lib/engine/enrollment-state.js.map +1 -0
  94. package/src/lib/engine/gates.d.ts +125 -0
  95. package/src/lib/engine/gates.js +232 -0
  96. package/src/lib/engine/gates.js.map +1 -0
  97. package/src/lib/engine/index.d.ts +28 -0
  98. package/src/lib/engine/index.js +33 -0
  99. package/src/lib/engine/index.js.map +1 -0
  100. package/src/lib/engine/mailbox-health.d.ts +49 -0
  101. package/src/lib/engine/mailbox-health.js +83 -0
  102. package/src/lib/engine/mailbox-health.js.map +1 -0
  103. package/src/lib/engine/opt-out-intent.d.ts +41 -0
  104. package/src/lib/engine/opt-out-intent.js +220 -0
  105. package/src/lib/engine/opt-out-intent.js.map +1 -0
  106. package/src/lib/engine/recipient-country.d.ts +41 -0
  107. package/src/lib/engine/recipient-country.js +171 -0
  108. package/src/lib/engine/recipient-country.js.map +1 -0
  109. package/src/lib/engine/schedule.d.ts +70 -0
  110. package/src/lib/engine/schedule.js +115 -0
  111. package/src/lib/engine/schedule.js.map +1 -0
  112. package/src/lib/engine/sending-capacity.d.ts +96 -0
  113. package/src/lib/engine/sending-capacity.js +172 -0
  114. package/src/lib/engine/sending-capacity.js.map +1 -0
  115. package/src/lib/engine/sequence-validation.d.ts +90 -0
  116. package/src/lib/engine/sequence-validation.js +291 -0
  117. package/src/lib/engine/sequence-validation.js.map +1 -0
  118. package/src/lib/engine/thread-classification.d.ts +78 -0
  119. package/src/lib/engine/thread-classification.js +224 -0
  120. package/src/lib/engine/thread-classification.js.map +1 -0
  121. package/src/lib/engine/thread-message.d.ts +80 -0
  122. package/src/lib/engine/thread-message.js +143 -0
  123. package/src/lib/engine/thread-message.js.map +1 -0
  124. package/src/lib/enrollment/enroll-people.d.ts +81 -0
  125. package/src/lib/enrollment/enroll-people.js +250 -0
  126. package/src/lib/enrollment/enroll-people.js.map +1 -0
  127. package/src/lib/enrollment/gate-lookups.d.ts +32 -0
  128. package/src/lib/enrollment/gate-lookups.js +154 -0
  129. package/src/lib/enrollment/gate-lookups.js.map +1 -0
  130. package/src/lib/mailboxes/mailbox-api.d.ts +126 -0
  131. package/src/lib/mailboxes/mailbox-api.js +66 -0
  132. package/src/lib/mailboxes/mailbox-api.js.map +1 -0
  133. package/src/lib/mailboxes/mailbox-credentials.d.ts +95 -0
  134. package/src/lib/mailboxes/mailbox-credentials.js +102 -0
  135. package/src/lib/mailboxes/mailbox-credentials.js.map +1 -0
  136. package/src/lib/mailboxes/mailbox-erasure.d.ts +69 -0
  137. package/src/lib/mailboxes/mailbox-erasure.js +148 -0
  138. package/src/lib/mailboxes/mailbox-erasure.js.map +1 -0
  139. package/src/lib/mailboxes/mailbox-gate.d.ts +75 -0
  140. package/src/lib/mailboxes/mailbox-gate.js +98 -0
  141. package/src/lib/mailboxes/mailbox-gate.js.map +1 -0
  142. package/src/lib/mailboxes/mailbox-revoke.d.ts +59 -0
  143. package/src/lib/mailboxes/mailbox-revoke.js +41 -0
  144. package/src/lib/mailboxes/mailbox-revoke.js.map +1 -0
  145. package/src/lib/mailboxes/mailbox-routes.d.ts +119 -0
  146. package/src/lib/mailboxes/mailbox-routes.js +605 -0
  147. package/src/lib/mailboxes/mailbox-routes.js.map +1 -0
  148. package/src/lib/mailboxes/mailbox-settings.d.ts +110 -0
  149. package/src/lib/mailboxes/mailbox-settings.js +212 -0
  150. package/src/lib/mailboxes/mailbox-settings.js.map +1 -0
  151. package/src/lib/mailboxes/mailbox-transport.d.ts +64 -0
  152. package/src/lib/mailboxes/mailbox-transport.js +94 -0
  153. package/src/lib/mailboxes/mailbox-transport.js.map +1 -0
  154. package/src/lib/mailboxes/oauth-redirect.d.ts +41 -0
  155. package/src/lib/mailboxes/oauth-redirect.js +63 -0
  156. package/src/lib/mailboxes/oauth-redirect.js.map +1 -0
  157. package/src/lib/mailboxes/oauth-state.d.ts +152 -0
  158. package/src/lib/mailboxes/oauth-state.js +212 -0
  159. package/src/lib/mailboxes/oauth-state.js.map +1 -0
  160. package/src/lib/mailboxes/outreach-config.d.ts +64 -0
  161. package/src/lib/mailboxes/outreach-config.js +77 -0
  162. package/src/lib/mailboxes/outreach-config.js.map +1 -0
  163. package/src/lib/mailboxes/register-mailbox-routes.d.ts +47 -0
  164. package/src/lib/mailboxes/register-mailbox-routes.js +135 -0
  165. package/src/lib/mailboxes/register-mailbox-routes.js.map +1 -0
  166. package/src/lib/model/compliance-settings.d.ts +78 -0
  167. package/src/lib/model/compliance-settings.js +155 -0
  168. package/src/lib/model/compliance-settings.js.map +1 -0
  169. package/src/lib/model/outreach-api.d.ts +210 -0
  170. package/src/lib/model/outreach-api.js +28 -0
  171. package/src/lib/model/outreach-api.js.map +1 -0
  172. package/src/lib/model/outreach.types.d.ts +563 -0
  173. package/src/lib/model/outreach.types.js +225 -0
  174. package/src/lib/model/outreach.types.js.map +1 -0
  175. package/src/lib/model/sequence-draft.d.ts +84 -0
  176. package/src/lib/model/sequence-draft.js +239 -0
  177. package/src/lib/model/sequence-draft.js.map +1 -0
  178. package/src/lib/model/step-preview.d.ts +45 -0
  179. package/src/lib/model/step-preview.js +98 -0
  180. package/src/lib/model/step-preview.js.map +1 -0
  181. package/src/lib/model/stored-records.d.ts +23 -0
  182. package/src/lib/model/stored-records.js +84 -0
  183. package/src/lib/model/stored-records.js.map +1 -0
  184. package/src/lib/plugin.d.ts +46 -0
  185. package/src/lib/plugin.js +86 -0
  186. package/src/lib/plugin.js.map +1 -0
  187. package/src/lib/routes/enroll-routes.d.ts +64 -0
  188. package/src/lib/routes/enroll-routes.js +350 -0
  189. package/src/lib/routes/enroll-routes.js.map +1 -0
  190. package/src/lib/routes/enrollment-routes.d.ts +19 -0
  191. package/src/lib/routes/enrollment-routes.js +171 -0
  192. package/src/lib/routes/enrollment-routes.js.map +1 -0
  193. package/src/lib/routes/platform-deps.d.ts +51 -0
  194. package/src/lib/routes/platform-deps.js +66 -0
  195. package/src/lib/routes/platform-deps.js.map +1 -0
  196. package/src/lib/routes/preview-routes.d.ts +33 -0
  197. package/src/lib/routes/preview-routes.js +117 -0
  198. package/src/lib/routes/preview-routes.js.map +1 -0
  199. package/src/lib/routes/register-routes.d.ts +22 -0
  200. package/src/lib/routes/register-routes.js +92 -0
  201. package/src/lib/routes/register-routes.js.map +1 -0
  202. package/src/lib/routes/route-deps.d.ts +49 -0
  203. package/src/lib/routes/route-deps.js +27 -0
  204. package/src/lib/routes/route-deps.js.map +1 -0
  205. package/src/lib/routes/route-gate.d.ts +94 -0
  206. package/src/lib/routes/route-gate.js +87 -0
  207. package/src/lib/routes/route-gate.js.map +1 -0
  208. package/src/lib/routes/route-http.d.ts +32 -0
  209. package/src/lib/routes/route-http.js +62 -0
  210. package/src/lib/routes/route-http.js.map +1 -0
  211. package/src/lib/routes/sequence-routes.d.ts +32 -0
  212. package/src/lib/routes/sequence-routes.js +359 -0
  213. package/src/lib/routes/sequence-routes.js.map +1 -0
  214. package/src/lib/routes/settings-routes.d.ts +30 -0
  215. package/src/lib/routes/settings-routes.js +73 -0
  216. package/src/lib/routes/settings-routes.js.map +1 -0
  217. package/src/lib/runtime/enrollment-events.d.ts +86 -0
  218. package/src/lib/runtime/enrollment-events.js +141 -0
  219. package/src/lib/runtime/enrollment-events.js.map +1 -0
  220. package/src/lib/runtime/fixtures/fake-gmail.d.ts +121 -0
  221. package/src/lib/runtime/fixtures/fake-gmail.js +312 -0
  222. package/src/lib/runtime/fixtures/fake-gmail.js.map +1 -0
  223. package/src/lib/runtime/mailbox-health-store.d.ts +47 -0
  224. package/src/lib/runtime/mailbox-health-store.js +168 -0
  225. package/src/lib/runtime/mailbox-health-store.js.map +1 -0
  226. package/src/lib/runtime/person-erasure.d.ts +39 -0
  227. package/src/lib/runtime/person-erasure.js +57 -0
  228. package/src/lib/runtime/person-erasure.js.map +1 -0
  229. package/src/lib/runtime/platform-runtime-deps.d.ts +29 -0
  230. package/src/lib/runtime/platform-runtime-deps.js +98 -0
  231. package/src/lib/runtime/platform-runtime-deps.js.map +1 -0
  232. package/src/lib/runtime/runtime-deps.d.ts +77 -0
  233. package/src/lib/runtime/runtime-deps.js +18 -0
  234. package/src/lib/runtime/runtime-deps.js.map +1 -0
  235. package/src/lib/runtime/send-job.d.ts +42 -0
  236. package/src/lib/runtime/send-job.js +780 -0
  237. package/src/lib/runtime/send-job.js.map +1 -0
  238. package/src/lib/runtime/sync-job.d.ts +41 -0
  239. package/src/lib/runtime/sync-job.js +480 -0
  240. package/src/lib/runtime/sync-job.js.map +1 -0
  241. package/src/lib/runtime/timeline.d.ts +67 -0
  242. package/src/lib/runtime/timeline.js +93 -0
  243. package/src/lib/runtime/timeline.js.map +1 -0
  244. package/src/lib/runtime/unsubscribe-link.d.ts +75 -0
  245. package/src/lib/runtime/unsubscribe-link.js +121 -0
  246. package/src/lib/runtime/unsubscribe-link.js.map +1 -0
  247. package/src/lib/runtime/unsubscribe-route.d.ts +24 -0
  248. package/src/lib/runtime/unsubscribe-route.js +103 -0
  249. package/src/lib/runtime/unsubscribe-route.js.map +1 -0
  250. package/src/lib/server.d.ts +53 -0
  251. package/src/lib/server.js +94 -0
  252. package/src/lib/server.js.map +1 -0
  253. package/src/lib/storage/compliance-settings-store.d.ts +44 -0
  254. package/src/lib/storage/compliance-settings-store.js +78 -0
  255. package/src/lib/storage/compliance-settings-store.js.map +1 -0
  256. package/src/lib/storage/do-not-contact-store.d.ts +60 -0
  257. package/src/lib/storage/do-not-contact-store.js +168 -0
  258. package/src/lib/storage/do-not-contact-store.js.map +1 -0
  259. package/src/lib/storage/outreach-records.d.ts +33 -0
  260. package/src/lib/storage/outreach-records.js +35 -0
  261. package/src/lib/storage/outreach-records.js.map +1 -0
  262. package/src/lib/subprocessors.d.ts +48 -0
  263. package/src/lib/subprocessors.js +70 -0
  264. package/src/lib/subprocessors.js.map +1 -0
  265. package/src/lib/transport/gmail-client.d.ts +145 -0
  266. package/src/lib/transport/gmail-client.js +229 -0
  267. package/src/lib/transport/gmail-client.js.map +1 -0
  268. package/src/lib/transport/gmail-errors.d.ts +84 -0
  269. package/src/lib/transport/gmail-errors.js +138 -0
  270. package/src/lib/transport/gmail-errors.js.map +1 -0
  271. package/src/lib/transport/google-oauth.d.ts +138 -0
  272. package/src/lib/transport/google-oauth.js +247 -0
  273. package/src/lib/transport/google-oauth.js.map +1 -0
  274. package/src/lib/transport/http.d.ts +53 -0
  275. package/src/lib/transport/http.js +79 -0
  276. package/src/lib/transport/http.js.map +1 -0
  277. package/src/lib/transport/rfc5322.d.ts +116 -0
  278. package/src/lib/transport/rfc5322.js +330 -0
  279. package/src/lib/transport/rfc5322.js.map +1 -0
  280. package/src/lib/transport/send-message.d.ts +69 -0
  281. package/src/lib/transport/send-message.js +77 -0
  282. package/src/lib/transport/send-message.js.map +1 -0
@@ -0,0 +1,102 @@
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 { needsReseal, openSecret, sealSecret } from "@aglyn/shared-util-tools/secret-box";
17
+ import { createHash } from "node:crypto";
18
+ import { OUTREACH_COLLECTIONS } from "../model/outreach.types.js";
19
+ /** The context a mailbox's refresh token is sealed under. */ export function refreshTokenSealContext(mailboxId) {
20
+ return `${OUTREACH_COLLECTIONS.mailboxCredentials}/${mailboxId}#refreshToken`;
21
+ }
22
+ /** Seals a refresh token for one mailbox under the keyring's current key. */ export function sealMailboxRefreshToken(refreshToken, mailboxId, keyring) {
23
+ return {
24
+ sealedRefreshToken: sealSecret(refreshToken, keyring.current, {
25
+ context: refreshTokenSealContext(mailboxId)
26
+ }),
27
+ tokenKeyId: keyring.current.id
28
+ };
29
+ }
30
+ /**
31
+ * Opens a stored credential's refresh token. Throws `SecretBoxError` when it
32
+ * cannot be opened — a missing or rotated-away key, or a tampered value.
33
+ */ export function openMailboxRefreshToken(credential, keyring) {
34
+ const opened = openSecret(credential.sealedRefreshToken, keyring, {
35
+ context: refreshTokenSealContext(credential.mailboxId)
36
+ });
37
+ return {
38
+ refreshToken: opened.plaintext,
39
+ needsReseal: needsReseal(opened, keyring)
40
+ };
41
+ }
42
+ /**
43
+ * The id a member's mailbox for one Google account has in one organization.
44
+ *
45
+ * Derived rather than random, so connecting the same account again updates
46
+ * the same mailbox instead of adding a second one, and two members — or two
47
+ * organizations — connecting one account never share an id. The credential
48
+ * collection is top-level, keyed by this id, so it must be unique across
49
+ * 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)}`;
53
+ }
54
+ export function mailboxCredentialsRef(firestore, mailboxId) {
55
+ return firestore.collection(OUTREACH_COLLECTIONS.mailboxCredentials).doc(mailboxId);
56
+ }
57
+ export function mailboxRef(firestore, orgId, mailboxId) {
58
+ return firestore.collection('orgs').doc(orgId).collection(OUTREACH_COLLECTIONS.mailboxes).doc(mailboxId);
59
+ }
60
+ /** A stored credential read defensively, or `null` when it is not one. */ export function readMailboxCredentials(data) {
61
+ var _record_id, _record_connectedByUid, _record_providerAccountId, _record_email, _record_tokenKeyId;
62
+ 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) {
64
+ return null;
65
+ }
66
+ return {
67
+ id: String((_record_id = record.id) != null ? _record_id : record.mailboxId),
68
+ orgId: record.orgId,
69
+ mailboxId: record.mailboxId,
70
+ provider: 'google',
71
+ connectedByUid: String((_record_connectedByUid = record.connectedByUid) != null ? _record_connectedByUid : ''),
72
+ providerAccountId: String((_record_providerAccountId = record.providerAccountId) != null ? _record_providerAccountId : ''),
73
+ email: String((_record_email = record.email) != null ? _record_email : ''),
74
+ scopes: Array.isArray(record.scopes) ? record.scopes.map(String) : [],
75
+ sealedRefreshToken: record.sealedRefreshToken,
76
+ tokenKeyId: String((_record_tokenKeyId = record.tokenKeyId) != null ? _record_tokenKeyId : ''),
77
+ createdAtMs: Number(record.createdAtMs) || 0,
78
+ updatedAtMs: Number(record.updatedAtMs) || 0
79
+ };
80
+ }
81
+ /**
82
+ * How many OTHER stored credentials hold a grant for the same Google account.
83
+ *
84
+ * Google's revocation ends the whole grant this client holds for an account,
85
+ * not one token, so revoking while another mailbox still uses the account —
86
+ * the same shared inbox connected by two members, or one account connected
87
+ * in two organizations — would silently break that mailbox. A disconnect or
88
+ * an erasure asks this first and leaves the grant alone while it is shared.
89
+ *
90
+ * An erasure names what it is ending anyway — a whole organization's
91
+ * credentials, or every one of a person's — and those are not counted.
92
+ */ export async function countOtherCredentialsForAccount(firestore, input) {
93
+ if (!input.providerAccountId) return 0;
94
+ const snapshot = await firestore.collection(OUTREACH_COLLECTIONS.mailboxCredentials).where('providerAccountId', '==', input.providerAccountId).limit(20).get();
95
+ return snapshot.docs.filter((doc)=>{
96
+ var _input_excludeMailboxIds;
97
+ if (doc.id === input.mailboxId || ((_input_excludeMailboxIds = input.excludeMailboxIds) == null ? void 0 : _input_excludeMailboxIds.has(doc.id))) return false;
98
+ return !input.excludeOrgId || doc.get('orgId') !== input.excludeOrgId;
99
+ }).length;
100
+ }
101
+
102
+ //# sourceMappingURL=mailbox-credentials.js.map
@@ -0,0 +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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"}
@@ -0,0 +1,69 @@
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 { PluginOrgEraser } from '@aglyn/aglyn/plugin-manager/plugin-org-erasure';
18
+ import type { PluginUserEraser } from '@aglyn/aglyn/plugin-manager/plugin-user-erasure';
19
+ import { type OutreachRevokeDeps } from './mailbox-revoke';
20
+ /**
21
+ * OUTREACH'S SHARE OF AN ERASURE (AGL-2978).
22
+ *
23
+ * The workspace erasure deletes every stored Outreach grant on its own —
24
+ * `outreachMailboxCredentials` by its `orgId` field, and the mailboxes and
25
+ * pending connects with the organization's tree — in any process, whether
26
+ * or not this plugin is loaded. Deleting the platform's copy leaves the
27
+ * grant alive at Google, though: listed in the rep's account as an app with
28
+ * access to their mail until they remove it by hand. So Outreach registers a
29
+ * workspace eraser (`plugin-org-erasure`) that revokes each grant first,
30
+ * while the stored credential still exists to revoke it with. It deletes
31
+ * nothing itself; the erasure's own sweep does that right after.
32
+ *
33
+ * An ACCOUNT erasure (`plugin-user-erasure`, AGL-3106) reaches none of it on
34
+ * its own: a person's mailbox lives under the organization and their grant
35
+ * in a top-level collection, and the erasure removes the person, not the
36
+ * organizations they belonged to. So the account eraser below revokes every
37
+ * grant the person connected and deletes it, with their mailboxes and their
38
+ * pending connects — their Gmail address and a token that sends as them are
39
+ * about the person, and must not outlive them.
40
+ *
41
+ * Both erasers are registered from the console-only declarations entry:
42
+ * revoking opens a sealed token with `OUTREACH_TOKEN_KEY`, which only the
43
+ * console holds.
44
+ */
45
+ export interface OutreachErasureDeps extends OutreachRevokeDeps {
46
+ firestore(): FirebaseFirestore.Firestore;
47
+ }
48
+ /** The platform's own dependencies. Specs build their own. */
49
+ export declare function defaultOutreachErasureDeps(): OutreachErasureDeps;
50
+ /**
51
+ * The workspace eraser: revoke, at Google, every grant the organization
52
+ * holds. A grant another organization still uses for the same Google
53
+ * account is kept (`kept`), because revoking it would cut that
54
+ * organization's mailbox off too. A plan counts the grants and touches
55
+ * nothing, so its revocation figures are `null` — not measured, not zero.
56
+ */
57
+ export declare function createOutreachOrgEraser(deps: OutreachErasureDeps): PluginOrgEraser;
58
+ /**
59
+ * The account eraser (AGL-3106): every mailbox the person connected, in the
60
+ * organizations they belonged to and in any they had already left, revoked
61
+ * at Google and deleted with its stored grant; and their pending connects.
62
+ *
63
+ * A grant is kept at Google when a TEAMMATE still uses the same Google
64
+ * account — a shared inbox two members connected — because revoking it would
65
+ * cut the teammate's mailbox off; the erased person's own copy is deleted
66
+ * either way. Every other copy the person holds is being erased too, so it
67
+ * does not count as a use.
68
+ */
69
+ export declare function createOutreachUserEraser(deps: OutreachErasureDeps): PluginUserEraser;
@@ -0,0 +1,148 @@
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 { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
18
+ import { OUTREACH_COLLECTIONS } from "../model/outreach.types.js";
19
+ import { mailboxCredentialsRef, mailboxRef, readMailboxCredentials } from "./mailbox-credentials.js";
20
+ import { revokeMailboxGrant } from "./mailbox-revoke.js";
21
+ import { outreachOAuthStateRef } from "./oauth-state.js";
22
+ import { readOutreachGoogleConfig } from "./outreach-config.js";
23
+ /** The platform's own dependencies. Specs build their own. */ export function defaultOutreachErasureDeps() {
24
+ return {
25
+ firestore: ()=>firebaseAdmin.app().firestore(),
26
+ readConfig: readOutreachGoogleConfig,
27
+ transport: {}
28
+ };
29
+ }
30
+ const emptyTally = ()=>({
31
+ revoked: 0,
32
+ alreadyInvalid: 0,
33
+ kept: 0,
34
+ failed: 0
35
+ });
36
+ function count(tally, outcome) {
37
+ if (outcome === 'revoked') tally.revoked += 1;
38
+ else if (outcome === 'already-invalid') tally.alreadyInvalid += 1;
39
+ else if (outcome === 'kept-for-other-mailbox') tally.kept += 1;
40
+ else tally.failed += 1;
41
+ }
42
+ /** Firestore refuses a batch of more than 500 writes. */ const BATCH_LIMIT = 450;
43
+ /** Deletes every reference, in batches under Firestore's limit. */ async function deleteAll(firestore, refs) {
44
+ for(let start = 0; start < refs.length; start += BATCH_LIMIT){
45
+ const batch = firestore.batch();
46
+ for (const ref of refs.slice(start, start + BATCH_LIMIT))batch.delete(ref);
47
+ await batch.commit();
48
+ }
49
+ }
50
+ /**
51
+ * The workspace eraser: revoke, at Google, every grant the organization
52
+ * holds. A grant another organization still uses for the same Google
53
+ * account is kept (`kept`), because revoking it would cut that
54
+ * organization's mailbox off too. A plan counts the grants and touches
55
+ * nothing, so its revocation figures are `null` — not measured, not zero.
56
+ */ export function createOutreachOrgEraser(deps) {
57
+ return async ({ orgId, dryRun })=>{
58
+ const firestore = deps.firestore();
59
+ const rows = await firestore.collection(OUTREACH_COLLECTIONS.mailboxCredentials).where('orgId', '==', orgId).get();
60
+ if (dryRun) {
61
+ return {
62
+ grants: rows.size,
63
+ revoked: null,
64
+ alreadyInvalid: null,
65
+ kept: null,
66
+ failed: null
67
+ };
68
+ }
69
+ const tally = emptyTally();
70
+ for (const doc of rows.docs){
71
+ const credential = readMailboxCredentials(doc.data());
72
+ count(tally, credential ? await revokeMailboxGrant(firestore, credential, deps, {
73
+ excludeOrgId: orgId
74
+ }) : 'failed');
75
+ }
76
+ return _extends({
77
+ grants: rows.size
78
+ }, tally);
79
+ };
80
+ }
81
+ /**
82
+ * The account eraser (AGL-3106): every mailbox the person connected, in the
83
+ * organizations they belonged to and in any they had already left, revoked
84
+ * at Google and deleted with its stored grant; and their pending connects.
85
+ *
86
+ * A grant is kept at Google when a TEAMMATE still uses the same Google
87
+ * account — a shared inbox two members connected — because revoking it would
88
+ * cut the teammate's mailbox off; the erased person's own copy is deleted
89
+ * either way. Every other copy the person holds is being erased too, so it
90
+ * does not count as a use.
91
+ */ export function createOutreachUserEraser(deps) {
92
+ return async ({ uid, orgIds })=>{
93
+ const firestore = deps.firestore();
94
+ // By the person, across every organization — including one they have
95
+ // already left, which `orgIds` no longer names.
96
+ const credentials = await firestore.collection(OUTREACH_COLLECTIONS.mailboxCredentials).where('connectedByUid', '==', uid).get();
97
+ const mailboxes = new Map();
98
+ const stored = [];
99
+ for (const doc of credentials.docs){
100
+ var _ref, _doc_get;
101
+ const credential = readMailboxCredentials(doc.data());
102
+ const orgId = (_ref = credential == null ? void 0 : credential.orgId) != null ? _ref : String((_doc_get = doc.get('orgId')) != null ? _doc_get : '');
103
+ if (credential) stored.push(credential);
104
+ if (orgId) mailboxes.set(`${orgId}/${doc.id}`, {
105
+ orgId,
106
+ mailboxId: doc.id
107
+ });
108
+ }
109
+ // A mailbox whose grant is already gone still names the person's
110
+ // address; find those under each organization the person was in.
111
+ for (const orgId of orgIds){
112
+ const connected = await firestore.collection('orgs').doc(orgId).collection(OUTREACH_COLLECTIONS.mailboxes).where('connectedByUid', '==', uid).get();
113
+ for (const doc of connected.docs)mailboxes.set(`${orgId}/${doc.id}`, {
114
+ orgId,
115
+ mailboxId: doc.id
116
+ });
117
+ }
118
+ const theirs = new Set(credentials.docs.map((doc)=>doc.id));
119
+ const tally = emptyTally();
120
+ for (const credential of stored){
121
+ count(tally, await revokeMailboxGrant(firestore, credential, deps, {
122
+ excludeMailboxIds: theirs
123
+ }));
124
+ }
125
+ tally.failed += credentials.size - stored.length;
126
+ const orgsWithPending = new Set([
127
+ ...orgIds,
128
+ ...[
129
+ ...mailboxes.values()
130
+ ].map((entry)=>entry.orgId)
131
+ ]);
132
+ await deleteAll(firestore, [
133
+ ...credentials.docs.map((doc)=>mailboxCredentialsRef(firestore, doc.id)),
134
+ ...[
135
+ ...mailboxes.values()
136
+ ].map(({ orgId, mailboxId })=>mailboxRef(firestore, orgId, mailboxId)),
137
+ ...[
138
+ ...orgsWithPending
139
+ ].map((orgId)=>outreachOAuthStateRef(firestore, orgId, uid))
140
+ ]);
141
+ return _extends({
142
+ mailboxes: mailboxes.size,
143
+ grants: credentials.size
144
+ }, tally);
145
+ };
146
+ }
147
+
148
+ //# sourceMappingURL=mailbox-erasure.js.map
@@ -0,0 +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":"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;AAUA,MAAMC,aAAa,IAAwB,CAAA;QAAEC,SAAS;QAAGC,gBAAgB;QAAGC,MAAM;QAAGC,QAAQ;IAAE,CAAA;AAE/F,SAASC,MAAMC,KAAsB,EAAEC,OAAgC;IACrE,IAAIA,YAAY,WAAWD,MAAML,OAAO,IAAI;SACvC,IAAIM,YAAY,mBAAmBD,MAAMJ,cAAc,IAAI;SAC3D,IAAIK,YAAY,0BAA0BD,MAAMH,IAAI,IAAI;SACxDG,MAAMF,MAAM,IAAI;AACvB;AAEA,uDAAuD,GACvD,MAAMI,cAAc;AAEpB,iEAAiE,GACjE,eAAeC,UACbb,SAAsC,EACtCc,IAAoD;IAEpD,IAAK,IAAIC,QAAQ,GAAGA,QAAQD,KAAKE,MAAM,EAAED,SAASH,YAAa;QAC7D,MAAMK,QAAQjB,UAAUiB,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,MAAMzB,YAAYuB,KAAKvB,SAAS;QAChC,MAAM0B,OAAO,MAAM1B,UAChB2B,UAAU,CAACnC,qBAAqBoC,kBAAkB,EAClDC,KAAK,CAAC,SAAS,MAAML,OACrBM,GAAG;QACN,IAAIL,QAAQ;YACV,OAAO;gBAAEM,QAAQL,KAAKM,IAAI;gBAAE3B,SAAS;gBAAMC,gBAAgB;gBAAMC,MAAM;gBAAMC,QAAQ;YAAK;QAC5F;QACA,MAAME,QAAQN;QACd,KAAK,MAAM6B,OAAOP,KAAKQ,IAAI,CAAE;YAC3B,MAAMC,aAAaxC,uBAAuBsC,IAAIG,IAAI;YAClD3B,MACEC,OACAyB,aACI,MAAMvC,mBAAmBI,WAAWmC,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,MAAMxC,YAAYuB,KAAKvB,SAAS;QAChC,qEAAqE;QACrE,gDAAgD;QAChD,MAAMyC,cAAc,MAAMzC,UACvB2B,UAAU,CAACnC,qBAAqBoC,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,aAAaxC,uBAAuBsC,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,MAAMlD,UACrB2B,UAAU,CAAC,QACXM,GAAG,CAACT,OACJG,UAAU,CAACnC,qBAAqBkD,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,QAAQN;QACd,KAAK,MAAM+B,cAAcS,OAAQ;YAC/BnC,MAAMC,OAAO,MAAMd,mBAAmBI,WAAWmC,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,UAAUb,WAAW;eACtByC,YAAYP,IAAI,CAACmB,GAAG,CAAC,CAACpB,MAAQxC,sBAAsBO,WAAWiC,IAAIe,EAAE;eACrE;mBAAIN,UAAUc,MAAM;aAAG,CAACH,GAAG,CAAC,CAAC,EAAE7B,KAAK,EAAEyB,SAAS,EAAE,GAAKvD,WAAWM,WAAWwB,OAAOyB;eACnF;mBAAIM;aAAgB,CAACF,GAAG,CAAC,CAAC7B,QAAU3B,sBAAsBG,WAAWwB,OAAOe;SAChF;QAED,OAAO;YACLG,WAAWA,UAAUV,IAAI;YACzBD,QAAQU,YAAYT,IAAI;WACrBtB;IAEP;AACF"}
@@ -0,0 +1,75 @@
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 { DecodedIdToken } from 'firebase-admin/auth';
18
+ import type { OutreachApiRefusalReason } from './mailbox-api';
19
+ /**
20
+ * THE GATE EVERY AUTHENTICATED MAILBOX ROUTE CLIMBS (AGL-2978).
21
+ *
22
+ * The console dispatcher has already refused a request while
23
+ * `release_outreach` is off for the organization it names, an unverified
24
+ * account's token, a lockdown with no org in scope, and a caller over the
25
+ * console rate limit. What it cannot know is anything about the member, so
26
+ * each rung below is this plugin's:
27
+ *
28
+ * 401 no bearer token, or one the verifier refused
29
+ * 403 the account's address is unverified (an impersonation session is exempt)
30
+ * 400 the request did not name an organization
31
+ * 403 not a member of it, a site collaborator without org-wide reach, or
32
+ * without `outreach.use`
33
+ * 403 the organization does not hold the `outreach` entitlement
34
+ * 423 lockdown, now with the organization and the account in scope
35
+ *
36
+ * `resolveOrgPermissions` is always given the concrete org id. Called without
37
+ * one it resolves a caller with no org at all as that future org's owner,
38
+ * which is right for a fresh signup and wrong for every question asked here.
39
+ */
40
+ export interface OutreachGateDeps {
41
+ verifyIdToken(idToken: string): Promise<DecodedIdToken>;
42
+ resolveOrgPermissions(uid: string, context: {
43
+ orgId: string;
44
+ }): Promise<{
45
+ orgId: string | null;
46
+ /** `null` when the account is not on the organization's roster. */
47
+ role: string | null;
48
+ isOwner: boolean;
49
+ orgWide: boolean;
50
+ permissions: Partial<Record<string, boolean | undefined>>;
51
+ }>;
52
+ readOrg(orgId: string): Promise<Record<string, unknown> | null>;
53
+ lockdownRefusal(options: {
54
+ request: Request;
55
+ staff: boolean;
56
+ uid: string;
57
+ org: Record<string, unknown>;
58
+ }): Promise<Response | null>;
59
+ }
60
+ export interface OutreachGateContext {
61
+ uid: string;
62
+ /** The account's sign-in address, lower-cased, when the token carries one. */
63
+ email: string | null;
64
+ /** The account's name, when the token carries one. */
65
+ name: string | null;
66
+ staff: boolean;
67
+ orgId: string;
68
+ org: Record<string, unknown>;
69
+ /** An owner or admin of the organization, who may manage every mailbox. */
70
+ isOrgAdmin: boolean;
71
+ }
72
+ /** A refusal in the one shape every mailbox route answers with. */
73
+ export declare function refusal(status: number, reason: OutreachApiRefusalReason, error: string): Response;
74
+ export declare function readOrgId(value: unknown): string | null;
75
+ export declare function outreachMemberGate(request: Request, rawOrgId: unknown, deps: OutreachGateDeps): Promise<Response | OutreachGateContext>;
@@ -0,0 +1,98 @@
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 { checkEntitlement } from "@aglyn/aglyn/server";
17
+ import { isEmailVerified, isImpersonationSession } from "@aglyn/tenant-data-admin/server/firebase-admin";
18
+ import { invalidIdTokenResponse } from "@aglyn/tenant-data-admin/server/id-token-refusal";
19
+ import { OUTREACH_USE_PERMISSION } from "../constants/bundle-common.js";
20
+ /** A refusal in the one shape every mailbox route answers with. */ export function refusal(status, reason, error) {
21
+ return Response.json({
22
+ error,
23
+ reason
24
+ }, {
25
+ status,
26
+ headers: {
27
+ 'Cache-Control': 'no-store'
28
+ }
29
+ });
30
+ }
31
+ /** An organization id as a request may name one: a plain document id. */ const ORG_ID = /^[A-Za-z0-9_-]{1,128}$/;
32
+ export function readOrgId(value) {
33
+ const orgId = typeof value === 'string' ? value.trim() : '';
34
+ return ORG_ID.test(orgId) ? orgId : null;
35
+ }
36
+ function bearer(request) {
37
+ var _request_headers_get;
38
+ const authorization = (_request_headers_get = request.headers.get('authorization')) != null ? _request_headers_get : '';
39
+ return authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : null;
40
+ }
41
+ export async function outreachMemberGate(request, rawOrgId, deps) {
42
+ var _ref;
43
+ const token = bearer(request);
44
+ if (!token) return refusal(401, 'unauthenticated', 'Sign in to manage your connected mailboxes.');
45
+ let decoded;
46
+ try {
47
+ decoded = await deps.verifyIdToken(token);
48
+ } catch (error) {
49
+ // A refused credential is the caller's; an Auth outage is ours and
50
+ // propagates, so it answers 500 instead of "sign in again".
51
+ if (invalidIdTokenResponse(error)) {
52
+ return refusal(401, 'unauthenticated', 'Your sign-in could not be confirmed. Sign in again.');
53
+ }
54
+ throw error;
55
+ }
56
+ if (!isEmailVerified(decoded) && !isImpersonationSession(decoded)) {
57
+ return refusal(403, 'email-unverified', 'Verify your email address before connecting a mailbox.');
58
+ }
59
+ const orgId = readOrgId(rawOrgId);
60
+ if (!orgId) return refusal(400, 'org-required', 'Open an organization before managing its mailboxes.');
61
+ const membership = await deps.resolveOrgPermissions(decoded.uid, {
62
+ orgId
63
+ });
64
+ // A targeted org the account is not on answers with that org's id and no
65
+ // role — as does a lookup that failed closed — so the role is the test.
66
+ if (!membership.role || membership.orgId !== orgId) {
67
+ return refusal(403, 'not-a-member', 'You are not a member of that organization.');
68
+ }
69
+ if (!membership.orgWide) {
70
+ return refusal(403, 'not-org-wide', 'Sequences covers the whole organization, and your access is to particular sites.');
71
+ }
72
+ if (membership.permissions[OUTREACH_USE_PERMISSION] !== true) {
73
+ return refusal(403, 'permission', 'Your role does not include Use Sequences.');
74
+ }
75
+ const org = (_ref = await deps.readOrg(orgId)) != null ? _ref : {};
76
+ if (!checkEntitlement(org, 'outreach')) {
77
+ return refusal(403, 'entitlement', "Sequences isn't available to this workspace yet.");
78
+ }
79
+ const staff = decoded['staff'] === true;
80
+ const locked = await deps.lockdownRefusal({
81
+ request,
82
+ staff,
83
+ uid: decoded.uid,
84
+ org
85
+ });
86
+ if (locked) return locked;
87
+ return {
88
+ uid: decoded.uid,
89
+ email: typeof decoded.email === 'string' ? decoded.email.trim().toLowerCase() : null,
90
+ name: typeof decoded['name'] === 'string' ? String(decoded['name']) : null,
91
+ staff,
92
+ orgId,
93
+ org,
94
+ isOrgAdmin: membership.isOwner
95
+ };
96
+ }
97
+
98
+ //# sourceMappingURL=mailbox-gate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/mailboxes/mailbox-gate.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 { checkEntitlement } from '@aglyn/aglyn/server'\nimport {\n isEmailVerified,\n isImpersonationSession,\n} from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport type { DecodedIdToken } from 'firebase-admin/auth'\nimport { OUTREACH_USE_PERMISSION } from '../constants/bundle-common'\nimport type { OutreachApiRefusalReason } from './mailbox-api'\n\n/**\n * THE GATE EVERY AUTHENTICATED MAILBOX ROUTE CLIMBS (AGL-2978).\n *\n * The console dispatcher has already refused a request while\n * `release_outreach` is off for the organization it names, an unverified\n * account's token, a lockdown with no org in scope, and a caller over the\n * console rate limit. What it cannot know is anything about the member, so\n * each rung below is this plugin's:\n *\n * 401 no bearer token, or one the verifier refused\n * 403 the account's address is unverified (an impersonation session is exempt)\n * 400 the request did not name an organization\n * 403 not a member of it, a site collaborator without org-wide reach, or\n * without `outreach.use`\n * 403 the organization does not hold the `outreach` entitlement\n * 423 lockdown, now with the organization and the account in scope\n *\n * `resolveOrgPermissions` is always given the concrete org id. Called without\n * one it resolves a caller with no org at all as that future org's owner,\n * which is right for a fresh signup and wrong for every question asked here.\n */\n\nexport interface OutreachGateDeps {\n verifyIdToken(idToken: string): Promise<DecodedIdToken>\n resolveOrgPermissions(\n uid: string,\n context: { orgId: string },\n ): Promise<{\n orgId: string | null\n /** `null` when the account is not on the organization's roster. */\n role: string | null\n isOwner: boolean\n orgWide: boolean\n permissions: Partial<Record<string, boolean | undefined>>\n }>\n readOrg(orgId: string): Promise<Record<string, unknown> | null>\n lockdownRefusal(options: {\n request: Request\n staff: boolean\n uid: string\n org: Record<string, unknown>\n }): Promise<Response | null>\n}\n\nexport interface OutreachGateContext {\n uid: string\n /** The account's sign-in address, lower-cased, when the token carries one. */\n email: string | null\n /** The account's name, when the token carries one. */\n name: string | null\n staff: boolean\n orgId: string\n org: Record<string, unknown>\n /** An owner or admin of the organization, who may manage every mailbox. */\n isOrgAdmin: boolean\n}\n\n/** A refusal in the one shape every mailbox route answers with. */\nexport function refusal(status: number, reason: OutreachApiRefusalReason, error: string): Response {\n return Response.json({ error, reason }, { status, headers: { 'Cache-Control': 'no-store' } })\n}\n\n/** An organization id as a request may name one: a plain document id. */\nconst ORG_ID = /^[A-Za-z0-9_-]{1,128}$/\n\nexport function readOrgId(value: unknown): string | null {\n const orgId = typeof value === 'string' ? value.trim() : ''\n return ORG_ID.test(orgId) ? orgId : null\n}\n\nfunction bearer(request: Request): string | null {\n const authorization = request.headers.get('authorization') ?? ''\n return authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : null\n}\n\nexport async function outreachMemberGate(\n request: Request,\n rawOrgId: unknown,\n deps: OutreachGateDeps,\n): Promise<Response | OutreachGateContext> {\n const token = bearer(request)\n if (!token) return refusal(401, 'unauthenticated', 'Sign in to manage your connected mailboxes.')\n let decoded: DecodedIdToken\n try {\n decoded = await deps.verifyIdToken(token)\n } catch (error) {\n // A refused credential is the caller's; an Auth outage is ours and\n // propagates, so it answers 500 instead of \"sign in again\".\n if (invalidIdTokenResponse(error)) {\n return refusal(401, 'unauthenticated', 'Your sign-in could not be confirmed. Sign in again.')\n }\n throw error\n }\n if (!isEmailVerified(decoded) && !isImpersonationSession(decoded)) {\n return refusal(403, 'email-unverified', 'Verify your email address before connecting a mailbox.')\n }\n\n const orgId = readOrgId(rawOrgId)\n if (!orgId) return refusal(400, 'org-required', 'Open an organization before managing its mailboxes.')\n\n const membership = await deps.resolveOrgPermissions(decoded.uid, { orgId })\n // A targeted org the account is not on answers with that org's id and no\n // role — as does a lookup that failed closed — so the role is the test.\n if (!membership.role || membership.orgId !== orgId) {\n return refusal(403, 'not-a-member', 'You are not a member of that organization.')\n }\n if (!membership.orgWide) {\n return refusal(403, 'not-org-wide', 'Sequences covers the whole organization, and your access is to particular sites.')\n }\n if (membership.permissions[OUTREACH_USE_PERMISSION] !== true) {\n return refusal(403, 'permission', 'Your role does not include Use Sequences.')\n }\n\n const org = (await deps.readOrg(orgId)) ?? {}\n if (!checkEntitlement(org, 'outreach')) {\n return refusal(403, 'entitlement', \"Sequences isn't available to this workspace yet.\")\n }\n const staff = decoded['staff'] === true\n const locked = await deps.lockdownRefusal({ request, staff, uid: decoded.uid, org })\n if (locked) return locked\n\n return {\n uid: decoded.uid,\n email: typeof decoded.email === 'string' ? decoded.email.trim().toLowerCase() : null,\n name: typeof decoded['name'] === 'string' ? String(decoded['name']) : null,\n staff,\n orgId,\n org,\n isOrgAdmin: membership.isOwner,\n }\n}\n"],"names":["checkEntitlement","isEmailVerified","isImpersonationSession","invalidIdTokenResponse","OUTREACH_USE_PERMISSION","refusal","status","reason","error","Response","json","headers","ORG_ID","readOrgId","value","orgId","trim","test","bearer","request","authorization","get","startsWith","slice","length","outreachMemberGate","rawOrgId","deps","token","decoded","verifyIdToken","membership","resolveOrgPermissions","uid","role","orgWide","permissions","org","readOrg","staff","locked","lockdownRefusal","email","toLowerCase","name","String","isOrgAdmin","isOwner"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,gBAAgB,QAAQ,sBAAqB;AACtD,SACEC,eAAe,EACfC,sBAAsB,QACjB,iDAAgD;AACvD,SAASC,sBAAsB,QAAQ,mDAAkD;AAEzF,SAASC,uBAAuB,QAAQ,gCAA4B;AA4DpE,iEAAiE,GACjE,OAAO,SAASC,QAAQC,MAAc,EAAEC,MAAgC,EAAEC,KAAa;IACrF,OAAOC,SAASC,IAAI,CAAC;QAAEF;QAAOD;IAAO,GAAG;QAAED;QAAQK,SAAS;YAAE,iBAAiB;QAAW;IAAE;AAC7F;AAEA,uEAAuE,GACvE,MAAMC,SAAS;AAEf,OAAO,SAASC,UAAUC,KAAc;IACtC,MAAMC,QAAQ,OAAOD,UAAU,WAAWA,MAAME,IAAI,KAAK;IACzD,OAAOJ,OAAOK,IAAI,CAACF,SAASA,QAAQ;AACtC;AAEA,SAASG,OAAOC,OAAgB;QACRA;IAAtB,MAAMC,iBAAgBD,uBAAAA,QAAQR,OAAO,CAACU,GAAG,CAAC,4BAApBF,uBAAwC;IAC9D,OAAOC,cAAcE,UAAU,CAAC,aAAaF,cAAcG,KAAK,CAAC,UAAUC,MAAM,IAAI;AACvF;AAEA,OAAO,eAAeC,mBACpBN,OAAgB,EAChBO,QAAiB,EACjBC,IAAsB;QAmCT;IAjCb,MAAMC,QAAQV,OAAOC;IACrB,IAAI,CAACS,OAAO,OAAOvB,QAAQ,KAAK,mBAAmB;IACnD,IAAIwB;IACJ,IAAI;QACFA,UAAU,MAAMF,KAAKG,aAAa,CAACF;IACrC,EAAE,OAAOpB,OAAO;QACd,mEAAmE;QACnE,4DAA4D;QAC5D,IAAIL,uBAAuBK,QAAQ;YACjC,OAAOH,QAAQ,KAAK,mBAAmB;QACzC;QACA,MAAMG;IACR;IACA,IAAI,CAACP,gBAAgB4B,YAAY,CAAC3B,uBAAuB2B,UAAU;QACjE,OAAOxB,QAAQ,KAAK,oBAAoB;IAC1C;IAEA,MAAMU,QAAQF,UAAUa;IACxB,IAAI,CAACX,OAAO,OAAOV,QAAQ,KAAK,gBAAgB;IAEhD,MAAM0B,aAAa,MAAMJ,KAAKK,qBAAqB,CAACH,QAAQI,GAAG,EAAE;QAAElB;IAAM;IACzE,yEAAyE;IACzE,wEAAwE;IACxE,IAAI,CAACgB,WAAWG,IAAI,IAAIH,WAAWhB,KAAK,KAAKA,OAAO;QAClD,OAAOV,QAAQ,KAAK,gBAAgB;IACtC;IACA,IAAI,CAAC0B,WAAWI,OAAO,EAAE;QACvB,OAAO9B,QAAQ,KAAK,gBAAgB;IACtC;IACA,IAAI0B,WAAWK,WAAW,CAAChC,wBAAwB,KAAK,MAAM;QAC5D,OAAOC,QAAQ,KAAK,cAAc;IACpC;IAEA,MAAMgC,OAAO,OAAA,MAAMV,KAAKW,OAAO,CAACvB,kBAAnB,OAA8B,CAAC;IAC5C,IAAI,CAACf,iBAAiBqC,KAAK,aAAa;QACtC,OAAOhC,QAAQ,KAAK,eAAe;IACrC;IACA,MAAMkC,QAAQV,OAAO,CAAC,QAAQ,KAAK;IACnC,MAAMW,SAAS,MAAMb,KAAKc,eAAe,CAAC;QAAEtB;QAASoB;QAAON,KAAKJ,QAAQI,GAAG;QAAEI;IAAI;IAClF,IAAIG,QAAQ,OAAOA;IAEnB,OAAO;QACLP,KAAKJ,QAAQI,GAAG;QAChBS,OAAO,OAAOb,QAAQa,KAAK,KAAK,WAAWb,QAAQa,KAAK,CAAC1B,IAAI,GAAG2B,WAAW,KAAK;QAChFC,MAAM,OAAOf,OAAO,CAAC,OAAO,KAAK,WAAWgB,OAAOhB,OAAO,CAAC,OAAO,IAAI;QACtEU;QACAxB;QACAsB;QACAS,YAAYf,WAAWgB,OAAO;IAChC;AACF"}
@@ -0,0 +1,59 @@
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 '../transport/http';
18
+ import type { OutreachMailboxDisconnectResponse } from './mailbox-api';
19
+ import { type OutreachGoogleMailboxCredentials } from './mailbox-credentials';
20
+ import type { OutreachGoogleConfigResult } from './outreach-config';
21
+ /**
22
+ * TELLING GOOGLE A STORED GRANT IS OVER (AGL-2978).
23
+ *
24
+ * A disconnect, a workspace erasure and an account erasure all end a
25
+ * mailbox's grant the same way: open its sealed refresh token with this
26
+ * deployment's key and revoke it at Google — unless another stored
27
+ * credential still uses the same Google account, because Google's
28
+ * revocation ends the whole grant this client holds for the account, and
29
+ * revoking it would silently break that other mailbox.
30
+ *
31
+ * Never throws. A grant that cannot be revoked is still deleted by whoever
32
+ * called this; the outcome says which happened.
33
+ */
34
+ /**
35
+ * What became of a grant at Google: `revoked`, `already-invalid`,
36
+ * `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.
39
+ */
40
+ export type OutreachGrantRevocation = OutreachMailboxDisconnectResponse['revocation'];
41
+ export interface OutreachRevokeDeps {
42
+ readConfig(): OutreachGoogleConfigResult;
43
+ /** The network and retry policy the revocation call uses. */
44
+ transport: TransportDeps;
45
+ }
46
+ export interface RevokeMailboxGrantOptions {
47
+ /**
48
+ * Credentials of this organization do not count as another use of the
49
+ * account: the workspace erasure is ending all of them.
50
+ */
51
+ excludeOrgId?: string;
52
+ /**
53
+ * These credentials do not count as another use of the account either:
54
+ * the account erasure is ending every one of the person's grants.
55
+ */
56
+ excludeMailboxIds?: ReadonlySet<string>;
57
+ }
58
+ /** 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>;