@aglyn/tenant-data-admin 1.0.0-beta.203 → 1.0.0-beta.204
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 +7 -7
- package/src/lib/server/capture-email-check.d.ts +109 -0
- package/src/lib/server/capture-email-check.js +362 -0
- package/src/lib/server/capture-email-check.js.map +1 -0
- package/src/lib/server/card-payment-velocity.d.ts +11 -16
- package/src/lib/server/card-payment-velocity.js +24 -28
- package/src/lib/server/card-payment-velocity.js.map +1 -1
- package/src/lib/server/email-deliverability.d.ts +19 -3
- package/src/lib/server/email-deliverability.js +34 -16
- package/src/lib/server/email-deliverability.js.map +1 -1
- package/src/lib/server/held-page-subject.d.ts +93 -0
- package/src/lib/server/held-page-subject.js +206 -0
- package/src/lib/server/held-page-subject.js.map +1 -0
- package/src/lib/server/host-email-tokens.d.ts +42 -1
- package/src/lib/server/host-email-tokens.js +100 -12
- package/src/lib/server/host-email-tokens.js.map +1 -1
- package/src/lib/server/host-visitor-records.js +21 -5
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.d.ts +16 -5
- package/src/lib/server/hosted-page-review.js +242 -61
- package/src/lib/server/hosted-page-review.js.map +1 -1
- package/src/lib/server/list-members.js +16 -0
- package/src/lib/server/list-members.js.map +1 -1
- package/src/lib/server/outbound-send-review.d.ts +22 -0
- package/src/lib/server/outbound-send-review.js +74 -6
- package/src/lib/server/outbound-send-review.js.map +1 -1
- package/src/lib/server/payment-fraud-signal.d.ts +20 -12
- package/src/lib/server/payment-fraud-signal.js +41 -11
- package/src/lib/server/payment-fraud-signal.js.map +1 -1
- package/src/lib/server/payment-risk-record.d.ts +37 -13
- package/src/lib/server/payment-risk-record.js +23 -8
- package/src/lib/server/payment-risk-record.js.map +1 -1
- package/src/lib/server/render-system-email.d.ts +3 -0
- package/src/lib/server/render-system-email.js +39 -11
- package/src/lib/server/render-system-email.js.map +1 -1
- package/src/lib/server/risk-notice.d.ts +327 -0
- package/src/lib/server/risk-notice.js +1240 -0
- package/src/lib/server/risk-notice.js.map +1 -0
- package/src/lib/server/sender-readiness.d.ts +36 -0
- package/src/lib/server/sender-readiness.js +99 -0
- package/src/lib/server/sender-readiness.js.map +1 -0
- package/src/lib/server/test-firestore.js +1 -0
- package/src/lib/server/test-firestore.js.map +1 -1
- package/src/lib/server/upsert-contact.js +19 -3
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/tenancy.d.ts +2 -0
- package/src/tenancy.js +3 -0
- package/src/tenancy.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/list-members.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 * The only writer of `orgs/{orgId}/lists/{listId}/members` (AGL-2499).\n *\n * Two enrollment routes reach this collection — the commerce newsletter\n * handler and the workflow `enrollList` step — and they used to derive the\n * document id two incompatible ways: a full `sha256(email)` on one side, a\n * `hmac('aglyn-list-member', email)` truncated to 20 hex on the other. The\n * same person subscribing by both routes became two members of one list.\n *\n * The id is now `personKey` on both, which is the derivation both\n * `docs/specs/email-overhaul.md` §3d and `docs/specs/reusable-forms.md` §4\n * specify, and it normalizes before hashing so casing cannot fork it either.\n *\n * ## Why a helper and not two corrected call sites\n *\n * Two call sites that merely agree today are what produced the split: nothing\n * about either one said the other existed. A third route — the reusable-forms\n * capture path is already specified — would have been written the same way.\n * Enrolling goes through this function so that the id has one definition and\n * no caller is offered the chance to derive its own.\n */\n\nimport type { DocumentReference } from 'firebase-admin/firestore'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n normalizeContactEmail,\n personKey,\n CAPTURED_BY_HOST_FIELD,\n MARKETING_CONSENT_BASIS_FIELD,\n MARKETING_CONSENT_BY_HOST_FIELD,\n MARKETING_CONSENT_FIELD,\n marketingConsentFieldsForGroup,\n readMarketingBasis,\n type ConsentGroup,\n} from '@aglyn/aglyn/server'\nimport { consentGroupForGrant } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { nameSearchTokens } from '@aglyn/aglyn/app-utils/name-search'\nimport { createHash, createHmac } from 'node:crypto'\nimport { emailSearchTokens } from './email-suppression'\n\n/**\n * What a list's membership table searches a member by (AGL-3321): the word\n * prefixes of their name and of their address (`emailSearchTokens`), one\n * array, so one `array-contains` finds \"dana\" in either.\n *\n * Mirrored by `tools/scripts/backfill-email-list-filters.mjs`, which stamps\n * the rows written before the field; the two are held to the same fixtures\n * (`tools/scripts/lib/email-search-tokens.fixtures.json`, which the\n * script-side twin in `tools/scripts/lib/email-search-tokens.mjs` answers too).\n */\nexport function listMemberSearchTokens(\n email: string | null | undefined,\n name: string | null | undefined,\n): string[] {\n return [...new Set([...emailSearchTokens(email), ...nameSearchTokens(name)])]\n}\n\n/**\n * The two ids this collection was written under before `personKey`.\n *\n * Read-only, and never written for a new row: they exist so an address\n * already enrolled under a legacy id is *found* rather than duplicated. See\n * `enrollListMember` for why that lookup is the migration.\n *\n * Both take the normalized address, which is what the two original call sites\n * happened to pass — each lowercased at its own entry point before hashing, so\n * every row already on the collection was keyed from a lowercased address even\n * though neither derivation enforced it.\n */\nfunction legacyListMemberIds(normalizedEmail: string): string[] {\n return [\n createHash('sha256').update(normalizedEmail).digest('hex'),\n createHmac('sha256', 'aglyn-list-member')\n .update(normalizedEmail)\n .digest('hex')\n .slice(0, 20),\n ]\n}\n\n/**\n * Every document id one address may be filed under on a list's `members`:\n * the canonical key first, then the two ids earlier writers minted. A\n * reader that must find the person wherever they were enrolled — the\n * erasure sweep (AGL-2623) — asks for all of them with one `getAll`, the\n * same set `enrollListMember` reads before it writes, so a member enrolled\n * under an old id is neither missed nor duplicated.\n */\nexport function listMemberDocIds(email: unknown): string[] {\n const normalized = normalizeContactEmail(email)\n if (!normalized) return []\n const key = personKey(normalized)\n if (!key) return []\n return [key, ...legacyListMemberIds(normalized).filter((id) => id !== key)]\n}\n\n/**\n * What makes an enrollment mailable, and who is answerable for it.\n *\n * Two values and no third. They are not interchangeable and the difference is\n * the whole reason the basis is stored rather than implied:\n *\n * - `contact-opt-in` — the person ticked a box. The enrollment carries a\n * decision they made, and `atMs` is when THEY made it, not when the row was\n * written. Nobody is answerable because nobody asserted anything.\n * - `operator-attested` — a console account stated that they have this\n * person's permission. That statement is itself the basis, so it is only\n * worth storing with the account that made it and the moment they did.\n *\n * Rendering the second as though it were the first is what a support or\n * compliance question cannot recover from, which is why the basis rides\n * beside `marketingConsent` on every row that has one.\n */\nexport type ListMemberConsentBasis = 'contact-opt-in' | 'operator-attested'\n\n/** A basis, with the attribution that makes it answerable. */\nexport interface ListMemberConsent {\n basis: ListMemberConsentBasis\n /**\n * When the basis was recorded.\n *\n * For a pass-through this is the ORIGINAL opt-in moment carried over from\n * the person's own record, because the question a consent record answers is\n * when the person said yes — not when somebody copied their answer onto a\n * list.\n */\n atMs: number\n /** The console account that attested. Meaningless for a pass-through. */\n byUid?: string | null\n /**\n * Why, in the operator's own terms, for whoever audits this later.\n *\n * Written for an attestation and ignored for a pass-through, which needs no\n * explanation beyond the person's own record. An import fills it from what\n * the FILE declared — the opt-in source and date the merchant supplied per\n * row — because \"a declared basis per address, not one checkbox over the\n * file\" is only worth asking for if the declaration survives onto the row\n * it was made about.\n */\n reason?: string\n}\n\nexport interface EnrollListMemberInput {\n /** `orgs/{orgId}/lists/{listId}` — the caller has already proved it exists. */\n listRef: DocumentReference\n /**\n * The consent group the enrollment is being made IN THE NAME OF.\n *\n * Lists live on the org and every site in it can mail one, so a membership\n * carries a basis that no path could attribute to a controller. A GROUP\n * rather than a site, because a business running three sites under one name\n * legitimately enrolls somebody into all three at once — and an agency's\n * client, which declared no group, gets exactly itself.\n *\n * Required and undefaulted: a caller that has not resolved a group has not\n * decided who this person agreed to hear from.\n */\n group: ConsentGroup\n email: string\n name?: string\n /** Free-form provenance: `'newsletter'`, `'action:{actionId}'`. */\n source: string\n /**\n * Explicit marketing opt-in, with a consent timestamp — the same shape\n * `upsertHostContact` and `addHostLead` already carry.\n *\n * A list membership had NO consent field of any kind, so `audience: 'list'`\n * had nothing for the send-time join to read\n * (`docs/specs/email-overhaul.md` §1d/§3f). This is the field that closes\n * it, and it is the caller's captured checkbox: enrolling is an act, and\n * `false`/omitted therefore writes nothing rather than a basis.\n */\n marketingConsent?: boolean\n /**\n * The basis in full, for a caller that knows more than \"a box was ticked\".\n *\n * `marketingConsent: true` is the shorthand a capture surface uses: it means\n * `contact-opt-in`, recorded now, asserted by nobody. Anything else — a\n * pass-through carrying the person's ORIGINAL opt-in date, or an\n * attestation carrying the account that made it — has to say so, and this\n * is where it says it. The two inputs are normalized to one value before\n * anything is written, so there is a single writer of the consent fields.\n */\n consent?: ListMemberConsent\n /**\n * The person's own grants, as `grantEntriesAsRecorded` read them off their\n * record — what a PASS-THROUGH carries (AGL-3320).\n *\n * A `contact-opt-in` consent with these writes exactly these sites, with\n * each grant's own moment and the disclosure it was given under: an opt-in\n * given to one site before the group existed stays that site's on the list,\n * and one given under the group's sentence reaches the sites it named.\n * Re-recording the pass-through over {@link group} instead would widen the\n * first to every site the group names today.\n */\n grantEntries?: Readonly<Record<string, Readonly<Record<string, unknown>>>>\n /**\n * The `consentGroupDisclosureKey` a capture surface rendered beside its\n * checkbox, for a {@link marketingConsent} captured just now. The grant\n * pools across {@link group} only when it is the group's current key.\n *\n * Every other basis without {@link grantEntries} — an attestation, a\n * capture that rendered no disclosure — is recorded for the enrolling site\n * alone: an operator's word about one site's list is not a disclosure\n * anybody read about the rest.\n */\n disclosedConsentGroup?: string | null\n /**\n * How the person got here: enrolled by hand or by an automation\n * (`'manual'`), or selected by a dynamic list's rule (`'rule'`).\n *\n * The materializer needs to tell its own rows apart from a manual one on\n * the same list, because a person who stops matching the rule leaves and a\n * person somebody added by hand does not.\n */\n via?: 'manual' | 'rule'\n}\n\nexport interface EnrolledListMember {\n /** The document actually written — a legacy id when one was adopted. */\n memberId: string\n /** True when an existing legacy-keyed row was written instead of a new one. */\n adopted: boolean\n /** False when the row already existed under some id. */\n created: boolean\n}\n\n/** Why nothing was written. */\nexport type EnrollListMemberRefusal =\n /** Not an address this collection can key. Nothing to write, nothing to fix. */\n | 'unusable-address'\n /**\n * The person's membership records a REFUSAL. Not overridable by any caller\n * and not reported as a failure to fix, because the fix would be to ignore\n * it.\n */\n | 'declined'\n\n/**\n * Enrolled, or refused and why.\n *\n * A union rather than `EnrolledListMember | null`, because the two refusals\n * are not the same event and a caller that cannot tell them apart is a caller\n * that reports a person's recorded refusal as a malformed address.\n */\nexport type EnrollListMemberResult =\n | ({ enrolled: true } & EnrolledListMember)\n | { enrolled: false; refusal: EnrollListMemberRefusal }\n\n/**\n * Enrolls one address into a list, at one document per person.\n *\n * ## The legacy lookup is the migration\n *\n * Changing the derivation without one would strand every row written under the\n * old ids: the next enrollment of an address already on the list would key a\n * *new* document beside the old one, so a defect that produced duplicates only\n * when two routes met would start producing them on a single route. Rather\n * than rewrite those ids — a bulk operation that has to delete the row it\n * replaces, and deleting an enrollment destroys the consent record that says\n * the person asked to be there — the write resolves the person's existing\n * document and keeps using it.\n *\n * The cost is one `getAll` of three refs, on a human-triggered signup, in\n * place of the blind `set` this replaced. It is one round trip, not three.\n *\n * `addedAt` is stamped only when the document is created, so re-enrolling\n * keeps the date the person actually joined — the same \"earliest wins\" collapse\n * `docs/specs/email-overhaul.md` §3d asks a backfill to preserve.\n *\n * ## A recorded refusal stops every route through this function\n *\n * This is the only writer of the collection, so the refusal check belongs\n * here and not at the four call sites: a guard on the button that adds\n * somebody by hand leaves the newsletter handler, the workflow `enrollList`\n * step and the dynamic-list materializer free to put the same person back.\n * The membership snapshot is already in hand from the id resolution above, so\n * the check costs no read.\n *\n * It answers the question about the LIST ROW. A refusal recorded on the\n * person's CRM record is a different document this function does not fetch —\n * a caller that can see one is expected to consult it, and the send-time\n * consent join reads the row this function writes either way.\n */\nexport async function enrollListMember(\n input: EnrollListMemberInput,\n): Promise<EnrollListMemberResult> {\n const email = normalizeContactEmail(input.email)\n if (!email) return { enrolled: false, refusal: 'unusable-address' }\n const key = personKey(email)\n if (!key) return { enrolled: false, refusal: 'unusable-address' }\n\n const members = input.listRef.collection('members')\n const canonicalRef = members.doc(key)\n const legacyRefs = legacyListMemberIds(email)\n // A legacy derivation that happens to agree with `personKey` — `sha256` of\n // the same normalized address does — must not be fetched twice: `getAll`\n // rejects duplicate references.\n .filter((id) => id !== key)\n .map((id) => members.doc(id))\n\n const snapshots = await input.listRef.firestore.getAll(\n canonicalRef,\n ...legacyRefs,\n )\n const existing = snapshots.find((snapshot) => snapshot.exists)\n const target = existing?.ref ?? canonicalRef\n\n if (\n existing &&\n readMarketingBasis(existing.data() as Record<string, unknown>, input.group)\n .basis === 'declined'\n ) {\n return { enrolled: false, refusal: 'declined' }\n }\n\n /*\n * ONE value from the two ways a caller may state a basis.\n *\n * `marketingConsent: true` is a capture surface saying \"a box was ticked\n * just now\", which is `contact-opt-in` recorded at this moment with nobody\n * asserting it. Normalizing here rather than branching at the write is what\n * keeps the consent fields to a single writer — two branches stamping\n * overlapping subsets of them is how a row comes to carry a basis with no\n * timestamp, or a timestamp with no basis.\n */\n const consent: ListMemberConsent | null =\n input.consent ??\n (input.marketingConsent\n ? { basis: 'contact-opt-in', atMs: Date.now() }\n : null)\n\n /*\n * The name the row will hold after this write — the caller's, else the one\n * it already carries — because the search tokens are the address's and\n * that name's, and a re-enrollment without a name keeps the stored one.\n */\n const name = input.name || (existing?.get('name') as string | undefined) || null\n await target.set(\n {\n email,\n ...(input.name ? { name: input.name } : {}),\n searchTokens: listMemberSearchTokens(email, name),\n source: input.source,\n /*\n * `via` is on every row, so the table's How filter can ask for it\n * (AGL-3321): the caller's, else `manual` on a row that has none — what\n * an absent `via` has always meant. A caller that says nothing never\n * moves a stored one.\n */\n ...(input.via\n ? { via: input.via }\n : existing?.get('via')\n ? {}\n : { via: 'manual' }),\n /*\n * Written only when the caller has a basis, and never unwritten.\n *\n * A merge that stamped `marketingConsent: false` on the omitted case\n * would erase a basis this person gave on an earlier enrollment, and a\n * withdrawn consent is not the same event as a re-enrollment that\n * happened to carry no checkbox. Withdrawal has its own path — the\n * unsubscribe link and the suppression list.\n *\n * `marketingConsentAtMs` moves with each fresh basis on purpose: the\n * question a consent record answers is when the person last said yes.\n *\n * The `marketingConsent` family rather than the `consent*` names\n * `docs/specs/email-overhaul.md` §3d sketched: `readMarketingBasis` is\n * the shipped reader and it reads these, so a second vocabulary here\n * would be a basis the send-time join cannot see. The two new fields\n * are attribution ON that field — they say why the person is mailable,\n * never whether — so nothing about the send decision moves into them.\n *\n * Written UNDER the enrolling GROUP, through the one writer helper. A\n * list lives on the org and every site in it can mail one, so a basis\n * written at the top of this row would enroll a person into one\n * client's newsletter and make them mailable by all of them. A group of\n * one — the default — is that client and nobody else.\n *\n * `marketingConsentByUid` is written on EVERY basis, `null` for a\n * pass-through. Leaving it absent would let a real opt-in that\n * supersedes an earlier attestation inherit the attesting account, and\n * a row reading \"this account vouched for them\" when the person ticked\n * a box themselves is a false attribution in the one direction a\n * compliance answer cannot afford.\n *\n * `marketingConsentReason` follows the same rule for the same reason:\n * always written, `''` for a pass-through, so a later real opt-in\n * cannot inherit the sentence an earlier import wrote about where the\n * merchant said the address came from.\n *\n * WHICH sites the basis covers is `membershipConsentFields`' to say,\n * and it never re-derives a grant from the group as it stands now.\n */\n ...(consent ? membershipConsentFields(input, consent) : {}),\n ...(existing\n ? {}\n : {\n addedAt: FieldValue.serverTimestamp(),\n [CAPTURED_BY_HOST_FIELD]: [input.group.hostId],\n }),\n },\n { merge: true },\n )\n\n return {\n enrolled: true,\n memberId: target.id,\n adopted: target.id !== key,\n created: !existing,\n }\n}\n\n/**\n * The consent fields one enrollment writes: the basis, its attribution, and\n * the sites it covers (AGL-3320).\n *\n * Three ways in, and the sites follow from which one it is:\n *\n * - a PASS-THROUGH with the person's {@link EnrollListMemberInput.grantEntries}\n * writes those sites and no others, each grant with its own moment and\n * the disclosure it was given under;\n * - a capture's checkbox pools over the group only when its surface sent\n * back the group's current disclosure key — `consentGroupForGrant`;\n * - everything else, an attestation among them, is the enrolling site's\n * alone.\n *\n * The attribution is the membership's own on every entry, the pass-through's\n * included, for the reason the write explains: an entry that kept an earlier\n * attestation's account under a person's own opt-in would be a false claim\n * about who vouched for them.\n */\nfunction membershipConsentFields(\n input: EnrollListMemberInput,\n consent: ListMemberConsent,\n): Record<string, unknown> {\n const attribution = {\n [MARKETING_CONSENT_BASIS_FIELD]: consent.basis,\n marketingConsentByUid: consent.byUid ?? null,\n marketingConsentReason: consent.reason ?? '',\n }\n const carried =\n consent.basis === 'contact-opt-in'\n ? Object.entries(input.grantEntries ?? {})\n : []\n if (carried.length) {\n return {\n [MARKETING_CONSENT_BY_HOST_FIELD]: Object.fromEntries(\n carried.map(([hostId, entry]) => [\n hostId,\n {\n ...entry,\n [MARKETING_CONSENT_FIELD]: true,\n // The grant's own moment; the pass-through's only when the\n // person's record never stamped one, as the basis itself falls\n // back to now.\n marketingConsentAtMs: entry['marketingConsentAtMs'] ?? consent.atMs,\n ...attribution,\n },\n ]),\n ),\n }\n }\n return marketingConsentFieldsForGroup(\n consentGroupForGrant(input.group, input.disclosedConsentGroup),\n consent.atMs,\n attribution,\n )\n}\n"],"names":["FieldValue","normalizeContactEmail","personKey","CAPTURED_BY_HOST_FIELD","MARKETING_CONSENT_BASIS_FIELD","MARKETING_CONSENT_BY_HOST_FIELD","MARKETING_CONSENT_FIELD","marketingConsentFieldsForGroup","readMarketingBasis","consentGroupForGrant","nameSearchTokens","createHash","createHmac","emailSearchTokens","listMemberSearchTokens","email","name","Set","legacyListMemberIds","normalizedEmail","update","digest","slice","listMemberDocIds","normalized","key","filter","id","enrollListMember","input","enrolled","refusal","members","listRef","collection","canonicalRef","doc","legacyRefs","map","snapshots","firestore","getAll","existing","find","snapshot","exists","target","ref","data","group","basis","consent","marketingConsent","atMs","Date","now","get","set","searchTokens","source","via","membershipConsentFields","addedAt","serverTimestamp","hostId","merge","memberId","adopted","created","attribution","marketingConsentByUid","byUid","marketingConsentReason","reason","carried","Object","entries","grantEntries","length","fromEntries","entry","marketingConsentAtMs","disclosedConsentGroup"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/list-members.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 * The only writer of `orgs/{orgId}/lists/{listId}/members` (AGL-2499).\n *\n * Two enrollment routes reach this collection — the commerce newsletter\n * handler and the workflow `enrollList` step — and they used to derive the\n * document id two incompatible ways: a full `sha256(email)` on one side, a\n * `hmac('aglyn-list-member', email)` truncated to 20 hex on the other. The\n * same person subscribing by both routes became two members of one list.\n *\n * The id is now `personKey` on both, which is the derivation both\n * `docs/specs/email-overhaul.md` §3d and `docs/specs/reusable-forms.md` §4\n * specify, and it normalizes before hashing so casing cannot fork it either.\n *\n * ## Why a helper and not two corrected call sites\n *\n * Two call sites that merely agree today are what produced the split: nothing\n * about either one said the other existed. A third route — the reusable-forms\n * capture path is already specified — would have been written the same way.\n * Enrolling goes through this function so that the id has one definition and\n * no caller is offered the chance to derive its own.\n */\n\nimport type { DocumentReference } from 'firebase-admin/firestore'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n normalizeContactEmail,\n personKey,\n CAPTURED_BY_HOST_FIELD,\n MARKETING_CONSENT_BASIS_FIELD,\n MARKETING_CONSENT_BY_HOST_FIELD,\n MARKETING_CONSENT_FIELD,\n marketingConsentFieldsForGroup,\n readMarketingBasis,\n type ConsentGroup,\n} from '@aglyn/aglyn/server'\nimport { consentGroupForGrant } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { nameSearchTokens } from '@aglyn/aglyn/app-utils/name-search'\nimport { createHash, createHmac } from 'node:crypto'\nimport { emailSearchTokens } from './email-suppression'\nimport { scheduleCapturedEmailCheck } from './capture-email-check'\n\n/**\n * What a list's membership table searches a member by (AGL-3321): the word\n * prefixes of their name and of their address (`emailSearchTokens`), one\n * array, so one `array-contains` finds \"dana\" in either.\n *\n * Mirrored by `tools/scripts/backfill-email-list-filters.mjs`, which stamps\n * the rows written before the field; the two are held to the same fixtures\n * (`tools/scripts/lib/email-search-tokens.fixtures.json`, which the\n * script-side twin in `tools/scripts/lib/email-search-tokens.mjs` answers too).\n */\nexport function listMemberSearchTokens(\n email: string | null | undefined,\n name: string | null | undefined,\n): string[] {\n return [...new Set([...emailSearchTokens(email), ...nameSearchTokens(name)])]\n}\n\n/**\n * The two ids this collection was written under before `personKey`.\n *\n * Read-only, and never written for a new row: they exist so an address\n * already enrolled under a legacy id is *found* rather than duplicated. See\n * `enrollListMember` for why that lookup is the migration.\n *\n * Both take the normalized address, which is what the two original call sites\n * happened to pass — each lowercased at its own entry point before hashing, so\n * every row already on the collection was keyed from a lowercased address even\n * though neither derivation enforced it.\n */\nfunction legacyListMemberIds(normalizedEmail: string): string[] {\n return [\n createHash('sha256').update(normalizedEmail).digest('hex'),\n createHmac('sha256', 'aglyn-list-member')\n .update(normalizedEmail)\n .digest('hex')\n .slice(0, 20),\n ]\n}\n\n/**\n * Every document id one address may be filed under on a list's `members`:\n * the canonical key first, then the two ids earlier writers minted. A\n * reader that must find the person wherever they were enrolled — the\n * erasure sweep (AGL-2623) — asks for all of them with one `getAll`, the\n * same set `enrollListMember` reads before it writes, so a member enrolled\n * under an old id is neither missed nor duplicated.\n */\nexport function listMemberDocIds(email: unknown): string[] {\n const normalized = normalizeContactEmail(email)\n if (!normalized) return []\n const key = personKey(normalized)\n if (!key) return []\n return [key, ...legacyListMemberIds(normalized).filter((id) => id !== key)]\n}\n\n/**\n * What makes an enrollment mailable, and who is answerable for it.\n *\n * Two values and no third. They are not interchangeable and the difference is\n * the whole reason the basis is stored rather than implied:\n *\n * - `contact-opt-in` — the person ticked a box. The enrollment carries a\n * decision they made, and `atMs` is when THEY made it, not when the row was\n * written. Nobody is answerable because nobody asserted anything.\n * - `operator-attested` — a console account stated that they have this\n * person's permission. That statement is itself the basis, so it is only\n * worth storing with the account that made it and the moment they did.\n *\n * Rendering the second as though it were the first is what a support or\n * compliance question cannot recover from, which is why the basis rides\n * beside `marketingConsent` on every row that has one.\n */\nexport type ListMemberConsentBasis = 'contact-opt-in' | 'operator-attested'\n\n/** A basis, with the attribution that makes it answerable. */\nexport interface ListMemberConsent {\n basis: ListMemberConsentBasis\n /**\n * When the basis was recorded.\n *\n * For a pass-through this is the ORIGINAL opt-in moment carried over from\n * the person's own record, because the question a consent record answers is\n * when the person said yes — not when somebody copied their answer onto a\n * list.\n */\n atMs: number\n /** The console account that attested. Meaningless for a pass-through. */\n byUid?: string | null\n /**\n * Why, in the operator's own terms, for whoever audits this later.\n *\n * Written for an attestation and ignored for a pass-through, which needs no\n * explanation beyond the person's own record. An import fills it from what\n * the FILE declared — the opt-in source and date the merchant supplied per\n * row — because \"a declared basis per address, not one checkbox over the\n * file\" is only worth asking for if the declaration survives onto the row\n * it was made about.\n */\n reason?: string\n}\n\nexport interface EnrollListMemberInput {\n /** `orgs/{orgId}/lists/{listId}` — the caller has already proved it exists. */\n listRef: DocumentReference\n /**\n * The consent group the enrollment is being made IN THE NAME OF.\n *\n * Lists live on the org and every site in it can mail one, so a membership\n * carries a basis that no path could attribute to a controller. A GROUP\n * rather than a site, because a business running three sites under one name\n * legitimately enrolls somebody into all three at once — and an agency's\n * client, which declared no group, gets exactly itself.\n *\n * Required and undefaulted: a caller that has not resolved a group has not\n * decided who this person agreed to hear from.\n */\n group: ConsentGroup\n email: string\n name?: string\n /** Free-form provenance: `'newsletter'`, `'action:{actionId}'`. */\n source: string\n /**\n * Explicit marketing opt-in, with a consent timestamp — the same shape\n * `upsertHostContact` and `addHostLead` already carry.\n *\n * A list membership had NO consent field of any kind, so `audience: 'list'`\n * had nothing for the send-time join to read\n * (`docs/specs/email-overhaul.md` §1d/§3f). This is the field that closes\n * it, and it is the caller's captured checkbox: enrolling is an act, and\n * `false`/omitted therefore writes nothing rather than a basis.\n */\n marketingConsent?: boolean\n /**\n * The basis in full, for a caller that knows more than \"a box was ticked\".\n *\n * `marketingConsent: true` is the shorthand a capture surface uses: it means\n * `contact-opt-in`, recorded now, asserted by nobody. Anything else — a\n * pass-through carrying the person's ORIGINAL opt-in date, or an\n * attestation carrying the account that made it — has to say so, and this\n * is where it says it. The two inputs are normalized to one value before\n * anything is written, so there is a single writer of the consent fields.\n */\n consent?: ListMemberConsent\n /**\n * The person's own grants, as `grantEntriesAsRecorded` read them off their\n * record — what a PASS-THROUGH carries (AGL-3320).\n *\n * A `contact-opt-in` consent with these writes exactly these sites, with\n * each grant's own moment and the disclosure it was given under: an opt-in\n * given to one site before the group existed stays that site's on the list,\n * and one given under the group's sentence reaches the sites it named.\n * Re-recording the pass-through over {@link group} instead would widen the\n * first to every site the group names today.\n */\n grantEntries?: Readonly<Record<string, Readonly<Record<string, unknown>>>>\n /**\n * The `consentGroupDisclosureKey` a capture surface rendered beside its\n * checkbox, for a {@link marketingConsent} captured just now. The grant\n * pools across {@link group} only when it is the group's current key.\n *\n * Every other basis without {@link grantEntries} — an attestation, a\n * capture that rendered no disclosure — is recorded for the enrolling site\n * alone: an operator's word about one site's list is not a disclosure\n * anybody read about the rest.\n */\n disclosedConsentGroup?: string | null\n /**\n * How the person got here: enrolled by hand or by an automation\n * (`'manual'`), or selected by a dynamic list's rule (`'rule'`).\n *\n * The materializer needs to tell its own rows apart from a manual one on\n * the same list, because a person who stops matching the rule leaves and a\n * person somebody added by hand does not.\n */\n via?: 'manual' | 'rule'\n}\n\nexport interface EnrolledListMember {\n /** The document actually written — a legacy id when one was adopted. */\n memberId: string\n /** True when an existing legacy-keyed row was written instead of a new one. */\n adopted: boolean\n /** False when the row already existed under some id. */\n created: boolean\n}\n\n/** Why nothing was written. */\nexport type EnrollListMemberRefusal =\n /** Not an address this collection can key. Nothing to write, nothing to fix. */\n | 'unusable-address'\n /**\n * The person's membership records a REFUSAL. Not overridable by any caller\n * and not reported as a failure to fix, because the fix would be to ignore\n * it.\n */\n | 'declined'\n\n/**\n * Enrolled, or refused and why.\n *\n * A union rather than `EnrolledListMember | null`, because the two refusals\n * are not the same event and a caller that cannot tell them apart is a caller\n * that reports a person's recorded refusal as a malformed address.\n */\nexport type EnrollListMemberResult =\n | ({ enrolled: true } & EnrolledListMember)\n | { enrolled: false; refusal: EnrollListMemberRefusal }\n\n/**\n * Enrolls one address into a list, at one document per person.\n *\n * ## The legacy lookup is the migration\n *\n * Changing the derivation without one would strand every row written under the\n * old ids: the next enrollment of an address already on the list would key a\n * *new* document beside the old one, so a defect that produced duplicates only\n * when two routes met would start producing them on a single route. Rather\n * than rewrite those ids — a bulk operation that has to delete the row it\n * replaces, and deleting an enrollment destroys the consent record that says\n * the person asked to be there — the write resolves the person's existing\n * document and keeps using it.\n *\n * The cost is one `getAll` of three refs, on a human-triggered signup, in\n * place of the blind `set` this replaced. It is one round trip, not three.\n *\n * `addedAt` is stamped only when the document is created, so re-enrolling\n * keeps the date the person actually joined — the same \"earliest wins\" collapse\n * `docs/specs/email-overhaul.md` §3d asks a backfill to preserve.\n *\n * ## A recorded refusal stops every route through this function\n *\n * This is the only writer of the collection, so the refusal check belongs\n * here and not at the four call sites: a guard on the button that adds\n * somebody by hand leaves the newsletter handler, the workflow `enrollList`\n * step and the dynamic-list materializer free to put the same person back.\n * The membership snapshot is already in hand from the id resolution above, so\n * the check costs no read.\n *\n * It answers the question about the LIST ROW. A refusal recorded on the\n * person's CRM record is a different document this function does not fetch —\n * a caller that can see one is expected to consult it, and the send-time\n * consent join reads the row this function writes either way.\n */\nexport async function enrollListMember(\n input: EnrollListMemberInput,\n): Promise<EnrollListMemberResult> {\n const email = normalizeContactEmail(input.email)\n if (!email) return { enrolled: false, refusal: 'unusable-address' }\n const key = personKey(email)\n if (!key) return { enrolled: false, refusal: 'unusable-address' }\n\n const members = input.listRef.collection('members')\n const canonicalRef = members.doc(key)\n const legacyRefs = legacyListMemberIds(email)\n // A legacy derivation that happens to agree with `personKey` — `sha256` of\n // the same normalized address does — must not be fetched twice: `getAll`\n // rejects duplicate references.\n .filter((id) => id !== key)\n .map((id) => members.doc(id))\n\n const snapshots = await input.listRef.firestore.getAll(\n canonicalRef,\n ...legacyRefs,\n )\n const existing = snapshots.find((snapshot) => snapshot.exists)\n const target = existing?.ref ?? canonicalRef\n\n if (\n existing &&\n readMarketingBasis(existing.data() as Record<string, unknown>, input.group)\n .basis === 'declined'\n ) {\n return { enrolled: false, refusal: 'declined' }\n }\n\n /*\n * ONE value from the two ways a caller may state a basis.\n *\n * `marketingConsent: true` is a capture surface saying \"a box was ticked\n * just now\", which is `contact-opt-in` recorded at this moment with nobody\n * asserting it. Normalizing here rather than branching at the write is what\n * keeps the consent fields to a single writer — two branches stamping\n * overlapping subsets of them is how a row comes to carry a basis with no\n * timestamp, or a timestamp with no basis.\n */\n const consent: ListMemberConsent | null =\n input.consent ??\n (input.marketingConsent\n ? { basis: 'contact-opt-in', atMs: Date.now() }\n : null)\n\n /*\n * The name the row will hold after this write — the caller's, else the one\n * it already carries — because the search tokens are the address's and\n * that name's, and a re-enrollment without a name keeps the stored one.\n */\n const name = input.name || (existing?.get('name') as string | undefined) || null\n await target.set(\n {\n email,\n ...(input.name ? { name: input.name } : {}),\n searchTokens: listMemberSearchTokens(email, name),\n source: input.source,\n /*\n * `via` is on every row, so the table's How filter can ask for it\n * (AGL-3321): the caller's, else `manual` on a row that has none — what\n * an absent `via` has always meant. A caller that says nothing never\n * moves a stored one.\n */\n ...(input.via\n ? { via: input.via }\n : existing?.get('via')\n ? {}\n : { via: 'manual' }),\n /*\n * Written only when the caller has a basis, and never unwritten.\n *\n * A merge that stamped `marketingConsent: false` on the omitted case\n * would erase a basis this person gave on an earlier enrollment, and a\n * withdrawn consent is not the same event as a re-enrollment that\n * happened to carry no checkbox. Withdrawal has its own path — the\n * unsubscribe link and the suppression list.\n *\n * `marketingConsentAtMs` moves with each fresh basis on purpose: the\n * question a consent record answers is when the person last said yes.\n *\n * The `marketingConsent` family rather than the `consent*` names\n * `docs/specs/email-overhaul.md` §3d sketched: `readMarketingBasis` is\n * the shipped reader and it reads these, so a second vocabulary here\n * would be a basis the send-time join cannot see. The two new fields\n * are attribution ON that field — they say why the person is mailable,\n * never whether — so nothing about the send decision moves into them.\n *\n * Written UNDER the enrolling GROUP, through the one writer helper. A\n * list lives on the org and every site in it can mail one, so a basis\n * written at the top of this row would enroll a person into one\n * client's newsletter and make them mailable by all of them. A group of\n * one — the default — is that client and nobody else.\n *\n * `marketingConsentByUid` is written on EVERY basis, `null` for a\n * pass-through. Leaving it absent would let a real opt-in that\n * supersedes an earlier attestation inherit the attesting account, and\n * a row reading \"this account vouched for them\" when the person ticked\n * a box themselves is a false attribution in the one direction a\n * compliance answer cannot afford.\n *\n * `marketingConsentReason` follows the same rule for the same reason:\n * always written, `''` for a pass-through, so a later real opt-in\n * cannot inherit the sentence an earlier import wrote about where the\n * merchant said the address came from.\n *\n * WHICH sites the basis covers is `membershipConsentFields`' to say,\n * and it never re-derives a grant from the group as it stands now.\n */\n ...(consent ? membershipConsentFields(input, consent) : {}),\n ...(existing\n ? {}\n : {\n addedAt: FieldValue.serverTimestamp(),\n [CAPTURED_BY_HOST_FIELD]: [input.group.hostId],\n }),\n },\n { merge: true },\n )\n\n /*\n * A person NEW to the list has their address's domain checked after the\n * response (AGL-3328), so the CRM record they are reads \"Would bounce\"\n * when it takes no mail. The list's owner names whose records those are:\n * an organization's list its own, a site's list the site's organization.\n */\n if (!existing) {\n const owner = input.listRef.parent?.parent\n const ownerKind = owner?.parent?.id\n scheduleCapturedEmailCheck({\n orgId: ownerKind === 'orgs' ? owner?.id : null,\n hostId: ownerKind === 'hosts' ? owner?.id : input.group?.hostId,\n email,\n })\n }\n\n return {\n enrolled: true,\n memberId: target.id,\n adopted: target.id !== key,\n created: !existing,\n }\n}\n\n/**\n * The consent fields one enrollment writes: the basis, its attribution, and\n * the sites it covers (AGL-3320).\n *\n * Three ways in, and the sites follow from which one it is:\n *\n * - a PASS-THROUGH with the person's {@link EnrollListMemberInput.grantEntries}\n * writes those sites and no others, each grant with its own moment and\n * the disclosure it was given under;\n * - a capture's checkbox pools over the group only when its surface sent\n * back the group's current disclosure key — `consentGroupForGrant`;\n * - everything else, an attestation among them, is the enrolling site's\n * alone.\n *\n * The attribution is the membership's own on every entry, the pass-through's\n * included, for the reason the write explains: an entry that kept an earlier\n * attestation's account under a person's own opt-in would be a false claim\n * about who vouched for them.\n */\nfunction membershipConsentFields(\n input: EnrollListMemberInput,\n consent: ListMemberConsent,\n): Record<string, unknown> {\n const attribution = {\n [MARKETING_CONSENT_BASIS_FIELD]: consent.basis,\n marketingConsentByUid: consent.byUid ?? null,\n marketingConsentReason: consent.reason ?? '',\n }\n const carried =\n consent.basis === 'contact-opt-in'\n ? Object.entries(input.grantEntries ?? {})\n : []\n if (carried.length) {\n return {\n [MARKETING_CONSENT_BY_HOST_FIELD]: Object.fromEntries(\n carried.map(([hostId, entry]) => [\n hostId,\n {\n ...entry,\n [MARKETING_CONSENT_FIELD]: true,\n // The grant's own moment; the pass-through's only when the\n // person's record never stamped one, as the basis itself falls\n // back to now.\n marketingConsentAtMs: entry['marketingConsentAtMs'] ?? consent.atMs,\n ...attribution,\n },\n ]),\n ),\n }\n }\n return marketingConsentFieldsForGroup(\n consentGroupForGrant(input.group, input.disclosedConsentGroup),\n consent.atMs,\n attribution,\n )\n}\n"],"names":["FieldValue","normalizeContactEmail","personKey","CAPTURED_BY_HOST_FIELD","MARKETING_CONSENT_BASIS_FIELD","MARKETING_CONSENT_BY_HOST_FIELD","MARKETING_CONSENT_FIELD","marketingConsentFieldsForGroup","readMarketingBasis","consentGroupForGrant","nameSearchTokens","createHash","createHmac","emailSearchTokens","scheduleCapturedEmailCheck","listMemberSearchTokens","email","name","Set","legacyListMemberIds","normalizedEmail","update","digest","slice","listMemberDocIds","normalized","key","filter","id","enrollListMember","input","enrolled","refusal","members","listRef","collection","canonicalRef","doc","legacyRefs","map","snapshots","firestore","getAll","existing","find","snapshot","exists","target","ref","data","group","basis","consent","marketingConsent","atMs","Date","now","get","set","searchTokens","source","via","membershipConsentFields","addedAt","serverTimestamp","hostId","merge","owner","parent","ownerKind","orgId","memberId","adopted","created","attribution","marketingConsentByUid","byUid","marketingConsentReason","reason","carried","Object","entries","grantEntries","length","fromEntries","entry","marketingConsentAtMs","disclosedConsentGroup"],"mappings":"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"}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { OUTBOUND_REVIEW_YOUNG_DAYS, type PhishingScreenInput, type PhishingScreenSignal } from '@aglyn/shared-util-email/outbound-phishing-screen';
|
|
18
18
|
import { type OutboundScreenGateRequest, type OutboundScreenGateVerdict, type SendingWorkspace } from '@aglyn/shared-util-email/outbound-screen-gate';
|
|
19
|
+
import { type RiskEventInput, type RiskEventItem } from './risk-notice';
|
|
19
20
|
/** A workspace younger than this many days has the soft rules applied. */
|
|
20
21
|
export { OUTBOUND_REVIEW_YOUNG_DAYS };
|
|
21
22
|
/**
|
|
@@ -141,7 +142,28 @@ export interface OutboundHoldFiling {
|
|
|
141
142
|
/** The URL the row reports: the flagged host, or the held page. */
|
|
142
143
|
url: string | null;
|
|
143
144
|
reportedHostname: string | null;
|
|
145
|
+
/**
|
|
146
|
+
* What the workspace's owners are told the first time (AGL-3368): the held
|
|
147
|
+
* item in their words and its console page. The alert title and body above
|
|
148
|
+
* are the STAFF half; the owners read the catalog's own words, which never
|
|
149
|
+
* name the signals.
|
|
150
|
+
*/
|
|
151
|
+
item?: RiskEventItem | null;
|
|
152
|
+
/** A held page, described (AGL-3374): stored on the owners' notice for the console. */
|
|
153
|
+
page?: RiskEventInput['page'];
|
|
144
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* The held item as the owners see it, from the surface and the source path
|
|
157
|
+
* (AGL-3368): the campaign's own email page, the automation list, or nothing
|
|
158
|
+
* for a message the send seam held (the notice then leads to Holds & reviews).
|
|
159
|
+
*/
|
|
160
|
+
export declare function heldSendItem(input: {
|
|
161
|
+
kind: HeldOutboundSendKind;
|
|
162
|
+
path: string;
|
|
163
|
+
hostId: string;
|
|
164
|
+
subject: string;
|
|
165
|
+
context?: string | null;
|
|
166
|
+
}): RiskEventItem;
|
|
145
167
|
/**
|
|
146
168
|
* Write (or count) the review row for a hold, and tell staff the first time.
|
|
147
169
|
* Returns the row's decision state — a row staff already decided answers
|
|
@@ -58,7 +58,7 @@ import { setOutboundScreenGate } from "@aglyn/shared-util-email/outbound-screen-
|
|
|
58
58
|
import { FieldValue } from "firebase-admin/firestore";
|
|
59
59
|
import { orgAgeDays } from "./org-age.js";
|
|
60
60
|
import firebaseAdmin from "./firebase-admin.js";
|
|
61
|
-
import {
|
|
61
|
+
import { notifyRiskEvent } from "./risk-notice.js";
|
|
62
62
|
/** A workspace younger than this many days has the soft rules applied. */ export { OUTBOUND_REVIEW_YOUNG_DAYS };
|
|
63
63
|
/**
|
|
64
64
|
* Where a held campaign is parked: a send time no processor run reaches.
|
|
@@ -139,6 +139,51 @@ import { notifyStaff } from "./notifications.js";
|
|
|
139
139
|
/** The staff-facing reference, in the intake's `AR-` style. */ export function heldOutboundReference(reviewId) {
|
|
140
140
|
return `HS-${reviewId.slice(0, 10).toUpperCase()}`;
|
|
141
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* The held item as the owners see it, from the surface and the source path
|
|
144
|
+
* (AGL-3368): the campaign's own email page, the automation list, or nothing
|
|
145
|
+
* for a message the send seam held (the notice then leads to Holds & reviews).
|
|
146
|
+
*/ export function heldSendItem(input) {
|
|
147
|
+
var _input_path_split_pop;
|
|
148
|
+
const subject = input.subject.slice(0, 120);
|
|
149
|
+
const id = (_input_path_split_pop = input.path.split('/').pop()) != null ? _input_path_split_pop : '';
|
|
150
|
+
switch(input.kind){
|
|
151
|
+
case 'campaign':
|
|
152
|
+
return {
|
|
153
|
+
label: `the campaign "${subject}"`,
|
|
154
|
+
path: id ? `/org/emails/messages/${encodeURIComponent(id)}` : null
|
|
155
|
+
};
|
|
156
|
+
case 'orgAutomation':
|
|
157
|
+
return {
|
|
158
|
+
label: `the automated email "${subject}"`,
|
|
159
|
+
path: '/org/automation'
|
|
160
|
+
};
|
|
161
|
+
case 'workflow':
|
|
162
|
+
case 'action':
|
|
163
|
+
return {
|
|
164
|
+
label: `the automated email "${subject}"`,
|
|
165
|
+
path: `/${input.hostId}/automation`
|
|
166
|
+
};
|
|
167
|
+
case 'page':
|
|
168
|
+
// A page's subject is its label (`heldPageLabel`, AGL-3374); a row
|
|
169
|
+
// filed before that carried its URL.
|
|
170
|
+
return {
|
|
171
|
+
label: /^https?:\/\//.test(subject) ? `the page ${subject}` : subject,
|
|
172
|
+
path: null
|
|
173
|
+
};
|
|
174
|
+
case 'listing':
|
|
175
|
+
// The publisher's own Listings section of the Marketplace hub.
|
|
176
|
+
return {
|
|
177
|
+
label: `the marketplace submission "${subject}"`,
|
|
178
|
+
path: '/org/marketplace/listings'
|
|
179
|
+
};
|
|
180
|
+
default:
|
|
181
|
+
return {
|
|
182
|
+
label: input.context ? `the email "${subject}" (${input.context})` : `the email "${subject}"`,
|
|
183
|
+
path: null
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
142
187
|
/**
|
|
143
188
|
* Write (or count) the review row for a hold, and tell staff the first time.
|
|
144
189
|
* Returns the row's decision state — a row staff already decided answers
|
|
@@ -196,11 +241,27 @@ import { notifyStaff } from "./notifications.js";
|
|
|
196
241
|
});
|
|
197
242
|
});
|
|
198
243
|
if (first && state === 'held') {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
244
|
+
var _filing_item, _filing_page;
|
|
245
|
+
// The owners learn what was held and how to ask for a review; staff get
|
|
246
|
+
// the alert with the evidence and a link to this row. One seam, once.
|
|
247
|
+
await notifyRiskEvent({
|
|
248
|
+
kind: filing.heldSend.kind === 'page' ? 'page-held' : filing.heldSend.kind === 'listing' ? 'listing-held' : 'email-held',
|
|
249
|
+
orgId: filing.heldSend.orgId,
|
|
250
|
+
hostId: filing.heldSend.hostId,
|
|
251
|
+
reviewId: filing.reviewId,
|
|
252
|
+
reference,
|
|
253
|
+
occurredAtMs: filing.heldSend.heldAtMs,
|
|
254
|
+
item: (_filing_item = filing.item) != null ? _filing_item : heldSendItem({
|
|
255
|
+
kind: filing.heldSend.kind,
|
|
256
|
+
path: filing.heldSend.path,
|
|
257
|
+
hostId: filing.heldSend.hostId,
|
|
258
|
+
subject: filing.heldSend.subject
|
|
259
|
+
}),
|
|
260
|
+
page: (_filing_page = filing.page) != null ? _filing_page : null,
|
|
261
|
+
staffEvidence: [
|
|
262
|
+
filing.alertBody,
|
|
263
|
+
...describePhishingScreenSignals(filing.heldSend.signals)
|
|
264
|
+
].join(' ')
|
|
204
265
|
});
|
|
205
266
|
}
|
|
206
267
|
return state;
|
|
@@ -388,6 +449,13 @@ const seamMemo = new Map();
|
|
|
388
449
|
extra: {
|
|
389
450
|
heldContext: context
|
|
390
451
|
},
|
|
452
|
+
item: heldSendItem({
|
|
453
|
+
kind: 'message',
|
|
454
|
+
path: `hosts/${workspace.hostId}`,
|
|
455
|
+
hostId: workspace.hostId,
|
|
456
|
+
subject,
|
|
457
|
+
context
|
|
458
|
+
}),
|
|
391
459
|
headline: `Held ${context ? `"${context}" ` : ''}email "${subject}" from a workspace ` + `${(_workspace_ageDays = workspace.ageDays) != null ? _workspace_ageDays : '?'} day(s) old. Every later message from this site ` + 'carrying the same signals waits on this decision.',
|
|
392
460
|
alertTitle: 'Outbound email held for review — possible phishing',
|
|
393
461
|
alertBody: `An email from a site was held: "${subject.slice(0, 120)}".`,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/outbound-send-review.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 * HELD FOR REVIEW: outbound mail from a young workspace (AGL-3356).\n *\n * The phishing screen (`screenOutboundEmail`, shared-util-email) decides\n * whether a message carries a phishing signal, and `signalsThatHold` beside\n * it decides which signals hold for whom. This module is what happens next,\n * for every surface that screens — the send seam every tenant message\n * crosses ({@link installOutboundScreenGate}), the campaign core and the\n * automation step before they commit, and a site publish\n * (`hosted-page-review.ts`) — so none restates it:\n *\n * - WHO IS SCREENED. Every workspace, in two tiers. A lookalike link holds\n * whatever the workspace's age. The soft rules (a brand in the sender\n * name, the three-part lure) hold only for a workspace younger than\n * {@link OUTBOUND_REVIEW_YOUNG_DAYS}: the incident's workspace was days\n * old, and an established customer's clumsy copy never waits on them. An\n * org whose creation date cannot be read is an existing customer (the same\n * reading `orgAgeDays` gives the sending ramp) and is not young.\n * - WHERE A HOLD GOES. Into the abuse queue at `/admin/abuse-reports` — the\n * staff surface that already exists to answer \"is this phishing\", with its\n * audit trail, its urgent count and its staff notification — as a\n * `phishing` row whose `heldSend` names the message. Not a new queue: a\n * second place to look is a place nobody looks.\n * - HOW STAFF DECIDE. By the row's existing status. `dismissed` (a false\n * positive) RELEASES the send; `actioned` REJECTS it. The admin route\n * calls {@link decideHeldOutboundSend} in the same act as the status\n * write, so the decision and its audit row cannot come apart.\n * - WHAT A RELEASE COVERS. Exactly the content that was held: the review\n * row's id is derived from the message's source and a hash of its\n * content, so an edited message is screened afresh rather than riding a\n * release granted to different words.\n *\n * A hold is never a drop. A held campaign is parked as a scheduled send that\n * a release puts back on the clock; a held automation step fails that one\n * run with a reason the merchant can read, or — after a wait — stays queued\n * until the decision.\n *=========================================*/\n\nimport { createHash } from 'crypto'\nimport { ABUSE_REPORT_COLLECTION } from '@aglyn/aglyn/app-utils/abuse-report'\nimport { TENANT_APEX } from '@aglyn/aglyn/app-utils/host-naming'\nimport {\n describePhishingScreenSignals,\n isYoungWorkspaceAge,\n OUTBOUND_REVIEW_YOUNG_DAYS,\n type PhishingScreenInput,\n type PhishingScreenSignal,\n screenOutboundEmail,\n signalsThatHold,\n} from '@aglyn/shared-util-email/outbound-phishing-screen'\nimport {\n type OutboundScreenGateRequest,\n type OutboundScreenGateVerdict,\n type SendingWorkspace,\n setOutboundScreenGate,\n} from '@aglyn/shared-util-email/outbound-screen-gate'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { orgAgeDays } from './org-age'\nimport firebaseAdmin from './firebase-admin'\nimport { notifyStaff } from './notifications'\n\n/** A workspace younger than this many days has the soft rules applied. */\nexport { OUTBOUND_REVIEW_YOUNG_DAYS }\n\n/**\n * Where a held campaign is parked: a send time no processor run reaches.\n * A release replaces it with \"now\"; nothing else ever does, so a hold that\n * nobody decides stays a hold rather than lapsing into a send.\n */\nexport const HELD_SEND_AT_MS = 253402300799000\n\n/**\n * Which surface held it: a campaign, an automation step, any other tenant\n * message at the send seam (`message`), a site publish (`page`), or a\n * submission to a public catalog a plugin keeps — a marketplace listing, its\n * publisher profile (`listing`, AGL-3365).\n */\nexport type HeldOutboundSendKind =\n | 'campaign'\n | 'action'\n | 'workflow'\n | 'orgAutomation'\n | 'message'\n | 'page'\n | 'listing'\n\n/** The decision state a held send carries on its review row. */\nexport type HeldOutboundSendState = 'held' | 'released' | 'rejected'\n\n/** What the review row records about the message it holds. */\nexport interface HeldOutboundSend {\n kind: HeldOutboundSendKind\n /** The document the message came from — a send, an action, a workflow. */\n path: string\n hostId: string\n orgId: string | null\n contentHash: string\n subject: string\n fromName: string | null\n signals: PhishingScreenSignal[]\n state: HeldOutboundSendState\n heldAtMs: number\n /** Workspace age in days when it was held, for the reviewer. */\n ageDays: number | null\n}\n\n/**\n * Is this workspace young enough to screen?\n *\n * Age alone, deliberately. Payment history was the other candidate and the\n * incident is the argument against it: the actor PAID, on a card that never\n * met 3-D Secure, within days of signing up. A paid invoice proves a card\n * worked, not that its holder is who they say.\n */\nexport function isYoungWorkspace(\n org: { createdAt?: unknown } | null | undefined,\n nowMs: number = Date.now(),\n): boolean {\n return isYoungWorkspaceAge(orgAgeDays(org?.createdAt, nowMs))\n}\n\n/**\n * The workspace a host's mail belongs to, as the send seam's screen reads it\n * — stamped on the sending identity by `hostSendingIdentity` from the host\n * and org documents it has already read.\n */\nexport function sendingWorkspaceFor(input: {\n hostId: string\n orgId: string | null\n org?: Record<string, unknown> | null\n host?: Record<string, unknown> | null\n nowMs?: number\n}): SendingWorkspace {\n const identity = workspaceScreenIdentity({ org: input.org, host: input.host })\n return {\n hostId: input.hostId,\n orgId: input.orgId,\n ageDays: orgAgeDays(\n (input.org as { createdAt?: unknown } | null | undefined)?.createdAt,\n input.nowMs ?? Date.now(),\n ),\n ownNames: (identity.ownNames ?? []).filter((name): name is string => Boolean(name)),\n ownDomains: (identity.ownDomains ?? []).filter((domain): domain is string => Boolean(domain)),\n }\n}\n\n/**\n * The workspace's own names and hosts, as the screen reads them: a brand in\n * its own name is its own, and a link to its own site is not \"elsewhere\".\n */\nexport function workspaceScreenIdentity(input: {\n org?: Record<string, unknown> | null\n host?: Record<string, unknown> | null\n}): Pick<PhishingScreenInput, 'ownNames' | 'ownDomains'> {\n const org = input.org ?? {}\n const host = input.host ?? {}\n const text = (value: unknown) => (typeof value === 'string' ? value : '')\n const subdomain = text(host['subdomain'])\n return {\n ownNames: [text(org['name']), text(host['name']), subdomain],\n ownDomains: [\n subdomain ? `${subdomain}.${TENANT_APEX}` : '',\n text(host['cname']),\n text(host['sendingDomain']),\n ],\n }\n}\n\n/**\n * JSON with every object's keys sorted, so the same design serializes to the\n * same string however it was read. Firestore hands back a map's keys in an\n * order that differs by reader, and a release keyed on a hash of an unsorted\n * serialization would not match the very content it released.\n */\nexport function canonicalJson(value: unknown): string {\n return JSON.stringify(value, (_key, entry) =>\n entry && typeof entry === 'object' && !Array.isArray(entry)\n ? Object.fromEntries(\n Object.keys(entry as Record<string, unknown>)\n .sort()\n .map((key) => [key, (entry as Record<string, unknown>)[key]]),\n )\n : entry,\n )\n}\n\n/** A stable hash of what a message says, so a release covers these words only. */\nexport function outboundContentHash(parts: readonly unknown[]): string {\n return createHash('sha256').update(canonicalJson(parts)).digest('hex')\n}\n\n/**\n * The review row's id: one row per (source, content). Hex, so the admin\n * route's report-id pattern addresses it like any intake row.\n */\nexport function heldOutboundReviewId(path: string, contentHash: string): string {\n return createHash('sha256')\n .update(`outbound-hold:${path}:${contentHash}`)\n .digest('hex')\n .slice(0, 40)\n}\n\n/** The staff-facing reference, in the intake's `AR-` style. */\nexport function heldOutboundReference(reviewId: string): string {\n return `HS-${reviewId.slice(0, 10).toUpperCase()}`\n}\n\nexport interface OutboundScreenRequest {\n kind: HeldOutboundSendKind\n path: string\n hostId: string\n orgId: string | null\n org: Record<string, unknown> | null\n host: Record<string, unknown> | null\n subject: string\n fromName?: string | null\n /**\n * The address it leaves from (AGL-3362). Read by the lookalike rule only,\n * and kept out of the content hash so a release granted before this was\n * read still names the same row.\n */\n fromAddress?: string | null\n replyTo?: string | readonly string[] | null\n preheader?: string | null\n bodies: readonly (string | null | undefined)[]\n nowMs?: number\n}\n\n/** What a screen answers for one piece of content. */\nexport type OutboundScreenOutcome =\n | {\n outcome: 'send'\n /** The review row staff released this content under, when there is one. */\n releasedReviewId?: string\n }\n | { outcome: 'held' | 'rejected'; reviewId: string; reference: string; contentHash: string }\n\n/** What a surface files when its screen holds, for {@link fileOutboundHold}. */\nexport interface OutboundHoldFiling {\n reviewId: string\n heldSend: HeldOutboundSend\n /** Extra fields a surface records beside `heldSend` (a page's version). */\n extra?: Record<string, unknown>\n /** The first line of the row's details, naming what was held. */\n headline: string\n /** The staff alert's title and body, the first time. */\n alertTitle: string\n alertBody: string\n /** The URL the row reports: the flagged host, or the held page. */\n url: string | null\n reportedHostname: string | null\n}\n\n/**\n * Write (or count) the review row for a hold, and tell staff the first time.\n * Returns the row's decision state — a row staff already decided answers\n * with that decision and is not written.\n *\n * One writer for every surface, so a campaign, a seam message and a page\n * land in the queue in the one shape the admin route and page already read.\n */\nexport async function fileOutboundHold(\n filing: OutboundHoldFiling,\n): Promise<HeldOutboundSendState> {\n const firestore = firebaseAdmin.app().firestore()\n const ref = firestore.collection(ABUSE_REPORT_COLLECTION).doc(filing.reviewId)\n const reference = heldOutboundReference(filing.reviewId)\n let first = false\n let state = 'held' as HeldOutboundSendState\n await firestore.runTransaction(async (transaction) => {\n const existing = await transaction.get(ref)\n first = !existing.exists\n const stored = existing.exists\n ? (existing.get('heldSend') as Partial<HeldOutboundSend> | undefined)\n : undefined\n state = stored?.state === 'released' || stored?.state === 'rejected'\n ? stored.state\n : 'held'\n if (state !== 'held') return\n transaction.set(\n ref,\n {\n reference,\n category: 'phishing',\n severity: 'urgent',\n // Who filed it: the screen, not a person. The queue reads it to\n // say so, and a reporter-contact field stays honestly empty.\n source: 'outbound-screen',\n // The flagged host as TEXT — the page never renders a reported URL\n // as a link, and this one may be a phishing kit's.\n url: filing.url,\n reportedHostname: filing.reportedHostname,\n hostId: filing.heldSend.hostId,\n orgId: filing.heldSend.orgId,\n details: [\n filing.headline,\n ...describePhishingScreenSignals(filing.heldSend.signals),\n filing.heldSend.kind === 'page'\n ? 'Dismiss to release this publish; mark it actioned to reject it.'\n : filing.heldSend.kind === 'listing'\n ? 'Dismiss to release this submission (the publisher submits it again and it ' +\n 'goes through); mark it actioned to reject it.'\n : 'Dismiss to release this send; mark it actioned to reject it.',\n ]\n .join('\\n')\n .slice(0, 5000),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n // Every message the same content held counts here, so a workflow\n // firing on each new contact reads as the volume it is.\n reportCount: FieldValue.increment(1),\n heldSend: first\n ? filing.heldSend\n : { ...filing.heldSend, heldAtMs: stored?.heldAtMs ?? filing.heldSend.heldAtMs },\n ...(filing.extra ?? {}),\n updatedAt: FieldValue.serverTimestamp(),\n ...(first\n ? { status: 'open', createdAt: FieldValue.serverTimestamp() }\n : {}),\n },\n { merge: true },\n )\n })\n if (first && state === 'held') {\n await notifyStaff({\n type: 'system.abuseReportUrgent',\n title: filing.alertTitle,\n body: `${filing.alertBody} Reference ${reference}.`,\n link: '/admin/abuse-reports',\n })\n }\n return state\n}\n\n/** The first signal that names a host, for the row's reported URL. */\nexport function flaggedHostOf(signals: readonly PhishingScreenSignal[]): string | null {\n const flagged = signals.find(\n (signal): signal is Extract<PhishingScreenSignal, { host: string }> => 'host' in signal,\n )\n return flagged?.host ?? null\n}\n\n/**\n * The screen's answer for one message, and what to do with it.\n *\n * - `send` — clean under the tiers, or held earlier and RELEASED by staff\n * for exactly this content (then `releasedReviewId` names the row, and the\n * caller hands it to `sendEmail` so the seam's screen honors it too).\n * - `held` — hold it. The review row is written (or its count bumped) and\n * staff are told the first time.\n * - `rejected` — staff rejected exactly this content. Never sent.\n *\n * Every workspace is screened; `signalsThatHold` applies the tiers, so an\n * established workspace is held only by a lookalike link.\n *\n * Fails OPEN on a store error, the posture of every other control on the\n * send path: an outage on the review queue must not become an outage on\n * every workspace's mail. The screen itself cannot fail.\n */\nexport async function screenOutboundSend(\n request: OutboundScreenRequest,\n): Promise<OutboundScreenOutcome> {\n const nowMs = request.nowMs ?? Date.now()\n const ageDays = orgAgeDays(\n (request.org as { createdAt?: unknown } | null)?.createdAt,\n nowMs,\n )\n const verdict = screenOutboundEmail({\n subject: request.subject,\n fromName: request.fromName ?? null,\n fromAddress: request.fromAddress ?? null,\n replyTo: request.replyTo ?? null,\n preheader: request.preheader ?? null,\n bodies: request.bodies,\n ...workspaceScreenIdentity({ org: request.org, host: request.host }),\n })\n const signals = signalsThatHold(verdict.signals, { ageDays })\n if (!signals.length) return { outcome: 'send' }\n\n const contentHash = outboundContentHash([\n request.subject,\n request.fromName ?? '',\n request.replyTo ?? '',\n request.preheader ?? '',\n ...request.bodies.map((body) => body ?? ''),\n ])\n const reviewId = heldOutboundReviewId(request.path, contentHash)\n const reference = heldOutboundReference(reviewId)\n try {\n const heldSend: HeldOutboundSend = {\n kind: request.kind,\n path: request.path,\n hostId: request.hostId,\n orgId: request.orgId,\n contentHash,\n subject: request.subject.slice(0, 300),\n fromName: request.fromName ? String(request.fromName).slice(0, 120) : null,\n signals,\n state: 'held',\n heldAtMs: nowMs,\n ageDays,\n }\n const flagged = flaggedHostOf(signals)\n const state = await fileOutboundHold({\n reviewId,\n heldSend,\n headline:\n `Held ${request.kind === 'campaign' ? 'campaign' : `${request.kind} email step`} ` +\n `\"${heldSend.subject}\" from a workspace ${ageDays ?? '?'} day(s) old.`,\n alertTitle: 'Outbound email held for review — possible phishing',\n alertBody:\n `A ${request.kind === 'campaign' ? 'campaign' : 'automated email'} ` +\n `was held: \"${request.subject.slice(0, 120)}\".`,\n url: flagged ? `https://${flagged}/` : null,\n reportedHostname: flagged,\n })\n if (state === 'released') return { outcome: 'send', releasedReviewId: reviewId }\n return { outcome: state === 'rejected' ? 'rejected' : 'held', reviewId, reference, contentHash }\n } catch (error) {\n console.error('[outbound-review] hold could not be recorded — allowing', error)\n return { outcome: 'send' }\n }\n}\n\n/**\n * How long this process trusts what it last learned about a seam row: that\n * it is held (so the next message carrying the same signals skips the\n * write), or how staff decided it. Short, because a decision must reach a\n * sender within a minute; long enough that a newsletter fanned out to a\n * thousand people costs one transaction rather than a thousand on one\n * document.\n */\nconst SEAM_MEMO_TTL_MS = 60_000\nconst seamMemo = new Map<string, { state: HeldOutboundSendState; atMs: number }>()\n\n/** Test seam: forget what this process learned about seam rows. */\nexport function resetOutboundSeamMemoForTests(): void {\n seamMemo.clear()\n}\n\n/**\n * The seam row's id for a site and the signals that held. Keyed on the\n * SIGNALS rather than the words: a message at the seam is already\n * personalized per recipient, so its words differ for every person while\n * what makes it phishing — the lookalike host, the brand and the lure — does\n * not. A release therefore covers this site's use of exactly these signals,\n * and a message carrying any other signal is screened afresh.\n */\nexport function seamReviewId(hostId: string, signals: readonly PhishingScreenSignal[]): string {\n const key = signals\n .map((signal) => canonicalJson(signal))\n .sort()\n .join('\\n')\n return heldOutboundReviewId(`hosts/${hostId}/outbound`, outboundContentHash([key]))\n}\n\n/** Is this row a staff RELEASE for this site? */\nasync function isReleaseForHost(reviewId: string, hostId: string): Promise<boolean> {\n const memoKey = `release:${hostId}:${reviewId}`\n const memo = seamMemo.get(memoKey)\n if (memo && Date.now() - memo.atMs < SEAM_MEMO_TTL_MS) return memo.state === 'released'\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection(ABUSE_REPORT_COLLECTION)\n .doc(reviewId)\n .get()\n const held = snapshot.get('heldSend') as Partial<HeldOutboundSend> | undefined\n const released = held?.state === 'released' && held?.hostId === hostId\n seamMemo.set(memoKey, { state: released ? 'released' : 'held', atMs: Date.now() })\n return released\n}\n\n/**\n * The send seam's gate: what `sendEmail` asks when a tenant message carries\n * a signal that holds (see `outbound-screen-gate.ts`). Installed at module\n * load by {@link installOutboundScreenGate}.\n *\n * A seam hold REFUSES that one message (`held-for-review`) — the sender's own\n * failure handling reports it, the way it reports a suppression — and files\n * one row per site and signal set. Once staff release it, the site's next\n * message carrying those signals sends; reject it, and none does.\n */\nexport async function screenSeamMessage(\n request: OutboundScreenGateRequest,\n): Promise<OutboundScreenGateVerdict> {\n const { workspace } = request\n if (\n request.releasedReviewId &&\n (await isReleaseForHost(request.releasedReviewId, workspace.hostId))\n ) {\n return { outcome: 'send' }\n }\n const reviewId = seamReviewId(workspace.hostId, request.signals)\n const reference = heldOutboundReference(reviewId)\n const memo = seamMemo.get(reviewId)\n if (memo && Date.now() - memo.atMs < SEAM_MEMO_TTL_MS) {\n return memo.state === 'released'\n ? { outcome: 'send' }\n : { outcome: memo.state === 'rejected' ? 'rejected' : 'held', reference }\n }\n const nowMs = Date.now()\n const subject = String(request.subject ?? '').slice(0, 300)\n const context = request.context ? String(request.context).slice(0, 80) : null\n const flagged = flaggedHostOf(request.signals)\n const state = await fileOutboundHold({\n reviewId,\n heldSend: {\n kind: 'message',\n path: `hosts/${workspace.hostId}`,\n hostId: workspace.hostId,\n orgId: workspace.orgId,\n contentHash: outboundContentHash([request.signals]),\n subject,\n fromName: request.fromName ? String(request.fromName).slice(0, 120) : null,\n signals: request.signals,\n state: 'held',\n heldAtMs: nowMs,\n ageDays: workspace.ageDays,\n },\n extra: { heldContext: context },\n headline:\n `Held ${context ? `\"${context}\" ` : ''}email \"${subject}\" from a workspace ` +\n `${workspace.ageDays ?? '?'} day(s) old. Every later message from this site ` +\n 'carrying the same signals waits on this decision.',\n alertTitle: 'Outbound email held for review — possible phishing',\n alertBody: `An email from a site was held: \"${subject.slice(0, 120)}\".`,\n url: flagged ? `https://${flagged}/` : null,\n reportedHostname: flagged,\n })\n seamMemo.set(reviewId, { state, atMs: Date.now() })\n return state === 'released'\n ? { outcome: 'send' }\n : { outcome: state === 'rejected' ? 'rejected' : 'held', reference }\n}\n\n/**\n * Puts the review store on `sendEmail`'s phishing screen.\n *\n * **Called at module load**, from the bottom of this file, for the reason\n * `installEmailSendGovernor` is — and this module is also imported by\n * `sending-domains.ts`, so any sender that resolved its identity through\n * `hostSendingIdentity` (the only thing that stamps a workspace on it, and\n * so the only thing the seam screens) has installed it.\n */\nexport function installOutboundScreenGate(): void {\n if (typeof setOutboundScreenGate !== 'function') return\n setOutboundScreenGate((request) => screenSeamMessage(request))\n}\n\n/**\n * Apply a staff decision to a held send (the admin abuse route, in the same\n * act as the row's status change).\n *\n * The row's `heldSend.state` is written first: it is what the screen reads,\n * so an automation step released here sends on its next run whatever\n * happens to the rest. A CAMPAIGN is also put back on the clock (released)\n * or canceled (rejected) — but only while it is still the parked send this\n * row held: a send that has since been edited, sent or canceled by the\n * merchant is left alone, and the answer says so.\n */\nexport async function decideHeldOutboundSend(input: {\n reviewId: string\n decision: 'release' | 'reject'\n actorUid: string\n actorEmail: string | null\n nowMs?: number\n firestore?: FirebaseFirestore.Firestore\n}): Promise<'released' | 'rejected' | 'not-held' | 'campaign-moved'> {\n const nowMs = input.nowMs ?? Date.now()\n const firestore = input.firestore ?? firebaseAdmin.app().firestore()\n const ref = firestore.collection(ABUSE_REPORT_COLLECTION).doc(input.reviewId)\n const snapshot = await ref.get()\n const heldSend = snapshot.get('heldSend') as HeldOutboundSend | undefined\n if (!heldSend?.path || !heldSend.contentHash) return 'not-held'\n const state: HeldOutboundSendState =\n input.decision === 'release' ? 'released' : 'rejected'\n const decided = {\n state,\n decidedAtMs: nowMs,\n decidedByUid: input.actorUid,\n decidedByEmail: input.actorEmail,\n }\n await ref.set(\n { heldSend: { ...heldSend, ...decided } },\n { merge: true },\n )\n if (heldSend.kind !== 'campaign') return state\n\n const sendRef = firestore.doc(heldSend.path)\n const moved = await firestore.runTransaction(async (transaction) => {\n const send = await transaction.get(sendRef)\n const review = send.get('staffReview') as\n | { reviewId?: string; state?: string }\n | undefined\n const status = String(send.get('status') ?? '')\n if (\n !send.exists ||\n review?.reviewId !== input.reviewId ||\n (status !== 'scheduled' && status !== 'draft')\n ) {\n return true\n }\n transaction.set(\n sendRef,\n input.decision === 'release'\n ? {\n status: 'scheduled',\n // Due now: the next processor run sends it, and the screen\n // finds this row released for exactly this content.\n sendAtMs: nowMs,\n staffReview: { ...review, ...decided },\n }\n : {\n status: 'canceled',\n sendAtMs: FieldValue.delete(),\n staffReview: { ...review, ...decided },\n },\n { merge: true },\n )\n return false\n })\n return moved ? 'campaign-moved' : state\n}\n\ninstallOutboundScreenGate()\n"],"names":["createHash","ABUSE_REPORT_COLLECTION","TENANT_APEX","describePhishingScreenSignals","isYoungWorkspaceAge","OUTBOUND_REVIEW_YOUNG_DAYS","screenOutboundEmail","signalsThatHold","setOutboundScreenGate","FieldValue","orgAgeDays","firebaseAdmin","notifyStaff","HELD_SEND_AT_MS","isYoungWorkspace","org","nowMs","Date","now","createdAt","sendingWorkspaceFor","input","identity","workspaceScreenIdentity","host","hostId","orgId","ageDays","ownNames","filter","name","Boolean","ownDomains","domain","text","value","subdomain","canonicalJson","JSON","stringify","_key","entry","Array","isArray","Object","fromEntries","keys","sort","map","key","outboundContentHash","parts","update","digest","heldOutboundReviewId","path","contentHash","slice","heldOutboundReference","reviewId","toUpperCase","fileOutboundHold","filing","firestore","app","ref","collection","doc","reference","first","state","runTransaction","transaction","existing","get","exists","stored","undefined","set","category","severity","source","url","reportedHostname","heldSend","details","headline","signals","kind","join","reporterEmail","reporterName","dmca","reportCount","increment","heldAtMs","extra","updatedAt","serverTimestamp","status","merge","type","title","alertTitle","body","alertBody","link","flaggedHostOf","flagged","find","signal","screenOutboundSend","request","verdict","subject","fromName","fromAddress","replyTo","preheader","bodies","length","outcome","String","releasedReviewId","error","console","SEAM_MEMO_TTL_MS","seamMemo","Map","resetOutboundSeamMemoForTests","clear","seamReviewId","isReleaseForHost","memoKey","memo","atMs","snapshot","held","released","screenSeamMessage","workspace","context","heldContext","installOutboundScreenGate","decideHeldOutboundSend","decision","decided","decidedAtMs","decidedByUid","actorUid","decidedByEmail","actorEmail","sendRef","moved","send","review","sendAtMs","staffReview","delete"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/outbound-send-review.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 * HELD FOR REVIEW: outbound mail from a young workspace (AGL-3356).\n *\n * The phishing screen (`screenOutboundEmail`, shared-util-email) decides\n * whether a message carries a phishing signal, and `signalsThatHold` beside\n * it decides which signals hold for whom. This module is what happens next,\n * for every surface that screens — the send seam every tenant message\n * crosses ({@link installOutboundScreenGate}), the campaign core and the\n * automation step before they commit, and a site publish\n * (`hosted-page-review.ts`) — so none restates it:\n *\n * - WHO IS SCREENED. Every workspace, in two tiers. A lookalike link holds\n * whatever the workspace's age. The soft rules (a brand in the sender\n * name, the three-part lure) hold only for a workspace younger than\n * {@link OUTBOUND_REVIEW_YOUNG_DAYS}: the incident's workspace was days\n * old, and an established customer's clumsy copy never waits on them. An\n * org whose creation date cannot be read is an existing customer (the same\n * reading `orgAgeDays` gives the sending ramp) and is not young.\n * - WHERE A HOLD GOES. Into the abuse queue at `/admin/abuse-reports` — the\n * staff surface that already exists to answer \"is this phishing\", with its\n * audit trail, its urgent count and its staff notification — as a\n * `phishing` row whose `heldSend` names the message. Not a new queue: a\n * second place to look is a place nobody looks.\n * - HOW STAFF DECIDE. By the row's existing status. `dismissed` (a false\n * positive) RELEASES the send; `actioned` REJECTS it. The admin route\n * calls {@link decideHeldOutboundSend} in the same act as the status\n * write, so the decision and its audit row cannot come apart.\n * - WHAT A RELEASE COVERS. Exactly the content that was held: the review\n * row's id is derived from the message's source and a hash of its\n * content, so an edited message is screened afresh rather than riding a\n * release granted to different words.\n *\n * A hold is never a drop. A held campaign is parked as a scheduled send that\n * a release puts back on the clock; a held automation step fails that one\n * run with a reason the merchant can read, or — after a wait — stays queued\n * until the decision.\n *=========================================*/\n\nimport { createHash } from 'crypto'\nimport { ABUSE_REPORT_COLLECTION } from '@aglyn/aglyn/app-utils/abuse-report'\nimport { TENANT_APEX } from '@aglyn/aglyn/app-utils/host-naming'\nimport {\n describePhishingScreenSignals,\n isYoungWorkspaceAge,\n OUTBOUND_REVIEW_YOUNG_DAYS,\n type PhishingScreenInput,\n type PhishingScreenSignal,\n screenOutboundEmail,\n signalsThatHold,\n} from '@aglyn/shared-util-email/outbound-phishing-screen'\nimport {\n type OutboundScreenGateRequest,\n type OutboundScreenGateVerdict,\n type SendingWorkspace,\n setOutboundScreenGate,\n} from '@aglyn/shared-util-email/outbound-screen-gate'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { orgAgeDays } from './org-age'\nimport firebaseAdmin from './firebase-admin'\nimport { notifyRiskEvent, type RiskEventInput, type RiskEventItem } from './risk-notice'\n\n/** A workspace younger than this many days has the soft rules applied. */\nexport { OUTBOUND_REVIEW_YOUNG_DAYS }\n\n/**\n * Where a held campaign is parked: a send time no processor run reaches.\n * A release replaces it with \"now\"; nothing else ever does, so a hold that\n * nobody decides stays a hold rather than lapsing into a send.\n */\nexport const HELD_SEND_AT_MS = 253402300799000\n\n/**\n * Which surface held it: a campaign, an automation step, any other tenant\n * message at the send seam (`message`), a site publish (`page`), or a\n * submission to a public catalog a plugin keeps — a marketplace listing, its\n * publisher profile (`listing`, AGL-3365).\n */\nexport type HeldOutboundSendKind =\n | 'campaign'\n | 'action'\n | 'workflow'\n | 'orgAutomation'\n | 'message'\n | 'page'\n | 'listing'\n\n/** The decision state a held send carries on its review row. */\nexport type HeldOutboundSendState = 'held' | 'released' | 'rejected'\n\n/** What the review row records about the message it holds. */\nexport interface HeldOutboundSend {\n kind: HeldOutboundSendKind\n /** The document the message came from — a send, an action, a workflow. */\n path: string\n hostId: string\n orgId: string | null\n contentHash: string\n subject: string\n fromName: string | null\n signals: PhishingScreenSignal[]\n state: HeldOutboundSendState\n heldAtMs: number\n /** Workspace age in days when it was held, for the reviewer. */\n ageDays: number | null\n}\n\n/**\n * Is this workspace young enough to screen?\n *\n * Age alone, deliberately. Payment history was the other candidate and the\n * incident is the argument against it: the actor PAID, on a card that never\n * met 3-D Secure, within days of signing up. A paid invoice proves a card\n * worked, not that its holder is who they say.\n */\nexport function isYoungWorkspace(\n org: { createdAt?: unknown } | null | undefined,\n nowMs: number = Date.now(),\n): boolean {\n return isYoungWorkspaceAge(orgAgeDays(org?.createdAt, nowMs))\n}\n\n/**\n * The workspace a host's mail belongs to, as the send seam's screen reads it\n * — stamped on the sending identity by `hostSendingIdentity` from the host\n * and org documents it has already read.\n */\nexport function sendingWorkspaceFor(input: {\n hostId: string\n orgId: string | null\n org?: Record<string, unknown> | null\n host?: Record<string, unknown> | null\n nowMs?: number\n}): SendingWorkspace {\n const identity = workspaceScreenIdentity({ org: input.org, host: input.host })\n return {\n hostId: input.hostId,\n orgId: input.orgId,\n ageDays: orgAgeDays(\n (input.org as { createdAt?: unknown } | null | undefined)?.createdAt,\n input.nowMs ?? Date.now(),\n ),\n ownNames: (identity.ownNames ?? []).filter((name): name is string => Boolean(name)),\n ownDomains: (identity.ownDomains ?? []).filter((domain): domain is string => Boolean(domain)),\n }\n}\n\n/**\n * The workspace's own names and hosts, as the screen reads them: a brand in\n * its own name is its own, and a link to its own site is not \"elsewhere\".\n */\nexport function workspaceScreenIdentity(input: {\n org?: Record<string, unknown> | null\n host?: Record<string, unknown> | null\n}): Pick<PhishingScreenInput, 'ownNames' | 'ownDomains'> {\n const org = input.org ?? {}\n const host = input.host ?? {}\n const text = (value: unknown) => (typeof value === 'string' ? value : '')\n const subdomain = text(host['subdomain'])\n return {\n ownNames: [text(org['name']), text(host['name']), subdomain],\n ownDomains: [\n subdomain ? `${subdomain}.${TENANT_APEX}` : '',\n text(host['cname']),\n text(host['sendingDomain']),\n ],\n }\n}\n\n/**\n * JSON with every object's keys sorted, so the same design serializes to the\n * same string however it was read. Firestore hands back a map's keys in an\n * order that differs by reader, and a release keyed on a hash of an unsorted\n * serialization would not match the very content it released.\n */\nexport function canonicalJson(value: unknown): string {\n return JSON.stringify(value, (_key, entry) =>\n entry && typeof entry === 'object' && !Array.isArray(entry)\n ? Object.fromEntries(\n Object.keys(entry as Record<string, unknown>)\n .sort()\n .map((key) => [key, (entry as Record<string, unknown>)[key]]),\n )\n : entry,\n )\n}\n\n/** A stable hash of what a message says, so a release covers these words only. */\nexport function outboundContentHash(parts: readonly unknown[]): string {\n return createHash('sha256').update(canonicalJson(parts)).digest('hex')\n}\n\n/**\n * The review row's id: one row per (source, content). Hex, so the admin\n * route's report-id pattern addresses it like any intake row.\n */\nexport function heldOutboundReviewId(path: string, contentHash: string): string {\n return createHash('sha256')\n .update(`outbound-hold:${path}:${contentHash}`)\n .digest('hex')\n .slice(0, 40)\n}\n\n/** The staff-facing reference, in the intake's `AR-` style. */\nexport function heldOutboundReference(reviewId: string): string {\n return `HS-${reviewId.slice(0, 10).toUpperCase()}`\n}\n\nexport interface OutboundScreenRequest {\n kind: HeldOutboundSendKind\n path: string\n hostId: string\n orgId: string | null\n org: Record<string, unknown> | null\n host: Record<string, unknown> | null\n subject: string\n fromName?: string | null\n /**\n * The address it leaves from (AGL-3362). Read by the lookalike rule only,\n * and kept out of the content hash so a release granted before this was\n * read still names the same row.\n */\n fromAddress?: string | null\n replyTo?: string | readonly string[] | null\n preheader?: string | null\n bodies: readonly (string | null | undefined)[]\n nowMs?: number\n}\n\n/** What a screen answers for one piece of content. */\nexport type OutboundScreenOutcome =\n | {\n outcome: 'send'\n /** The review row staff released this content under, when there is one. */\n releasedReviewId?: string\n }\n | { outcome: 'held' | 'rejected'; reviewId: string; reference: string; contentHash: string }\n\n/** What a surface files when its screen holds, for {@link fileOutboundHold}. */\nexport interface OutboundHoldFiling {\n reviewId: string\n heldSend: HeldOutboundSend\n /** Extra fields a surface records beside `heldSend` (a page's version). */\n extra?: Record<string, unknown>\n /** The first line of the row's details, naming what was held. */\n headline: string\n /** The staff alert's title and body, the first time. */\n alertTitle: string\n alertBody: string\n /** The URL the row reports: the flagged host, or the held page. */\n url: string | null\n reportedHostname: string | null\n /**\n * What the workspace's owners are told the first time (AGL-3368): the held\n * item in their words and its console page. The alert title and body above\n * are the STAFF half; the owners read the catalog's own words, which never\n * name the signals.\n */\n item?: RiskEventItem | null\n /** A held page, described (AGL-3374): stored on the owners' notice for the console. */\n page?: RiskEventInput['page']\n}\n\n/**\n * The held item as the owners see it, from the surface and the source path\n * (AGL-3368): the campaign's own email page, the automation list, or nothing\n * for a message the send seam held (the notice then leads to Holds & reviews).\n */\nexport function heldSendItem(input: {\n kind: HeldOutboundSendKind\n path: string\n hostId: string\n subject: string\n context?: string | null\n}): RiskEventItem {\n const subject = input.subject.slice(0, 120)\n const id = input.path.split('/').pop() ?? ''\n switch (input.kind) {\n case 'campaign':\n return {\n label: `the campaign \"${subject}\"`,\n path: id ? `/org/emails/messages/${encodeURIComponent(id)}` : null,\n }\n case 'orgAutomation':\n return { label: `the automated email \"${subject}\"`, path: '/org/automation' }\n case 'workflow':\n case 'action':\n return { label: `the automated email \"${subject}\"`, path: `/${input.hostId}/automation` }\n case 'page':\n // A page's subject is its label (`heldPageLabel`, AGL-3374); a row\n // filed before that carried its URL.\n return { label: /^https?:\\/\\//.test(subject) ? `the page ${subject}` : subject, path: null }\n case 'listing':\n // The publisher's own Listings section of the Marketplace hub.\n return { label: `the marketplace submission \"${subject}\"`, path: '/org/marketplace/listings' }\n default:\n return {\n label: input.context\n ? `the email \"${subject}\" (${input.context})`\n : `the email \"${subject}\"`,\n path: null,\n }\n }\n}\n\n/**\n * Write (or count) the review row for a hold, and tell staff the first time.\n * Returns the row's decision state — a row staff already decided answers\n * with that decision and is not written.\n *\n * One writer for every surface, so a campaign, a seam message and a page\n * land in the queue in the one shape the admin route and page already read.\n */\nexport async function fileOutboundHold(\n filing: OutboundHoldFiling,\n): Promise<HeldOutboundSendState> {\n const firestore = firebaseAdmin.app().firestore()\n const ref = firestore.collection(ABUSE_REPORT_COLLECTION).doc(filing.reviewId)\n const reference = heldOutboundReference(filing.reviewId)\n let first = false\n let state = 'held' as HeldOutboundSendState\n await firestore.runTransaction(async (transaction) => {\n const existing = await transaction.get(ref)\n first = !existing.exists\n const stored = existing.exists\n ? (existing.get('heldSend') as Partial<HeldOutboundSend> | undefined)\n : undefined\n state = stored?.state === 'released' || stored?.state === 'rejected'\n ? stored.state\n : 'held'\n if (state !== 'held') return\n transaction.set(\n ref,\n {\n reference,\n category: 'phishing',\n severity: 'urgent',\n // Who filed it: the screen, not a person. The queue reads it to\n // say so, and a reporter-contact field stays honestly empty.\n source: 'outbound-screen',\n // The flagged host as TEXT — the page never renders a reported URL\n // as a link, and this one may be a phishing kit's.\n url: filing.url,\n reportedHostname: filing.reportedHostname,\n hostId: filing.heldSend.hostId,\n orgId: filing.heldSend.orgId,\n details: [\n filing.headline,\n ...describePhishingScreenSignals(filing.heldSend.signals),\n filing.heldSend.kind === 'page'\n ? 'Dismiss to release this publish; mark it actioned to reject it.'\n : filing.heldSend.kind === 'listing'\n ? 'Dismiss to release this submission (the publisher submits it again and it ' +\n 'goes through); mark it actioned to reject it.'\n : 'Dismiss to release this send; mark it actioned to reject it.',\n ]\n .join('\\n')\n .slice(0, 5000),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n // Every message the same content held counts here, so a workflow\n // firing on each new contact reads as the volume it is.\n reportCount: FieldValue.increment(1),\n heldSend: first\n ? filing.heldSend\n : { ...filing.heldSend, heldAtMs: stored?.heldAtMs ?? filing.heldSend.heldAtMs },\n ...(filing.extra ?? {}),\n updatedAt: FieldValue.serverTimestamp(),\n ...(first\n ? { status: 'open', createdAt: FieldValue.serverTimestamp() }\n : {}),\n },\n { merge: true },\n )\n })\n if (first && state === 'held') {\n // The owners learn what was held and how to ask for a review; staff get\n // the alert with the evidence and a link to this row. One seam, once.\n await notifyRiskEvent({\n kind:\n filing.heldSend.kind === 'page'\n ? 'page-held'\n : filing.heldSend.kind === 'listing'\n ? 'listing-held'\n : 'email-held',\n orgId: filing.heldSend.orgId,\n hostId: filing.heldSend.hostId,\n reviewId: filing.reviewId,\n reference,\n occurredAtMs: filing.heldSend.heldAtMs,\n item:\n filing.item ??\n heldSendItem({\n kind: filing.heldSend.kind,\n path: filing.heldSend.path,\n hostId: filing.heldSend.hostId,\n subject: filing.heldSend.subject,\n }),\n page: filing.page ?? null,\n staffEvidence: [\n filing.alertBody,\n ...describePhishingScreenSignals(filing.heldSend.signals),\n ].join(' '),\n })\n }\n return state\n}\n\n/** The first signal that names a host, for the row's reported URL. */\nexport function flaggedHostOf(signals: readonly PhishingScreenSignal[]): string | null {\n const flagged = signals.find(\n (signal): signal is Extract<PhishingScreenSignal, { host: string }> => 'host' in signal,\n )\n return flagged?.host ?? null\n}\n\n/**\n * The screen's answer for one message, and what to do with it.\n *\n * - `send` — clean under the tiers, or held earlier and RELEASED by staff\n * for exactly this content (then `releasedReviewId` names the row, and the\n * caller hands it to `sendEmail` so the seam's screen honors it too).\n * - `held` — hold it. The review row is written (or its count bumped) and\n * staff are told the first time.\n * - `rejected` — staff rejected exactly this content. Never sent.\n *\n * Every workspace is screened; `signalsThatHold` applies the tiers, so an\n * established workspace is held only by a lookalike link.\n *\n * Fails OPEN on a store error, the posture of every other control on the\n * send path: an outage on the review queue must not become an outage on\n * every workspace's mail. The screen itself cannot fail.\n */\nexport async function screenOutboundSend(\n request: OutboundScreenRequest,\n): Promise<OutboundScreenOutcome> {\n const nowMs = request.nowMs ?? Date.now()\n const ageDays = orgAgeDays(\n (request.org as { createdAt?: unknown } | null)?.createdAt,\n nowMs,\n )\n const verdict = screenOutboundEmail({\n subject: request.subject,\n fromName: request.fromName ?? null,\n fromAddress: request.fromAddress ?? null,\n replyTo: request.replyTo ?? null,\n preheader: request.preheader ?? null,\n bodies: request.bodies,\n ...workspaceScreenIdentity({ org: request.org, host: request.host }),\n })\n const signals = signalsThatHold(verdict.signals, { ageDays })\n if (!signals.length) return { outcome: 'send' }\n\n const contentHash = outboundContentHash([\n request.subject,\n request.fromName ?? '',\n request.replyTo ?? '',\n request.preheader ?? '',\n ...request.bodies.map((body) => body ?? ''),\n ])\n const reviewId = heldOutboundReviewId(request.path, contentHash)\n const reference = heldOutboundReference(reviewId)\n try {\n const heldSend: HeldOutboundSend = {\n kind: request.kind,\n path: request.path,\n hostId: request.hostId,\n orgId: request.orgId,\n contentHash,\n subject: request.subject.slice(0, 300),\n fromName: request.fromName ? String(request.fromName).slice(0, 120) : null,\n signals,\n state: 'held',\n heldAtMs: nowMs,\n ageDays,\n }\n const flagged = flaggedHostOf(signals)\n const state = await fileOutboundHold({\n reviewId,\n heldSend,\n headline:\n `Held ${request.kind === 'campaign' ? 'campaign' : `${request.kind} email step`} ` +\n `\"${heldSend.subject}\" from a workspace ${ageDays ?? '?'} day(s) old.`,\n alertTitle: 'Outbound email held for review — possible phishing',\n alertBody:\n `A ${request.kind === 'campaign' ? 'campaign' : 'automated email'} ` +\n `was held: \"${request.subject.slice(0, 120)}\".`,\n url: flagged ? `https://${flagged}/` : null,\n reportedHostname: flagged,\n })\n if (state === 'released') return { outcome: 'send', releasedReviewId: reviewId }\n return { outcome: state === 'rejected' ? 'rejected' : 'held', reviewId, reference, contentHash }\n } catch (error) {\n console.error('[outbound-review] hold could not be recorded — allowing', error)\n return { outcome: 'send' }\n }\n}\n\n/**\n * How long this process trusts what it last learned about a seam row: that\n * it is held (so the next message carrying the same signals skips the\n * write), or how staff decided it. Short, because a decision must reach a\n * sender within a minute; long enough that a newsletter fanned out to a\n * thousand people costs one transaction rather than a thousand on one\n * document.\n */\nconst SEAM_MEMO_TTL_MS = 60_000\nconst seamMemo = new Map<string, { state: HeldOutboundSendState; atMs: number }>()\n\n/** Test seam: forget what this process learned about seam rows. */\nexport function resetOutboundSeamMemoForTests(): void {\n seamMemo.clear()\n}\n\n/**\n * The seam row's id for a site and the signals that held. Keyed on the\n * SIGNALS rather than the words: a message at the seam is already\n * personalized per recipient, so its words differ for every person while\n * what makes it phishing — the lookalike host, the brand and the lure — does\n * not. A release therefore covers this site's use of exactly these signals,\n * and a message carrying any other signal is screened afresh.\n */\nexport function seamReviewId(hostId: string, signals: readonly PhishingScreenSignal[]): string {\n const key = signals\n .map((signal) => canonicalJson(signal))\n .sort()\n .join('\\n')\n return heldOutboundReviewId(`hosts/${hostId}/outbound`, outboundContentHash([key]))\n}\n\n/** Is this row a staff RELEASE for this site? */\nasync function isReleaseForHost(reviewId: string, hostId: string): Promise<boolean> {\n const memoKey = `release:${hostId}:${reviewId}`\n const memo = seamMemo.get(memoKey)\n if (memo && Date.now() - memo.atMs < SEAM_MEMO_TTL_MS) return memo.state === 'released'\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection(ABUSE_REPORT_COLLECTION)\n .doc(reviewId)\n .get()\n const held = snapshot.get('heldSend') as Partial<HeldOutboundSend> | undefined\n const released = held?.state === 'released' && held?.hostId === hostId\n seamMemo.set(memoKey, { state: released ? 'released' : 'held', atMs: Date.now() })\n return released\n}\n\n/**\n * The send seam's gate: what `sendEmail` asks when a tenant message carries\n * a signal that holds (see `outbound-screen-gate.ts`). Installed at module\n * load by {@link installOutboundScreenGate}.\n *\n * A seam hold REFUSES that one message (`held-for-review`) — the sender's own\n * failure handling reports it, the way it reports a suppression — and files\n * one row per site and signal set. Once staff release it, the site's next\n * message carrying those signals sends; reject it, and none does.\n */\nexport async function screenSeamMessage(\n request: OutboundScreenGateRequest,\n): Promise<OutboundScreenGateVerdict> {\n const { workspace } = request\n if (\n request.releasedReviewId &&\n (await isReleaseForHost(request.releasedReviewId, workspace.hostId))\n ) {\n return { outcome: 'send' }\n }\n const reviewId = seamReviewId(workspace.hostId, request.signals)\n const reference = heldOutboundReference(reviewId)\n const memo = seamMemo.get(reviewId)\n if (memo && Date.now() - memo.atMs < SEAM_MEMO_TTL_MS) {\n return memo.state === 'released'\n ? { outcome: 'send' }\n : { outcome: memo.state === 'rejected' ? 'rejected' : 'held', reference }\n }\n const nowMs = Date.now()\n const subject = String(request.subject ?? '').slice(0, 300)\n const context = request.context ? String(request.context).slice(0, 80) : null\n const flagged = flaggedHostOf(request.signals)\n const state = await fileOutboundHold({\n reviewId,\n heldSend: {\n kind: 'message',\n path: `hosts/${workspace.hostId}`,\n hostId: workspace.hostId,\n orgId: workspace.orgId,\n contentHash: outboundContentHash([request.signals]),\n subject,\n fromName: request.fromName ? String(request.fromName).slice(0, 120) : null,\n signals: request.signals,\n state: 'held',\n heldAtMs: nowMs,\n ageDays: workspace.ageDays,\n },\n extra: { heldContext: context },\n item: heldSendItem({ kind: 'message', path: `hosts/${workspace.hostId}`, hostId: workspace.hostId, subject, context }),\n headline:\n `Held ${context ? `\"${context}\" ` : ''}email \"${subject}\" from a workspace ` +\n `${workspace.ageDays ?? '?'} day(s) old. Every later message from this site ` +\n 'carrying the same signals waits on this decision.',\n alertTitle: 'Outbound email held for review — possible phishing',\n alertBody: `An email from a site was held: \"${subject.slice(0, 120)}\".`,\n url: flagged ? `https://${flagged}/` : null,\n reportedHostname: flagged,\n })\n seamMemo.set(reviewId, { state, atMs: Date.now() })\n return state === 'released'\n ? { outcome: 'send' }\n : { outcome: state === 'rejected' ? 'rejected' : 'held', reference }\n}\n\n/**\n * Puts the review store on `sendEmail`'s phishing screen.\n *\n * **Called at module load**, from the bottom of this file, for the reason\n * `installEmailSendGovernor` is — and this module is also imported by\n * `sending-domains.ts`, so any sender that resolved its identity through\n * `hostSendingIdentity` (the only thing that stamps a workspace on it, and\n * so the only thing the seam screens) has installed it.\n */\nexport function installOutboundScreenGate(): void {\n if (typeof setOutboundScreenGate !== 'function') return\n setOutboundScreenGate((request) => screenSeamMessage(request))\n}\n\n/**\n * Apply a staff decision to a held send (the admin abuse route, in the same\n * act as the row's status change).\n *\n * The row's `heldSend.state` is written first: it is what the screen reads,\n * so an automation step released here sends on its next run whatever\n * happens to the rest. A CAMPAIGN is also put back on the clock (released)\n * or canceled (rejected) — but only while it is still the parked send this\n * row held: a send that has since been edited, sent or canceled by the\n * merchant is left alone, and the answer says so.\n */\nexport async function decideHeldOutboundSend(input: {\n reviewId: string\n decision: 'release' | 'reject'\n actorUid: string\n actorEmail: string | null\n nowMs?: number\n firestore?: FirebaseFirestore.Firestore\n}): Promise<'released' | 'rejected' | 'not-held' | 'campaign-moved'> {\n const nowMs = input.nowMs ?? Date.now()\n const firestore = input.firestore ?? firebaseAdmin.app().firestore()\n const ref = firestore.collection(ABUSE_REPORT_COLLECTION).doc(input.reviewId)\n const snapshot = await ref.get()\n const heldSend = snapshot.get('heldSend') as HeldOutboundSend | undefined\n if (!heldSend?.path || !heldSend.contentHash) return 'not-held'\n const state: HeldOutboundSendState =\n input.decision === 'release' ? 'released' : 'rejected'\n const decided = {\n state,\n decidedAtMs: nowMs,\n decidedByUid: input.actorUid,\n decidedByEmail: input.actorEmail,\n }\n await ref.set(\n { heldSend: { ...heldSend, ...decided } },\n { merge: true },\n )\n if (heldSend.kind !== 'campaign') return state\n\n const sendRef = firestore.doc(heldSend.path)\n const moved = await firestore.runTransaction(async (transaction) => {\n const send = await transaction.get(sendRef)\n const review = send.get('staffReview') as\n | { reviewId?: string; state?: string }\n | undefined\n const status = String(send.get('status') ?? '')\n if (\n !send.exists ||\n review?.reviewId !== input.reviewId ||\n (status !== 'scheduled' && status !== 'draft')\n ) {\n return true\n }\n transaction.set(\n sendRef,\n input.decision === 'release'\n ? {\n status: 'scheduled',\n // Due now: the next processor run sends it, and the screen\n // finds this row released for exactly this content.\n sendAtMs: nowMs,\n staffReview: { ...review, ...decided },\n }\n : {\n status: 'canceled',\n sendAtMs: FieldValue.delete(),\n staffReview: { ...review, ...decided },\n },\n { merge: true },\n )\n return false\n })\n return moved ? 'campaign-moved' : state\n}\n\ninstallOutboundScreenGate()\n"],"names":["createHash","ABUSE_REPORT_COLLECTION","TENANT_APEX","describePhishingScreenSignals","isYoungWorkspaceAge","OUTBOUND_REVIEW_YOUNG_DAYS","screenOutboundEmail","signalsThatHold","setOutboundScreenGate","FieldValue","orgAgeDays","firebaseAdmin","notifyRiskEvent","HELD_SEND_AT_MS","isYoungWorkspace","org","nowMs","Date","now","createdAt","sendingWorkspaceFor","input","identity","workspaceScreenIdentity","host","hostId","orgId","ageDays","ownNames","filter","name","Boolean","ownDomains","domain","text","value","subdomain","canonicalJson","JSON","stringify","_key","entry","Array","isArray","Object","fromEntries","keys","sort","map","key","outboundContentHash","parts","update","digest","heldOutboundReviewId","path","contentHash","slice","heldOutboundReference","reviewId","toUpperCase","heldSendItem","subject","id","split","pop","kind","label","encodeURIComponent","test","context","fileOutboundHold","filing","firestore","app","ref","collection","doc","reference","first","state","runTransaction","transaction","existing","get","exists","stored","undefined","set","category","severity","source","url","reportedHostname","heldSend","details","headline","signals","join","reporterEmail","reporterName","dmca","reportCount","increment","heldAtMs","extra","updatedAt","serverTimestamp","status","merge","occurredAtMs","item","page","staffEvidence","alertBody","flaggedHostOf","flagged","find","signal","screenOutboundSend","request","verdict","fromName","fromAddress","replyTo","preheader","bodies","length","outcome","body","String","alertTitle","releasedReviewId","error","console","SEAM_MEMO_TTL_MS","seamMemo","Map","resetOutboundSeamMemoForTests","clear","seamReviewId","isReleaseForHost","memoKey","memo","atMs","snapshot","held","released","screenSeamMessage","workspace","heldContext","installOutboundScreenGate","decideHeldOutboundSend","decision","decided","decidedAtMs","decidedByUid","actorUid","decidedByEmail","actorEmail","sendRef","moved","send","review","sendAtMs","staffReview","delete"],"mappings":"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"}
|
|
@@ -14,6 +14,13 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
+
import type { RiskEventInput } from './risk-notice';
|
|
18
|
+
/**
|
|
19
|
+
* The seam every signal here tells people through (AGL-3368): the owners
|
|
20
|
+
* and admins of the workspace, and staff. Passed in, like the Firestore
|
|
21
|
+
* handle, so the webhook's spec substitutes it with the barrel.
|
|
22
|
+
*/
|
|
23
|
+
export type RiskNotifier = (input: RiskEventInput) => Promise<unknown>;
|
|
17
24
|
/** Which Stripe signal this is. */
|
|
18
25
|
export type PaymentFraudSignalKind = 'early-fraud-warning' | 'radar-review' | 'dispute';
|
|
19
26
|
/** The Stripe object type each signal arrives as, for the queue's text. */
|
|
@@ -48,6 +55,17 @@ export declare function paymentFraudSignalReviewId(kind: PaymentFraudSignalKind,
|
|
|
48
55
|
export declare function paymentFraudSignalReference(reviewId: string): string;
|
|
49
56
|
/** Where staff act on the workspace's billing: its Subscription card. */
|
|
50
57
|
export declare function staffSubscriptionCardPath(orgId: string): string;
|
|
58
|
+
/**
|
|
59
|
+
* The Stripe Dashboard page for a signal: the dispute itself, else the
|
|
60
|
+
* payment it is about.
|
|
61
|
+
*/
|
|
62
|
+
export declare function stripeSignalDashboardUrl(signal: {
|
|
63
|
+
kind: PaymentFraudSignalKind;
|
|
64
|
+
stripeObjectId: string;
|
|
65
|
+
chargeId: string | null;
|
|
66
|
+
paymentIntentId: string | null;
|
|
67
|
+
livemode: boolean;
|
|
68
|
+
}): string | null;
|
|
51
69
|
/** `$56.00 USD`, or a sentence saying the amount is not known. */
|
|
52
70
|
export declare function formatSignalAmount(amountCents: number | null, currency: string): string;
|
|
53
71
|
/**
|
|
@@ -86,12 +104,7 @@ export declare function describePaymentFraudSignal(signal: PaymentFraudSignal):
|
|
|
86
104
|
*/
|
|
87
105
|
export declare function recordPaymentFraudSignal(signal: PaymentFraudSignal, deps: {
|
|
88
106
|
firestore: FirebaseFirestore.Firestore;
|
|
89
|
-
|
|
90
|
-
type: 'system.abuseReportUrgent';
|
|
91
|
-
title: string;
|
|
92
|
-
body: string;
|
|
93
|
-
link: string;
|
|
94
|
-
}) => Promise<void>;
|
|
107
|
+
notifyRisk: RiskNotifier;
|
|
95
108
|
}): Promise<{
|
|
96
109
|
reviewId: string;
|
|
97
110
|
reference: string;
|
|
@@ -139,12 +152,7 @@ export declare function recordSellerFraudSignal(input: {
|
|
|
139
152
|
orgIds: string[];
|
|
140
153
|
}, deps: {
|
|
141
154
|
firestore: FirebaseFirestore.Firestore;
|
|
142
|
-
|
|
143
|
-
type: 'system.abuseReportUrgent';
|
|
144
|
-
title: string;
|
|
145
|
-
body: string;
|
|
146
|
-
link: string;
|
|
147
|
-
}) => Promise<void>;
|
|
155
|
+
notifyRisk: RiskNotifier;
|
|
148
156
|
nowMs?: number;
|
|
149
157
|
}): Promise<{
|
|
150
158
|
first: boolean;
|