@aglyn/plugins-outreach 1.0.0-beta.150 → 1.0.0-beta.152
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 +12 -12
- package/src/lib/components/compliance-section.js +1 -1
- package/src/lib/components/compliance-section.js.map +1 -1
- package/src/lib/components/mailbox-card.d.ts +2 -0
- package/src/lib/components/mailbox-card.js +19 -3
- package/src/lib/components/mailbox-card.js.map +1 -1
- package/src/lib/components/use-outreach-mailbox-api.d.ts +2 -1
- package/src/lib/components/use-outreach-mailbox-api.js +5 -3
- package/src/lib/components/use-outreach-mailbox-api.js.map +1 -1
- package/src/lib/engine/compose.d.ts +7 -2
- package/src/lib/engine/compose.js +11 -5
- package/src/lib/engine/compose.js.map +1 -1
- package/src/lib/engine/opt-out-intent.js +1 -1
- package/src/lib/engine/opt-out-intent.js.map +1 -1
- package/src/lib/mailboxes/mailbox-api.d.ts +21 -2
- package/src/lib/mailboxes/mailbox-api.js.map +1 -1
- package/src/lib/mailboxes/mailbox-routes.js +41 -8
- package/src/lib/mailboxes/mailbox-routes.js.map +1 -1
- package/src/lib/mailboxes/oauth-redirect.d.ts +15 -4
- package/src/lib/mailboxes/oauth-redirect.js +15 -6
- package/src/lib/mailboxes/oauth-redirect.js.map +1 -1
- package/src/lib/model/outreach.types.d.ts +1 -1
- package/src/lib/model/outreach.types.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/compose.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 EXACTLY IS SENT (AGL-2979).\n *\n * The composer turns a step, an enrollment and the organization's settings\n * into the message the transport sends: the recipient, the subject, the\n * plain text, and the headers that thread it and let the recipient leave.\n * The transport adds the From line and the MIME; nothing here knows either.\n *\n * ## The footer is not optional\n *\n * Every email ends with the organization's legal name, its postal address,\n * a sentence saying the email is a business solicitation, and the way out —\n * reply \"no\". Those are what CAN-SPAM requires of a commercial email, and\n * the composer appends them itself, after the merge, as literal text: no\n * template can leave them off, and no merge field can rewrite them. With no\n * postal address or no legal name, it refuses to compose at all.\n *\n * ## Threading\n *\n * An email after the first is a reply in the thread unless its step says\n * otherwise: `Re:` and the thread's own subject, `In-Reply-To` the last\n * message sent into the thread, `References` every one of them, and the\n * provider thread to send into. A `Re:` on an email that answers nothing is\n * a deception, so a reply step with no thread to answer is refused rather\n * than sent with a borrowed one, and a subject that starts with `Re:` or\n * `Fwd:` on an email that starts a thread is refused too.\n *\n * ## Merge fields\n *\n * The CRM's own resolver fills them, from the documents the runtime already\n * read, and the enrollment's personal line joins them as\n * `{{enrollment.personalLine}}` through the resolver's `extra` fields.\n * Fields that render empty are returned beside the message, for the\n * runtime to decide whether \"Hi ,\" is worth sending.\n *==========================================*/\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { CRM_EMAIL_SUBJECT_MAX } from '@aglyn/aglyn/app-utils/crm'\nimport {\n type CrmMergeContext,\n resolveCrmMergeFields,\n} from '@aglyn/aglyn/app-utils/crm-email-templates'\nimport { crmThreadSubject } from '@aglyn/aglyn/app-utils/crm-inbound'\nimport type {\n OutreachEnrollment,\n OutreachOrgSettings,\n OutreachSequence,\n} from '../model/outreach.types'\nimport { normalizeOutreachPersonalLine } from './gates'\nimport {\n isInThreadEmailStep,\n OUTREACH_PERSONAL_LINE_FIELD,\n outreachSubjectHasReplyPrefix,\n} from './sequence-validation'\n\n/** The way out every email offers, word for word. */\nexport const OUTREACH_OPT_OUT_LINE = 'Not relevant? Reply \"no\" and I won\\'t email again.'\n\n/** The sentence that says what the email is. */\nexport function outreachSolicitationStatement(senderName: string): string {\n return `This is a business solicitation from ${senderName}.`\n}\n\n/**\n * The message the transport sends. Header values are single lines already;\n * the text uses `\\n` line breaks, which the transport writes as CRLF.\n */\nexport interface ComposedOutreachEmail {\n /** The recipient's address, normalized. */\n to: string\n subject: string\n /** The plain-text body, footer included. */\n text: string\n /** The provider thread a reply step is sent into. */\n threadId?: string\n /** The `Message-ID` this email answers, angle brackets included. */\n inReplyTo?: string\n /** Every `Message-ID` in the thread so far, oldest first, space-separated. */\n references?: string\n /** The one-click unsubscribe URL, for `List-Unsubscribe` with `List-Unsubscribe-Post`. */\n listUnsubscribeUrl?: string\n /** A `mailto:` URI for `List-Unsubscribe`. */\n listUnsubscribeMailto?: string\n}\n\nexport type OutreachComposeErrorCode =\n | 'missing_postal_address'\n | 'missing_legal_name'\n | 'not_an_email_step'\n | 'invalid_recipient'\n | 'missing_subject'\n | 'subject_reply_prefix'\n | 'missing_body'\n | 'missing_thread'\n | 'invalid_unsubscribe_url'\n | 'invalid_unsubscribe_mailto'\n\nexport interface OutreachComposeError {\n code: OutreachComposeErrorCode\n message: string\n}\n\nexport interface ComposeOutreachEmailInput {\n sequence: Pick<OutreachSequence, 'steps'>\n enrollment: Pick<\n OutreachEnrollment,\n 'email' | 'stepIndex' | 'personalLine' | 'threadSubject' | 'messageIds' | 'gmailThreadId'\n >\n orgSettings: Pick<OutreachOrgSettings, 'legalName' | 'brandName' | 'postalAddress'> | null | undefined\n /**\n * What the CRM resolver reads: the contact, the sending site's group id,\n * the sender (the mailbox's display name and address), the site. Its\n * `extra` may carry fields of the caller's own; the personal line is added\n * here.\n */\n merge: CrmMergeContext | null | undefined\n /** The CRM template's body, read by the caller, when the step names a template. */\n templateBody?: string | null\n /** The signed one-click unsubscribe URL, minted by the runtime. */\n listUnsubscribeUrl?: string | null\n /** An address, or a `mailto:` URI, that unsubscribes whoever writes to it. */\n listUnsubscribeMailto?: string | null\n}\n\nexport interface OutreachComposeResult {\n email: ComposedOutreachEmail | null\n error: OutreachComposeError | null\n /** Merge fields that rendered empty, unique, in order of appearance. */\n unresolvedFields: string[]\n}\n\nconst oneLine = (value: unknown): string =>\n typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n\n/**\n * The footer every email ends with, or the reason it cannot be written.\n *\n * The postal address may be stored over several lines; the footer prints it\n * on one, the parts separated by commas.\n */\nexport function composeOutreachFooter(\n settings: Pick<OutreachOrgSettings, 'legalName' | 'brandName' | 'postalAddress'> | null | undefined,\n): { footer: string | null; error: OutreachComposeError | null } {\n const postalAddress = String(settings?.postalAddress ?? '')\n .split(/\\r\\n|\\r|\\n/)\n .map((line) => oneLine(line).replace(/,+$/, ''))\n .filter(Boolean)\n .join(', ')\n if (!postalAddress) {\n return {\n footer: null,\n error: {\n code: 'missing_postal_address',\n message:\n \"This email can't be sent without your organization's postal address. Add it in Sequences settings.\",\n },\n }\n }\n const legalName = oneLine(settings?.legalName)\n if (!legalName) {\n return {\n footer: null,\n error: {\n code: 'missing_legal_name',\n message:\n \"This email can't be sent without your organization's legal name. Add it in Sequences settings.\",\n },\n }\n }\n const senderName = oneLine(settings?.brandName) || legalName\n return {\n footer: `${legalName} · ${postalAddress}\\n${outreachSolicitationStatement(senderName)} ${OUTREACH_OPT_OUT_LINE}`,\n error: null,\n }\n}\n\n/** A `Message-ID` in its header form — `<id@host>` — or `null` for one that is not. */\nexport function outreachMessageIdHeader(value: unknown): string | null {\n const bare = String(value ?? '').trim().replace(/^<+/, '').replace(/>+$/, '')\n if (!bare || /[\\s<>]/.test(bare)) return null\n return `<${bare}>`\n}\n\nfunction unsubscribeUrl(value: unknown): string | null | undefined {\n const raw = String(value ?? '').trim()\n if (!raw) return undefined\n if (/[\\r\\n]/.test(raw)) return null\n try {\n const url = new URL(raw)\n return url.protocol === 'https:' ? url.href : null\n } catch {\n return null\n }\n}\n\nfunction unsubscribeMailto(value: unknown): string | null | undefined {\n const raw = String(value ?? '').trim()\n if (!raw) return undefined\n if (/[\\r\\n]/.test(raw)) return null\n if (!/^mailto:/i.test(raw)) {\n const address = normalizeContactEmail(raw)\n return address ? `mailto:${address}?subject=unsubscribe` : null\n }\n let target = raw.slice('mailto:'.length).split('?')[0]\n try {\n target = decodeURIComponent(target)\n } catch {\n return null\n }\n return normalizeContactEmail(target) ? raw : null\n}\n\nconst refuse = (\n code: OutreachComposeErrorCode,\n message: string,\n): OutreachComposeResult => ({ email: null, error: { code, message }, unresolvedFields: [] })\n\n/** The email one step sends to one person — see the module note. */\nexport function composeOutreachEmail(input: ComposeOutreachEmailInput): OutreachComposeResult {\n const { footer, error } = composeOutreachFooter(input.orgSettings)\n if (error) return { email: null, error, unresolvedFields: [] }\n\n const steps = input.sequence?.steps ?? []\n const stepIndex = input.enrollment?.stepIndex\n const step = steps[stepIndex]\n if (step?.kind !== 'email') {\n return refuse('not_an_email_step', 'The step this enrollment is on does not send an email.')\n }\n const to = normalizeContactEmail(input.enrollment.email)\n if (!to) {\n return refuse('invalid_recipient', \"This enrollment's address isn't a valid email address.\")\n }\n\n const context: CrmMergeContext = {\n ...(input.merge ?? {}),\n extra: {\n ...(input.merge?.extra ?? {}),\n [OUTREACH_PERSONAL_LINE_FIELD]: normalizeOutreachPersonalLine(input.enrollment.personalLine),\n },\n }\n const unresolved: string[] = []\n const render = (text: string): string => {\n const result = resolveCrmMergeFields(text, context)\n for (const key of result.unresolved) if (!unresolved.includes(key)) unresolved.push(key)\n return result.text\n }\n\n const email: ComposedOutreachEmail = { to, subject: '', text: '' }\n if (isInThreadEmailStep(steps, stepIndex)) {\n const threadSubject = crmThreadSubject(input.enrollment.threadSubject)\n const messageIds = (input.enrollment.messageIds ?? [])\n .map(outreachMessageIdHeader)\n .filter((id): id is string => id !== null)\n const threadId = String(input.enrollment.gmailThreadId ?? '').trim()\n if (!threadSubject || !messageIds.length || !threadId) {\n return refuse(\n 'missing_thread',\n 'This email replies in the thread, and no earlier email in the thread was sent.',\n )\n }\n email.subject = `Re: ${threadSubject}`.slice(0, CRM_EMAIL_SUBJECT_MAX)\n email.threadId = threadId\n email.inReplyTo = messageIds[messageIds.length - 1]\n email.references = messageIds.join(' ')\n } else {\n const subject = oneLine(render(String(step.subject ?? '')))\n if (!subject) return refuse('missing_subject', 'This email has no subject.')\n if (outreachSubjectHasReplyPrefix(subject)) {\n return refuse(\n 'subject_reply_prefix',\n 'This email starts a thread, so its subject may not begin with \"Re:\" or \"Fwd:\".',\n )\n }\n email.subject = subject.slice(0, CRM_EMAIL_SUBJECT_MAX)\n }\n\n const source = step.templateId ? String(input.templateBody ?? '') : String(step.body ?? '')\n const body = render(source)\n .replace(/\\r\\n?/g, '\\n')\n .split('\\n')\n .map((line) => line.replace(/[ \\t]+$/, ''))\n .join('\\n')\n .replace(/^\\n+|\\n+$/g, '')\n if (!body.trim()) return refuse('missing_body', 'This email has no body.')\n email.text = `${body}\\n\\n${footer}`\n\n const url = unsubscribeUrl(input.listUnsubscribeUrl)\n if (url === null) {\n return refuse('invalid_unsubscribe_url', 'The unsubscribe link must be an https URL.')\n }\n if (url) email.listUnsubscribeUrl = url\n const mailto = unsubscribeMailto(input.listUnsubscribeMailto)\n if (mailto === null) {\n return refuse('invalid_unsubscribe_mailto', 'The unsubscribe address is not an email address.')\n }\n if (mailto) email.listUnsubscribeMailto = mailto\n\n return { email, error: null, unresolvedFields: unresolved }\n}\n"],"names":["normalizeContactEmail","CRM_EMAIL_SUBJECT_MAX","resolveCrmMergeFields","crmThreadSubject","normalizeOutreachPersonalLine","isInThreadEmailStep","OUTREACH_PERSONAL_LINE_FIELD","outreachSubjectHasReplyPrefix","OUTREACH_OPT_OUT_LINE","outreachSolicitationStatement","senderName","oneLine","value","replace","trim","composeOutreachFooter","settings","postalAddress","String","split","map","line","filter","Boolean","join","footer","error","code","message","legalName","brandName","outreachMessageIdHeader","bare","test","unsubscribeUrl","raw","undefined","url","URL","protocol","href","unsubscribeMailto","address","target","slice","length","decodeURIComponent","refuse","email","unresolvedFields","composeOutreachEmail","input","step","orgSettings","steps","sequence","stepIndex","enrollment","kind","to","context","merge","extra","personalLine","unresolved","render","text","result","key","includes","push","subject","threadSubject","messageIds","id","threadId","gmailThreadId","inReplyTo","references","source","templateId","templateBody","body","listUnsubscribeUrl","mailto","listUnsubscribeMailto"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/compose.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 EXACTLY IS SENT (AGL-2979).\n *\n * The composer turns a step, an enrollment and the organization's settings\n * into the message the transport sends: the recipient, the subject, the\n * plain text, and the headers that thread it and let the recipient leave.\n * The transport adds the From line and the MIME; nothing here knows either.\n *\n * ## The footer is not optional\n *\n * Every email ends with the organization's legal name, its postal address,\n * a sentence saying the email is a sales email, and the way out — reply\n * \"no\". Those are what CAN-SPAM requires of a commercial email (16 CFR 316:\n * identifiable as a solicitation, no fixed phrase), and\n * the composer appends them itself, after the merge, as literal text: no\n * template can leave them off, and no merge field can rewrite them. With no\n * postal address or no legal name, it refuses to compose at all.\n *\n * ## Threading\n *\n * An email after the first is a reply in the thread unless its step says\n * otherwise: `Re:` and the thread's own subject, `In-Reply-To` the last\n * message sent into the thread, `References` every one of them, and the\n * provider thread to send into. A `Re:` on an email that answers nothing is\n * a deception, so a reply step with no thread to answer is refused rather\n * than sent with a borrowed one, and a subject that starts with `Re:` or\n * `Fwd:` on an email that starts a thread is refused too.\n *\n * ## Merge fields\n *\n * The CRM's own resolver fills them, from the documents the runtime already\n * read, and the enrollment's personal line joins them as\n * `{{enrollment.personalLine}}` through the resolver's `extra` fields.\n * Fields that render empty are returned beside the message, for the\n * runtime to decide whether \"Hi ,\" is worth sending.\n *==========================================*/\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { CRM_EMAIL_SUBJECT_MAX } from '@aglyn/aglyn/app-utils/crm'\nimport {\n type CrmMergeContext,\n resolveCrmMergeFields,\n} from '@aglyn/aglyn/app-utils/crm-email-templates'\nimport { crmThreadSubject } from '@aglyn/aglyn/app-utils/crm-inbound'\nimport type {\n OutreachEnrollment,\n OutreachOrgSettings,\n OutreachSequence,\n} from '../model/outreach.types'\nimport { normalizeOutreachPersonalLine } from './gates'\nimport {\n isInThreadEmailStep,\n OUTREACH_PERSONAL_LINE_FIELD,\n outreachSubjectHasReplyPrefix,\n} from './sequence-validation'\n\n/** The way out every email offers, word for word. */\nexport const OUTREACH_OPT_OUT_LINE = 'Not interested? Reply \"no\" and I won\\'t email again.'\n\n/**\n * The sentence that says what the email is. \"Sales email\", not \"business\n * solicitation\": an inbox-placement test on 2026-09-19 named the latter\n * phrase as a spam trigger, and the law asks that the email be identifiable\n * as a solicitation, not for any particular words (AGL-3228).\n */\nexport function outreachSolicitationStatement(senderName: string): string {\n return `This is a sales email from ${senderName}.`\n}\n\n/**\n * The message the transport sends. Header values are single lines already;\n * the text uses `\\n` line breaks, which the transport writes as CRLF.\n */\nexport interface ComposedOutreachEmail {\n /** The recipient's address, normalized. */\n to: string\n subject: string\n /** The plain-text body, footer included. */\n text: string\n /** The provider thread a reply step is sent into. */\n threadId?: string\n /** The `Message-ID` this email answers, angle brackets included. */\n inReplyTo?: string\n /** Every `Message-ID` in the thread so far, oldest first, space-separated. */\n references?: string\n /** The one-click unsubscribe URL, for `List-Unsubscribe` with `List-Unsubscribe-Post`. */\n listUnsubscribeUrl?: string\n /** A `mailto:` URI for `List-Unsubscribe`. */\n listUnsubscribeMailto?: string\n}\n\nexport type OutreachComposeErrorCode =\n | 'missing_postal_address'\n | 'missing_legal_name'\n | 'not_an_email_step'\n | 'invalid_recipient'\n | 'missing_subject'\n | 'subject_reply_prefix'\n | 'missing_body'\n | 'missing_thread'\n | 'invalid_unsubscribe_url'\n | 'invalid_unsubscribe_mailto'\n\nexport interface OutreachComposeError {\n code: OutreachComposeErrorCode\n message: string\n}\n\nexport interface ComposeOutreachEmailInput {\n sequence: Pick<OutreachSequence, 'steps'>\n enrollment: Pick<\n OutreachEnrollment,\n 'email' | 'stepIndex' | 'personalLine' | 'threadSubject' | 'messageIds' | 'gmailThreadId'\n >\n orgSettings: Pick<OutreachOrgSettings, 'legalName' | 'brandName' | 'postalAddress'> | null | undefined\n /**\n * What the CRM resolver reads: the contact, the sending site's group id,\n * the sender (the mailbox's display name and address), the site. Its\n * `extra` may carry fields of the caller's own; the personal line is added\n * here.\n */\n merge: CrmMergeContext | null | undefined\n /** The CRM template's body, read by the caller, when the step names a template. */\n templateBody?: string | null\n /** The signed one-click unsubscribe URL, minted by the runtime. */\n listUnsubscribeUrl?: string | null\n /** An address, or a `mailto:` URI, that unsubscribes whoever writes to it. */\n listUnsubscribeMailto?: string | null\n}\n\nexport interface OutreachComposeResult {\n email: ComposedOutreachEmail | null\n error: OutreachComposeError | null\n /** Merge fields that rendered empty, unique, in order of appearance. */\n unresolvedFields: string[]\n}\n\nconst oneLine = (value: unknown): string =>\n typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n\n/**\n * The footer every email ends with, or the reason it cannot be written.\n *\n * The postal address may be stored over several lines; the footer prints it\n * on one, the parts separated by commas.\n */\nexport function composeOutreachFooter(\n settings: Pick<OutreachOrgSettings, 'legalName' | 'brandName' | 'postalAddress'> | null | undefined,\n): { footer: string | null; error: OutreachComposeError | null } {\n const postalAddress = String(settings?.postalAddress ?? '')\n .split(/\\r\\n|\\r|\\n/)\n .map((line) => oneLine(line).replace(/,+$/, ''))\n .filter(Boolean)\n .join(', ')\n if (!postalAddress) {\n return {\n footer: null,\n error: {\n code: 'missing_postal_address',\n message:\n \"This email can't be sent without your organization's postal address. Add it in Sequences settings.\",\n },\n }\n }\n const legalName = oneLine(settings?.legalName)\n if (!legalName) {\n return {\n footer: null,\n error: {\n code: 'missing_legal_name',\n message:\n \"This email can't be sent without your organization's legal name. Add it in Sequences settings.\",\n },\n }\n }\n const senderName = oneLine(settings?.brandName) || legalName\n return {\n footer: `${legalName} · ${postalAddress}\\n${outreachSolicitationStatement(senderName)} ${OUTREACH_OPT_OUT_LINE}`,\n error: null,\n }\n}\n\n/** A `Message-ID` in its header form — `<id@host>` — or `null` for one that is not. */\nexport function outreachMessageIdHeader(value: unknown): string | null {\n const bare = String(value ?? '').trim().replace(/^<+/, '').replace(/>+$/, '')\n if (!bare || /[\\s<>]/.test(bare)) return null\n return `<${bare}>`\n}\n\nfunction unsubscribeUrl(value: unknown): string | null | undefined {\n const raw = String(value ?? '').trim()\n if (!raw) return undefined\n if (/[\\r\\n]/.test(raw)) return null\n try {\n const url = new URL(raw)\n return url.protocol === 'https:' ? url.href : null\n } catch {\n return null\n }\n}\n\nfunction unsubscribeMailto(value: unknown): string | null | undefined {\n const raw = String(value ?? '').trim()\n if (!raw) return undefined\n if (/[\\r\\n]/.test(raw)) return null\n if (!/^mailto:/i.test(raw)) {\n const address = normalizeContactEmail(raw)\n return address ? `mailto:${address}?subject=unsubscribe` : null\n }\n let target = raw.slice('mailto:'.length).split('?')[0]\n try {\n target = decodeURIComponent(target)\n } catch {\n return null\n }\n return normalizeContactEmail(target) ? raw : null\n}\n\nconst refuse = (\n code: OutreachComposeErrorCode,\n message: string,\n): OutreachComposeResult => ({ email: null, error: { code, message }, unresolvedFields: [] })\n\n/** The email one step sends to one person — see the module note. */\nexport function composeOutreachEmail(input: ComposeOutreachEmailInput): OutreachComposeResult {\n const { footer, error } = composeOutreachFooter(input.orgSettings)\n if (error) return { email: null, error, unresolvedFields: [] }\n\n const steps = input.sequence?.steps ?? []\n const stepIndex = input.enrollment?.stepIndex\n const step = steps[stepIndex]\n if (step?.kind !== 'email') {\n return refuse('not_an_email_step', 'The step this enrollment is on does not send an email.')\n }\n const to = normalizeContactEmail(input.enrollment.email)\n if (!to) {\n return refuse('invalid_recipient', \"This enrollment's address isn't a valid email address.\")\n }\n\n const context: CrmMergeContext = {\n ...(input.merge ?? {}),\n extra: {\n ...(input.merge?.extra ?? {}),\n [OUTREACH_PERSONAL_LINE_FIELD]: normalizeOutreachPersonalLine(input.enrollment.personalLine),\n },\n }\n const unresolved: string[] = []\n const render = (text: string): string => {\n const result = resolveCrmMergeFields(text, context)\n for (const key of result.unresolved) if (!unresolved.includes(key)) unresolved.push(key)\n return result.text\n }\n\n const email: ComposedOutreachEmail = { to, subject: '', text: '' }\n if (isInThreadEmailStep(steps, stepIndex)) {\n const threadSubject = crmThreadSubject(input.enrollment.threadSubject)\n const messageIds = (input.enrollment.messageIds ?? [])\n .map(outreachMessageIdHeader)\n .filter((id): id is string => id !== null)\n const threadId = String(input.enrollment.gmailThreadId ?? '').trim()\n if (!threadSubject || !messageIds.length || !threadId) {\n return refuse(\n 'missing_thread',\n 'This email replies in the thread, and no earlier email in the thread was sent.',\n )\n }\n email.subject = `Re: ${threadSubject}`.slice(0, CRM_EMAIL_SUBJECT_MAX)\n email.threadId = threadId\n email.inReplyTo = messageIds[messageIds.length - 1]\n email.references = messageIds.join(' ')\n } else {\n const subject = oneLine(render(String(step.subject ?? '')))\n if (!subject) return refuse('missing_subject', 'This email has no subject.')\n if (outreachSubjectHasReplyPrefix(subject)) {\n return refuse(\n 'subject_reply_prefix',\n 'This email starts a thread, so its subject may not begin with \"Re:\" or \"Fwd:\".',\n )\n }\n email.subject = subject.slice(0, CRM_EMAIL_SUBJECT_MAX)\n }\n\n const source = step.templateId ? String(input.templateBody ?? '') : String(step.body ?? '')\n const body = render(source)\n .replace(/\\r\\n?/g, '\\n')\n .split('\\n')\n .map((line) => line.replace(/[ \\t]+$/, ''))\n .join('\\n')\n .replace(/^\\n+|\\n+$/g, '')\n if (!body.trim()) return refuse('missing_body', 'This email has no body.')\n email.text = `${body}\\n\\n${footer}`\n\n const url = unsubscribeUrl(input.listUnsubscribeUrl)\n if (url === null) {\n return refuse('invalid_unsubscribe_url', 'The unsubscribe link must be an https URL.')\n }\n if (url) email.listUnsubscribeUrl = url\n const mailto = unsubscribeMailto(input.listUnsubscribeMailto)\n if (mailto === null) {\n return refuse('invalid_unsubscribe_mailto', 'The unsubscribe address is not an email address.')\n }\n if (mailto) email.listUnsubscribeMailto = mailto\n\n return { email, error: null, unresolvedFields: unresolved }\n}\n"],"names":["normalizeContactEmail","CRM_EMAIL_SUBJECT_MAX","resolveCrmMergeFields","crmThreadSubject","normalizeOutreachPersonalLine","isInThreadEmailStep","OUTREACH_PERSONAL_LINE_FIELD","outreachSubjectHasReplyPrefix","OUTREACH_OPT_OUT_LINE","outreachSolicitationStatement","senderName","oneLine","value","replace","trim","composeOutreachFooter","settings","postalAddress","String","split","map","line","filter","Boolean","join","footer","error","code","message","legalName","brandName","outreachMessageIdHeader","bare","test","unsubscribeUrl","raw","undefined","url","URL","protocol","href","unsubscribeMailto","address","target","slice","length","decodeURIComponent","refuse","email","unresolvedFields","composeOutreachEmail","input","step","orgSettings","steps","sequence","stepIndex","enrollment","kind","to","context","merge","extra","personalLine","unresolved","render","text","result","key","includes","push","subject","threadSubject","messageIds","id","threadId","gmailThreadId","inReplyTo","references","source","templateId","templateBody","body","listUnsubscribeUrl","mailto","listUnsubscribeMailto"],"mappings":"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"}
|
|
@@ -49,7 +49,7 @@ const NONE = {
|
|
|
49
49
|
complaint: false,
|
|
50
50
|
matched: null
|
|
51
51
|
};
|
|
52
|
-
/** A line of the footer every Outreach email carries, however a client re-quoted it. */ const FOOTER_LINE = /reply\s+["“”'‘’]?no["“”'‘’]?\s+and\s+i\s+won['’]?t\s+email|business\s+solicitation\s+from/i;
|
|
52
|
+
/** A line of the footer every Outreach email carries, however a client re-quoted it. */ const FOOTER_LINE = /reply\s+["“”'‘’]?no["“”'‘’]?\s+and\s+i\s+won['’]?t\s+email|(?:business\s+solicitation|sales\s+email)\s+from/i;
|
|
53
53
|
/**
|
|
54
54
|
* The words a reply adds to its thread: the text above the quoted history,
|
|
55
55
|
* with a signature and any quoted footer line removed.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/opt-out-intent.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 * A REPLY THAT SAYS STOP (AGL-2979).\n *\n * Every Outreach email tells its reader to reply \"no\" to be left alone, so\n * a reply has to be read for that. Any reply already stops its sequence;\n * reading one as an OPT-OUT also puts the address on the organization's\n * do-not-contact list, so no later sequence reaches it either. Both mistakes\n * cost something: a missed opt-out lets another sequence write to somebody\n * who said no, and a false one silently loses a prospect who said \"no rush\".\n *\n * ## Only the reply's own words\n *\n * A reply quotes the email it answers, and that email's footer says \"Reply\n * 'no'\". So the quoted history is cut first — the CRM capture reader's\n * `stripQuotedHistory` — along with a signature and any footer line a mail\n * client quoted without marking it.\n *\n * ## What counts\n *\n * - A short reply that says nothing but no: \"No.\", \"No thanks\", \"Nope\",\n * \"Pass\", \"Stop\" — with a greeting and a sign-off allowed around it.\n * - A request anywhere in the reply: unsubscribe, remove me, take me off\n * the list, stop emailing, don't contact me, opt out, no more emails —\n * unless it is negated (\"please don't remove me\").\n * - \"Not interested\", unless it defers (\"not interested yet\", \"not\n * interested right now, try me next quarter\") or asks (\"not interested?\").\n * - A reply that calls the email spam, which is also a COMPLAINT.\n *\n * \"No rush\", \"no problem\" and \"no worries\" are none of these: a \"no\" only\n * counts when it is the whole answer.\n *==========================================*/\n\nimport { stripQuotedHistory } from '@aglyn/aglyn/app-utils/crm-inbound'\n\nexport interface OutreachOptOutIntent {\n /** The reply asks not to be emailed again. */\n optOut: boolean\n /** The reply calls the email spam — an opt-out that also counts against the mailbox. */\n complaint: boolean\n /** The words that decided it, for the timeline and the log; `null` when nothing matched. */\n matched: string | null\n}\n\nconst NONE: OutreachOptOutIntent = { optOut: false, complaint: false, matched: null }\n\n/** A line of the footer every Outreach email carries, however a client re-quoted it. */\nconst FOOTER_LINE = /reply\\s+[\"“”'‘’]?no[\"“”'‘’]?\\s+and\\s+i\\s+won['’]?t\\s+email|business\\s+solicitation\\s+from/i\n\n/**\n * The words a reply adds to its thread: the text above the quoted history,\n * with a signature and any quoted footer line removed.\n */\nexport function outreachFreshReplyText(text: unknown): string {\n const body = stripQuotedHistory(\n String(text ?? '')\n .replace(/\\r\\n?/g, '\\n')\n .replace(/[\\u00a0\\u202f]/g, ' '),\n )\n const lines = body.split('\\n')\n const signature = lines.findIndex((line) => /^--\\s*$/.test(line))\n return (signature >= 0 ? lines.slice(0, signature) : lines)\n .filter((line) => !FOOTER_LINE.test(line))\n .join('\\n')\n .trim()\n}\n\n/** Curly quotes straightened, case folded, whitespace settled. */\nfunction fold(text: string): string {\n return text\n .replace(/[‘’]/g, \"'\")\n .replace(/[“”]/g, '\"')\n .toLowerCase()\n .replace(/\\s+/g, ' ')\n .trim()\n}\n\nconst GREETING_LINE = /^(hi|hello|hey|dear|greetings|good (morning|afternoon|evening))\\b[^\\n]{0,40}$/i\nconst GREETING_LEAD = /^(hi|hello|hey|dear|greetings)(\\s+[a-z'’-]+)?\\s*[,!.:–—-]\\s*/i\nconst SIGN_OFF =\n /^(thanks|thank you|thx|many thanks|cheers|regards|best|best regards|kind regards|warm regards|sincerely|all the best|sent from my\\b.*|get outlook\\b.*)[\\s,.!]*$/i\nconst NAME = \"[A-Z][a-z'’-]+( [A-Z][a-z'’-]+)?\"\n/** Thanks, then a signed name: \"No thanks, Jordan\" or \"No thanks — Jordan\". */\nconst THANKS_THEN_NAME = new RegExp(`\\\\b(thanks|thank you|thx|cheers)[\\\\s,–—-]+${NAME}[.!]?\\\\s*$`, 'i')\nconst NAME_LINE = new RegExp(`^${NAME}[.!]?$`)\n\n/**\n * What is left of a reply once its greeting, its sign-off and a name signed\n * after \"thanks\" are gone. A name is only taken off after a thank-you,\n * because \"No, WordPress.\" is an answer about a platform, not a signature.\n */\nfunction answerOnly(fresh: string): string {\n const lines = fresh\n .split('\\n')\n .map((line) => line.trim())\n .filter(Boolean)\n if (lines.length > 1 && GREETING_LINE.test(lines[0])) lines.shift()\n const signOff = lines.findIndex((line, index) => index > 0 && SIGN_OFF.test(line))\n const kept = signOff >= 0 ? lines.slice(0, signOff) : lines\n if (\n kept.length > 1 &&\n NAME_LINE.test(kept[kept.length - 1]) &&\n /\\b(thanks|thank you|thx|cheers)[\\s,.!]*$/i.test(kept[kept.length - 2])\n ) {\n kept.pop()\n }\n return kept\n .join(' ')\n .replace(GREETING_LEAD, '')\n .replace(THANKS_THEN_NAME, '$1')\n}\n\n/** The only words a reply may hold to be read as a bare \"no\". */\nconst BARE_NO_WORDS = new Set([\n 'no',\n 'nope',\n 'nah',\n 'pass',\n \"i'll\",\n 'ill',\n 'i',\n 'will',\n 'stop',\n 'unsubscribe',\n 'remove',\n 'me',\n 'us',\n 'please',\n 'thanks',\n 'thank',\n 'you',\n 'thx',\n 'sorry',\n 'but',\n 'not',\n 'for',\n 'interested',\n])\nconst BARE_NO_TRIGGERS = new Set(['no', 'nope', 'nah', 'pass', 'stop', 'unsubscribe', 'remove'])\n\nfunction bareNo(answer: string): string | null {\n const words = fold(answer)\n .replace(/[^a-z' ]+/g, ' ')\n .split(' ')\n .filter(Boolean)\n if (!words.length || words.length > 6) return null\n if (!words.every((word) => BARE_NO_WORDS.has(word))) return null\n const said = words.join(' ')\n if (\n words.some((word) => BARE_NO_TRIGGERS.has(word)) ||\n /\\bnot interested\\b|\\bnot for (me|us)\\b/.test(said)\n ) {\n return said\n }\n return null\n}\n\n/** Negations that turn a request around when they come just before it. */\nconst NEGATED = /\\b(don't|dont|do not|didn't|didnt|did not|never|not|no need to|please don't|wouldn't|won't)\\s+(\\w+\\s+){0,2}$/\n\nconst REQUESTS: readonly RegExp[] = [\n /\\bunsubscrib(e|ed|ing)\\b/,\n /\\bopt(ed|ing)?[ -]?(me )?out\\b/,\n /\\bremove (me|us|my (email|e-mail|address|name|details|info))\\b/,\n /\\bremove\\b[^.!?\\n]{0,30}\\bfrom (your|the|this|all) (list|lists|mailing list|emails?|database|system|sequence)\\b/,\n /\\btake (me|us|my (email|e-mail|address|name)) off\\b/,\n /\\btake\\b[^.!?\\n]{0,30}\\boff (your|the|this|all) (list|lists|mailing list|emails?)\\b/,\n // Aimed at us: \"stop emailing me\", \"stop sending these\", \"stop contacting.\" —\n // not \"we had to stop sending newsletters ourselves\".\n /\\b(stop|quit) (emailing|e-mailing|mailing|contacting|messaging|writing|sending|reaching out)(?=\\s*([.!,;]|$)|\\s+(me|us|my|our|this|these|those|them|to (me|us)|emails?|e-mails?|messages?|mail)\\b)/,\n /\\bplease stop\\b/,\n /^stop\\b(?! (by|in|over|at|the|thinking|worrying))/,\n /\\b(do not|don't|dont|never) (email|e-mail|contact|message|write to|reach out to) (me|us)\\b/,\n /\\bno (more|further) (emails?|e-mails?|messages|mail|contact)\\b/,\n /\\b(don't|do not) want (to (hear|receive)|any more|these)\\b[^.!?\\n]{0,20}\\b(emails?|messages|from you)\\b/,\n]\n\nconst COMPLAINTS: readonly RegExp[] = [\n /\\b(this|that|it|you|your emails?|these emails?|this email)\\s*(is|are|'s|'re)\\s+(just\\s+)?spam\\b/,\n /\\b(stop|quit|don't|do not) spam(ming)?\\b/,\n /\\bspamming (me|us|my|our)\\b/,\n /\\b(i|we)('ve| have| just| will| am| are)?\\s*(report(ed|ing)?|mark(ed|ing)?|flag(ged|ging)?)\\b[^.!?\\n]{0,30}\\b(as|for) spam\\b/,\n]\n\n/** Words after \"not interested\" that put it off rather than rule it out. */\nconst DEFERRAL =\n /^[\\s,]*(yet|right now|for now|now|at the moment|at this (time|moment|point|stage)|today|this (week|month|quarter|year)|until|in the (near )?future|but\\b|however\\b|though\\b)/\n\nfunction notInterested(text: string): string | null {\n const pattern = /\\bnot (really |at all )?interested\\b/g\n for (let match = pattern.exec(text); match; match = pattern.exec(text)) {\n const before = text.slice(0, match.index)\n const after = text.slice(match.index + match[0].length)\n const sentenceEnd = after.search(/[.!?\\n]/)\n const rest = sentenceEnd >= 0 ? after.slice(0, sentenceEnd + 1) : after\n if (rest.trimEnd().endsWith('?')) continue\n if (DEFERRAL.test(after)) continue\n if (/\\b(if|unless|in case|are you|were you|you're|you are)\\s+(\\w+\\s+){0,2}$/.test(before)) continue\n return match[0]\n }\n return null\n}\n\n/**\n * Whether a SUBJECT says nothing but stop — \"Unsubscribe\", \"Remove me\",\n * \"STOP\" — as a message sent from an unsubscribe link, or typed fresh, does.\n * Only a bare answer counts: a subject is a sender's headline, and \"Stop\n * losing leads\" is one.\n */\nexport function detectOutreachOptOutSubject(subject: unknown): OutreachOptOutIntent {\n const said = bareNo(String(subject ?? ''))\n return said ? { optOut: true, complaint: false, matched: said } : NONE\n}\n\n/**\n * Whether a reply's text asks to be left alone — see the module note. Pass\n * the reply's text as received; the quoted history is removed here.\n */\nexport function detectOutreachOptOutIntent(text: unknown): OutreachOptOutIntent {\n const fresh = outreachFreshReplyText(text)\n if (!fresh) return NONE\n const folded = fold(fresh)\n for (const pattern of COMPLAINTS) {\n const match = pattern.exec(folded)\n if (match) return { optOut: true, complaint: true, matched: match[0] }\n }\n const bare = bareNo(answerOnly(fresh))\n if (bare) return { optOut: true, complaint: false, matched: bare }\n for (const pattern of REQUESTS) {\n const match = pattern.exec(folded)\n if (match && !NEGATED.test(folded.slice(0, match.index))) {\n return { optOut: true, complaint: false, matched: match[0] }\n }\n }\n const declined = notInterested(folded)\n if (declined) return { optOut: true, complaint: false, matched: declined }\n return NONE\n}\n"],"names":["stripQuotedHistory","NONE","optOut","complaint","matched","FOOTER_LINE","outreachFreshReplyText","text","body","String","replace","lines","split","signature","findIndex","line","test","slice","filter","join","trim","fold","toLowerCase","GREETING_LINE","GREETING_LEAD","SIGN_OFF","NAME","THANKS_THEN_NAME","RegExp","NAME_LINE","answerOnly","fresh","map","Boolean","length","shift","signOff","index","kept","pop","BARE_NO_WORDS","Set","BARE_NO_TRIGGERS","bareNo","answer","words","every","word","has","said","some","NEGATED","REQUESTS","COMPLAINTS","DEFERRAL","notInterested","pattern","match","exec","before","after","sentenceEnd","search","rest","trimEnd","endsWith","detectOutreachOptOutSubject","subject","detectOutreachOptOutIntent","folded","bare","declined"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/opt-out-intent.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 * A REPLY THAT SAYS STOP (AGL-2979).\n *\n * Every Outreach email tells its reader to reply \"no\" to be left alone, so\n * a reply has to be read for that. Any reply already stops its sequence;\n * reading one as an OPT-OUT also puts the address on the organization's\n * do-not-contact list, so no later sequence reaches it either. Both mistakes\n * cost something: a missed opt-out lets another sequence write to somebody\n * who said no, and a false one silently loses a prospect who said \"no rush\".\n *\n * ## Only the reply's own words\n *\n * A reply quotes the email it answers, and that email's footer says \"Reply\n * 'no'\". So the quoted history is cut first — the CRM capture reader's\n * `stripQuotedHistory` — along with a signature and any footer line a mail\n * client quoted without marking it.\n *\n * ## What counts\n *\n * - A short reply that says nothing but no: \"No.\", \"No thanks\", \"Nope\",\n * \"Pass\", \"Stop\" — with a greeting and a sign-off allowed around it.\n * - A request anywhere in the reply: unsubscribe, remove me, take me off\n * the list, stop emailing, don't contact me, opt out, no more emails —\n * unless it is negated (\"please don't remove me\").\n * - \"Not interested\", unless it defers (\"not interested yet\", \"not\n * interested right now, try me next quarter\") or asks (\"not interested?\").\n * - A reply that calls the email spam, which is also a COMPLAINT.\n *\n * \"No rush\", \"no problem\" and \"no worries\" are none of these: a \"no\" only\n * counts when it is the whole answer.\n *==========================================*/\n\nimport { stripQuotedHistory } from '@aglyn/aglyn/app-utils/crm-inbound'\n\nexport interface OutreachOptOutIntent {\n /** The reply asks not to be emailed again. */\n optOut: boolean\n /** The reply calls the email spam — an opt-out that also counts against the mailbox. */\n complaint: boolean\n /** The words that decided it, for the timeline and the log; `null` when nothing matched. */\n matched: string | null\n}\n\nconst NONE: OutreachOptOutIntent = { optOut: false, complaint: false, matched: null }\n\n/** A line of the footer every Outreach email carries, however a client re-quoted it. */\nconst FOOTER_LINE =\n /reply\\s+[\"“”'‘’]?no[\"“”'‘’]?\\s+and\\s+i\\s+won['’]?t\\s+email|(?:business\\s+solicitation|sales\\s+email)\\s+from/i\n\n/**\n * The words a reply adds to its thread: the text above the quoted history,\n * with a signature and any quoted footer line removed.\n */\nexport function outreachFreshReplyText(text: unknown): string {\n const body = stripQuotedHistory(\n String(text ?? '')\n .replace(/\\r\\n?/g, '\\n')\n .replace(/[\\u00a0\\u202f]/g, ' '),\n )\n const lines = body.split('\\n')\n const signature = lines.findIndex((line) => /^--\\s*$/.test(line))\n return (signature >= 0 ? lines.slice(0, signature) : lines)\n .filter((line) => !FOOTER_LINE.test(line))\n .join('\\n')\n .trim()\n}\n\n/** Curly quotes straightened, case folded, whitespace settled. */\nfunction fold(text: string): string {\n return text\n .replace(/[‘’]/g, \"'\")\n .replace(/[“”]/g, '\"')\n .toLowerCase()\n .replace(/\\s+/g, ' ')\n .trim()\n}\n\nconst GREETING_LINE = /^(hi|hello|hey|dear|greetings|good (morning|afternoon|evening))\\b[^\\n]{0,40}$/i\nconst GREETING_LEAD = /^(hi|hello|hey|dear|greetings)(\\s+[a-z'’-]+)?\\s*[,!.:–—-]\\s*/i\nconst SIGN_OFF =\n /^(thanks|thank you|thx|many thanks|cheers|regards|best|best regards|kind regards|warm regards|sincerely|all the best|sent from my\\b.*|get outlook\\b.*)[\\s,.!]*$/i\nconst NAME = \"[A-Z][a-z'’-]+( [A-Z][a-z'’-]+)?\"\n/** Thanks, then a signed name: \"No thanks, Jordan\" or \"No thanks — Jordan\". */\nconst THANKS_THEN_NAME = new RegExp(`\\\\b(thanks|thank you|thx|cheers)[\\\\s,–—-]+${NAME}[.!]?\\\\s*$`, 'i')\nconst NAME_LINE = new RegExp(`^${NAME}[.!]?$`)\n\n/**\n * What is left of a reply once its greeting, its sign-off and a name signed\n * after \"thanks\" are gone. A name is only taken off after a thank-you,\n * because \"No, WordPress.\" is an answer about a platform, not a signature.\n */\nfunction answerOnly(fresh: string): string {\n const lines = fresh\n .split('\\n')\n .map((line) => line.trim())\n .filter(Boolean)\n if (lines.length > 1 && GREETING_LINE.test(lines[0])) lines.shift()\n const signOff = lines.findIndex((line, index) => index > 0 && SIGN_OFF.test(line))\n const kept = signOff >= 0 ? lines.slice(0, signOff) : lines\n if (\n kept.length > 1 &&\n NAME_LINE.test(kept[kept.length - 1]) &&\n /\\b(thanks|thank you|thx|cheers)[\\s,.!]*$/i.test(kept[kept.length - 2])\n ) {\n kept.pop()\n }\n return kept\n .join(' ')\n .replace(GREETING_LEAD, '')\n .replace(THANKS_THEN_NAME, '$1')\n}\n\n/** The only words a reply may hold to be read as a bare \"no\". */\nconst BARE_NO_WORDS = new Set([\n 'no',\n 'nope',\n 'nah',\n 'pass',\n \"i'll\",\n 'ill',\n 'i',\n 'will',\n 'stop',\n 'unsubscribe',\n 'remove',\n 'me',\n 'us',\n 'please',\n 'thanks',\n 'thank',\n 'you',\n 'thx',\n 'sorry',\n 'but',\n 'not',\n 'for',\n 'interested',\n])\nconst BARE_NO_TRIGGERS = new Set(['no', 'nope', 'nah', 'pass', 'stop', 'unsubscribe', 'remove'])\n\nfunction bareNo(answer: string): string | null {\n const words = fold(answer)\n .replace(/[^a-z' ]+/g, ' ')\n .split(' ')\n .filter(Boolean)\n if (!words.length || words.length > 6) return null\n if (!words.every((word) => BARE_NO_WORDS.has(word))) return null\n const said = words.join(' ')\n if (\n words.some((word) => BARE_NO_TRIGGERS.has(word)) ||\n /\\bnot interested\\b|\\bnot for (me|us)\\b/.test(said)\n ) {\n return said\n }\n return null\n}\n\n/** Negations that turn a request around when they come just before it. */\nconst NEGATED = /\\b(don't|dont|do not|didn't|didnt|did not|never|not|no need to|please don't|wouldn't|won't)\\s+(\\w+\\s+){0,2}$/\n\nconst REQUESTS: readonly RegExp[] = [\n /\\bunsubscrib(e|ed|ing)\\b/,\n /\\bopt(ed|ing)?[ -]?(me )?out\\b/,\n /\\bremove (me|us|my (email|e-mail|address|name|details|info))\\b/,\n /\\bremove\\b[^.!?\\n]{0,30}\\bfrom (your|the|this|all) (list|lists|mailing list|emails?|database|system|sequence)\\b/,\n /\\btake (me|us|my (email|e-mail|address|name)) off\\b/,\n /\\btake\\b[^.!?\\n]{0,30}\\boff (your|the|this|all) (list|lists|mailing list|emails?)\\b/,\n // Aimed at us: \"stop emailing me\", \"stop sending these\", \"stop contacting.\" —\n // not \"we had to stop sending newsletters ourselves\".\n /\\b(stop|quit) (emailing|e-mailing|mailing|contacting|messaging|writing|sending|reaching out)(?=\\s*([.!,;]|$)|\\s+(me|us|my|our|this|these|those|them|to (me|us)|emails?|e-mails?|messages?|mail)\\b)/,\n /\\bplease stop\\b/,\n /^stop\\b(?! (by|in|over|at|the|thinking|worrying))/,\n /\\b(do not|don't|dont|never) (email|e-mail|contact|message|write to|reach out to) (me|us)\\b/,\n /\\bno (more|further) (emails?|e-mails?|messages|mail|contact)\\b/,\n /\\b(don't|do not) want (to (hear|receive)|any more|these)\\b[^.!?\\n]{0,20}\\b(emails?|messages|from you)\\b/,\n]\n\nconst COMPLAINTS: readonly RegExp[] = [\n /\\b(this|that|it|you|your emails?|these emails?|this email)\\s*(is|are|'s|'re)\\s+(just\\s+)?spam\\b/,\n /\\b(stop|quit|don't|do not) spam(ming)?\\b/,\n /\\bspamming (me|us|my|our)\\b/,\n /\\b(i|we)('ve| have| just| will| am| are)?\\s*(report(ed|ing)?|mark(ed|ing)?|flag(ged|ging)?)\\b[^.!?\\n]{0,30}\\b(as|for) spam\\b/,\n]\n\n/** Words after \"not interested\" that put it off rather than rule it out. */\nconst DEFERRAL =\n /^[\\s,]*(yet|right now|for now|now|at the moment|at this (time|moment|point|stage)|today|this (week|month|quarter|year)|until|in the (near )?future|but\\b|however\\b|though\\b)/\n\nfunction notInterested(text: string): string | null {\n const pattern = /\\bnot (really |at all )?interested\\b/g\n for (let match = pattern.exec(text); match; match = pattern.exec(text)) {\n const before = text.slice(0, match.index)\n const after = text.slice(match.index + match[0].length)\n const sentenceEnd = after.search(/[.!?\\n]/)\n const rest = sentenceEnd >= 0 ? after.slice(0, sentenceEnd + 1) : after\n if (rest.trimEnd().endsWith('?')) continue\n if (DEFERRAL.test(after)) continue\n if (/\\b(if|unless|in case|are you|were you|you're|you are)\\s+(\\w+\\s+){0,2}$/.test(before)) continue\n return match[0]\n }\n return null\n}\n\n/**\n * Whether a SUBJECT says nothing but stop — \"Unsubscribe\", \"Remove me\",\n * \"STOP\" — as a message sent from an unsubscribe link, or typed fresh, does.\n * Only a bare answer counts: a subject is a sender's headline, and \"Stop\n * losing leads\" is one.\n */\nexport function detectOutreachOptOutSubject(subject: unknown): OutreachOptOutIntent {\n const said = bareNo(String(subject ?? ''))\n return said ? { optOut: true, complaint: false, matched: said } : NONE\n}\n\n/**\n * Whether a reply's text asks to be left alone — see the module note. Pass\n * the reply's text as received; the quoted history is removed here.\n */\nexport function detectOutreachOptOutIntent(text: unknown): OutreachOptOutIntent {\n const fresh = outreachFreshReplyText(text)\n if (!fresh) return NONE\n const folded = fold(fresh)\n for (const pattern of COMPLAINTS) {\n const match = pattern.exec(folded)\n if (match) return { optOut: true, complaint: true, matched: match[0] }\n }\n const bare = bareNo(answerOnly(fresh))\n if (bare) return { optOut: true, complaint: false, matched: bare }\n for (const pattern of REQUESTS) {\n const match = pattern.exec(folded)\n if (match && !NEGATED.test(folded.slice(0, match.index))) {\n return { optOut: true, complaint: false, matched: match[0] }\n }\n }\n const declined = notInterested(folded)\n if (declined) return { optOut: true, complaint: false, matched: declined }\n return NONE\n}\n"],"names":["stripQuotedHistory","NONE","optOut","complaint","matched","FOOTER_LINE","outreachFreshReplyText","text","body","String","replace","lines","split","signature","findIndex","line","test","slice","filter","join","trim","fold","toLowerCase","GREETING_LINE","GREETING_LEAD","SIGN_OFF","NAME","THANKS_THEN_NAME","RegExp","NAME_LINE","answerOnly","fresh","map","Boolean","length","shift","signOff","index","kept","pop","BARE_NO_WORDS","Set","BARE_NO_TRIGGERS","bareNo","answer","words","every","word","has","said","some","NEGATED","REQUESTS","COMPLAINTS","DEFERRAL","notInterested","pattern","match","exec","before","after","sentenceEnd","search","rest","trimEnd","endsWith","detectOutreachOptOutSubject","subject","detectOutreachOptOutIntent","folded","bare","declined"],"mappings":"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"}
|
|
@@ -26,9 +26,25 @@ export interface OutreachApiRefusal {
|
|
|
26
26
|
error: string;
|
|
27
27
|
reason: OutreachApiRefusalReason;
|
|
28
28
|
}
|
|
29
|
+
/** A reason `GET outreach/mailboxes/availability` answers `configured: false`. */
|
|
30
|
+
export type OutreachMailboxAvailabilityGate = {
|
|
31
|
+
gate: 'google';
|
|
32
|
+
missing: string[];
|
|
33
|
+
} | {
|
|
34
|
+
gate: 'state';
|
|
35
|
+
} | {
|
|
36
|
+
gate: 'redirect';
|
|
37
|
+
};
|
|
29
38
|
/** `GET outreach/mailboxes/availability` */
|
|
30
39
|
export interface OutreachMailboxAvailability {
|
|
31
40
|
configured: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* When not configured, which gates refused, so an operator reading the
|
|
43
|
+
* response knows what to set: the Google client (`google`, with the names
|
|
44
|
+
* of the missing variables), the state signing secret (`state`), or the
|
|
45
|
+
* console origin to register (`redirect`). Absent when configured.
|
|
46
|
+
*/
|
|
47
|
+
missing?: OutreachMailboxAvailabilityGate[];
|
|
32
48
|
/**
|
|
33
49
|
* Whether the viewer is an organization owner or admin, who may change,
|
|
34
50
|
* pause and disconnect any member's mailbox. The routes decide this on
|
|
@@ -80,10 +96,13 @@ export interface OutreachMailboxResponse {
|
|
|
80
96
|
ok: true;
|
|
81
97
|
mailbox: OutreachMailbox;
|
|
82
98
|
}
|
|
83
|
-
/**
|
|
99
|
+
/**
|
|
100
|
+
* `POST outreach/mailboxes/test` — `{ mailboxId, to? }`. `to` is the address
|
|
101
|
+
* the test goes to; left off, the account's own (AGL-3228).
|
|
102
|
+
*/
|
|
84
103
|
export interface OutreachMailboxTestResponse {
|
|
85
104
|
ok: true;
|
|
86
|
-
/** The
|
|
105
|
+
/** The address the test went to. */
|
|
87
106
|
sentTo: string;
|
|
88
107
|
gmailMessageId: string;
|
|
89
108
|
sentAtMs: number;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/mailboxes/mailbox-api.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { OutreachMailbox, OutreachSendWindow } from '../model/outreach.types'\n\n/**\n * THE MAILBOX ROUTES' CONTRACT (AGL-2978): what the Mailboxes panel sends,\n * what the routes answer, and the fragment a connect comes back through.\n * Client-safe — types, constants and two string functions.\n */\n\n/** Every reason a mailbox route refuses with, for a caller to branch on. */\nexport type OutreachApiRefusalReason =\n | 'unauthenticated'\n | 'email-unverified'\n | 'org-required'\n | 'not-a-member'\n | 'not-org-wide'\n | 'permission'\n | 'entitlement'\n | 'not-configured'\n | 'method-not-allowed'\n | 'invalid-request'\n | 'mailbox-not-found'\n | 'not-your-mailbox'\n | 'mailbox-limit'\n | 'state-invalid'\n | 'state-expired'\n | 'state-user-mismatch'\n | 'state-org-mismatch'\n | 'state-replayed'\n | 'state-superseded'\n | 'code-rejected'\n | 'refresh-token-missing'\n | 'scopes-missing'\n | 'identity-unverified'\n | 'account-mismatch'\n | 'reconnect-required'\n | 'invalid-settings'\n | 'rate-limited'\n | 'google-unavailable'\n\nexport interface OutreachApiRefusal {\n error: string\n reason: OutreachApiRefusalReason\n}\n\n/** `GET outreach/mailboxes/availability` */\nexport interface OutreachMailboxAvailability {\n configured: boolean\n /**\n * Whether the viewer is an organization owner or admin, who may change,\n * pause and disconnect any member's mailbox. The routes decide this on\n * every call; the panel reads it to offer only what will be allowed.\n */\n canManageAll: boolean\n}\n\n/** `POST outreach/mailboxes/connect` */\nexport interface OutreachConnectRequest {\n orgId: string\n}\nexport interface OutreachConnectResponse {\n /** Google's consent address; the browser goes there. */\n url: string\n}\n\n/** `POST outreach/mailboxes/connect/complete` */\nexport interface OutreachConnectCompleteRequest {\n orgId: string\n code: string\n state: string\n /** The browser's IANA timezone, for a new mailbox's sending window. */\n timezone?: string\n}\nexport interface OutreachConnectCompleteResponse {\n ok: true\n mailbox: OutreachMailbox\n /** False when an existing mailbox for the account was reconnected. */\n created: boolean\n /** Pending addresses of the member's that Gmail's send-as list confirmed. */\n confirmedAliases: string[]\n}\n\n/** `POST outreach/mailboxes/settings` — every field but the ids is optional. */\nexport interface OutreachMailboxSettingsRequest {\n orgId: string\n mailboxId: string\n sendAs?: string\n displayName?: string\n dailyCap?: number\n window?: OutreachSendWindow\n timezone?: string\n}\n\n/** `POST outreach/mailboxes/status` */\nexport interface OutreachMailboxStatusRequest {\n orgId: string\n mailboxId: string\n paused: boolean\n}\n\nexport interface OutreachMailboxResponse {\n ok: true\n mailbox: OutreachMailbox\n}\n\n
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/mailboxes/mailbox-api.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { OutreachMailbox, OutreachSendWindow } from '../model/outreach.types'\n\n/**\n * THE MAILBOX ROUTES' CONTRACT (AGL-2978): what the Mailboxes panel sends,\n * what the routes answer, and the fragment a connect comes back through.\n * Client-safe — types, constants and two string functions.\n */\n\n/** Every reason a mailbox route refuses with, for a caller to branch on. */\nexport type OutreachApiRefusalReason =\n | 'unauthenticated'\n | 'email-unverified'\n | 'org-required'\n | 'not-a-member'\n | 'not-org-wide'\n | 'permission'\n | 'entitlement'\n | 'not-configured'\n | 'method-not-allowed'\n | 'invalid-request'\n | 'mailbox-not-found'\n | 'not-your-mailbox'\n | 'mailbox-limit'\n | 'state-invalid'\n | 'state-expired'\n | 'state-user-mismatch'\n | 'state-org-mismatch'\n | 'state-replayed'\n | 'state-superseded'\n | 'code-rejected'\n | 'refresh-token-missing'\n | 'scopes-missing'\n | 'identity-unverified'\n | 'account-mismatch'\n | 'reconnect-required'\n | 'invalid-settings'\n | 'rate-limited'\n | 'google-unavailable'\n\nexport interface OutreachApiRefusal {\n error: string\n reason: OutreachApiRefusalReason\n}\n\n/** A reason `GET outreach/mailboxes/availability` answers `configured: false`. */\nexport type OutreachMailboxAvailabilityGate =\n | { gate: 'google'; missing: string[] }\n | { gate: 'state' }\n | { gate: 'redirect' }\n\n/** `GET outreach/mailboxes/availability` */\nexport interface OutreachMailboxAvailability {\n configured: boolean\n /**\n * When not configured, which gates refused, so an operator reading the\n * response knows what to set: the Google client (`google`, with the names\n * of the missing variables), the state signing secret (`state`), or the\n * console origin to register (`redirect`). Absent when configured.\n */\n missing?: OutreachMailboxAvailabilityGate[]\n /**\n * Whether the viewer is an organization owner or admin, who may change,\n * pause and disconnect any member's mailbox. The routes decide this on\n * every call; the panel reads it to offer only what will be allowed.\n */\n canManageAll: boolean\n}\n\n/** `POST outreach/mailboxes/connect` */\nexport interface OutreachConnectRequest {\n orgId: string\n}\nexport interface OutreachConnectResponse {\n /** Google's consent address; the browser goes there. */\n url: string\n}\n\n/** `POST outreach/mailboxes/connect/complete` */\nexport interface OutreachConnectCompleteRequest {\n orgId: string\n code: string\n state: string\n /** The browser's IANA timezone, for a new mailbox's sending window. */\n timezone?: string\n}\nexport interface OutreachConnectCompleteResponse {\n ok: true\n mailbox: OutreachMailbox\n /** False when an existing mailbox for the account was reconnected. */\n created: boolean\n /** Pending addresses of the member's that Gmail's send-as list confirmed. */\n confirmedAliases: string[]\n}\n\n/** `POST outreach/mailboxes/settings` — every field but the ids is optional. */\nexport interface OutreachMailboxSettingsRequest {\n orgId: string\n mailboxId: string\n sendAs?: string\n displayName?: string\n dailyCap?: number\n window?: OutreachSendWindow\n timezone?: string\n}\n\n/** `POST outreach/mailboxes/status` */\nexport interface OutreachMailboxStatusRequest {\n orgId: string\n mailboxId: string\n paused: boolean\n}\n\nexport interface OutreachMailboxResponse {\n ok: true\n mailbox: OutreachMailbox\n}\n\n/**\n * `POST outreach/mailboxes/test` — `{ mailboxId, to? }`. `to` is the address\n * the test goes to; left off, the account's own (AGL-3228).\n */\nexport interface OutreachMailboxTestResponse {\n ok: true\n /** The address the test went to. */\n sentTo: string\n gmailMessageId: string\n sentAtMs: number\n}\n\n/** `POST outreach/mailboxes/disconnect` */\nexport interface OutreachMailboxDisconnectResponse {\n ok: true\n /**\n * What became of the grant at Google: `revoked`, `already-invalid`,\n * `kept-for-other-mailbox` when another mailbox still uses the account,\n * or `failed` when Google could not be told — the stored grant is deleted\n * either way.\n */\n revocation: 'revoked' | 'already-invalid' | 'kept-for-other-mailbox' | 'failed'\n}\n\n/**\n * The fragment key a connect returns through (AGL-2978).\n *\n * Google redirects to the callback route with the authorization code in the\n * query; the callback moves it into the FRAGMENT of the Mailboxes page, which\n * no server receives — not in a request line, not in a `Referer` — and the\n * page takes it out of the address bar before it finishes the connect with\n * the member's own session. The cross-domain session handoff uses the\n * fragment for the same reason.\n */\nexport const OUTREACH_CONNECT_FRAGMENT_KEY = 'outreachConnect'\n\n/** Why a connect came back without a code. */\nexport type OutreachConnectReturnError = 'access_denied' | 'expired' | 'google_error'\n\nexport type OutreachConnectReturn =\n | { kind: 'code'; code: string; state: string }\n | { kind: 'error'; reason: OutreachConnectReturnError }\n\n/** The fragment for a connect's return, without the leading `#`. */\nexport function buildConnectReturnFragment(value: OutreachConnectReturn): string {\n const params = new URLSearchParams()\n if (value.kind === 'code') {\n params.set(OUTREACH_CONNECT_FRAGMENT_KEY, 'code')\n params.set('code', value.code)\n params.set('state', value.state)\n } else {\n params.set(OUTREACH_CONNECT_FRAGMENT_KEY, 'error')\n params.set('reason', value.reason)\n }\n return params.toString()\n}\n\nconst RETURN_ERRORS: readonly OutreachConnectReturnError[] = ['access_denied', 'expired', 'google_error']\n\n/** A connect's return read off a location hash, or `null` when it carries none. */\nexport function parseConnectReturnFragment(hash: string | null | undefined): OutreachConnectReturn | null {\n const params = new URLSearchParams(String(hash ?? '').replace(/^#/, ''))\n const kind = params.get(OUTREACH_CONNECT_FRAGMENT_KEY)\n if (kind === 'code') {\n const code = params.get('code') ?? ''\n const state = params.get('state') ?? ''\n return code && state ? { kind: 'code', code, state } : null\n }\n if (kind === 'error') {\n const reason = params.get('reason') as OutreachConnectReturnError | null\n return { kind: 'error', reason: reason && RETURN_ERRORS.includes(reason) ? reason : 'google_error' }\n }\n return null\n}\n"],"names":["OUTREACH_CONNECT_FRAGMENT_KEY","buildConnectReturnFragment","value","params","URLSearchParams","kind","set","code","state","reason","toString","RETURN_ERRORS","parseConnectReturnFragment","hash","String","replace","get","includes"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GA+ID;;;;;;;;;CASC,GACD,OAAO,MAAMA,gCAAgC,kBAAiB;AAS9D,kEAAkE,GAClE,OAAO,SAASC,2BAA2BC,KAA4B;IACrE,MAAMC,SAAS,IAAIC;IACnB,IAAIF,MAAMG,IAAI,KAAK,QAAQ;QACzBF,OAAOG,GAAG,CAACN,+BAA+B;QAC1CG,OAAOG,GAAG,CAAC,QAAQJ,MAAMK,IAAI;QAC7BJ,OAAOG,GAAG,CAAC,SAASJ,MAAMM,KAAK;IACjC,OAAO;QACLL,OAAOG,GAAG,CAACN,+BAA+B;QAC1CG,OAAOG,GAAG,CAAC,UAAUJ,MAAMO,MAAM;IACnC;IACA,OAAON,OAAOO,QAAQ;AACxB;AAEA,MAAMC,gBAAuD;IAAC;IAAiB;IAAW;CAAe;AAEzG,iFAAiF,GACjF,OAAO,SAASC,2BAA2BC,IAA+B;IACxE,MAAMV,SAAS,IAAIC,gBAAgBU,OAAOD,eAAAA,OAAQ,IAAIE,OAAO,CAAC,MAAM;IACpE,MAAMV,OAAOF,OAAOa,GAAG,CAAChB;IACxB,IAAIK,SAAS,QAAQ;YACNF,aACCA;QADd,MAAMI,QAAOJ,cAAAA,OAAOa,GAAG,CAAC,mBAAXb,cAAsB;QACnC,MAAMK,SAAQL,eAAAA,OAAOa,GAAG,CAAC,oBAAXb,eAAuB;QACrC,OAAOI,QAAQC,QAAQ;YAAEH,MAAM;YAAQE;YAAMC;QAAM,IAAI;IACzD;IACA,IAAIH,SAAS,SAAS;QACpB,MAAMI,SAASN,OAAOa,GAAG,CAAC;QAC1B,OAAO;YAAEX,MAAM;YAASI,QAAQA,UAAUE,cAAcM,QAAQ,CAACR,UAAUA,SAAS;QAAe;IACrG;IACA,OAAO;AACT"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
1
2
|
/**
|
|
2
3
|
* @license
|
|
3
4
|
* Copyright 2026 Aglyn LLC
|
|
@@ -13,7 +14,7 @@
|
|
|
13
14
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
15
|
* See the License for the specific language governing permissions and
|
|
15
16
|
* limitations under the License.
|
|
16
|
-
*/ import {
|
|
17
|
+
*/ import { normalizeContactEmail } from "@aglyn/aglyn/app-utils/contacts";
|
|
17
18
|
import { createGmailClient } from "../transport/gmail-client.js";
|
|
18
19
|
import { GmailTransportError } from "../transport/gmail-errors.js";
|
|
19
20
|
import { buildGoogleAuthorizationUrl, exchangeGoogleAuthorizationCode, grantedScopesInclude, OUTREACH_REQUIRED_GRANTED_SCOPES, pkceChallenge, readGoogleIdToken } from "../transport/google-oauth.js";
|
|
@@ -88,11 +89,26 @@ export function createOutreachMailboxRoutes(deps) {
|
|
|
88
89
|
if (request.method !== 'GET') return methodNotAllowed('GET');
|
|
89
90
|
const gate = await outreachMemberGate(request, new URL(request.url).searchParams.get('orgId'), deps.gate);
|
|
90
91
|
if (gate instanceof Response) return gate;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
// Every gate is named, not the first: an operator fixing a deployment
|
|
93
|
+
// should see the whole list once rather than one variable per redeploy.
|
|
94
|
+
const missing = [];
|
|
95
|
+
const config = deps.readConfig();
|
|
96
|
+
if (config.configured === false) missing.push({
|
|
97
|
+
gate: 'google',
|
|
98
|
+
missing: config.missing
|
|
99
|
+
});
|
|
100
|
+
if (!deps.stateSigningConfigured()) missing.push({
|
|
101
|
+
gate: 'state'
|
|
95
102
|
});
|
|
103
|
+
if (!deps.redirectUri(request.url)) missing.push({
|
|
104
|
+
gate: 'redirect'
|
|
105
|
+
});
|
|
106
|
+
return ok(_extends({
|
|
107
|
+
configured: missing.length === 0,
|
|
108
|
+
canManageAll: gate.isOrgAdmin
|
|
109
|
+
}, missing.length ? {
|
|
110
|
+
missing
|
|
111
|
+
} : {}));
|
|
96
112
|
};
|
|
97
113
|
const connect = async (request)=>{
|
|
98
114
|
if (request.method !== 'POST') return methodNotAllowed('POST');
|
|
@@ -504,6 +520,19 @@ export function createOutreachMailboxRoutes(deps) {
|
|
|
504
520
|
if (!limited.allowed) {
|
|
505
521
|
return refusal(429, 'rate-limited', `A mailbox can send ${OUTREACH_TEST_SENDS_PER_HOUR} tests an hour. Try again later.`);
|
|
506
522
|
}
|
|
523
|
+
/*
|
|
524
|
+
* To the member's own address unless they name another (AGL-3228). A
|
|
525
|
+
* test to oneself never leaves Google, so it arrives with no
|
|
526
|
+
* Authentication-Results at all and cannot show whether the send-as
|
|
527
|
+
* domain's DKIM and DMARC hold; an outside mailbox the member can read
|
|
528
|
+
* is the only place that can be seen. The rate limit above is the
|
|
529
|
+
* whole guard on who may be written to — it is the member's own
|
|
530
|
+
* account sending, as it could from its own compose window.
|
|
531
|
+
*/ const named = body['to'];
|
|
532
|
+
const to = named === undefined || named === null || String(named).trim() === '' ? mailbox.email : normalizeContactEmail(String(named));
|
|
533
|
+
if (!to) {
|
|
534
|
+
return refusal(400, 'invalid-request', `"${String(named).trim()}" isn't a valid email address.`);
|
|
535
|
+
}
|
|
507
536
|
const firestore = deps.firestore();
|
|
508
537
|
const nowMs = deps.now();
|
|
509
538
|
const opened = await openOutreachMailboxClient(firestore, {
|
|
@@ -528,17 +557,21 @@ export function createOutreachMailboxRoutes(deps) {
|
|
|
528
557
|
address: mailbox.sendAs,
|
|
529
558
|
name: mailbox.displayName
|
|
530
559
|
},
|
|
531
|
-
to
|
|
560
|
+
to,
|
|
532
561
|
subject: 'Sequences test message',
|
|
533
562
|
text: [
|
|
534
563
|
'This is a test message from Sequences.',
|
|
535
564
|
'',
|
|
536
|
-
`It was sent through
|
|
565
|
+
`It was sent through the connected mailbox ${mailbox.email} as ${mailbox.sendAs}. ` + 'If it reached the inbox, this mailbox can send.',
|
|
566
|
+
...to === mailbox.email ? [] : [
|
|
567
|
+
'',
|
|
568
|
+
'To read whether the sender is authenticated, open this message\u2019s original source ' + 'and look for dkim=pass, spf=pass and dmarc=pass in Authentication-Results.'
|
|
569
|
+
]
|
|
537
570
|
].join('\n')
|
|
538
571
|
});
|
|
539
572
|
return ok({
|
|
540
573
|
ok: true,
|
|
541
|
-
sentTo:
|
|
574
|
+
sentTo: to,
|
|
542
575
|
gmailMessageId: sent.gmailMessageId,
|
|
543
576
|
sentAtMs: nowMs
|
|
544
577
|
});
|