@aglyn/plugins-outreach 1.0.0-beta.218 → 1.0.0-beta.219
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.
- package/package.json +14 -14
- package/src/lib/components/compliance-section.js +72 -34
- package/src/lib/components/compliance-section.js.map +1 -1
- package/src/lib/components/enroll-dialog.d.ts +12 -2
- package/src/lib/components/enroll-dialog.js +72 -18
- package/src/lib/components/enroll-dialog.js.map +1 -1
- package/src/lib/components/enrollment-detail.d.ts +5 -1
- package/src/lib/components/enrollment-detail.js +12 -6
- package/src/lib/components/enrollment-detail.js.map +1 -1
- package/src/lib/components/outreach-console-sections.d.ts +1 -1
- package/src/lib/components/outreach-console-sections.js +1 -1
- package/src/lib/components/outreach-console-sections.js.map +1 -1
- package/src/lib/components/sequence-editor.js +85 -40
- package/src/lib/components/sequence-editor.js.map +1 -1
- package/src/lib/components/use-outreach-api.d.ts +2 -1
- package/src/lib/components/use-outreach-api.js +5 -3
- package/src/lib/components/use-outreach-api.js.map +1 -1
- package/src/lib/engine/compose.d.ts +1 -1
- package/src/lib/engine/compose.js +8 -4
- package/src/lib/engine/compose.js.map +1 -1
- package/src/lib/engine/enrollment-activity.d.ts +9 -1
- package/src/lib/engine/enrollment-activity.js +15 -2
- package/src/lib/engine/enrollment-activity.js.map +1 -1
- package/src/lib/engine/enrollment-state.d.ts +33 -2
- package/src/lib/engine/enrollment-state.js +66 -7
- package/src/lib/engine/enrollment-state.js.map +1 -1
- package/src/lib/engine/sending-capacity.d.ts +1 -1
- package/src/lib/engine/sending-capacity.js +1 -1
- package/src/lib/engine/sending-capacity.js.map +1 -1
- package/src/lib/engine/sequence-validation.d.ts +20 -3
- package/src/lib/engine/sequence-validation.js +30 -4
- package/src/lib/engine/sequence-validation.js.map +1 -1
- package/src/lib/enrollment/enroll-people.js +6 -4
- package/src/lib/enrollment/enroll-people.js.map +1 -1
- package/src/lib/enrollment/step-overrides.d.ts +4 -2
- package/src/lib/enrollment/step-overrides.js +11 -4
- package/src/lib/enrollment/step-overrides.js.map +1 -1
- package/src/lib/mailboxes/mailbox-api.d.ts +5 -1
- package/src/lib/mailboxes/mailbox-api.js.map +1 -1
- package/src/lib/mailboxes/mailbox-routes.d.ts +5 -2
- package/src/lib/mailboxes/mailbox-routes.js +10 -7
- package/src/lib/mailboxes/mailbox-routes.js.map +1 -1
- package/src/lib/mailboxes/mailbox-settings.d.ts +2 -2
- package/src/lib/mailboxes/mailbox-settings.js +1 -1
- package/src/lib/mailboxes/mailbox-settings.js.map +1 -1
- package/src/lib/model/enrollment-timeline.d.ts +1 -1
- package/src/lib/model/enrollment-timeline.js +22 -3
- package/src/lib/model/enrollment-timeline.js.map +1 -1
- package/src/lib/model/outreach-api.d.ts +19 -1
- package/src/lib/model/outreach-api.js.map +1 -1
- package/src/lib/model/outreach.types.d.ts +25 -0
- package/src/lib/model/outreach.types.js.map +1 -1
- package/src/lib/model/step-preview.d.ts +2 -0
- package/src/lib/model/step-preview.js +11 -7
- package/src/lib/model/step-preview.js.map +1 -1
- package/src/lib/model/stored-records.js +26 -0
- package/src/lib/model/stored-records.js.map +1 -1
- package/src/lib/plugin.js +1 -1
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/routes/enroll-routes.d.ts +6 -0
- package/src/lib/routes/enroll-routes.js +21 -6
- package/src/lib/routes/enroll-routes.js.map +1 -1
- package/src/lib/routes/preview-routes.js +14 -5
- package/src/lib/routes/preview-routes.js.map +1 -1
- package/src/lib/routes/step-test-routes.js +3 -3
- package/src/lib/routes/step-test-routes.js.map +1 -1
- package/src/lib/testing/stand-in-crm-record-routes.d.ts +25 -0
- package/src/lib/testing/stand-in-crm-record-routes.js +46 -0
- package/src/lib/testing/stand-in-crm-record-routes.js.map +1 -0
- package/src/lib/transport/gmail-errors.d.ts +27 -0
- package/src/lib/transport/gmail-errors.js +56 -0
- package/src/lib/transport/gmail-errors.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/step-test-routes.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 { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport type { ComposedOutreachEmail } from '../engine/compose'\nimport { hasOutreachValidationErrors, validateOutreachSteps } from '../engine/sequence-validation'\nimport { mailboxRef } from '../mailboxes/mailbox-credentials'\nimport { outreachLocalDay } from '../mailboxes/mailbox-settings'\nimport {\n markOutreachMailboxReconnectRequired,\n type OpenedOutreachMailbox,\n} from '../mailboxes/mailbox-transport'\nimport type { OutreachStepTestResponse } from '../model/outreach-api'\nimport { OUTREACH_COLLECTIONS, type OutreachMailbox } from '../model/outreach.types'\nimport { previewOutreachStep } from '../model/step-preview'\nimport {\n newOutreachLinkId,\n OUTREACH_TEST_LINK_ENROLLMENT,\n outreachStoredLink,\n outreachStoredOpen,\n type OutreachStoredLink,\n type OutreachStoredOpen,\n} from '../runtime/click-link'\nimport { readStoredOutreachMailbox } from '../storage/outreach-records'\nimport { GmailTransportError } from '../transport/gmail-errors'\nimport { Rfc5322MessageError } from '../transport/rfc5322'\nimport { sendComposedOutreachEmail } from '../transport/send-message'\nimport { readOutreachStepRender } from './preview-routes'\nimport type { OutreachRouteDeps } from './route-deps'\nimport { outreachMethodNotAllowed, outreachOk, outreachRefusal, readOutreachJsonBody } from './route-http'\n\n/**\n * A TEST OF ONE STEP, IN A REAL INBOX (AGL-3325): `outreach/steps/test`.\n *\n * The Mailboxes page's test proves a mailbox can send; this proves what a\n * step LOOKS like once it has been through Gmail. It is the preview's email\n * — the same reader, the same person or sample person, the same personal\n * line, the same footer — sent through the sequence's mailbox to the member\n * who asked, or to an address they typed, with `[Test] ` in front of the\n * subject so it never reads as real mail.\n *\n * ## Rendered as a send, sent as a test\n *\n * A later step in a thread is written as `Re:` the thread's subject, as the\n * preview writes it, but sent as a new message: there is no thread to\n * answer, and the preview's placeholder message id must not leave the\n * server. When the sequence counts clicks, every link is rewritten to a\n * short link as a real send's would be, so the member sees the link their\n * recipients will see — but each is stored as a TEST link, which the click\n * route follows and never records (`click-link.ts`). A member clicking their\n * own test must not move the sequence's `clicks` or `machineClicks`.\n *\n * Merge fields the person has nothing for are sent empty and named in the\n * answer, where a real send would pause: the point of a test is to see the\n * gap, not to be refused it.\n *\n * ## Counted on nothing but the mailbox's tests\n *\n * A test is a real Gmail send and spends the account's own limits. It is\n * not a person emailed: it moves neither the sequence's `stats` nor the\n * mailbox's `health.daily.<day>.sent`, it reserves nothing of the daily cap,\n * it files nothing on anyone's record, and it enrolls nobody. It is tallied\n * on `health.daily.<day>.tests`, which the Mailboxes card shows as \"Tests\n * today\", and held to {@link OUTREACH_STEP_TESTS_PER_HOUR} a mailbox.\n *\n * ## Who, and from what\n *\n * The mailbox's own member, or an organization owner or admin — the people\n * who may edit and activate the sequence. A paused mailbox, or one waiting\n * to be reconnected, refuses and says which; a member who cannot send is\n * told where to fix it rather than shown a Google error.\n */\n\n/** Tests one mailbox may send in an hour: a few steps for a few people. */\nexport const OUTREACH_STEP_TESTS_PER_HOUR = 20\n\n/** What every test's subject starts with. */\nexport const OUTREACH_TEST_SUBJECT_PREFIX = '[Test] '\n\n/** The route's reach beyond the shared route deps: the mailbox's Gmail, the limit, the links. */\nexport interface OutreachStepTestDeps {\n /** Opens a mailbox's Gmail client from its sealed grant. */\n openMailbox(mailboxId: string): Promise<OpenedOutreachMailbox>\n consumeRateLimit(key: string, options: { limit: number; windowMs: number }): Promise<{ allowed: boolean }>\n /** The short tracking link for a stored link's id, or `null` when none can be made. */\n clickLinkUrl(linkId: string, trackingOrigin?: string | null): string | null\n /** The click-tracking origin for mail sent as `senderAddress` (AGL-3306), or `null` for the console's. */\n clickLinkOrigin?(input: { orgId: string; senderAddress: string }): Promise<string | null>\n}\n\n/** What the route answers: the response the console reads, and the fields the test sent empty. */\nexport interface OutreachStepTestAnswer extends OutreachStepTestResponse {\n unresolvedFields: string[]\n}\n\nconst RECONNECT_SENTENCE =\n \"This sequence's mailbox needs to be connected again before it can send. Reconnect it on the Mailboxes page.\"\n\n/** Why the mailbox cannot send a test now, in a sentence, or `null`. */\nfunction mailboxRefusal(mailbox: OutreachMailbox | null): string | null {\n if (!mailbox) return 'This sequence has no mailbox. Choose one and save before sending a test.'\n if (mailbox.status === 'paused') {\n return \"This sequence's mailbox is paused. Resume it on the Mailboxes page before sending a test.\"\n }\n if (mailbox.status === 'reconnect_required' || mailbox.status === 'disconnected') return RECONNECT_SENTENCE\n return null\n}\n\nexport function createOutreachStepTestRoute(\n deps: OutreachRouteDeps,\n testDeps: OutreachStepTestDeps,\n): PluginWebApiHandler {\n return async (request) => {\n if (request.method !== 'POST') return outreachMethodNotAllowed('POST')\n const body = await readOutreachJsonBody(request)\n // The address the member typed, when they typed one — read before any\n // record, so a typo is answered before a Gmail call.\n const named = typeof body['to'] === 'string' ? body['to'].trim() : ''\n const typed = named ? normalizeContactEmail(named) : null\n if (named && !typed) {\n return outreachRefusal(400, 'invalid-request', `\"${named}\" isn't a valid email address.`)\n }\n const loaded = await readOutreachStepRender(deps, request, body)\n if (loaded instanceof Response) return loaded\n const { caller, sequence, stepIndex, mailbox } = loaded\n\n const refusedBy = mailboxRefusal(mailbox)\n if (refusedBy || !mailbox) return outreachRefusal(409, 'mailbox-unavailable', refusedBy ?? RECONNECT_SENTENCE)\n if (mailbox.connectedByUid !== caller.uid && !caller.isOrgAdmin) {\n return outreachRefusal(\n 403,\n 'permission',\n \"Only the member who connected this sequence's mailbox, or an organization owner or admin, can send a test from it.\",\n )\n }\n const to = typed ?? caller.email\n if (!to) return outreachRefusal(400, 'invalid-request', 'Type an address to send the test to.')\n // The rules every sequence email keeps, asked of the stored steps: a\n // sequence saved under an older rule is refused rather than sent broken.\n const issues = validateOutreachSteps(sequence.steps)\n if (hasOutreachValidationErrors(issues)) {\n return outreachRefusal(400, 'invalid-sequence', \"Fix the sequence's steps before sending a test.\", { issues })\n }\n const limited = await testDeps.consumeRateLimit(`outreach-step-test:${mailbox.id}`, {\n limit: OUTREACH_STEP_TESTS_PER_HOUR,\n windowMs: 60 * 60 * 1000,\n })\n if (!limited.allowed) {\n return outreachRefusal(\n 429,\n 'rate-limited',\n `A mailbox can send ${OUTREACH_STEP_TESTS_PER_HOUR} tests an hour. Try again later.`,\n )\n }\n\n const firestore = deps.firestore()\n const nowMs = deps.now()\n const sender = { address: mailbox.sendAs || mailbox.email, name: mailbox.displayName || null }\n /*\n * The links, as a real send would rewrite them (AGL-3239, AGL-3297) and\n * marked as a test's: the member sees what a recipient sees, and their\n * own click counts for nothing.\n */\n let linkOrigin: string | null = null\n if ((sequence.settings.trackClicks || sequence.settings.countOpens) && testDeps.clickLinkOrigin) {\n linkOrigin = await testDeps\n .clickLinkOrigin({ orgId: caller.orgId, senderAddress: sender.address })\n .catch(() => null)\n }\n const shortLinks: Array<{ id: string; doc: OutreachStoredLink }> = []\n const rewriteLink = sequence.settings.trackClicks\n ? (link: { url: string; index: number }) => {\n const doc = outreachStoredLink(\n {\n orgId: caller.orgId,\n enrollmentId: OUTREACH_TEST_LINK_ENROLLMENT,\n stepIndex,\n linkIndex: link.index,\n url: link.url,\n test: true,\n },\n nowMs,\n )\n const id = newOutreachLinkId()\n const url = doc ? testDeps.clickLinkUrl(id, linkOrigin) : null\n if (!doc || !url) return null\n shortLinks.push({ id, doc: { ...doc, sequenceId: sequence.id } })\n return url\n }\n : null\n /*\n * The tracking image (AGL-3395), as a real send would carry it and\n * marked as a test's: the member sees both parts a recipient gets, and\n * their own open is recorded nowhere.\n */\n let pixel: { id: string; doc: OutreachStoredOpen; url: string } | null = null\n if (sequence.settings.countOpens) {\n const doc = outreachStoredOpen(\n { orgId: caller.orgId, enrollmentId: OUTREACH_TEST_LINK_ENROLLMENT, stepIndex, test: true },\n nowMs,\n )\n const id = newOutreachLinkId()\n const url = doc ? testDeps.clickLinkUrl(id, linkOrigin) : null\n if (doc && url) pixel = { id, doc: { ...doc, sequenceId: sequence.id }, url }\n }\n let composed = previewOutreachStep({ ...loaded.input, rewriteLink, openPixelUrl: pixel?.url ?? null })\n if (composed.error || !composed.email) {\n return outreachRefusal(409, 'invalid-sequence', composed.error?.message ?? \"This email can't be written yet.\")\n }\n if (shortLinks.length || pixel) {\n try {\n const batch = firestore.batch()\n for (const link of shortLinks) {\n batch.create(firestore.collection(OUTREACH_COLLECTIONS.links).doc(link.id), link.doc)\n }\n if (pixel) batch.create(firestore.collection(OUTREACH_COLLECTIONS.links).doc(pixel.id), pixel.doc)\n await batch.commit()\n } catch (error) {\n // A link that resolves nowhere is worse than one shown as written:\n // the test goes out with the step's own links, as plain text.\n console.error('[outreach] the test links could not be stored; the test carries the links as written', error)\n composed = previewOutreachStep({ ...loaded.input, rewriteLink: null })\n if (composed.error || !composed.email) {\n return outreachRefusal(409, 'invalid-sequence', composed.error?.message ?? \"This email can't be written yet.\")\n }\n }\n }\n\n const opened = await testDeps.openMailbox(mailbox.id)\n if (opened.ok === false) {\n if (opened.reason === 'not-configured') {\n return outreachRefusal(503, 'mailbox-unavailable', \"Google mailboxes aren't set up on this deployment.\")\n }\n await markOutreachMailboxReconnectRequired(firestore, {\n orgId: caller.orgId,\n mailboxId: mailbox.id,\n errorCode: opened.reason,\n nowMs,\n })\n return outreachRefusal(409, 'mailbox-unavailable', RECONNECT_SENTENCE)\n }\n /*\n * The preview's email, addressed to the member: `[Test]` in front, and a\n * `Re:` step sent as a new message — no thread id, no `In-Reply-To`, no\n * `References` — since the thread it answers exists only on screen. The\n * unsubscribe header is left off: it names an enrollment, and there is\n * none.\n */\n const email: ComposedOutreachEmail = {\n to,\n subject: `${OUTREACH_TEST_SUBJECT_PREFIX}${composed.email.subject}`,\n text: composed.email.text,\n ...(composed.email.html ? { html: composed.email.html } : {}),\n trackedLinks: composed.email.trackedLinks,\n }\n let subject: string\n try {\n subject = (await sendComposedOutreachEmail(opened.client, email, sender)).subject\n } catch (error) {\n if (error instanceof GmailTransportError && error.reconnectRequired) {\n await markOutreachMailboxReconnectRequired(firestore, {\n orgId: caller.orgId,\n mailboxId: mailbox.id,\n errorCode: error.code,\n nowMs,\n })\n return outreachRefusal(409, 'mailbox-unavailable', 'Google refused this mailbox’s access. Connect it again.')\n }\n if (error instanceof Rfc5322MessageError) {\n return outreachRefusal(400, 'invalid-request', `The test could not be written: ${error.message}`)\n }\n if (!(error instanceof GmailTransportError)) throw error\n return outreachRefusal(\n 502,\n 'google-unavailable',\n error.retryable ? 'Google did not answer. Try again in a moment.' : `Google refused the request: ${error.message}`,\n )\n }\n\n const testsToday = await countTest(firestore, caller.orgId, mailbox, nowMs)\n return outreachOk({\n ok: true,\n stepIndex,\n sentTo: to,\n subject,\n sentAtMs: nowMs,\n testsToday,\n unresolvedFields: composed.unresolvedFields,\n } satisfies OutreachStepTestAnswer)\n }\n}\n\n/**\n * One more test on the mailbox's local day, in `health.daily.<day>.tests`\n * beside the day's sends and never in them. Bookkeeping beside the act: the\n * email has left, so a failure is logged and answered as no count.\n */\nasync function countTest(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n mailbox: OutreachMailbox,\n nowMs: number,\n): Promise<number> {\n const ref = mailboxRef(firestore, orgId, mailbox.id)\n try {\n return await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const stored = readStoredOutreachMailbox(mailbox.id, snapshot.exists ? snapshot.data() : undefined)\n if (!stored) return 0\n const day = outreachLocalDay(nowMs, stored.timezone)\n const counts = stored.health?.daily?.[day] ?? { sent: 0, bounces: 0, replies: 0 }\n const tests = Math.max(0, Number(counts.tests) || 0) + 1\n transaction.update(ref, { [`health.daily.${day}`]: { ...counts, tests }, updatedAtMs: nowMs })\n return tests\n })\n } catch (error) {\n console.warn('[outreach] a test send could not be counted on its mailbox', error)\n return 0\n }\n}\n"],"names":["normalizeContactEmail","hasOutreachValidationErrors","validateOutreachSteps","mailboxRef","outreachLocalDay","markOutreachMailboxReconnectRequired","OUTREACH_COLLECTIONS","previewOutreachStep","newOutreachLinkId","OUTREACH_TEST_LINK_ENROLLMENT","outreachStoredLink","outreachStoredOpen","readStoredOutreachMailbox","GmailTransportError","Rfc5322MessageError","sendComposedOutreachEmail","readOutreachStepRender","outreachMethodNotAllowed","outreachOk","outreachRefusal","readOutreachJsonBody","OUTREACH_STEP_TESTS_PER_HOUR","OUTREACH_TEST_SUBJECT_PREFIX","RECONNECT_SENTENCE","mailboxRefusal","mailbox","status","createOutreachStepTestRoute","deps","testDeps","request","method","body","named","trim","typed","loaded","Response","caller","sequence","stepIndex","refusedBy","connectedByUid","uid","isOrgAdmin","to","email","issues","steps","limited","consumeRateLimit","id","limit","windowMs","allowed","firestore","nowMs","now","sender","address","sendAs","name","displayName","linkOrigin","settings","trackClicks","countOpens","clickLinkOrigin","orgId","senderAddress","catch","shortLinks","rewriteLink","link","doc","enrollmentId","linkIndex","index","url","test","clickLinkUrl","push","sequenceId","pixel","composed","input","openPixelUrl","error","message","length","batch","create","collection","links","commit","console","opened","openMailbox","ok","reason","mailboxId","errorCode","subject","text","html","trackedLinks","client","reconnectRequired","code","retryable","testsToday","countTest","sentTo","sentAtMs","unresolvedFields","ref","runTransaction","transaction","stored","snapshot","get","exists","data","undefined","day","timezone","counts","health","daily","sent","bounces","replies","tests","Math","max","Number","update","updatedAtMs","warn"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/step-test-routes.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 { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport type { ComposedOutreachEmail } from '../engine/compose'\nimport { hasOutreachValidationErrors, validateOutreachSteps } from '../engine/sequence-validation'\nimport { mailboxRef } from '../mailboxes/mailbox-credentials'\nimport { outreachLocalDay } from '../mailboxes/mailbox-settings'\nimport {\n markOutreachMailboxReconnectRequired,\n type OpenedOutreachMailbox,\n} from '../mailboxes/mailbox-transport'\nimport type { OutreachStepTestResponse } from '../model/outreach-api'\nimport { OUTREACH_COLLECTIONS, type OutreachMailbox } from '../model/outreach.types'\nimport { previewOutreachStep } from '../model/step-preview'\nimport {\n newOutreachLinkId,\n OUTREACH_TEST_LINK_ENROLLMENT,\n outreachStoredLink,\n outreachStoredOpen,\n type OutreachStoredLink,\n type OutreachStoredOpen,\n} from '../runtime/click-link'\nimport { readStoredOutreachMailbox } from '../storage/outreach-records'\nimport { GmailTransportError, googleFailureAnswer } from '../transport/gmail-errors'\nimport { Rfc5322MessageError } from '../transport/rfc5322'\nimport { sendComposedOutreachEmail } from '../transport/send-message'\nimport { readOutreachStepRender } from './preview-routes'\nimport type { OutreachRouteDeps } from './route-deps'\nimport { outreachMethodNotAllowed, outreachOk, outreachRefusal, readOutreachJsonBody } from './route-http'\n\n/**\n * A TEST OF ONE STEP, IN A REAL INBOX (AGL-3325): `outreach/steps/test`.\n *\n * The Mailboxes page's test proves a mailbox can send; this proves what a\n * step LOOKS like once it has been through Gmail. It is the preview's email\n * — the same reader, the same person or sample person, the same personal\n * line, the same footer — sent through the sequence's mailbox to the member\n * who asked, or to an address they typed, with `[Test] ` in front of the\n * subject so it never reads as real mail.\n *\n * ## Rendered as a send, sent as a test\n *\n * A later step in a thread is written as `Re:` the thread's subject, as the\n * preview writes it, but sent as a new message: there is no thread to\n * answer, and the preview's placeholder message id must not leave the\n * server. When the sequence counts clicks, every link is rewritten to a\n * short link as a real send's would be, so the member sees the link their\n * recipients will see — but each is stored as a TEST link, which the click\n * route follows and never records (`click-link.ts`). A member clicking their\n * own test must not move the sequence's `clicks` or `machineClicks`.\n *\n * Merge fields the person has nothing for are sent empty and named in the\n * answer, where a real send would pause: the point of a test is to see the\n * gap, not to be refused it.\n *\n * ## Counted on nothing but the mailbox's tests\n *\n * A test is a real Gmail send and spends the account's own limits. It is\n * not a person emailed: it moves neither the sequence's `stats` nor the\n * mailbox's `health.daily.<day>.sent`, it reserves nothing of the daily cap,\n * it files nothing on anyone's record, and it enrolls nobody. It is tallied\n * on `health.daily.<day>.tests`, which the Mailboxes card shows as \"Tests\n * today\", and held to {@link OUTREACH_STEP_TESTS_PER_HOUR} a mailbox.\n *\n * ## Who, and from what\n *\n * The mailbox's own member, or an organization owner or admin — the people\n * who may edit and activate the sequence. A paused mailbox, or one waiting\n * to be reconnected, refuses and says which; a member who cannot send is\n * told where to fix it rather than shown a Google error.\n */\n\n/** Tests one mailbox may send in an hour: a few steps for a few people. */\nexport const OUTREACH_STEP_TESTS_PER_HOUR = 20\n\n/** What every test's subject starts with. */\nexport const OUTREACH_TEST_SUBJECT_PREFIX = '[Test] '\n\n/** The route's reach beyond the shared route deps: the mailbox's Gmail, the limit, the links. */\nexport interface OutreachStepTestDeps {\n /** Opens a mailbox's Gmail client from its sealed grant. */\n openMailbox(mailboxId: string): Promise<OpenedOutreachMailbox>\n consumeRateLimit(key: string, options: { limit: number; windowMs: number }): Promise<{ allowed: boolean }>\n /** The short tracking link for a stored link's id, or `null` when none can be made. */\n clickLinkUrl(linkId: string, trackingOrigin?: string | null): string | null\n /** The click-tracking origin for mail sent as `senderAddress` (AGL-3306), or `null` for the console's. */\n clickLinkOrigin?(input: { orgId: string; senderAddress: string }): Promise<string | null>\n}\n\n/** What the route answers: the response the console reads, and the fields the test sent empty. */\nexport interface OutreachStepTestAnswer extends OutreachStepTestResponse {\n unresolvedFields: string[]\n}\n\nconst RECONNECT_SENTENCE =\n \"This sequence's mailbox needs to be connected again before it can send. Reconnect it on the Mailboxes page.\"\n\n/** Why the mailbox cannot send a test now, in a sentence, or `null`. */\nfunction mailboxRefusal(mailbox: OutreachMailbox | null): string | null {\n if (!mailbox) return 'This sequence has no mailbox. Choose one and save before sending a test.'\n if (mailbox.status === 'paused') {\n return \"This sequence's mailbox is paused. Resume it on the Mailboxes page before sending a test.\"\n }\n if (mailbox.status === 'reconnect_required' || mailbox.status === 'disconnected') return RECONNECT_SENTENCE\n return null\n}\n\nexport function createOutreachStepTestRoute(\n deps: OutreachRouteDeps,\n testDeps: OutreachStepTestDeps,\n): PluginWebApiHandler {\n return async (request) => {\n if (request.method !== 'POST') return outreachMethodNotAllowed('POST')\n const body = await readOutreachJsonBody(request)\n // The address the member typed, when they typed one — read before any\n // record, so a typo is answered before a Gmail call.\n const named = typeof body['to'] === 'string' ? body['to'].trim() : ''\n const typed = named ? normalizeContactEmail(named) : null\n if (named && !typed) {\n return outreachRefusal(400, 'invalid-request', `\"${named}\" isn't a valid email address.`)\n }\n const loaded = await readOutreachStepRender(deps, request, body)\n if (loaded instanceof Response) return loaded\n const { caller, sequence, stepIndex, mailbox } = loaded\n\n const refusedBy = mailboxRefusal(mailbox)\n if (refusedBy || !mailbox) return outreachRefusal(409, 'mailbox-unavailable', refusedBy ?? RECONNECT_SENTENCE)\n if (mailbox.connectedByUid !== caller.uid && !caller.isOrgAdmin) {\n return outreachRefusal(\n 403,\n 'permission',\n \"Only the member who connected this sequence's mailbox, or an organization owner or admin, can send a test from it.\",\n )\n }\n const to = typed ?? caller.email\n if (!to) return outreachRefusal(400, 'invalid-request', 'Type an address to send the test to.')\n // The rules every sequence email keeps, asked of the stored steps: a\n // sequence saved under an older rule is refused rather than sent broken.\n const issues = validateOutreachSteps(sequence.steps)\n if (hasOutreachValidationErrors(issues)) {\n return outreachRefusal(400, 'invalid-sequence', \"Fix the sequence's steps before sending a test.\", { issues })\n }\n const limited = await testDeps.consumeRateLimit(`outreach-step-test:${mailbox.id}`, {\n limit: OUTREACH_STEP_TESTS_PER_HOUR,\n windowMs: 60 * 60 * 1000,\n })\n if (!limited.allowed) {\n return outreachRefusal(\n 429,\n 'rate-limited',\n `A mailbox can send ${OUTREACH_STEP_TESTS_PER_HOUR} tests an hour. Try again later.`,\n )\n }\n\n const firestore = deps.firestore()\n const nowMs = deps.now()\n const sender = { address: mailbox.sendAs || mailbox.email, name: mailbox.displayName || null }\n /*\n * The links, as a real send would rewrite them (AGL-3239, AGL-3297) and\n * marked as a test's: the member sees what a recipient sees, and their\n * own click counts for nothing.\n */\n let linkOrigin: string | null = null\n if ((sequence.settings.trackClicks || sequence.settings.countOpens) && testDeps.clickLinkOrigin) {\n linkOrigin = await testDeps\n .clickLinkOrigin({ orgId: caller.orgId, senderAddress: sender.address })\n .catch(() => null)\n }\n const shortLinks: Array<{ id: string; doc: OutreachStoredLink }> = []\n const rewriteLink = sequence.settings.trackClicks\n ? (link: { url: string; index: number }) => {\n const doc = outreachStoredLink(\n {\n orgId: caller.orgId,\n enrollmentId: OUTREACH_TEST_LINK_ENROLLMENT,\n stepIndex,\n linkIndex: link.index,\n url: link.url,\n test: true,\n },\n nowMs,\n )\n const id = newOutreachLinkId()\n const url = doc ? testDeps.clickLinkUrl(id, linkOrigin) : null\n if (!doc || !url) return null\n shortLinks.push({ id, doc: { ...doc, sequenceId: sequence.id } })\n return url\n }\n : null\n /*\n * The tracking image (AGL-3395), as a real send would carry it and\n * marked as a test's: the member sees both parts a recipient gets, and\n * their own open is recorded nowhere.\n */\n let pixel: { id: string; doc: OutreachStoredOpen; url: string } | null = null\n if (sequence.settings.countOpens) {\n const doc = outreachStoredOpen(\n { orgId: caller.orgId, enrollmentId: OUTREACH_TEST_LINK_ENROLLMENT, stepIndex, test: true },\n nowMs,\n )\n const id = newOutreachLinkId()\n const url = doc ? testDeps.clickLinkUrl(id, linkOrigin) : null\n if (doc && url) pixel = { id, doc: { ...doc, sequenceId: sequence.id }, url }\n }\n let composed = previewOutreachStep({ ...loaded.input, rewriteLink, openPixelUrl: pixel?.url ?? null })\n if (composed.error || !composed.email) {\n return outreachRefusal(409, 'invalid-sequence', composed.error?.message ?? \"This email can't be written yet.\")\n }\n if (shortLinks.length || pixel) {\n try {\n const batch = firestore.batch()\n for (const link of shortLinks) {\n batch.create(firestore.collection(OUTREACH_COLLECTIONS.links).doc(link.id), link.doc)\n }\n if (pixel) batch.create(firestore.collection(OUTREACH_COLLECTIONS.links).doc(pixel.id), pixel.doc)\n await batch.commit()\n } catch (error) {\n // A link that resolves nowhere is worse than one shown as written:\n // the test goes out with the step's own links, as plain text.\n console.error('[outreach] the test links could not be stored; the test carries the links as written', error)\n composed = previewOutreachStep({ ...loaded.input, rewriteLink: null })\n if (composed.error || !composed.email) {\n return outreachRefusal(409, 'invalid-sequence', composed.error?.message ?? \"This email can't be written yet.\")\n }\n }\n }\n\n const opened = await testDeps.openMailbox(mailbox.id)\n if (opened.ok === false) {\n if (opened.reason === 'not-configured') {\n return outreachRefusal(503, 'mailbox-unavailable', \"Google mailboxes aren't set up on this deployment.\")\n }\n await markOutreachMailboxReconnectRequired(firestore, {\n orgId: caller.orgId,\n mailboxId: mailbox.id,\n errorCode: opened.reason,\n nowMs,\n })\n return outreachRefusal(409, 'mailbox-unavailable', RECONNECT_SENTENCE)\n }\n /*\n * The preview's email, addressed to the member: `[Test]` in front, and a\n * `Re:` step sent as a new message — no thread id, no `In-Reply-To`, no\n * `References` — since the thread it answers exists only on screen. The\n * unsubscribe header is left off: it names an enrollment, and there is\n * none.\n */\n const email: ComposedOutreachEmail = {\n to,\n subject: `${OUTREACH_TEST_SUBJECT_PREFIX}${composed.email.subject}`,\n text: composed.email.text,\n ...(composed.email.html ? { html: composed.email.html } : {}),\n trackedLinks: composed.email.trackedLinks,\n }\n let subject: string\n try {\n subject = (await sendComposedOutreachEmail(opened.client, email, sender)).subject\n } catch (error) {\n if (error instanceof GmailTransportError && error.reconnectRequired) {\n await markOutreachMailboxReconnectRequired(firestore, {\n orgId: caller.orgId,\n mailboxId: mailbox.id,\n errorCode: error.code,\n nowMs,\n })\n return outreachRefusal(409, 'mailbox-unavailable', 'Google refused this mailbox’s access. Connect it again.')\n }\n if (error instanceof Rfc5322MessageError) {\n return outreachRefusal(400, 'invalid-request', `The test could not be written: ${error.message}`)\n }\n const answer = googleFailureAnswer(error, 'step test send')\n return outreachRefusal(answer.status, answer.reason, answer.error)\n }\n\n const testsToday = await countTest(firestore, caller.orgId, mailbox, nowMs)\n return outreachOk({\n ok: true,\n stepIndex,\n sentTo: to,\n subject,\n sentAtMs: nowMs,\n testsToday,\n unresolvedFields: composed.unresolvedFields,\n } satisfies OutreachStepTestAnswer)\n }\n}\n\n/**\n * One more test on the mailbox's local day, in `health.daily.<day>.tests`\n * beside the day's sends and never in them. Bookkeeping beside the act: the\n * email has left, so a failure is logged and answered as no count.\n */\nasync function countTest(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n mailbox: OutreachMailbox,\n nowMs: number,\n): Promise<number> {\n const ref = mailboxRef(firestore, orgId, mailbox.id)\n try {\n return await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const stored = readStoredOutreachMailbox(mailbox.id, snapshot.exists ? snapshot.data() : undefined)\n if (!stored) return 0\n const day = outreachLocalDay(nowMs, stored.timezone)\n const counts = stored.health?.daily?.[day] ?? { sent: 0, bounces: 0, replies: 0 }\n const tests = Math.max(0, Number(counts.tests) || 0) + 1\n transaction.update(ref, { [`health.daily.${day}`]: { ...counts, tests }, updatedAtMs: nowMs })\n return tests\n })\n } catch (error) {\n console.warn('[outreach] a test send could not be counted on its mailbox', error)\n return 0\n }\n}\n"],"names":["normalizeContactEmail","hasOutreachValidationErrors","validateOutreachSteps","mailboxRef","outreachLocalDay","markOutreachMailboxReconnectRequired","OUTREACH_COLLECTIONS","previewOutreachStep","newOutreachLinkId","OUTREACH_TEST_LINK_ENROLLMENT","outreachStoredLink","outreachStoredOpen","readStoredOutreachMailbox","GmailTransportError","googleFailureAnswer","Rfc5322MessageError","sendComposedOutreachEmail","readOutreachStepRender","outreachMethodNotAllowed","outreachOk","outreachRefusal","readOutreachJsonBody","OUTREACH_STEP_TESTS_PER_HOUR","OUTREACH_TEST_SUBJECT_PREFIX","RECONNECT_SENTENCE","mailboxRefusal","mailbox","status","createOutreachStepTestRoute","deps","testDeps","request","method","body","named","trim","typed","loaded","Response","caller","sequence","stepIndex","refusedBy","connectedByUid","uid","isOrgAdmin","to","email","issues","steps","limited","consumeRateLimit","id","limit","windowMs","allowed","firestore","nowMs","now","sender","address","sendAs","name","displayName","linkOrigin","settings","trackClicks","countOpens","clickLinkOrigin","orgId","senderAddress","catch","shortLinks","rewriteLink","link","doc","enrollmentId","linkIndex","index","url","test","clickLinkUrl","push","sequenceId","pixel","composed","input","openPixelUrl","error","message","length","batch","create","collection","links","commit","console","opened","openMailbox","ok","reason","mailboxId","errorCode","subject","text","html","trackedLinks","client","reconnectRequired","code","answer","testsToday","countTest","sentTo","sentAtMs","unresolvedFields","ref","runTransaction","transaction","stored","snapshot","get","exists","data","undefined","day","timezone","counts","health","daily","sent","bounces","replies","tests","Math","max","Number","update","updatedAtMs","warn"],"mappings":"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"}
|
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
/**
|
|
18
|
+
* The CRM's `contact`, `lead` and `company` record routes (AGL-3080), stood in
|
|
19
|
+
* for this plugin's specs, which may not load the CRM. They answer what the
|
|
20
|
+
* CRM publishes — a record's page under the site's hub or the organization's,
|
|
21
|
+
* and the Contacts list opened on one address — and what it publishes is held
|
|
22
|
+
* in its own `crm-record-routes.spec.ts`. Registered under the owner's id,
|
|
23
|
+
* `crm`, so a spec asking what happens where it is not loaded unregisters it.
|
|
24
|
+
*/
|
|
25
|
+
export declare function standInCrmRecordRoutes(): void;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { registerPluginRecordRoute } from "@aglyn/aglyn/plugin-manager/plugin-record-routes";
|
|
18
|
+
/**
|
|
19
|
+
* The CRM's `contact`, `lead` and `company` record routes (AGL-3080), stood in
|
|
20
|
+
* for this plugin's specs, which may not load the CRM. They answer what the
|
|
21
|
+
* CRM publishes — a record's page under the site's hub or the organization's,
|
|
22
|
+
* and the Contacts list opened on one address — and what it publishes is held
|
|
23
|
+
* in its own `crm-record-routes.spec.ts`. Registered under the owner's id,
|
|
24
|
+
* `crm`, so a spec asking what happens where it is not loaded unregisters it.
|
|
25
|
+
*/ export function standInCrmRecordRoutes() {
|
|
26
|
+
const hub = ({ orgSlug, host })=>host ? `/${orgSlug}/hosts/${host}/crm` : `/${orgSlug}/crm`;
|
|
27
|
+
const sectionRoute = (section)=>({
|
|
28
|
+
list: (context)=>`${hub(context)}/${section}`,
|
|
29
|
+
record: (context, id)=>`${hub(context)}/${section}/${encodeURIComponent(id)}`
|
|
30
|
+
});
|
|
31
|
+
registerPluginRecordRoute('contact', _extends({}, sectionRoute('contacts'), {
|
|
32
|
+
byEmail: (context, email)=>`${hub(context)}/contacts?${new URLSearchParams({
|
|
33
|
+
email
|
|
34
|
+
}).toString()}`
|
|
35
|
+
}), {
|
|
36
|
+
pluginId: 'crm'
|
|
37
|
+
});
|
|
38
|
+
registerPluginRecordRoute('lead', sectionRoute('leads'), {
|
|
39
|
+
pluginId: 'crm'
|
|
40
|
+
});
|
|
41
|
+
registerPluginRecordRoute('company', sectionRoute('companies'), {
|
|
42
|
+
pluginId: 'crm'
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
//# sourceMappingURL=stand-in-crm-record-routes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/testing/stand-in-crm-record-routes.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 registerPluginRecordRoute,\n type PluginRecordRoute,\n type PluginRecordRouteContext,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\n\n/**\n * The CRM's `contact`, `lead` and `company` record routes (AGL-3080), stood in\n * for this plugin's specs, which may not load the CRM. They answer what the\n * CRM publishes — a record's page under the site's hub or the organization's,\n * and the Contacts list opened on one address — and what it publishes is held\n * in its own `crm-record-routes.spec.ts`. Registered under the owner's id,\n * `crm`, so a spec asking what happens where it is not loaded unregisters it.\n */\nexport function standInCrmRecordRoutes(): void {\n const hub = ({ orgSlug, host }: PluginRecordRouteContext) =>\n host ? `/${orgSlug}/hosts/${host}/crm` : `/${orgSlug}/crm`\n const sectionRoute = (section: string): PluginRecordRoute => ({\n list: (context) => `${hub(context)}/${section}`,\n record: (context, id) => `${hub(context)}/${section}/${encodeURIComponent(id)}`,\n })\n registerPluginRecordRoute(\n 'contact',\n {\n ...sectionRoute('contacts'),\n byEmail: (context, email) =>\n `${hub(context)}/contacts?${new URLSearchParams({ email }).toString()}`,\n },\n { pluginId: 'crm' },\n )\n registerPluginRecordRoute('lead', sectionRoute('leads'), { pluginId: 'crm' })\n registerPluginRecordRoute('company', sectionRoute('companies'), { pluginId: 'crm' })\n}\n"],"names":["registerPluginRecordRoute","standInCrmRecordRoutes","hub","orgSlug","host","sectionRoute","section","list","context","record","id","encodeURIComponent","byEmail","email","URLSearchParams","toString","pluginId"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,yBAAyB,QAGpB,mDAAkD;AAEzD;;;;;;;CAOC,GACD,OAAO,SAASC;IACd,MAAMC,MAAM,CAAC,EAAEC,OAAO,EAAEC,IAAI,EAA4B,GACtDA,OAAO,CAAC,CAAC,EAAED,QAAQ,OAAO,EAAEC,KAAK,IAAI,CAAC,GAAG,CAAC,CAAC,EAAED,QAAQ,IAAI,CAAC;IAC5D,MAAME,eAAe,CAACC,UAAwC,CAAA;YAC5DC,MAAM,CAACC,UAAY,GAAGN,IAAIM,SAAS,CAAC,EAAEF,SAAS;YAC/CG,QAAQ,CAACD,SAASE,KAAO,GAAGR,IAAIM,SAAS,CAAC,EAAEF,QAAQ,CAAC,EAAEK,mBAAmBD,KAAK;QACjF,CAAA;IACAV,0BACE,WACA,aACKK,aAAa;QAChBO,SAAS,CAACJ,SAASK,QACjB,GAAGX,IAAIM,SAAS,UAAU,EAAE,IAAIM,gBAAgB;gBAAED;YAAM,GAAGE,QAAQ,IAAI;QAE3E;QAAEC,UAAU;IAAM;IAEpBhB,0BAA0B,QAAQK,aAAa,UAAU;QAAEW,UAAU;IAAM;IAC3EhB,0BAA0B,WAAWK,aAAa,cAAc;QAAEW,UAAU;IAAM;AACpF"}
|
|
@@ -82,3 +82,30 @@ export declare function isRateLimitBody(body: unknown): boolean;
|
|
|
82
82
|
export declare function gmailApiError(status: number, body: unknown, init?: {
|
|
83
83
|
retryAfterMs?: number | null;
|
|
84
84
|
}): GmailTransportError;
|
|
85
|
+
/** The refusal reasons a route answers a Google failure with. */
|
|
86
|
+
export type GoogleFailureReason = 'mail-service-unavailable' | 'google-refused' | 'rate-limited' | 'google-unavailable';
|
|
87
|
+
/** How a route answers one Google failure: its status, its reason and the sentence a person reads. */
|
|
88
|
+
export interface GoogleFailureAnswer {
|
|
89
|
+
status: number;
|
|
90
|
+
reason: GoogleFailureReason;
|
|
91
|
+
error: string;
|
|
92
|
+
}
|
|
93
|
+
/** What a person reads when the Google account behind a mailbox has no Gmail. */
|
|
94
|
+
export declare const OUTREACH_MAIL_SERVICE_UNAVAILABLE_MESSAGE: string;
|
|
95
|
+
/**
|
|
96
|
+
* A Google failure a route could not handle itself, answered by whose failure
|
|
97
|
+
* it is. Every route that talks to Google on a person's behalf answers
|
|
98
|
+
* through this one decision.
|
|
99
|
+
*
|
|
100
|
+
* Only an answer Google did not give — an outage, a dropped connection, a
|
|
101
|
+
* body that could not be read — or a request Google called malformed (which
|
|
102
|
+
* is this code's to fix) is a 5xx. A refusal about the ACCOUNT is the
|
|
103
|
+
* person's to act on: an account with no Gmail, one an administrator has
|
|
104
|
+
* barred from the Gmail API, or one Google is rate-limiting. Those are 4xx
|
|
105
|
+
* with a sentence that says what to do, so they do not page as an outage.
|
|
106
|
+
*
|
|
107
|
+
* Every failure is logged with Google's own code, status and reason, which is
|
|
108
|
+
* the one place an operator can read what Google actually said. A value that
|
|
109
|
+
* is not a {@link GmailTransportError} is rethrown: it is not Google's.
|
|
110
|
+
*/
|
|
111
|
+
export declare function googleFailureAnswer(error: unknown, route: string): GoogleFailureAnswer;
|
|
@@ -134,5 +134,61 @@ export class GmailTransportError extends Error {
|
|
|
134
134
|
if (status === 403) return make('forbidden', `Gmail refused the request${detail}`);
|
|
135
135
|
return make('unexpected', `Gmail answered ${status}${detail}`);
|
|
136
136
|
}
|
|
137
|
+
/** What a person reads when the Google account behind a mailbox has no Gmail. */ export const OUTREACH_MAIL_SERVICE_UNAVAILABLE_MESSAGE = 'This Google account does not have Gmail turned on yet. A new Google Workspace user can take up to a day ' + 'before Gmail is ready; otherwise ask your Workspace administrator to turn Gmail on for the account. ' + 'Then try again.';
|
|
138
|
+
/**
|
|
139
|
+
* A Google failure a route could not handle itself, answered by whose failure
|
|
140
|
+
* it is. Every route that talks to Google on a person's behalf answers
|
|
141
|
+
* through this one decision.
|
|
142
|
+
*
|
|
143
|
+
* Only an answer Google did not give — an outage, a dropped connection, a
|
|
144
|
+
* body that could not be read — or a request Google called malformed (which
|
|
145
|
+
* is this code's to fix) is a 5xx. A refusal about the ACCOUNT is the
|
|
146
|
+
* person's to act on: an account with no Gmail, one an administrator has
|
|
147
|
+
* barred from the Gmail API, or one Google is rate-limiting. Those are 4xx
|
|
148
|
+
* with a sentence that says what to do, so they do not page as an outage.
|
|
149
|
+
*
|
|
150
|
+
* Every failure is logged with Google's own code, status and reason, which is
|
|
151
|
+
* the one place an operator can read what Google actually said. A value that
|
|
152
|
+
* is not a {@link GmailTransportError} is rethrown: it is not Google's.
|
|
153
|
+
*/ export function googleFailureAnswer(error, route) {
|
|
154
|
+
if (!(error instanceof GmailTransportError)) throw error;
|
|
155
|
+
const detail = {
|
|
156
|
+
code: error.code,
|
|
157
|
+
status: error.status,
|
|
158
|
+
providerReason: error.providerReason,
|
|
159
|
+
message: error.message
|
|
160
|
+
};
|
|
161
|
+
switch(error.code){
|
|
162
|
+
case 'mail_service_unavailable':
|
|
163
|
+
console.warn(`[outreach] ${route}: the Google account has no Gmail service`, detail);
|
|
164
|
+
return {
|
|
165
|
+
status: 422,
|
|
166
|
+
reason: 'mail-service-unavailable',
|
|
167
|
+
error: OUTREACH_MAIL_SERVICE_UNAVAILABLE_MESSAGE
|
|
168
|
+
};
|
|
169
|
+
case 'forbidden':
|
|
170
|
+
console.warn(`[outreach] ${route}: Google refused the account`, detail);
|
|
171
|
+
return {
|
|
172
|
+
status: 422,
|
|
173
|
+
reason: 'google-refused',
|
|
174
|
+
error: `Google refused this account's request: ${error.message} ` + 'If it is a Google Workspace account, ask its administrator whether Gmail API access is allowed for it.'
|
|
175
|
+
};
|
|
176
|
+
case 'rate_limited':
|
|
177
|
+
case 'quota_exceeded':
|
|
178
|
+
console.warn(`[outreach] ${route}: Google is limiting the account`, detail);
|
|
179
|
+
return {
|
|
180
|
+
status: 429,
|
|
181
|
+
reason: 'rate-limited',
|
|
182
|
+
error: error.code === 'quota_exceeded' ? 'This Google account has used its Gmail quota for today. Try again tomorrow.' : 'Google is rate-limiting this account. Try again in a few minutes.'
|
|
183
|
+
};
|
|
184
|
+
default:
|
|
185
|
+
console.error(`[outreach] ${route}: Google call failed`, detail);
|
|
186
|
+
return {
|
|
187
|
+
status: 502,
|
|
188
|
+
reason: 'google-unavailable',
|
|
189
|
+
error: error.retryable ? 'Google did not answer. Try again in a moment.' : `Google refused the request: ${error.message}`
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
137
193
|
|
|
138
194
|
//# sourceMappingURL=gmail-errors.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/transport/gmail-errors.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\n/**\n * WHAT A GOOGLE CALL CAN FAIL WITH, AS OUTREACH ACTS ON IT (AGL-2978).\n *\n * Google answers in two dialects — the OAuth token endpoint's\n * `{ error: 'invalid_grant' }` and the Gmail API's\n * `{ error: { code, status, errors: [{ reason }] } }` — and a caller should\n * not have to speak either. Every failure the transport raises is one\n * {@link GmailTransportError} carrying the three decisions a caller makes:\n *\n * - `reconnectRequired` — the grant itself is gone (revoked, expired, a\n * password change, a scope removed). Nothing will work until the rep\n * connects again, so the mailbox goes to `reconnect_required`.\n * - `retryable` — the request may succeed later as it stands (a rate limit,\n * an outage, a dropped connection). `retryAfterMs` says when, if Google\n * said.\n * - neither — the request itself is wrong, or the deployment is (a bad\n * client id or secret), and retrying it changes nothing.\n */\n\nexport type GmailTransportErrorCode =\n /** The refresh token was refused: revoked, expired or otherwise dead. */\n | 'invalid_grant'\n /** The grant is missing a scope the call needs. */\n | 'insufficient_scope'\n /** The OAuth client id or secret was refused — a deployment problem. */\n | 'client_misconfigured'\n /** Rate-limited, still after backing off. */\n | 'rate_limited'\n /** The account's sending or API quota for the day is spent. */\n | 'quota_exceeded'\n /** Google answered 5xx, still after backing off. */\n | 'unavailable'\n /** The request never got an answer: a network failure or a timeout. */\n | 'network'\n /** The access token was refused even after a fresh one was minted. */\n | 'unauthorized'\n /** The account has no Gmail service (a Workspace user without Gmail). */\n | 'mail_service_unavailable'\n | 'not_found'\n /** Google refused the request as malformed. */\n | 'invalid_request'\n | 'forbidden'\n /** An answer the transport could not read. */\n | 'unexpected'\n\nexport interface GmailTransportErrorInit {\n status?: number | null\n retryAfterMs?: number | null\n /** The provider's own reason string, kept for logs and health rows. */\n providerReason?: string | null\n}\n\nconst RECONNECT: ReadonlySet<GmailTransportErrorCode> = new Set([\n 'invalid_grant',\n 'insufficient_scope',\n])\n\nconst RETRYABLE: ReadonlySet<GmailTransportErrorCode> = new Set([\n 'rate_limited',\n 'unavailable',\n 'network',\n])\n\nexport class GmailTransportError extends Error {\n readonly code: GmailTransportErrorCode\n readonly status: number | null\n readonly retryAfterMs: number | null\n readonly providerReason: string | null\n\n constructor(code: GmailTransportErrorCode, message: string, init: GmailTransportErrorInit = {}) {\n super(message)\n this.name = 'GmailTransportError'\n this.code = code\n this.status = init.status ?? null\n this.retryAfterMs = init.retryAfterMs ?? null\n this.providerReason = init.providerReason ?? null\n }\n\n /** The grant is gone; the mailbox needs connecting again. */\n get reconnectRequired(): boolean {\n return RECONNECT.has(this.code)\n }\n\n /** The same request may succeed later. */\n get retryable(): boolean {\n return RETRYABLE.has(this.code)\n }\n}\n\n/** Whether a thrown value says the mailbox's grant is gone. */\nexport function isReconnectRequired(error: unknown): boolean {\n return error instanceof GmailTransportError && error.reconnectRequired\n}\n\n/** The OAuth token endpoint's error body: `{ error, error_description }`. */\nexport function tokenEndpointError(status: number, body: unknown): GmailTransportError {\n const record = (body ?? {}) as { error?: unknown; error_description?: unknown }\n const reason = typeof record.error === 'string' ? record.error : null\n const described = typeof record.error_description === 'string' ? record.error_description : ''\n if (reason === 'invalid_grant') {\n return new GmailTransportError(\n 'invalid_grant',\n 'Google refused the mailbox grant. The mailbox must be connected again.',\n { status, providerReason: reason },\n )\n }\n if (reason === 'invalid_client' || reason === 'unauthorized_client') {\n return new GmailTransportError(\n 'client_misconfigured',\n 'Google refused this deployment’s OAuth client. Check its client id and secret.',\n { status, providerReason: reason },\n )\n }\n if (reason === 'invalid_scope') {\n return new GmailTransportError('insufficient_scope', 'Google refused a requested scope.', {\n status,\n providerReason: reason,\n })\n }\n return new GmailTransportError(\n status >= 500 ? 'unavailable' : 'invalid_request',\n `Google’s token endpoint refused the request${described ? `: ${described.slice(0, 200)}` : '.'}`,\n { status, providerReason: reason },\n )\n}\n\ninterface GoogleApiErrorBody {\n error?: {\n code?: number\n message?: string\n status?: string\n errors?: Array<{ reason?: string; message?: string }>\n details?: Array<{ reason?: string }>\n }\n}\n\n/** Every reason string a Gmail API error body carries. */\nfunction apiReasons(body: unknown): string[] {\n const error = (body as GoogleApiErrorBody | null)?.error\n return [\n ...(error?.errors ?? []).map((entry) => entry?.reason),\n ...(error?.details ?? []).map((entry) => entry?.reason),\n error?.status,\n ].filter((reason): reason is string => typeof reason === 'string' && reason.length > 0)\n}\n\n/** Whether a Gmail API 403 is a rate limit, which backs off like a 429. */\nexport function isRateLimitBody(body: unknown): boolean {\n return apiReasons(body).some((reason) =>\n ['rateLimitExceeded', 'userRateLimitExceeded', 'RESOURCE_EXHAUSTED'].includes(reason),\n )\n}\n\n/** A Gmail API error response, mapped. */\nexport function gmailApiError(\n status: number,\n body: unknown,\n init: { retryAfterMs?: number | null } = {},\n): GmailTransportError {\n const reasons = apiReasons(body)\n const providerReason = reasons[0] ?? null\n const has = (...names: string[]) => reasons.some((reason) => names.includes(reason))\n const message = (body as GoogleApiErrorBody | null)?.error?.message\n const detail = typeof message === 'string' && message ? `: ${message.slice(0, 200)}` : '.'\n const make = (code: GmailTransportErrorCode, text: string) =>\n new GmailTransportError(code, text, { status, providerReason, retryAfterMs: init.retryAfterMs })\n\n if (status === 429 || (status === 403 && isRateLimitBody(body))) {\n return make('rate_limited', 'Gmail is rate-limiting this mailbox. Try again later.')\n }\n if (has('dailyLimitExceeded', 'quotaExceeded')) {\n return make('quota_exceeded', 'This mailbox has spent its Gmail quota for the day.')\n }\n if (has('insufficientPermissions', 'ACCESS_TOKEN_SCOPE_INSUFFICIENT')) {\n return make('insufficient_scope', 'The mailbox grant is missing a permission Sequences needs.')\n }\n if (status >= 500) return make('unavailable', `Gmail is unavailable${detail}`)\n if (status === 401) return make('unauthorized', 'Gmail refused the mailbox’s access token.')\n if (status === 400 && has('failedPrecondition') && /mail service not enabled/i.test(String(message))) {\n return make('mail_service_unavailable', 'This Google account has no Gmail service.')\n }\n if (status === 404) return make('not_found', `Gmail found nothing there${detail}`)\n if (status === 400) return make('invalid_request', `Gmail refused the request${detail}`)\n if (status === 403) return make('forbidden', `Gmail refused the request${detail}`)\n return make('unexpected', `Gmail answered ${status}${detail}`)\n}\n"],"names":["RECONNECT","Set","RETRYABLE","GmailTransportError","Error","reconnectRequired","has","code","retryable","message","init","name","status","retryAfterMs","providerReason","isReconnectRequired","error","tokenEndpointError","body","record","reason","described","error_description","slice","apiReasons","errors","map","entry","details","filter","length","isRateLimitBody","some","includes","gmailApiError","reasons","names","detail","make","text","test","String"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/transport/gmail-errors.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\n/**\n * WHAT A GOOGLE CALL CAN FAIL WITH, AS OUTREACH ACTS ON IT (AGL-2978).\n *\n * Google answers in two dialects — the OAuth token endpoint's\n * `{ error: 'invalid_grant' }` and the Gmail API's\n * `{ error: { code, status, errors: [{ reason }] } }` — and a caller should\n * not have to speak either. Every failure the transport raises is one\n * {@link GmailTransportError} carrying the three decisions a caller makes:\n *\n * - `reconnectRequired` — the grant itself is gone (revoked, expired, a\n * password change, a scope removed). Nothing will work until the rep\n * connects again, so the mailbox goes to `reconnect_required`.\n * - `retryable` — the request may succeed later as it stands (a rate limit,\n * an outage, a dropped connection). `retryAfterMs` says when, if Google\n * said.\n * - neither — the request itself is wrong, or the deployment is (a bad\n * client id or secret), and retrying it changes nothing.\n */\n\nexport type GmailTransportErrorCode =\n /** The refresh token was refused: revoked, expired or otherwise dead. */\n | 'invalid_grant'\n /** The grant is missing a scope the call needs. */\n | 'insufficient_scope'\n /** The OAuth client id or secret was refused — a deployment problem. */\n | 'client_misconfigured'\n /** Rate-limited, still after backing off. */\n | 'rate_limited'\n /** The account's sending or API quota for the day is spent. */\n | 'quota_exceeded'\n /** Google answered 5xx, still after backing off. */\n | 'unavailable'\n /** The request never got an answer: a network failure or a timeout. */\n | 'network'\n /** The access token was refused even after a fresh one was minted. */\n | 'unauthorized'\n /** The account has no Gmail service (a Workspace user without Gmail). */\n | 'mail_service_unavailable'\n | 'not_found'\n /** Google refused the request as malformed. */\n | 'invalid_request'\n | 'forbidden'\n /** An answer the transport could not read. */\n | 'unexpected'\n\nexport interface GmailTransportErrorInit {\n status?: number | null\n retryAfterMs?: number | null\n /** The provider's own reason string, kept for logs and health rows. */\n providerReason?: string | null\n}\n\nconst RECONNECT: ReadonlySet<GmailTransportErrorCode> = new Set([\n 'invalid_grant',\n 'insufficient_scope',\n])\n\nconst RETRYABLE: ReadonlySet<GmailTransportErrorCode> = new Set([\n 'rate_limited',\n 'unavailable',\n 'network',\n])\n\nexport class GmailTransportError extends Error {\n readonly code: GmailTransportErrorCode\n readonly status: number | null\n readonly retryAfterMs: number | null\n readonly providerReason: string | null\n\n constructor(code: GmailTransportErrorCode, message: string, init: GmailTransportErrorInit = {}) {\n super(message)\n this.name = 'GmailTransportError'\n this.code = code\n this.status = init.status ?? null\n this.retryAfterMs = init.retryAfterMs ?? null\n this.providerReason = init.providerReason ?? null\n }\n\n /** The grant is gone; the mailbox needs connecting again. */\n get reconnectRequired(): boolean {\n return RECONNECT.has(this.code)\n }\n\n /** The same request may succeed later. */\n get retryable(): boolean {\n return RETRYABLE.has(this.code)\n }\n}\n\n/** Whether a thrown value says the mailbox's grant is gone. */\nexport function isReconnectRequired(error: unknown): boolean {\n return error instanceof GmailTransportError && error.reconnectRequired\n}\n\n/** The OAuth token endpoint's error body: `{ error, error_description }`. */\nexport function tokenEndpointError(status: number, body: unknown): GmailTransportError {\n const record = (body ?? {}) as { error?: unknown; error_description?: unknown }\n const reason = typeof record.error === 'string' ? record.error : null\n const described = typeof record.error_description === 'string' ? record.error_description : ''\n if (reason === 'invalid_grant') {\n return new GmailTransportError(\n 'invalid_grant',\n 'Google refused the mailbox grant. The mailbox must be connected again.',\n { status, providerReason: reason },\n )\n }\n if (reason === 'invalid_client' || reason === 'unauthorized_client') {\n return new GmailTransportError(\n 'client_misconfigured',\n 'Google refused this deployment’s OAuth client. Check its client id and secret.',\n { status, providerReason: reason },\n )\n }\n if (reason === 'invalid_scope') {\n return new GmailTransportError('insufficient_scope', 'Google refused a requested scope.', {\n status,\n providerReason: reason,\n })\n }\n return new GmailTransportError(\n status >= 500 ? 'unavailable' : 'invalid_request',\n `Google’s token endpoint refused the request${described ? `: ${described.slice(0, 200)}` : '.'}`,\n { status, providerReason: reason },\n )\n}\n\ninterface GoogleApiErrorBody {\n error?: {\n code?: number\n message?: string\n status?: string\n errors?: Array<{ reason?: string; message?: string }>\n details?: Array<{ reason?: string }>\n }\n}\n\n/** Every reason string a Gmail API error body carries. */\nfunction apiReasons(body: unknown): string[] {\n const error = (body as GoogleApiErrorBody | null)?.error\n return [\n ...(error?.errors ?? []).map((entry) => entry?.reason),\n ...(error?.details ?? []).map((entry) => entry?.reason),\n error?.status,\n ].filter((reason): reason is string => typeof reason === 'string' && reason.length > 0)\n}\n\n/** Whether a Gmail API 403 is a rate limit, which backs off like a 429. */\nexport function isRateLimitBody(body: unknown): boolean {\n return apiReasons(body).some((reason) =>\n ['rateLimitExceeded', 'userRateLimitExceeded', 'RESOURCE_EXHAUSTED'].includes(reason),\n )\n}\n\n/** A Gmail API error response, mapped. */\nexport function gmailApiError(\n status: number,\n body: unknown,\n init: { retryAfterMs?: number | null } = {},\n): GmailTransportError {\n const reasons = apiReasons(body)\n const providerReason = reasons[0] ?? null\n const has = (...names: string[]) => reasons.some((reason) => names.includes(reason))\n const message = (body as GoogleApiErrorBody | null)?.error?.message\n const detail = typeof message === 'string' && message ? `: ${message.slice(0, 200)}` : '.'\n const make = (code: GmailTransportErrorCode, text: string) =>\n new GmailTransportError(code, text, { status, providerReason, retryAfterMs: init.retryAfterMs })\n\n if (status === 429 || (status === 403 && isRateLimitBody(body))) {\n return make('rate_limited', 'Gmail is rate-limiting this mailbox. Try again later.')\n }\n if (has('dailyLimitExceeded', 'quotaExceeded')) {\n return make('quota_exceeded', 'This mailbox has spent its Gmail quota for the day.')\n }\n if (has('insufficientPermissions', 'ACCESS_TOKEN_SCOPE_INSUFFICIENT')) {\n return make('insufficient_scope', 'The mailbox grant is missing a permission Sequences needs.')\n }\n if (status >= 500) return make('unavailable', `Gmail is unavailable${detail}`)\n if (status === 401) return make('unauthorized', 'Gmail refused the mailbox’s access token.')\n if (status === 400 && has('failedPrecondition') && /mail service not enabled/i.test(String(message))) {\n return make('mail_service_unavailable', 'This Google account has no Gmail service.')\n }\n if (status === 404) return make('not_found', `Gmail found nothing there${detail}`)\n if (status === 400) return make('invalid_request', `Gmail refused the request${detail}`)\n if (status === 403) return make('forbidden', `Gmail refused the request${detail}`)\n return make('unexpected', `Gmail answered ${status}${detail}`)\n}\n\n/** The refusal reasons a route answers a Google failure with. */\nexport type GoogleFailureReason =\n | 'mail-service-unavailable'\n | 'google-refused'\n | 'rate-limited'\n | 'google-unavailable'\n\n/** How a route answers one Google failure: its status, its reason and the sentence a person reads. */\nexport interface GoogleFailureAnswer {\n status: number\n reason: GoogleFailureReason\n error: string\n}\n\n/** What a person reads when the Google account behind a mailbox has no Gmail. */\nexport const OUTREACH_MAIL_SERVICE_UNAVAILABLE_MESSAGE =\n 'This Google account does not have Gmail turned on yet. A new Google Workspace user can take up to a day ' +\n 'before Gmail is ready; otherwise ask your Workspace administrator to turn Gmail on for the account. ' +\n 'Then try again.'\n\n/**\n * A Google failure a route could not handle itself, answered by whose failure\n * it is. Every route that talks to Google on a person's behalf answers\n * through this one decision.\n *\n * Only an answer Google did not give — an outage, a dropped connection, a\n * body that could not be read — or a request Google called malformed (which\n * is this code's to fix) is a 5xx. A refusal about the ACCOUNT is the\n * person's to act on: an account with no Gmail, one an administrator has\n * barred from the Gmail API, or one Google is rate-limiting. Those are 4xx\n * with a sentence that says what to do, so they do not page as an outage.\n *\n * Every failure is logged with Google's own code, status and reason, which is\n * the one place an operator can read what Google actually said. A value that\n * is not a {@link GmailTransportError} is rethrown: it is not Google's.\n */\nexport function googleFailureAnswer(error: unknown, route: string): GoogleFailureAnswer {\n if (!(error instanceof GmailTransportError)) throw error\n const detail = {\n code: error.code,\n status: error.status,\n providerReason: error.providerReason,\n message: error.message,\n }\n switch (error.code) {\n case 'mail_service_unavailable':\n console.warn(`[outreach] ${route}: the Google account has no Gmail service`, detail)\n return { status: 422, reason: 'mail-service-unavailable', error: OUTREACH_MAIL_SERVICE_UNAVAILABLE_MESSAGE }\n case 'forbidden':\n console.warn(`[outreach] ${route}: Google refused the account`, detail)\n return {\n status: 422,\n reason: 'google-refused',\n error:\n `Google refused this account's request: ${error.message} ` +\n 'If it is a Google Workspace account, ask its administrator whether Gmail API access is allowed for it.',\n }\n case 'rate_limited':\n case 'quota_exceeded':\n console.warn(`[outreach] ${route}: Google is limiting the account`, detail)\n return {\n status: 429,\n reason: 'rate-limited',\n error:\n error.code === 'quota_exceeded'\n ? 'This Google account has used its Gmail quota for today. Try again tomorrow.'\n : 'Google is rate-limiting this account. Try again in a few minutes.',\n }\n default:\n console.error(`[outreach] ${route}: Google call failed`, detail)\n return {\n status: 502,\n reason: 'google-unavailable',\n error: error.retryable\n ? 'Google did not answer. Try again in a moment.'\n : `Google refused the request: ${error.message}`,\n }\n }\n}\n"],"names":["RECONNECT","Set","RETRYABLE","GmailTransportError","Error","reconnectRequired","has","code","retryable","message","init","name","status","retryAfterMs","providerReason","isReconnectRequired","error","tokenEndpointError","body","record","reason","described","error_description","slice","apiReasons","errors","map","entry","details","filter","length","isRateLimitBody","some","includes","gmailApiError","reasons","names","detail","make","text","test","String","OUTREACH_MAIL_SERVICE_UNAVAILABLE_MESSAGE","googleFailureAnswer","route","console","warn"],"mappings":"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"}
|