@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
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/mailboxes/mailbox-revoke.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 { SecretBoxError } from '@aglyn/shared-util-tools/secret-box'\nimport { GmailTransportError } from '../transport/gmail-errors'\nimport { revokeGoogleToken } from '../transport/google-oauth'\nimport type { TransportDeps } from '../transport/http'\nimport type { OutreachMailboxDisconnectResponse } from './mailbox-api'\nimport {\n countOtherCredentialsForAccount,\n openMailboxRefreshToken,\n type OutreachGoogleMailboxCredentials,\n} from './mailbox-credentials'\nimport type { OutreachGoogleConfigResult } from './outreach-config'\n\n/**\n * TELLING GOOGLE A STORED GRANT IS OVER (AGL-2978).\n *\n * A disconnect, a workspace erasure and an account erasure all end a\n * mailbox's grant the same way: open its sealed refresh token with this\n * deployment's key and revoke it at Google — unless another stored\n * credential still uses the same Google account, because Google's\n * revocation ends the whole grant this client holds for the account, and\n * revoking it would silently break that other mailbox.\n *\n * Never throws. A grant that cannot be revoked is still deleted by whoever\n * called this; the outcome says which happened.\n */\n\n/**\n * What became of a grant at Google: `revoked`, `already-invalid`,\n * `kept-for-other-mailbox` while another credential still uses the account,\n * or `failed` when Google could not be told — the token would not open, the\n * deployment is not configured, or Google did not answer.\n */\nexport type OutreachGrantRevocation = OutreachMailboxDisconnectResponse['revocation']\n\nexport interface OutreachRevokeDeps {\n readConfig(): OutreachGoogleConfigResult\n /** The network and retry policy the revocation call uses. */\n transport: TransportDeps\n}\n\nexport interface RevokeMailboxGrantOptions {\n /**\n * Credentials of this organization do not count as another use of the\n * account: the workspace erasure is ending all of them.\n */\n excludeOrgId?: string\n /**\n * These credentials do not count as another use of the account either:\n * the account erasure is ending every one of the person's grants.\n */\n excludeMailboxIds?: ReadonlySet<string>\n}\n\n/** Revokes one stored grant at Google, unless it is still shared. Never throws. */\nexport async function revokeMailboxGrant(\n firestore: FirebaseFirestore.Firestore,\n credential: OutreachGoogleMailboxCredentials,\n deps: OutreachRevokeDeps,\n options: RevokeMailboxGrantOptions = {},\n): Promise<OutreachGrantRevocation> {\n try {\n const others = await countOtherCredentialsForAccount(firestore, {\n providerAccountId: credential.providerAccountId,\n mailboxId: credential.mailboxId,\n excludeOrgId: options.excludeOrgId,\n excludeMailboxIds: options.excludeMailboxIds,\n })\n if (others > 0) return 'kept-for-other-mailbox'\n const config = deps.readConfig()\n if (!config.configured) return 'failed'\n const { refreshToken } = openMailboxRefreshToken(credential, config.config.keyring)\n return await revokeGoogleToken(refreshToken, deps.transport)\n } catch (error) {\n if (!(error instanceof GmailTransportError) && !(error instanceof SecretBoxError)) {\n console.error('[outreach] revoking a mailbox grant failed', error)\n }\n return 'failed'\n }\n}\n"],"names":["SecretBoxError","GmailTransportError","revokeGoogleToken","countOtherCredentialsForAccount","openMailboxRefreshToken","revokeMailboxGrant","firestore","credential","deps","options","others","providerAccountId","mailboxId","excludeOrgId","excludeMailboxIds","config","readConfig","configured","refreshToken","keyring","transport","error","console"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,cAAc,QAAQ,sCAAqC;AACpE,SAASC,mBAAmB,QAAQ,+BAA2B;AAC/D,SAASC,iBAAiB,QAAQ,+BAA2B;AAG7D,SACEC,+BAA+B,EAC/BC,uBAAuB,QAElB,2BAAuB;AA4C9B,iFAAiF,GACjF,OAAO,eAAeC,mBACpBC,SAAsC,EACtCC,UAA4C,EAC5CC,IAAwB,EACxBC,UAAqC,CAAC,CAAC;IAEvC,IAAI;QACF,MAAMC,SAAS,MAAMP,gCAAgCG,WAAW;YAC9DK,mBAAmBJ,WAAWI,iBAAiB;YAC/CC,WAAWL,WAAWK,SAAS;YAC/BC,cAAcJ,QAAQI,YAAY;YAClCC,mBAAmBL,QAAQK,iBAAiB;QAC9C;QACA,IAAIJ,SAAS,GAAG,OAAO;QACvB,MAAMK,SAASP,KAAKQ,UAAU;QAC9B,IAAI,CAACD,OAAOE,UAAU,EAAE,OAAO;QAC/B,MAAM,EAAEC,YAAY,EAAE,GAAGd,wBAAwBG,YAAYQ,OAAOA,MAAM,CAACI,OAAO;QAClF,OAAO,MAAMjB,kBAAkBgB,cAAcV,KAAKY,SAAS;IAC7D,EAAE,OAAOC,OAAO;QACd,IAAI,CAAEA,CAAAA,iBAAiBpB,mBAAkB,KAAM,CAAEoB,CAAAA,iBAAiBrB,cAAa,GAAI;YACjFsB,QAAQD,KAAK,CAAC,8CAA8CA;QAC9D;QACA,OAAO;IACT;AACF"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/mailboxes/mailbox-revoke.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 { SecretBoxError } from '@aglyn/shared-util-tools/secret-box'\nimport { GmailTransportError } from '../transport/gmail-errors'\nimport { revokeGoogleToken } from '../transport/google-oauth'\nimport type { TransportDeps } from '../transport/http'\nimport type { OutreachMailboxDisconnectResponse } from './mailbox-api'\nimport {\n countOtherCredentialsForAccount,\n openMailboxRefreshToken,\n type OutreachStoredMailboxCredentials,\n} from './mailbox-credentials'\nimport type { OutreachGoogleConfigResult } from './outreach-config'\n\n/**\n * TELLING GOOGLE A STORED GRANT IS OVER (AGL-2978).\n *\n * A disconnect, a workspace erasure and an account erasure all end a\n * mailbox's grant the same way: open its sealed refresh token with this\n * deployment's key and revoke it at Google — unless another stored\n * credential still uses the same Google account, because Google's\n * revocation ends the whole grant this client holds for the account, and\n * revoking it would silently break that other mailbox.\n *\n * A Microsoft grant (AGL-3489) has no such endpoint: Microsoft offers no\n * way for an app to revoke one refresh token, so the outcome is\n * `unsupported`, and deleting the stored grant is what ends it here.\n *\n * Never throws. A grant that cannot be revoked is still deleted by whoever\n * called this; the outcome says which happened.\n */\n\n/**\n * What became of a grant at the provider: `revoked`, `already-invalid`,\n * `kept-for-other-mailbox` while another credential still uses the account,\n * `unsupported` for a provider with no revocation, or `failed` when Google\n * could not be told — the token would not open, the deployment is not\n * configured, or Google did not answer.\n */\nexport type OutreachGrantRevocation = OutreachMailboxDisconnectResponse['revocation']\n\nexport interface OutreachRevokeDeps {\n readConfig(): OutreachGoogleConfigResult\n /** The network and retry policy the revocation call uses. */\n transport: TransportDeps\n}\n\nexport interface RevokeMailboxGrantOptions {\n /**\n * Credentials of this organization do not count as another use of the\n * account: the workspace erasure is ending all of them.\n */\n excludeOrgId?: string\n /**\n * These credentials do not count as another use of the account either:\n * the account erasure is ending every one of the person's grants.\n */\n excludeMailboxIds?: ReadonlySet<string>\n}\n\n/** Revokes one stored grant at Google, unless it is still shared. Never throws. */\nexport async function revokeMailboxGrant(\n firestore: FirebaseFirestore.Firestore,\n credential: OutreachStoredMailboxCredentials,\n deps: OutreachRevokeDeps,\n options: RevokeMailboxGrantOptions = {},\n): Promise<OutreachGrantRevocation> {\n if (credential.provider === 'microsoft') return 'unsupported'\n try {\n const others = await countOtherCredentialsForAccount(firestore, {\n providerAccountId: credential.providerAccountId,\n mailboxId: credential.mailboxId,\n excludeOrgId: options.excludeOrgId,\n excludeMailboxIds: options.excludeMailboxIds,\n })\n if (others > 0) return 'kept-for-other-mailbox'\n const config = deps.readConfig()\n if (!config.configured) return 'failed'\n const { refreshToken } = openMailboxRefreshToken(credential, config.config.keyring)\n return await revokeGoogleToken(refreshToken, deps.transport)\n } catch (error) {\n if (!(error instanceof GmailTransportError) && !(error instanceof SecretBoxError)) {\n console.error('[outreach] revoking a mailbox grant failed', error)\n }\n return 'failed'\n }\n}\n"],"names":["SecretBoxError","GmailTransportError","revokeGoogleToken","countOtherCredentialsForAccount","openMailboxRefreshToken","revokeMailboxGrant","firestore","credential","deps","options","provider","others","providerAccountId","mailboxId","excludeOrgId","excludeMailboxIds","config","readConfig","configured","refreshToken","keyring","transport","error","console"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,cAAc,QAAQ,sCAAqC;AACpE,SAASC,mBAAmB,QAAQ,+BAA2B;AAC/D,SAASC,iBAAiB,QAAQ,+BAA2B;AAG7D,SACEC,+BAA+B,EAC/BC,uBAAuB,QAElB,2BAAuB;AAiD9B,iFAAiF,GACjF,OAAO,eAAeC,mBACpBC,SAAsC,EACtCC,UAA4C,EAC5CC,IAAwB,EACxBC,UAAqC,CAAC,CAAC;IAEvC,IAAIF,WAAWG,QAAQ,KAAK,aAAa,OAAO;IAChD,IAAI;QACF,MAAMC,SAAS,MAAMR,gCAAgCG,WAAW;YAC9DM,mBAAmBL,WAAWK,iBAAiB;YAC/CC,WAAWN,WAAWM,SAAS;YAC/BC,cAAcL,QAAQK,YAAY;YAClCC,mBAAmBN,QAAQM,iBAAiB;QAC9C;QACA,IAAIJ,SAAS,GAAG,OAAO;QACvB,MAAMK,SAASR,KAAKS,UAAU;QAC9B,IAAI,CAACD,OAAOE,UAAU,EAAE,OAAO;QAC/B,MAAM,EAAEC,YAAY,EAAE,GAAGf,wBAAwBG,YAAYS,OAAOA,MAAM,CAACI,OAAO;QAClF,OAAO,MAAMlB,kBAAkBiB,cAAcX,KAAKa,SAAS;IAC7D,EAAE,OAAOC,OAAO;QACd,IAAI,CAAEA,CAAAA,iBAAiBrB,mBAAkB,KAAM,CAAEqB,CAAAA,iBAAiBtB,cAAa,GAAI;YACjFuB,QAAQD,KAAK,CAAC,8CAA8CA;QAC9D;QACA,OAAO;IACT;AACF"}
@@ -18,10 +18,16 @@ import type { PluginWebApiHandler } from '@aglyn/aglyn/server';
18
18
  import { type SenderReadiness, type SenderReadinessExpectation } from '@aglyn/shared-util-email';
19
19
  import type { TransportDeps } from '../transport/http';
20
20
  import { type OutreachGateDeps } from './mailbox-gate';
21
- import { type OutreachGoogleConfigResult } from './outreach-config';
21
+ import { type OutreachGoogleConfigResult, type OutreachMicrosoftConfigResult } from './outreach-config';
22
22
  /**
23
- * THE MAILBOX ROUTES (AGL-2978): connect a rep's Google mailbox, keep its
24
- * settings, pause it, test it, and disconnect it.
23
+ * THE MAILBOX ROUTES (AGL-2978): connect a rep's Google or Microsoft 365
24
+ * mailbox, keep its settings, pause it, test it, and disconnect it.
25
+ *
26
+ * A connect names its provider (AGL-3489). Both providers' consent screens
27
+ * return to the one callback; the signed state says which one a connect went
28
+ * to, and `connect/complete` redeems the code with that provider. Everything
29
+ * after the provider has named the account — the mailbox limit, the mailbox
30
+ * and its sealed grant, the activity row — is the same for both.
25
31
  *
26
32
  * ## The connect, end to end
27
33
  *
@@ -73,6 +79,8 @@ export interface OutreachMailboxRouteDeps {
73
79
  firestore(): FirebaseFirestore.Firestore;
74
80
  gate: OutreachGateDeps;
75
81
  readConfig(): OutreachGoogleConfigResult;
82
+ /** The Microsoft app registration (AGL-3489); the environment's when omitted. */
83
+ readMicrosoftConfig?(): OutreachMicrosoftConfigResult;
76
84
  /** Whether the signing secret the OAuth state needs is configured. */
77
85
  stateSigningConfigured(): boolean;
78
86
  redirectUri(requestUrl: string): string | null;
@@ -15,10 +15,13 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
17
  */ import { normalizeContactEmail } from "@aglyn/aglyn/app-utils/contacts";
18
- import { googleMailboxSenderExpectation } from "@aglyn/shared-util-email";
18
+ import { googleMailboxSenderExpectation, microsoftMailboxSenderExpectation } from "@aglyn/shared-util-email";
19
+ import { OUTREACH_MAILBOX_PROVIDERS } from "../model/outreach.types.js";
19
20
  import { createGmailClient } from "../transport/gmail-client.js";
21
+ import { createGraphClient } from "../transport/graph-client.js";
20
22
  import { GmailTransportError, googleFailureAnswer } from "../transport/gmail-errors.js";
21
23
  import { buildGoogleAuthorizationUrl, exchangeGoogleAuthorizationCode, grantedScopesInclude, OUTREACH_REQUIRED_GRANTED_SCOPES, pkceChallenge, readGoogleIdToken } from "../transport/google-oauth.js";
24
+ import { buildMicrosoftAuthorizationUrl, exchangeMicrosoftAuthorizationCode, microsoftScopesInclude, readMicrosoftIdToken } from "../transport/microsoft-oauth.js";
22
25
  import { Rfc5322MessageError } from "../transport/rfc5322.js";
23
26
  import { sendOutreachMessage } from "../transport/send-message.js";
24
27
  import { buildConnectReturnFragment } from "./mailbox-api.js";
@@ -28,7 +31,7 @@ import { outreachMemberGate, refusal } from "./mailbox-gate.js";
28
31
  import { defaultSendAs, isValidTimezone, OUTREACH_DEFAULT_DAILY_CAP, OUTREACH_DEFAULT_WINDOW, OUTREACH_MAX_MAILBOXES_PER_MEMBER, validateDailyCap, validateDisplayName, validateSendWindow, verifiedSendAsAddresses } from "./mailbox-settings.js";
29
32
  import { markOutreachMailboxReconnectRequired, openOutreachMailboxClient } from "./mailbox-transport.js";
30
33
  import { consumeOutreachOAuthState, mintOutreachOAuthState, outreachOidcNonce, outreachPkceVerifier, readOutreachOAuthState, recordOutreachOAuthState } from "./oauth-state.js";
31
- import { OUTREACH_NOT_CONFIGURED_MESSAGE } from "./outreach-config.js";
34
+ import { outreachNotConfiguredMessage, readOutreachMicrosoftConfig } from "./outreach-config.js";
32
35
  /** Test sends one mailbox may make in an hour. */ export const OUTREACH_TEST_SENDS_PER_HOUR = 5;
33
36
  /**
34
37
  * The org activity target a mailbox's rows are filed under: Outreach's own
@@ -58,9 +61,14 @@ async function readBody(request) {
58
61
  const body = await request.json().catch(()=>null);
59
62
  return body && typeof body === 'object' && !Array.isArray(body) ? body : {};
60
63
  }
61
- function notConfigured() {
62
- return refusal(503, 'not-configured', OUTREACH_NOT_CONFIGURED_MESSAGE);
64
+ function notConfigured(provider = 'google') {
65
+ return refusal(503, 'not-configured', outreachNotConfiguredMessage(provider));
63
66
  }
67
+ /** The provider a request names; Google when it names none, `null` for one that is not a provider. */ function readProvider(value) {
68
+ if (value === undefined || value === null || value === '') return 'google';
69
+ return OUTREACH_MAILBOX_PROVIDERS.includes(value) ? value : null;
70
+ }
71
+ const providerName = (provider)=>provider === 'microsoft' ? 'Microsoft' : 'Google';
64
72
  /** A mailbox document read defensively into the model shape. */ function readMailbox(id, data) {
65
73
  if (!data) return null;
66
74
  return _extends({}, data, {
@@ -69,6 +77,11 @@ function notConfigured() {
69
77
  }
70
78
  const activityName = (mailbox)=>mailbox.email;
71
79
  export function createOutreachMailboxRoutes(deps) {
80
+ const readMicrosoftConfig = ()=>{
81
+ var _deps_readMicrosoftConfig;
82
+ return ((_deps_readMicrosoftConfig = deps.readMicrosoftConfig) != null ? _deps_readMicrosoftConfig : readOutreachMicrosoftConfig)();
83
+ };
84
+ /** Whether a provider's client and the token key are configured here. */ const providerConfigured = (provider)=>provider === 'microsoft' ? readMicrosoftConfig().configured : deps.readConfig().configured;
72
85
  /** The gate, then the mailbox the body names, then who may touch it. */ async function loadManagedMailbox(request, body, who) {
73
86
  const gate = await outreachMemberGate(request, body['orgId'], deps.gate);
74
87
  if (gate instanceof Response) return gate;
@@ -94,18 +107,30 @@ export function createOutreachMailboxRoutes(deps) {
94
107
  // should see the whole list once rather than one variable per redeploy.
95
108
  const missing = [];
96
109
  const config = deps.readConfig();
110
+ const microsoft = readMicrosoftConfig();
97
111
  if (config.configured === false) missing.push({
98
112
  gate: 'google',
99
113
  missing: config.missing
100
114
  });
115
+ if (microsoft.configured === false) missing.push({
116
+ gate: 'microsoft',
117
+ missing: microsoft.missing
118
+ });
119
+ const shared = deps.stateSigningConfigured() && Boolean(deps.redirectUri(request.url));
101
120
  if (!deps.stateSigningConfigured()) missing.push({
102
121
  gate: 'state'
103
122
  });
104
123
  if (!deps.redirectUri(request.url)) missing.push({
105
124
  gate: 'redirect'
106
125
  });
126
+ const providers = {
127
+ google: shared && config.configured,
128
+ microsoft: shared && microsoft.configured
129
+ };
107
130
  return ok(_extends({
108
- configured: missing.length === 0,
131
+ // Google's readiness, as this field has always meant; `providers` says each.
132
+ configured: providers.google,
133
+ providers,
109
134
  canManageAll: gate.isOrgAdmin
110
135
  }, missing.length ? {
111
136
  missing
@@ -116,9 +141,40 @@ export function createOutreachMailboxRoutes(deps) {
116
141
  const body = await readBody(request);
117
142
  const gate = await outreachMemberGate(request, body['orgId'], deps.gate);
118
143
  if (gate instanceof Response) return gate;
119
- const config = deps.readConfig();
144
+ const provider = readProvider(body['provider']);
145
+ if (!provider) return refusal(400, 'invalid-request', 'Choose Google or Microsoft.');
120
146
  const redirectUri = deps.redirectUri(request.url);
121
- if (!config.configured || !redirectUri || !deps.stateSigningConfigured()) return notConfigured();
147
+ if (!redirectUri || !deps.stateSigningConfigured()) return notConfigured(provider);
148
+ if (provider === 'microsoft') {
149
+ const microsoft = readMicrosoftConfig();
150
+ if (!microsoft.configured) return notConfigured(provider);
151
+ const nowMs = deps.now();
152
+ const { state, claims } = mintOutreachOAuthState({
153
+ orgId: gate.orgId,
154
+ uid: gate.uid,
155
+ nowMs,
156
+ provider
157
+ });
158
+ await recordOutreachOAuthState(deps.firestore(), {
159
+ claims,
160
+ redirectUri,
161
+ nowMs
162
+ });
163
+ const url = buildMicrosoftAuthorizationUrl({
164
+ clientId: microsoft.config.clientId,
165
+ tenant: microsoft.config.tenant,
166
+ redirectUri,
167
+ state,
168
+ codeChallenge: pkceChallenge(outreachPkceVerifier(claims.nonce)),
169
+ nonce: outreachOidcNonce(claims.nonce),
170
+ loginHint: typeof body['loginHint'] === 'string' ? normalizeContactEmail(body['loginHint']) : null
171
+ });
172
+ return ok({
173
+ url
174
+ });
175
+ }
176
+ const config = deps.readConfig();
177
+ if (!config.configured) return notConfigured();
122
178
  const nowMs = deps.now();
123
179
  const { state, claims } = mintOutreachOAuthState({
124
180
  orgId: gate.orgId,
@@ -162,23 +218,27 @@ export function createOutreachMailboxRoutes(deps) {
162
218
  const slug = typeof (org == null ? void 0 : org['slug']) === 'string' ? org['slug'] : '';
163
219
  if (!slug) return plain(404, 'That organization could not be found.');
164
220
  let fragment;
165
- const googleError = params.get('error');
221
+ const providerError = params.get('error');
166
222
  const code = (_params_get = params.get('code')) != null ? _params_get : '';
167
- if (googleError) {
168
- fragment = {
223
+ // An error fragment names a Microsoft connect, so the page says whose screen it was.
224
+ const named = claims.provider === 'microsoft' ? {
225
+ provider: claims.provider
226
+ } : {};
227
+ if (providerError) {
228
+ fragment = _extends({
169
229
  kind: 'error',
170
- reason: googleError === 'access_denied' ? 'access_denied' : 'google_error'
171
- };
230
+ reason: providerError === 'access_denied' ? 'access_denied' : 'google_error'
231
+ }, named);
172
232
  } else if (!read.ok) {
173
- fragment = {
233
+ fragment = _extends({
174
234
  kind: 'error',
175
235
  reason: 'expired'
176
- };
236
+ }, named);
177
237
  } else if (!code || code.length > MAX_CODE_CHARS) {
178
- fragment = {
238
+ fragment = _extends({
179
239
  kind: 'error',
180
240
  reason: 'google_error'
181
- };
241
+ }, named);
182
242
  } else {
183
243
  var _params_get1;
184
244
  fragment = {
@@ -198,13 +258,12 @@ export function createOutreachMailboxRoutes(deps) {
198
258
  });
199
259
  };
200
260
  const connectComplete = async (request)=>{
201
- var _aliases_confirmed;
202
261
  if (request.method !== 'POST') return methodNotAllowed('POST');
203
262
  const body = await readBody(request);
204
263
  const gate = await outreachMemberGate(request, body['orgId'], deps.gate);
205
264
  if (gate instanceof Response) return gate;
206
- const config = deps.readConfig();
207
- if (!config.configured || !deps.stateSigningConfigured()) return notConfigured();
265
+ if (!deps.stateSigningConfigured()) return notConfigured();
266
+ if (!deps.readConfig().configured && !readMicrosoftConfig().configured) return notConfigured();
208
267
  const code = typeof body['code'] === 'string' ? body['code'] : '';
209
268
  if (!code || code.length > MAX_CODE_CHARS) return refusal(400, 'invalid-request', 'The connection did not return a code.');
210
269
  const firestore = deps.firestore();
@@ -221,6 +280,7 @@ export function createOutreachMailboxRoutes(deps) {
221
280
  return refusal(400, 'state-org-mismatch', 'This connection was started in another organization.');
222
281
  }
223
282
  if (!read.ok) return refusal(410, 'state-expired', 'This connection took too long. Connect the mailbox again.');
283
+ if (!providerConfigured(claims.provider)) return notConfigured(claims.provider);
224
284
  const consumed = await consumeOutreachOAuthState(firestore, {
225
285
  claims,
226
286
  nowMs
@@ -231,13 +291,36 @@ export function createOutreachMailboxRoutes(deps) {
231
291
  }
232
292
  return refusal(409, consumed.refusal, consumed.refusal === 'state-superseded' ? 'A newer connection was started. Finish that one, or connect the mailbox again.' : 'This connection was already used. Connect the mailbox again.');
233
293
  }
294
+ const account = claims.provider === 'microsoft' ? await redeemMicrosoftCode({
295
+ code,
296
+ claims,
297
+ redirectUri: consumed.redirectUri,
298
+ nowMs
299
+ }) : await redeemGoogleCode({
300
+ code,
301
+ claims,
302
+ redirectUri: consumed.redirectUri,
303
+ nowMs
304
+ });
305
+ if (account instanceof Response) return account;
306
+ return storeConnectedMailbox({
307
+ gate,
308
+ body,
309
+ account,
310
+ nowMs
311
+ });
312
+ };
313
+ /** Google's half of a connect: the code redeemed, the grant and the account checked. */ async function redeemGoogleCode(input) {
314
+ const { code, claims, nowMs } = input;
315
+ const config = deps.readConfig();
316
+ if (!config.configured) return notConfigured();
234
317
  let grant;
235
318
  try {
236
319
  grant = await exchangeGoogleAuthorizationCode({
237
320
  clientId: config.config.clientId,
238
321
  clientSecret: config.config.clientSecret,
239
322
  code,
240
- redirectUri: consumed.redirectUri,
323
+ redirectUri: input.redirectUri,
241
324
  codeVerifier: outreachPkceVerifier(claims.nonce)
242
325
  }, deps.transport);
243
326
  } catch (error) {
@@ -300,7 +383,105 @@ export function createOutreachMailboxRoutes(deps) {
300
383
  ...sendAsOptions
301
384
  ];
302
385
  }
303
- const mailboxId = outreachMailboxId(gate.orgId, gate.uid, identity.identity.sub);
386
+ return {
387
+ provider: 'google',
388
+ providerAccountId: identity.identity.sub,
389
+ email: profileEmail,
390
+ sendAsOptions,
391
+ scopes: grant.scope.split(/\s+/).filter(Boolean),
392
+ refreshToken: grant.refreshToken,
393
+ keyring: config.config.keyring
394
+ };
395
+ }
396
+ /**
397
+ * Microsoft's half of a connect (AGL-3489): the code redeemed, the grant's
398
+ * permissions checked, the ID token read, and the account read back from
399
+ * Graph — the same account the sign-in named, by its directory id.
400
+ */ async function redeemMicrosoftCode(input) {
401
+ const { code, claims, nowMs } = input;
402
+ const config = readMicrosoftConfig();
403
+ if (!config.configured) return notConfigured('microsoft');
404
+ const scopesMissing = ()=>refusal(422, 'scopes-missing', 'Sequences needs permission to send and to read your mail. Connect again and allow both.');
405
+ let grant;
406
+ try {
407
+ grant = await exchangeMicrosoftAuthorizationCode({
408
+ clientId: config.config.clientId,
409
+ clientSecret: config.config.clientSecret,
410
+ tenant: config.config.tenant,
411
+ code,
412
+ redirectUri: input.redirectUri,
413
+ codeVerifier: outreachPkceVerifier(claims.nonce)
414
+ }, deps.transport);
415
+ } catch (error) {
416
+ if (error instanceof GmailTransportError && error.code === 'invalid_grant') {
417
+ return refusal(400, 'code-rejected', "Microsoft's sign-in expired or was already used. Connect the mailbox again.");
418
+ }
419
+ if (error instanceof GmailTransportError && error.code === 'client_misconfigured') {
420
+ return refusal(503, 'not-configured', "Microsoft refused this deployment's app registration.");
421
+ }
422
+ if (error instanceof GmailTransportError && error.code === 'insufficient_scope') return scopesMissing();
423
+ return googleFailure(error, 'connect/complete Microsoft token exchange');
424
+ }
425
+ if (!grant.refreshToken) {
426
+ return refusal(422, 'refresh-token-missing', 'Microsoft did not grant offline access, so the mailbox could not send later. Connect it again.');
427
+ }
428
+ if (!microsoftScopesInclude(grant.scope)) return scopesMissing();
429
+ const identity = readMicrosoftIdToken(grant.idToken, {
430
+ clientId: config.config.clientId,
431
+ nonce: outreachOidcNonce(claims.nonce),
432
+ nowMs
433
+ });
434
+ if (identity.ok === false) {
435
+ return refusal(422, 'identity-unverified', "Microsoft's answer could not be verified. Connect the mailbox again.");
436
+ }
437
+ const client = createGraphClient(_extends({}, deps.transport, {
438
+ clientId: config.config.clientId,
439
+ clientSecret: config.config.clientSecret,
440
+ tenant: config.config.tenant,
441
+ refreshToken: grant.refreshToken,
442
+ accessToken: {
443
+ token: grant.accessToken,
444
+ expiresInSeconds: grant.expiresInSeconds
445
+ }
446
+ }));
447
+ let profile;
448
+ try {
449
+ profile = await client.getProfile();
450
+ } catch (error) {
451
+ if (error instanceof GmailTransportError && error.reconnectRequired) return scopesMissing();
452
+ return googleFailure(error, 'connect/complete Microsoft profile');
453
+ }
454
+ if (profile.id !== identity.identity.oid || !profile.emailAddress) {
455
+ return refusal(422, 'account-mismatch', 'The Microsoft 365 mailbox and the Microsoft sign-in did not match. Connect again.');
456
+ }
457
+ return {
458
+ provider: 'microsoft',
459
+ providerAccountId: identity.identity.sub,
460
+ email: profile.emailAddress,
461
+ sendAsOptions: [
462
+ {
463
+ email: profile.emailAddress,
464
+ displayName: profile.displayName,
465
+ isPrimary: true,
466
+ isDefault: true
467
+ }
468
+ ],
469
+ scopes: grant.scope.split(/\s+/).filter(Boolean),
470
+ refreshToken: grant.refreshToken,
471
+ keyring: config.config.keyring
472
+ };
473
+ }
474
+ /**
475
+ * The connect's end, whichever provider named the account: the member's
476
+ * mailbox limit, the mailbox (new, or a reconnect that keeps its
477
+ * settings), its sealed grant, the aliases the provider confirmed, and the
478
+ * activity row.
479
+ */ async function storeConnectedMailbox(input) {
480
+ var _aliases_confirmed;
481
+ const { gate, body, account, nowMs } = input;
482
+ const { provider, email: profileEmail, sendAsOptions } = account;
483
+ const firestore = deps.firestore();
484
+ const mailboxId = outreachMailboxId(gate.orgId, gate.uid, account.providerAccountId, provider);
304
485
  const mailboxDoc = mailboxRef(firestore, gate.orgId, mailboxId);
305
486
  const credentialDoc = mailboxCredentialsRef(firestore, mailboxId);
306
487
  const [existingMailbox, existingCredential] = await Promise.all([
@@ -321,11 +502,11 @@ export function createOutreachMailboxRoutes(deps) {
321
502
  var _ref;
322
503
  const keepSendAs = sendAsOptions.some((option)=>option.email === current.sendAs);
323
504
  reconnected = {
324
- provider: 'google',
505
+ provider,
325
506
  email: profileEmail,
326
507
  sendAs: keepSendAs ? current.sendAs : (_ref = defaultOption == null ? void 0 : defaultOption.email) != null ? _ref : profileEmail,
327
508
  sendAsOptions,
328
- // A reconnect restores a mailbox Google had cut off, and leaves a
509
+ // A reconnect restores a mailbox the provider had cut off, and leaves a
329
510
  // mailbox its member paused paused.
330
511
  status: current.status === 'paused' ? 'paused' : 'connected',
331
512
  connectedAtMs: nowMs,
@@ -338,7 +519,7 @@ export function createOutreachMailboxRoutes(deps) {
338
519
  const displayName = validateDisplayName((defaultOption == null ? void 0 : defaultOption.displayName) || gate.name || '');
339
520
  mailbox = {
340
521
  id: mailboxId,
341
- provider: 'google',
522
+ provider,
342
523
  email: profileEmail,
343
524
  sendAs: (_ref1 = defaultOption == null ? void 0 : defaultOption.email) != null ? _ref1 : profileEmail,
344
525
  sendAsOptions,
@@ -372,12 +553,12 @@ export function createOutreachMailboxRoutes(deps) {
372
553
  id: mailboxId,
373
554
  orgId: gate.orgId,
374
555
  mailboxId,
375
- provider: 'google',
556
+ provider,
376
557
  connectedByUid: gate.uid,
377
- providerAccountId: identity.identity.sub,
558
+ providerAccountId: account.providerAccountId,
378
559
  email: profileEmail,
379
- scopes: grant.scope.split(/\s+/).filter(Boolean)
380
- }, sealMailboxRefreshToken(grant.refreshToken, mailboxId, config.config.keyring), {
560
+ scopes: account.scopes
561
+ }, sealMailboxRefreshToken(account.refreshToken, mailboxId, account.keyring), {
381
562
  createdAtMs: Number(existingCredential.exists ? existingCredential.get('createdAtMs') : 0) || nowMs,
382
563
  updatedAtMs: nowMs
383
564
  });
@@ -416,7 +597,7 @@ export function createOutreachMailboxRoutes(deps) {
416
597
  created: !current,
417
598
  confirmedAliases: aliases.ok ? (_aliases_confirmed = aliases.confirmed) != null ? _aliases_confirmed : [] : []
418
599
  });
419
- };
600
+ }
420
601
  const settings = async (request)=>{
421
602
  if (request.method !== 'POST') return methodNotAllowed('POST');
422
603
  const body = await readBody(request);
@@ -429,7 +610,7 @@ export function createOutreachMailboxRoutes(deps) {
429
610
  var _mailbox_sendAsOptions;
430
611
  const sendAs = typeof body['sendAs'] === 'string' ? body['sendAs'].trim().toLowerCase() : '';
431
612
  if (!((_mailbox_sendAsOptions = mailbox.sendAsOptions) != null ? _mailbox_sendAsOptions : []).some((option)=>option.email === sendAs)) {
432
- return invalid('Choose one of the addresses Gmail has verified for this account.');
613
+ return invalid('Choose one of the addresses the provider has verified for this account.');
433
614
  }
434
615
  update.sendAs = sendAs;
435
616
  }
@@ -527,7 +708,8 @@ export function createOutreachMailboxRoutes(deps) {
527
708
  if (mailbox.status === 'reconnect_required' || mailbox.status === 'disconnected') {
528
709
  return refusal(409, 'reconnect-required', 'Reconnect this mailbox before sending from it.');
529
710
  }
530
- if (!deps.readConfig().configured) return notConfigured();
711
+ const provider = mailbox.provider === 'microsoft' ? 'microsoft' : 'google';
712
+ if (!providerConfigured(provider)) return notConfigured(provider);
531
713
  const limited = await deps.consumeRateLimit(`outreach-mailbox-test:${mailbox.id}`, {
532
714
  limit: OUTREACH_TEST_SENDS_PER_HOUR,
533
715
  windowMs: 60 * 60 * 1000
@@ -537,7 +719,7 @@ export function createOutreachMailboxRoutes(deps) {
537
719
  }
538
720
  /*
539
721
  * To the member's own address unless they name another (AGL-3228). A
540
- * test to oneself never leaves Google, so it arrives with no
722
+ * test to oneself never leaves the provider, so it arrives with no
541
723
  * Authentication-Results at all and cannot show whether the send-as
542
724
  * domain's DKIM and DMARC hold; an outside mailbox the member can read
543
725
  * is the only place that can be seen. The rate limit above is the
@@ -554,10 +736,11 @@ export function createOutreachMailboxRoutes(deps) {
554
736
  mailboxId: mailbox.id
555
737
  }, _extends({}, deps.transport, {
556
738
  readConfig: deps.readConfig,
739
+ readMicrosoftConfig,
557
740
  now: deps.now
558
741
  }));
559
742
  if (opened.ok === false) {
560
- if (opened.reason === 'not-configured') return notConfigured();
743
+ if (opened.reason === 'not-configured') return notConfigured(provider);
561
744
  await markOutreachMailboxReconnectRequired(firestore, {
562
745
  orgId: gate.orgId,
563
746
  mailboxId: mailbox.id,
@@ -598,7 +781,7 @@ export function createOutreachMailboxRoutes(deps) {
598
781
  errorCode: error.code,
599
782
  nowMs
600
783
  });
601
- return refusal(409, 'reconnect-required', 'Google refused this mailbox’s access. Connect it again.');
784
+ return refusal(409, 'reconnect-required', `${providerName(provider)} refused this mailbox’s access. Connect it again.`);
602
785
  }
603
786
  if (error instanceof Rfc5322MessageError) {
604
787
  return refusal(400, 'invalid-settings', `The test could not be written: ${error.message}`);
@@ -649,7 +832,7 @@ export function createOutreachMailboxRoutes(deps) {
649
832
  const mailbox = readMailbox(mailboxId, snapshot.exists ? snapshot.data() : undefined);
650
833
  if (!mailbox) return refusal(404, 'mailbox-not-found', 'That mailbox is not connected to this organization.');
651
834
  const sendAs = mailbox.sendAs || mailbox.email;
652
- const expectation = googleMailboxSenderExpectation(sendAs);
835
+ const expectation = mailbox.provider === 'microsoft' ? microsoftMailboxSenderExpectation(sendAs) : googleMailboxSenderExpectation(sendAs);
653
836
  const read = expectation ? await deps.readSenderReadiness(expectation, {
654
837
  fresh: body['fresh'] === true
655
838
  }).catch(()=>null) : null;