@aglyn/plugins-email 1.0.0-beta.196 → 1.0.0-beta.197
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 +17 -17
- package/src/lib/components/consent-group-access.d.ts +33 -0
- package/src/lib/components/consent-group-access.js +73 -0
- package/src/lib/components/consent-group-access.js.map +1 -0
- package/src/lib/components/consent-group-change-progress.d.ts +55 -0
- package/src/lib/components/consent-group-change-progress.js +282 -0
- package/src/lib/components/consent-group-change-progress.js.map +1 -0
- package/src/lib/components/consent-group-confirmation-card.d.ts +6 -16
- package/src/lib/components/consent-group-confirmation-card.js +7 -50
- package/src/lib/components/consent-group-confirmation-card.js.map +1 -1
- package/src/lib/components/consent-group-dialog.d.ts +53 -0
- package/src/lib/components/consent-group-dialog.js +495 -0
- package/src/lib/components/consent-group-dialog.js.map +1 -0
- package/src/lib/components/consent-group-editing.d.ts +127 -0
- package/src/lib/components/consent-group-editing.js +225 -0
- package/src/lib/components/consent-group-editing.js.map +1 -0
- package/src/lib/components/consent-group-review.d.ts +62 -0
- package/src/lib/components/consent-group-review.js +353 -0
- package/src/lib/components/consent-group-review.js.map +1 -0
- package/src/lib/components/consent-group-site-picker.d.ts +35 -0
- package/src/lib/components/consent-group-site-picker.js +190 -0
- package/src/lib/components/consent-group-site-picker.js.map +1 -0
- package/src/lib/components/consent-groups-api.d.ts +158 -0
- package/src/lib/components/consent-groups-api.js +250 -0
- package/src/lib/components/consent-groups-api.js.map +1 -0
- package/src/lib/components/consent-groups-card.d.ts +47 -0
- package/src/lib/components/consent-groups-card.js +351 -0
- package/src/lib/components/consent-groups-card.js.map +1 -0
- package/src/lib/components/email-screens-card.js +1 -1
- package/src/lib/components/email-screens-card.js.map +1 -1
- package/src/lib/components/emails-console-page.d.ts +1 -1
- package/src/lib/components/emails-console-page.js +62 -27
- package/src/lib/components/emails-console-page.js.map +1 -1
- package/src/lib/components/emails-console-sections.d.ts +1 -1
- package/src/lib/components/emails-console-sections.js +14 -1
- package/src/lib/components/emails-console-sections.js.map +1 -1
- package/src/lib/components/org-email-templates-card.js +1 -1
- package/src/lib/components/org-email-templates-card.js.map +1 -1
- package/src/lib/components/site-consent-group-card.d.ts +23 -0
- package/src/lib/components/site-consent-group-card.js +80 -0
- package/src/lib/components/site-consent-group-card.js.map +1 -0
- package/src/lib/components/suppressions-card.js +28 -7
- package/src/lib/components/suppressions-card.js.map +1 -1
- package/src/lib/server-console.js +4 -2
- package/src/lib/server-console.js.map +1 -1
- package/src/lib/server-list-gate.d.ts +9 -0
- package/src/lib/server-list-gate.js +23 -9
- package/src/lib/server-list-gate.js.map +1 -1
- package/src/lib/server-list-import.js +5 -2
- package/src/lib/server-list-import.js.map +1 -1
|
@@ -193,7 +193,7 @@ import { collectDynamicListCandidates, enrollListMember } from "@aglyn/tenant-da
|
|
|
193
193
|
});
|
|
194
194
|
const results = [];
|
|
195
195
|
for (const verdict of resolution.verdicts){
|
|
196
|
-
var _resolution_stored_get;
|
|
196
|
+
var _resolution_stored_get, _resolution_grants_get;
|
|
197
197
|
if (verdict.refusal || !verdict.email) {
|
|
198
198
|
results.push({
|
|
199
199
|
input: verdict.input,
|
|
@@ -242,7 +242,9 @@ import { collectDynamicListCandidates, enrollListMember } from "@aglyn/tenant-da
|
|
|
242
242
|
// made by hand is not a rule match that lapsed.
|
|
243
243
|
via: 'manual',
|
|
244
244
|
consent: decision
|
|
245
|
-
}
|
|
245
|
+
}, decision.basis === 'contact-opt-in' ? {
|
|
246
|
+
grantEntries: (_resolution_grants_get = resolution.grants.get(verdict.email)) != null ? _resolution_grants_get : {}
|
|
247
|
+
} : {}));
|
|
246
248
|
if (enrollment.enrolled === false) {
|
|
247
249
|
/*
|
|
248
250
|
* The membership itself records a refusal the CRM record did not.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/server-console.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 * Putting somebody on an email list from the Emails console.\n *\n * The audience card could create a list and delete a list and nothing else:\n * no way to see who was on one, no way to add anybody, no way to take anybody\n * off. Every member document in production arrived from a capture surface —\n * the newsletter handler, the workflow `enrollList` step, the dynamic-list\n * materializer, the Inbox assignment — so the one act a merchant most expects\n * of a list, \"add this person\", was the one act the product refused.\n *\n * ## Why this is a route and not a client write\n *\n * Rules put `orgs/{orgId}/lists/{listId}/members` behind org-wide membership,\n * and until now that block allowed a client CREATE. Nothing used it, and it\n * was the whole feature waiting to be built wrong: a browser that can write a\n * member document can write `marketingConsent: true` beside an address it has\n * never checked, which is a consent record minted by pressing a button. The\n * rules now deny client create and update on that collection, and this route\n * is the writer — through `enrollListMember`, which owns the document id, and\n * through the shared `list-assignment-policy`, which owns the basis.\n *\n * ## The same policy the Inbox uses, not a second one\n *\n * `assignmentBasis` and `assignmentReadout` are imported from the framework,\n * where they moved when this became their second caller. There is no third\n * consent basis and no console-only override: a stored `declined` refuses\n * here exactly as it refuses there, an attestation is recorded with the\n * account that made it, and a stored opt-in is carried across with the date\n * the PERSON set rather than the date somebody pressed Add.\n *\n * ## Reads only, until the operator has seen the count\n *\n * `email/list-members-preview` writes nothing. It answers, per address, what\n * would happen and why, so the attestation the operator gives on the second\n * call is given with the numbers in front of them. Both routes run the SAME\n * resolution over the SAME inputs — `resolveAddresses` — so the summary they\n * were shown is the summary that acts.\n *\n * ## Finding people is a read, and it uses the same gate\n *\n * `email/list-rule-preview` answers \"who do these filters select\" without\n * writing anything, so a fixed list can be filled from a search rather than\n * from somebody typing addresses one at a time. It runs the addresses it finds\n * through `resolveAddresses` as well — a bulk path that reached the membership\n * without the suppression check and the attestation would be a way to enroll\n * exactly the people the single-address path refuses.\n *\n * ## The gate itself is `server-list-gate.ts`\n *\n * Who may change a list's membership, and what is true about each address, are\n * asked by three route modules now: these two, and the file importer in\n * `server-list-import.ts`. They live in a module of their own so the importer\n * shares this file's answer rather than importing this file — which would be a\n * cycle, and a cycle whose module-level constants would evaluate in whichever\n * order the loader happened to reach them.\n */\n\nimport { registerEmailDesignDraftWriter } from './server-email-drafts'\nimport { registerEmailSuppressionsApi } from './server-suppressions'\nimport { registerEmailListImportApi } from './server-list-import'\nimport {\n LIST_MEMBER_BATCH_MAX,\n readAddresses,\n resolveAddresses,\n resolveListContext,\n} from './server-list-gate'\nimport {\n ASSIGNMENT_REFUSAL_MESSAGES,\n assignmentBasis,\n dynamicListRuleIsEmpty,\n normalizeDynamicListRule,\n readMarketingBasis,\n registerPluginApiRoute,\n type AssignmentRefusal,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n collectDynamicListCandidates,\n enrollListMember,\n} from '@aglyn/tenant-data-admin'\n\n/** `source` stamped on every membership these routes write. */\nexport const CONSOLE_ADD_SOURCE = 'console:list-add'\n\n/*\n * Re-exported, not redefined. `@aglyn/plugins-email/server` has published\n * these names since the add path shipped, and the console panel and its specs\n * import them from there; moving the gate must not move the plugin's public\n * surface with it.\n */\nexport {\n LIST_MEMBER_BATCH_MAX,\n resolveAddresses,\n resolveListContext,\n type AddressResolution,\n type AddressVerdict,\n type ListContext,\n type ResolvedBatch,\n} from './server-list-gate'\n\n\n/**\n * `POST email/list-members-preview` — what adding these addresses would do.\n *\n * Reads only, and reached by an explicit act — typing an address or pasting a\n * column — never on mount. It exists because the answer needs three things\n * the browser cannot have: the org's contacts (rules put them behind org-wide\n * membership, which the acting console session may not hold) and both\n * suppression lists. Computing any of it client-side would be a second copy\n * of the rule, on the surface whose whole job is to tell the operator the\n * truth about what is about to happen.\n *\n * It takes no attestation and has nowhere to put one. The preview answers\n * what is TRUE about each person, which is the input to the operator's\n * decision rather than a function of it — a preview whose numbers moved as\n * the box was ticked would not be a count anybody could stand behind.\n */\nexport const emailListMembersPreviewHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const requested = readAddresses(req)\n if ('error' in requested) {\n return res.status(400).json({ error: requested.error })\n }\n try {\n const context = await resolveListContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const { verdicts, optedIn, needAttestation, refused } =\n await resolveAddresses({\n hostId: context.hostId,\n inputs: requested.emails,\n })\n /*\n * Named fields rather than a spread of the resolution.\n *\n * The resolution carries the consent RECORDS it was computed from, for the\n * write path's use — raw stored basis, provenance, the account behind an\n * operator assertion. None of that is the browser's, and a spread would\n * put all of it on the wire the moment a field was added to the internal\n * shape. What the surface needs is the verdicts and the counts.\n */\n return res.status(200).json({\n listName: context.listName,\n verdicts,\n optedIn,\n needAttestation,\n refused,\n })\n } catch (error) {\n console.error('[email] list member preview failed', error)\n return res.status(500).json({ error: 'The addresses could not be checked.' })\n }\n}\n\n/**\n * `POST email/list-members-add` — put these addresses on the list.\n *\n * Body: `{ hostId, listId, email | emails[], name?, attestConsent? }`.\n * `attestConsent` is the operator STATING that they have these people's\n * permission; it is not a way to name a basis, because the pass-through basis\n * is derived server-side from each person's own record.\n *\n * ## One attestation, for the count the operator was shown\n *\n * The batch carries a single assertion because it is a single act: an\n * operator pasting a column is making one claim about where that column came\n * from. What makes that safe is that the claim is applied per address by the\n * same function the preview ran, so it reaches only the addresses that\n * actually need it — an address with a stored opt-in keeps its own basis and\n * its own date, and an address nothing can enroll is refused with the\n * attestation on the table.\n *\n * ## Partial success is the honest answer\n *\n * A batch where one address is suppressed and forty are fine is not a failed\n * request. Every address comes back with what happened to it, so 200 here\n * means \"the request was processed\", never \"everybody was added\" — the caller\n * reads the per-address verdicts, which is why they are returned rather than\n * a count.\n */\nexport const emailListMembersAddHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const requested = readAddresses(req)\n if ('error' in requested) {\n return res.status(400).json({ error: requested.error })\n }\n const attested = req.body?.attestConsent === true\n const name = String(req.body?.name ?? '').trim()\n\n try {\n const context = await resolveListContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const nowMs = Date.now()\n const resolution = await resolveAddresses({\n hostId: context.hostId,\n inputs: requested.emails,\n })\n\n const results = []\n for (const verdict of resolution.verdicts) {\n if (verdict.refusal || !verdict.email) {\n results.push({\n input: verdict.input,\n email: verdict.email,\n enrolled: false,\n reason: verdict.refusal,\n error: verdict.summary,\n })\n continue\n }\n /*\n * The ONE place a basis is decided, and the only place the attestation\n * is consulted.\n *\n * `resolveAddresses` has already answered the questions that are facts\n * about the person; this answers the question that is about the\n * operator, per address, from the consent record that resolution read.\n * `no-basis` — an address with nothing on record and nobody asserting\n * anything — is refused HERE and only here.\n */\n const decision = assignmentBasis({\n stored: resolution.stored.get(verdict.email) ?? readMarketingBasis(null, resolution.group),\n attested,\n actingUid: context.uid,\n nowMs,\n })\n if ('refusal' in decision) {\n results.push({\n input: verdict.input,\n email: verdict.email,\n enrolled: false,\n reason: decision.refusal,\n error: ASSIGNMENT_REFUSAL_MESSAGES[decision.refusal],\n })\n continue\n }\n const enrollment = await enrollListMember({\n listRef: context.listRef,\n group: resolution.group,\n email: verdict.email,\n ...(name && requested.emails.length === 1 ? { name } : {}),\n source: CONSOLE_ADD_SOURCE,\n // Never `'rule'`: the dynamic-list materializer reconciles its own\n // rows away when a person stops matching, and a decision somebody\n // made by hand is not a rule match that lapsed.\n via: 'manual',\n consent: decision,\n })\n if (enrollment.enrolled === false) {\n /*\n * The membership itself records a refusal the CRM record did not.\n * `enrollListMember` is the only writer of the collection and holds\n * the row, so it is the backstop for every enrollment route; reaching\n * it here means the two records disagree, and the refusal wins.\n */\n const reason: AssignmentRefusal =\n enrollment.refusal === 'declined' ? 'declined' : 'unroutable-address'\n results.push({\n input: verdict.input,\n email: verdict.email,\n enrolled: false,\n reason,\n error: ASSIGNMENT_REFUSAL_MESSAGES[reason],\n })\n continue\n }\n results.push({\n input: verdict.input,\n email: verdict.email,\n enrolled: true,\n memberId: enrollment.memberId,\n created: enrollment.created,\n basis: decision.basis,\n })\n }\n\n return res.status(200).json({\n listName: context.listName,\n added: results.filter((result) => result.enrolled).length,\n results,\n })\n } catch (error) {\n console.error('[email] list member add failed', error)\n return res.status(500).json({ error: 'The addresses could not be added.' })\n }\n}\n\n/**\n * WHO A SET OF FILTERS FINDS, and what would happen if you added them.\n *\n * ## The gap it fills\n *\n * The filters behind a list could only ever be MATERIALIZED — the sweep wrote\n * the matching people straight into the membership. A merchant could not ask\n * \"who is this\" without committing to it, and a fixed list could not use the\n * filters at all: its only way to gain a member was somebody typing or pasting\n * an address. This answers the question without writing anything.\n *\n * ## It is the same consent gate, not a second one\n *\n * The addresses the scan finds are put through {@link resolveAddresses} — the\n * exact function `email/list-members-preview` uses — so a suppressed address\n * is reported as refused here, and somebody with no opt-in on record is\n * reported as needing an attestation here, before any of them is offered for\n * adding. That is deliberate and load-bearing: a bulk path that skipped the\n * check would be a way to fill a list with people the one-at-a-time path\n * refuses, which is the defect class this product already has a register entry\n * for. The ADD still goes through `email/list-members-add`, which re-runs\n * every check server-side, so this preview is an honest readout rather than a\n * permission.\n *\n * ## What it will not do\n *\n * It writes nothing, it enrolls nobody, and it compares no count against any\n * limit. The batch cap below bounds how many addresses it hands back for a\n * single add — the scan itself still reports how many people it MATCHED, so a\n * merchant is told the audience is larger than one batch rather than shown a\n * truncated number as if it were the whole.\n */\nexport const emailListRulePreviewHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n try {\n const context = await resolveListContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const rule = normalizeDynamicListRule(req.body?.rule)\n if (dynamicListRuleIsEmpty(rule)) {\n /*\n * Not an error, and not an empty result either. A rule with no source\n * matches nobody by construction, and reporting that as \"0 people\" is\n * indistinguishable from a rule that ran and found none — which is the\n * confusion the `empty` flag exists to prevent everywhere else.\n */\n return res.status(200).json({\n matched: 0,\n truncated: false,\n complete: true,\n empty: true,\n emails: [],\n verdicts: [],\n optedIn: 0,\n needAttestation: 0,\n refused: 0,\n })\n }\n const scan = await collectDynamicListCandidates({\n hostId: context.hostId,\n rule,\n })\n /*\n * The addresses, in the scan's own order, capped at what one add can\n * carry. `matched` is reported separately and is NOT this length: a\n * merchant looking at a 400-person audience must be told it is 400 even\n * when the button in front of them adds 100.\n */\n const emails = scan.candidates\n .map((candidate) => candidate.email)\n .filter(Boolean)\n const batch = emails.slice(0, LIST_MEMBER_BATCH_MAX)\n const resolution = await resolveAddresses({\n hostId: context.hostId,\n inputs: batch,\n })\n return res.status(200).json({\n listName: context.listName,\n matched: emails.length,\n /*\n * Two different reasons a readout can be short of the truth, reported\n * apart: `truncated` is this batch being smaller than the match, and\n * `complete: false` is the SCAN having run out of budget, so `matched`\n * is itself a floor.\n */\n truncated: emails.length > batch.length,\n complete: scan.complete,\n empty: false,\n emails: batch,\n verdicts: resolution.verdicts,\n optedIn: resolution.optedIn,\n needAttestation: resolution.needAttestation,\n refused: resolution.refused,\n })\n } catch (error) {\n console.error('[email] list rule preview failed', error)\n return res\n .status(500)\n .json({ error: 'The audience could not be worked out.' })\n }\n}\n\n/**\n * Console API registration.\n *\n * None of these is on the machine-path exemption list in\n * `plugin-api-rate-limit.ts`, so each is counted against the console\n * dispatcher's per-subject budget — the ceiling that bounds a surface which\n * puts a person into a marketing audience, and which scans a whole contact\n * silo to work out who that would be.\n *\n * The limiter these are counted by is the CONSOLE one,\n * `consoleApiRateLimitRefusal`. The visitor limiter is a different limiter on\n * a different dispatcher: `visitorWriteRateLimitRefusal` is installed only in\n * `apps/tenant`, and no request to these routes has ever passed through it.\n * The two share a counter and an exemption list and nothing else — the console\n * key is the authenticated subject, because on an authenticated surface a\n * per-(site, IP) bucket both isolates nothing the identity has not already\n * isolated and hands one operator a fresh budget per site.\n */\nexport function registerEmailConsoleApi(): void {\n // The email design writer another plugin reaches through the core's\n // resource-drafts seam (AGL-2912). Registered here alone: the AI jobs that\n // write through it run only on the console, inline and on the beat\n // (AGL-3026).\n registerEmailDesignDraftWriter()\n registerPluginApiRoute('email/list-rule-preview', emailListRulePreviewHandler)\n registerPluginApiRoute(\n 'email/list-members-preview',\n emailListMembersPreviewHandler,\n )\n registerPluginApiRoute('email/list-members-add', emailListMembersAddHandler)\n // The importer is four routes over one staged job, so it has its own\n // module — but not its own gate: it reaches this file's `resolveListContext`\n // and `resolveAddresses` rather than a bulk-shaped copy of them, which is\n // the whole reason an import cannot enroll somebody the add path refuses.\n registerEmailListImportApi()\n // Suppressions live in their own module: they are a per-SITE list gated on\n // the site role, where list membership is an ORG audience gated on org-wide\n // access, and one file holding both gates is one file for the wrong one to\n // be copied out of.\n registerEmailSuppressionsApi()\n}\n"],"names":["registerEmailDesignDraftWriter","registerEmailSuppressionsApi","registerEmailListImportApi","LIST_MEMBER_BATCH_MAX","readAddresses","resolveAddresses","resolveListContext","ASSIGNMENT_REFUSAL_MESSAGES","assignmentBasis","dynamicListRuleIsEmpty","normalizeDynamicListRule","readMarketingBasis","registerPluginApiRoute","collectDynamicListCandidates","enrollListMember","CONSOLE_ADD_SOURCE","emailListMembersPreviewHandler","req","res","method","status","json","error","requested","context","ok","body","verdicts","optedIn","needAttestation","refused","hostId","inputs","emails","listName","console","emailListMembersAddHandler","attested","attestConsent","name","String","trim","nowMs","Date","now","resolution","results","verdict","refusal","email","push","input","enrolled","reason","summary","decision","stored","get","group","actingUid","uid","enrollment","listRef","length","source","via","consent","memberId","created","basis","added","filter","result","emailListRulePreviewHandler","rule","matched","truncated","complete","empty","scan","candidates","map","candidate","Boolean","batch","slice","registerEmailConsoleApi"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuDC,GAED,SAASA,8BAA8B,QAAQ,2BAAuB;AACtE,SAASC,4BAA4B,QAAQ,2BAAuB;AACpE,SAASC,0BAA0B,QAAQ,0BAAsB;AACjE,SACEC,qBAAqB,EACrBC,aAAa,EACbC,gBAAgB,EAChBC,kBAAkB,QACb,wBAAoB;AAC3B,SACEC,2BAA2B,EAC3BC,eAAe,EACfC,sBAAsB,EACtBC,wBAAwB,EACxBC,kBAAkB,EAClBC,sBAAsB,QAGjB,sBAAqB;AAC5B,SACEC,4BAA4B,EAC5BC,gBAAgB,QACX,2BAA0B;AAEjC,6DAA6D,GAC7D,OAAO,MAAMC,qBAAqB,mBAAkB;AAEpD;;;;;CAKC,GACD,SACEZ,qBAAqB,EACrBE,gBAAgB,EAChBC,kBAAkB,QAKb,wBAAoB;AAG3B;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMU,iCAAmD,OAC9DC,KACAC;IAEA,IAAID,IAAIE,MAAM,KAAK,QAAQ;QACzB,OAAOD,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAqB;IAC5D;IACA,MAAMC,YAAYnB,cAAca;IAChC,IAAI,WAAWM,WAAW;QACxB,OAAOL,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAOC,UAAUD,KAAK;QAAC;IACvD;IACA,IAAI;QACF,MAAME,UAAU,MAAMlB,mBAAmBW;QACzC,IAAIO,QAAQC,EAAE,KAAK,OAAO;YACxB,OAAOP,IAAIE,MAAM,CAACI,QAAQJ,MAAM,EAAEC,IAAI,CAACG,QAAQE,IAAI;QACrD;QACA,MAAM,EAAEC,QAAQ,EAAEC,OAAO,EAAEC,eAAe,EAAEC,OAAO,EAAE,GACnD,MAAMzB,iBAAiB;YACrB0B,QAAQP,QAAQO,MAAM;YACtBC,QAAQT,UAAUU,MAAM;QAC1B;QACF;;;;;;;;KAQC,GACD,OAAOf,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAC1Ba,UAAUV,QAAQU,QAAQ;YAC1BP;YACAC;YACAC;YACAC;QACF;IACF,EAAE,OAAOR,OAAO;QACda,QAAQb,KAAK,CAAC,sCAAsCA;QACpD,OAAOJ,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAsC;IAC7E;AACF,EAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;CAyBC,GACD,OAAO,MAAMc,6BAA+C,OAC1DnB,KACAC;;QASiBD,WACGA;IARpB,IAAIA,IAAIE,MAAM,KAAK,QAAQ;QACzB,OAAOD,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAqB;IAC5D;IACA,MAAMC,YAAYnB,cAAca;IAChC,IAAI,WAAWM,WAAW;QACxB,OAAOL,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAOC,UAAUD,KAAK;QAAC;IACvD;IACA,MAAMe,WAAWpB,EAAAA,YAAAA,IAAIS,IAAI,qBAART,UAAUqB,aAAa,MAAK;IAC7C,MAAMC,OAAOC,gBAAOvB,aAAAA,IAAIS,IAAI,qBAART,WAAUsB,IAAI,mBAAI,IAAIE,IAAI;IAE9C,IAAI;QACF,MAAMjB,UAAU,MAAMlB,mBAAmBW;QACzC,IAAIO,QAAQC,EAAE,KAAK,OAAO;YACxB,OAAOP,IAAIE,MAAM,CAACI,QAAQJ,MAAM,EAAEC,IAAI,CAACG,QAAQE,IAAI;QACrD;QACA,MAAMgB,QAAQC,KAAKC,GAAG;QACtB,MAAMC,aAAa,MAAMxC,iBAAiB;YACxC0B,QAAQP,QAAQO,MAAM;YACtBC,QAAQT,UAAUU,MAAM;QAC1B;QAEA,MAAMa,UAAU,EAAE;QAClB,KAAK,MAAMC,WAAWF,WAAWlB,QAAQ,CAAE;gBAsB/BkB;YArBV,IAAIE,QAAQC,OAAO,IAAI,CAACD,QAAQE,KAAK,EAAE;gBACrCH,QAAQI,IAAI,CAAC;oBACXC,OAAOJ,QAAQI,KAAK;oBACpBF,OAAOF,QAAQE,KAAK;oBACpBG,UAAU;oBACVC,QAAQN,QAAQC,OAAO;oBACvB1B,OAAOyB,QAAQO,OAAO;gBACxB;gBACA;YACF;YACA;;;;;;;;;OASC,GACD,MAAMC,WAAW/C,gBAAgB;gBAC/BgD,MAAM,GAAEX,yBAAAA,WAAWW,MAAM,CAACC,GAAG,CAACV,QAAQE,KAAK,aAAnCJ,yBAAwClC,mBAAmB,MAAMkC,WAAWa,KAAK;gBACzFrB;gBACAsB,WAAWnC,QAAQoC,GAAG;gBACtBlB;YACF;YACA,IAAI,aAAaa,UAAU;gBACzBT,QAAQI,IAAI,CAAC;oBACXC,OAAOJ,QAAQI,KAAK;oBACpBF,OAAOF,QAAQE,KAAK;oBACpBG,UAAU;oBACVC,QAAQE,SAASP,OAAO;oBACxB1B,OAAOf,2BAA2B,CAACgD,SAASP,OAAO,CAAC;gBACtD;gBACA;YACF;YACA,MAAMa,aAAa,MAAM/C,iBAAiB;gBACxCgD,SAAStC,QAAQsC,OAAO;gBACxBJ,OAAOb,WAAWa,KAAK;gBACvBT,OAAOF,QAAQE,KAAK;eAChBV,QAAQhB,UAAUU,MAAM,CAAC8B,MAAM,KAAK,IAAI;gBAAExB;YAAK,IAAI,CAAC;gBACxDyB,QAAQjD;gBACR,mEAAmE;gBACnE,kEAAkE;gBAClE,gDAAgD;gBAChDkD,KAAK;gBACLC,SAASX;;YAEX,IAAIM,WAAWT,QAAQ,KAAK,OAAO;gBACjC;;;;;SAKC,GACD,MAAMC,SACJQ,WAAWb,OAAO,KAAK,aAAa,aAAa;gBACnDF,QAAQI,IAAI,CAAC;oBACXC,OAAOJ,QAAQI,KAAK;oBACpBF,OAAOF,QAAQE,KAAK;oBACpBG,UAAU;oBACVC;oBACA/B,OAAOf,2BAA2B,CAAC8C,OAAO;gBAC5C;gBACA;YACF;YACAP,QAAQI,IAAI,CAAC;gBACXC,OAAOJ,QAAQI,KAAK;gBACpBF,OAAOF,QAAQE,KAAK;gBACpBG,UAAU;gBACVe,UAAUN,WAAWM,QAAQ;gBAC7BC,SAASP,WAAWO,OAAO;gBAC3BC,OAAOd,SAASc,KAAK;YACvB;QACF;QAEA,OAAOnD,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAC1Ba,UAAUV,QAAQU,QAAQ;YAC1BoC,OAAOxB,QAAQyB,MAAM,CAAC,CAACC,SAAWA,OAAOpB,QAAQ,EAAEW,MAAM;YACzDjB;QACF;IACF,EAAE,OAAOxB,OAAO;QACda,QAAQb,KAAK,CAAC,kCAAkCA;QAChD,OAAOJ,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAoC;IAC3E;AACF,EAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+BC,GACD,OAAO,MAAMmD,8BAAgD,OAC3DxD,KACAC;IAEA,IAAID,IAAIE,MAAM,KAAK,QAAQ;QACzB,OAAOD,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAqB;IAC5D;IACA,IAAI;YAKoCL;QAJtC,MAAMO,UAAU,MAAMlB,mBAAmBW;QACzC,IAAIO,QAAQC,EAAE,KAAK,OAAO;YACxB,OAAOP,IAAIE,MAAM,CAACI,QAAQJ,MAAM,EAAEC,IAAI,CAACG,QAAQE,IAAI;QACrD;QACA,MAAMgD,OAAOhE,0BAAyBO,YAAAA,IAAIS,IAAI,qBAART,UAAUyD,IAAI;QACpD,IAAIjE,uBAAuBiE,OAAO;YAChC;;;;;OAKC,GACD,OAAOxD,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAC1BsD,SAAS;gBACTC,WAAW;gBACXC,UAAU;gBACVC,OAAO;gBACP7C,QAAQ,EAAE;gBACVN,UAAU,EAAE;gBACZC,SAAS;gBACTC,iBAAiB;gBACjBC,SAAS;YACX;QACF;QACA,MAAMiD,OAAO,MAAMlE,6BAA6B;YAC9CkB,QAAQP,QAAQO,MAAM;YACtB2C;QACF;QACA;;;;;KAKC,GACD,MAAMzC,SAAS8C,KAAKC,UAAU,CAC3BC,GAAG,CAAC,CAACC,YAAcA,UAAUjC,KAAK,EAClCsB,MAAM,CAACY;QACV,MAAMC,QAAQnD,OAAOoD,KAAK,CAAC,GAAGlF;QAC9B,MAAM0C,aAAa,MAAMxC,iBAAiB;YACxC0B,QAAQP,QAAQO,MAAM;YACtBC,QAAQoD;QACV;QACA,OAAOlE,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAC1Ba,UAAUV,QAAQU,QAAQ;YAC1ByC,SAAS1C,OAAO8B,MAAM;YACtB;;;;;OAKC,GACDa,WAAW3C,OAAO8B,MAAM,GAAGqB,MAAMrB,MAAM;YACvCc,UAAUE,KAAKF,QAAQ;YACvBC,OAAO;YACP7C,QAAQmD;YACRzD,UAAUkB,WAAWlB,QAAQ;YAC7BC,SAASiB,WAAWjB,OAAO;YAC3BC,iBAAiBgB,WAAWhB,eAAe;YAC3CC,SAASe,WAAWf,OAAO;QAC7B;IACF,EAAE,OAAOR,OAAO;QACda,QAAQb,KAAK,CAAC,oCAAoCA;QAClD,OAAOJ,IACJE,MAAM,CAAC,KACPC,IAAI,CAAC;YAAEC,OAAO;QAAwC;IAC3D;AACF,EAAC;AAED;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,SAASgE;IACd,oEAAoE;IACpE,2EAA2E;IAC3E,mEAAmE;IACnE,cAAc;IACdtF;IACAY,uBAAuB,2BAA2B6D;IAClD7D,uBACE,8BACAI;IAEFJ,uBAAuB,0BAA0BwB;IACjD,qEAAqE;IACrE,6EAA6E;IAC7E,0EAA0E;IAC1E,0EAA0E;IAC1ElC;IACA,2EAA2E;IAC3E,4EAA4E;IAC5E,2EAA2E;IAC3E,oBAAoB;IACpBD;AACF"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/server-console.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 * Putting somebody on an email list from the Emails console.\n *\n * The audience card could create a list and delete a list and nothing else:\n * no way to see who was on one, no way to add anybody, no way to take anybody\n * off. Every member document in production arrived from a capture surface —\n * the newsletter handler, the workflow `enrollList` step, the dynamic-list\n * materializer, the Inbox assignment — so the one act a merchant most expects\n * of a list, \"add this person\", was the one act the product refused.\n *\n * ## Why this is a route and not a client write\n *\n * Rules put `orgs/{orgId}/lists/{listId}/members` behind org-wide membership,\n * and until now that block allowed a client CREATE. Nothing used it, and it\n * was the whole feature waiting to be built wrong: a browser that can write a\n * member document can write `marketingConsent: true` beside an address it has\n * never checked, which is a consent record minted by pressing a button. The\n * rules now deny client create and update on that collection, and this route\n * is the writer — through `enrollListMember`, which owns the document id, and\n * through the shared `list-assignment-policy`, which owns the basis.\n *\n * ## The same policy the Inbox uses, not a second one\n *\n * `assignmentBasis` and `assignmentReadout` are imported from the framework,\n * where they moved when this became their second caller. There is no third\n * consent basis and no console-only override: a stored `declined` refuses\n * here exactly as it refuses there, an attestation is recorded with the\n * account that made it, and a stored opt-in is carried across with the date\n * the PERSON set rather than the date somebody pressed Add.\n *\n * ## Reads only, until the operator has seen the count\n *\n * `email/list-members-preview` writes nothing. It answers, per address, what\n * would happen and why, so the attestation the operator gives on the second\n * call is given with the numbers in front of them. Both routes run the SAME\n * resolution over the SAME inputs — `resolveAddresses` — so the summary they\n * were shown is the summary that acts.\n *\n * ## Finding people is a read, and it uses the same gate\n *\n * `email/list-rule-preview` answers \"who do these filters select\" without\n * writing anything, so a fixed list can be filled from a search rather than\n * from somebody typing addresses one at a time. It runs the addresses it finds\n * through `resolveAddresses` as well — a bulk path that reached the membership\n * without the suppression check and the attestation would be a way to enroll\n * exactly the people the single-address path refuses.\n *\n * ## The gate itself is `server-list-gate.ts`\n *\n * Who may change a list's membership, and what is true about each address, are\n * asked by three route modules now: these two, and the file importer in\n * `server-list-import.ts`. They live in a module of their own so the importer\n * shares this file's answer rather than importing this file — which would be a\n * cycle, and a cycle whose module-level constants would evaluate in whichever\n * order the loader happened to reach them.\n */\n\nimport { registerEmailDesignDraftWriter } from './server-email-drafts'\nimport { registerEmailSuppressionsApi } from './server-suppressions'\nimport { registerEmailListImportApi } from './server-list-import'\nimport {\n LIST_MEMBER_BATCH_MAX,\n readAddresses,\n resolveAddresses,\n resolveListContext,\n} from './server-list-gate'\nimport {\n ASSIGNMENT_REFUSAL_MESSAGES,\n assignmentBasis,\n dynamicListRuleIsEmpty,\n normalizeDynamicListRule,\n readMarketingBasis,\n registerPluginApiRoute,\n type AssignmentRefusal,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n collectDynamicListCandidates,\n enrollListMember,\n} from '@aglyn/tenant-data-admin'\n\n/** `source` stamped on every membership these routes write. */\nexport const CONSOLE_ADD_SOURCE = 'console:list-add'\n\n/*\n * Re-exported, not redefined. `@aglyn/plugins-email/server` has published\n * these names since the add path shipped, and the console panel and its specs\n * import them from there; moving the gate must not move the plugin's public\n * surface with it.\n */\nexport {\n LIST_MEMBER_BATCH_MAX,\n resolveAddresses,\n resolveListContext,\n type AddressResolution,\n type AddressVerdict,\n type ListContext,\n type ResolvedBatch,\n} from './server-list-gate'\n\n\n/**\n * `POST email/list-members-preview` — what adding these addresses would do.\n *\n * Reads only, and reached by an explicit act — typing an address or pasting a\n * column — never on mount. It exists because the answer needs three things\n * the browser cannot have: the org's contacts (rules put them behind org-wide\n * membership, which the acting console session may not hold) and both\n * suppression lists. Computing any of it client-side would be a second copy\n * of the rule, on the surface whose whole job is to tell the operator the\n * truth about what is about to happen.\n *\n * It takes no attestation and has nowhere to put one. The preview answers\n * what is TRUE about each person, which is the input to the operator's\n * decision rather than a function of it — a preview whose numbers moved as\n * the box was ticked would not be a count anybody could stand behind.\n */\nexport const emailListMembersPreviewHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const requested = readAddresses(req)\n if ('error' in requested) {\n return res.status(400).json({ error: requested.error })\n }\n try {\n const context = await resolveListContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const { verdicts, optedIn, needAttestation, refused } =\n await resolveAddresses({\n hostId: context.hostId,\n inputs: requested.emails,\n })\n /*\n * Named fields rather than a spread of the resolution.\n *\n * The resolution carries the consent RECORDS it was computed from, for the\n * write path's use — raw stored basis, provenance, the account behind an\n * operator assertion. None of that is the browser's, and a spread would\n * put all of it on the wire the moment a field was added to the internal\n * shape. What the surface needs is the verdicts and the counts.\n */\n return res.status(200).json({\n listName: context.listName,\n verdicts,\n optedIn,\n needAttestation,\n refused,\n })\n } catch (error) {\n console.error('[email] list member preview failed', error)\n return res.status(500).json({ error: 'The addresses could not be checked.' })\n }\n}\n\n/**\n * `POST email/list-members-add` — put these addresses on the list.\n *\n * Body: `{ hostId, listId, email | emails[], name?, attestConsent? }`.\n * `attestConsent` is the operator STATING that they have these people's\n * permission; it is not a way to name a basis, because the pass-through basis\n * is derived server-side from each person's own record.\n *\n * ## One attestation, for the count the operator was shown\n *\n * The batch carries a single assertion because it is a single act: an\n * operator pasting a column is making one claim about where that column came\n * from. What makes that safe is that the claim is applied per address by the\n * same function the preview ran, so it reaches only the addresses that\n * actually need it — an address with a stored opt-in keeps its own basis and\n * its own date, and an address nothing can enroll is refused with the\n * attestation on the table.\n *\n * ## Partial success is the honest answer\n *\n * A batch where one address is suppressed and forty are fine is not a failed\n * request. Every address comes back with what happened to it, so 200 here\n * means \"the request was processed\", never \"everybody was added\" — the caller\n * reads the per-address verdicts, which is why they are returned rather than\n * a count.\n */\nexport const emailListMembersAddHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const requested = readAddresses(req)\n if ('error' in requested) {\n return res.status(400).json({ error: requested.error })\n }\n const attested = req.body?.attestConsent === true\n const name = String(req.body?.name ?? '').trim()\n\n try {\n const context = await resolveListContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const nowMs = Date.now()\n const resolution = await resolveAddresses({\n hostId: context.hostId,\n inputs: requested.emails,\n })\n\n const results = []\n for (const verdict of resolution.verdicts) {\n if (verdict.refusal || !verdict.email) {\n results.push({\n input: verdict.input,\n email: verdict.email,\n enrolled: false,\n reason: verdict.refusal,\n error: verdict.summary,\n })\n continue\n }\n /*\n * The ONE place a basis is decided, and the only place the attestation\n * is consulted.\n *\n * `resolveAddresses` has already answered the questions that are facts\n * about the person; this answers the question that is about the\n * operator, per address, from the consent record that resolution read.\n * `no-basis` — an address with nothing on record and nobody asserting\n * anything — is refused HERE and only here.\n */\n const decision = assignmentBasis({\n stored: resolution.stored.get(verdict.email) ?? readMarketingBasis(null, resolution.group),\n attested,\n actingUid: context.uid,\n nowMs,\n })\n if ('refusal' in decision) {\n results.push({\n input: verdict.input,\n email: verdict.email,\n enrolled: false,\n reason: decision.refusal,\n error: ASSIGNMENT_REFUSAL_MESSAGES[decision.refusal],\n })\n continue\n }\n const enrollment = await enrollListMember({\n listRef: context.listRef,\n group: resolution.group,\n email: verdict.email,\n ...(name && requested.emails.length === 1 ? { name } : {}),\n source: CONSOLE_ADD_SOURCE,\n // Never `'rule'`: the dynamic-list materializer reconciles its own\n // rows away when a person stops matching, and a decision somebody\n // made by hand is not a rule match that lapsed.\n via: 'manual',\n consent: decision,\n // A pass-through carries the person's grants as their record holds\n // them (AGL-3320); an attestation carries none and is this site's.\n ...(decision.basis === 'contact-opt-in'\n ? { grantEntries: resolution.grants.get(verdict.email) ?? {} }\n : {}),\n })\n if (enrollment.enrolled === false) {\n /*\n * The membership itself records a refusal the CRM record did not.\n * `enrollListMember` is the only writer of the collection and holds\n * the row, so it is the backstop for every enrollment route; reaching\n * it here means the two records disagree, and the refusal wins.\n */\n const reason: AssignmentRefusal =\n enrollment.refusal === 'declined' ? 'declined' : 'unroutable-address'\n results.push({\n input: verdict.input,\n email: verdict.email,\n enrolled: false,\n reason,\n error: ASSIGNMENT_REFUSAL_MESSAGES[reason],\n })\n continue\n }\n results.push({\n input: verdict.input,\n email: verdict.email,\n enrolled: true,\n memberId: enrollment.memberId,\n created: enrollment.created,\n basis: decision.basis,\n })\n }\n\n return res.status(200).json({\n listName: context.listName,\n added: results.filter((result) => result.enrolled).length,\n results,\n })\n } catch (error) {\n console.error('[email] list member add failed', error)\n return res.status(500).json({ error: 'The addresses could not be added.' })\n }\n}\n\n/**\n * WHO A SET OF FILTERS FINDS, and what would happen if you added them.\n *\n * ## The gap it fills\n *\n * The filters behind a list could only ever be MATERIALIZED — the sweep wrote\n * the matching people straight into the membership. A merchant could not ask\n * \"who is this\" without committing to it, and a fixed list could not use the\n * filters at all: its only way to gain a member was somebody typing or pasting\n * an address. This answers the question without writing anything.\n *\n * ## It is the same consent gate, not a second one\n *\n * The addresses the scan finds are put through {@link resolveAddresses} — the\n * exact function `email/list-members-preview` uses — so a suppressed address\n * is reported as refused here, and somebody with no opt-in on record is\n * reported as needing an attestation here, before any of them is offered for\n * adding. That is deliberate and load-bearing: a bulk path that skipped the\n * check would be a way to fill a list with people the one-at-a-time path\n * refuses, which is the defect class this product already has a register entry\n * for. The ADD still goes through `email/list-members-add`, which re-runs\n * every check server-side, so this preview is an honest readout rather than a\n * permission.\n *\n * ## What it will not do\n *\n * It writes nothing, it enrolls nobody, and it compares no count against any\n * limit. The batch cap below bounds how many addresses it hands back for a\n * single add — the scan itself still reports how many people it MATCHED, so a\n * merchant is told the audience is larger than one batch rather than shown a\n * truncated number as if it were the whole.\n */\nexport const emailListRulePreviewHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n try {\n const context = await resolveListContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const rule = normalizeDynamicListRule(req.body?.rule)\n if (dynamicListRuleIsEmpty(rule)) {\n /*\n * Not an error, and not an empty result either. A rule with no source\n * matches nobody by construction, and reporting that as \"0 people\" is\n * indistinguishable from a rule that ran and found none — which is the\n * confusion the `empty` flag exists to prevent everywhere else.\n */\n return res.status(200).json({\n matched: 0,\n truncated: false,\n complete: true,\n empty: true,\n emails: [],\n verdicts: [],\n optedIn: 0,\n needAttestation: 0,\n refused: 0,\n })\n }\n const scan = await collectDynamicListCandidates({\n hostId: context.hostId,\n rule,\n })\n /*\n * The addresses, in the scan's own order, capped at what one add can\n * carry. `matched` is reported separately and is NOT this length: a\n * merchant looking at a 400-person audience must be told it is 400 even\n * when the button in front of them adds 100.\n */\n const emails = scan.candidates\n .map((candidate) => candidate.email)\n .filter(Boolean)\n const batch = emails.slice(0, LIST_MEMBER_BATCH_MAX)\n const resolution = await resolveAddresses({\n hostId: context.hostId,\n inputs: batch,\n })\n return res.status(200).json({\n listName: context.listName,\n matched: emails.length,\n /*\n * Two different reasons a readout can be short of the truth, reported\n * apart: `truncated` is this batch being smaller than the match, and\n * `complete: false` is the SCAN having run out of budget, so `matched`\n * is itself a floor.\n */\n truncated: emails.length > batch.length,\n complete: scan.complete,\n empty: false,\n emails: batch,\n verdicts: resolution.verdicts,\n optedIn: resolution.optedIn,\n needAttestation: resolution.needAttestation,\n refused: resolution.refused,\n })\n } catch (error) {\n console.error('[email] list rule preview failed', error)\n return res\n .status(500)\n .json({ error: 'The audience could not be worked out.' })\n }\n}\n\n/**\n * Console API registration.\n *\n * None of these is on the machine-path exemption list in\n * `plugin-api-rate-limit.ts`, so each is counted against the console\n * dispatcher's per-subject budget — the ceiling that bounds a surface which\n * puts a person into a marketing audience, and which scans a whole contact\n * silo to work out who that would be.\n *\n * The limiter these are counted by is the CONSOLE one,\n * `consoleApiRateLimitRefusal`. The visitor limiter is a different limiter on\n * a different dispatcher: `visitorWriteRateLimitRefusal` is installed only in\n * `apps/tenant`, and no request to these routes has ever passed through it.\n * The two share a counter and an exemption list and nothing else — the console\n * key is the authenticated subject, because on an authenticated surface a\n * per-(site, IP) bucket both isolates nothing the identity has not already\n * isolated and hands one operator a fresh budget per site.\n */\nexport function registerEmailConsoleApi(): void {\n // The email design writer another plugin reaches through the core's\n // resource-drafts seam (AGL-2912). Registered here alone: the AI jobs that\n // write through it run only on the console, inline and on the beat\n // (AGL-3026).\n registerEmailDesignDraftWriter()\n registerPluginApiRoute('email/list-rule-preview', emailListRulePreviewHandler)\n registerPluginApiRoute(\n 'email/list-members-preview',\n emailListMembersPreviewHandler,\n )\n registerPluginApiRoute('email/list-members-add', emailListMembersAddHandler)\n // The importer is four routes over one staged job, so it has its own\n // module — but not its own gate: it reaches this file's `resolveListContext`\n // and `resolveAddresses` rather than a bulk-shaped copy of them, which is\n // the whole reason an import cannot enroll somebody the add path refuses.\n registerEmailListImportApi()\n // Suppressions live in their own module: they are a per-SITE list gated on\n // the site role, where list membership is an ORG audience gated on org-wide\n // access, and one file holding both gates is one file for the wrong one to\n // be copied out of.\n registerEmailSuppressionsApi()\n}\n"],"names":["registerEmailDesignDraftWriter","registerEmailSuppressionsApi","registerEmailListImportApi","LIST_MEMBER_BATCH_MAX","readAddresses","resolveAddresses","resolveListContext","ASSIGNMENT_REFUSAL_MESSAGES","assignmentBasis","dynamicListRuleIsEmpty","normalizeDynamicListRule","readMarketingBasis","registerPluginApiRoute","collectDynamicListCandidates","enrollListMember","CONSOLE_ADD_SOURCE","emailListMembersPreviewHandler","req","res","method","status","json","error","requested","context","ok","body","verdicts","optedIn","needAttestation","refused","hostId","inputs","emails","listName","console","emailListMembersAddHandler","attested","attestConsent","name","String","trim","nowMs","Date","now","resolution","results","verdict","refusal","email","push","input","enrolled","reason","summary","decision","stored","get","group","actingUid","uid","enrollment","listRef","length","source","via","consent","basis","grantEntries","grants","memberId","created","added","filter","result","emailListRulePreviewHandler","rule","matched","truncated","complete","empty","scan","candidates","map","candidate","Boolean","batch","slice","registerEmailConsoleApi"],"mappings":"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"}
|
|
@@ -136,6 +136,15 @@ export interface AddressResolution {
|
|
|
136
136
|
*/
|
|
137
137
|
export interface ResolvedBatch extends AddressResolution {
|
|
138
138
|
stored: Map<string, MarketingConsentRecord>;
|
|
139
|
+
/**
|
|
140
|
+
* Each opted-in person's own grants, as `grantEntriesAsRecorded` read them
|
|
141
|
+
* off the SAME contact document `stored` was read from — what a
|
|
142
|
+
* pass-through enrollment carries onto the membership (AGL-3320).
|
|
143
|
+
*
|
|
144
|
+
* From the same read for the reason `stored` is: the basis decided and the
|
|
145
|
+
* sites it covers must describe one version of the person's record.
|
|
146
|
+
*/
|
|
147
|
+
grants: Map<string, Record<string, Record<string, unknown>>>;
|
|
139
148
|
/**
|
|
140
149
|
* The consent group the resolution was made against, so the WRITE records
|
|
141
150
|
* a basis for exactly the controller the verdicts were computed for.
|
|
@@ -45,7 +45,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
45
45
|
* because an attestation is a fact about the operator and folding it in here
|
|
46
46
|
* would make the preview's numbers move as the box was ticked. The basis is
|
|
47
47
|
* decided once, at the write, by `assignmentBasis`.
|
|
48
|
-
*/ import { ASSIGNMENT_REFUSAL_MESSAGES, assignmentReadout, isOrgWideMember, normalizeContactEmail, readMarketingBasis } from "@aglyn/aglyn/server";
|
|
48
|
+
*/ import { ASSIGNMENT_REFUSAL_MESSAGES, assignmentReadout, grantEntriesAsRecorded, isOrgWideMember, normalizeContactEmail, readMarketingBasis } from "@aglyn/aglyn/server";
|
|
49
49
|
import { consentGroupForSite, filterSendableForHost, filterSuppressedEmails, firebaseAdmin, getOrgForHost, orgDataCollectionForHost, resolveOrgMembership } from "@aglyn/tenant-data-admin";
|
|
50
50
|
/**
|
|
51
51
|
* The most addresses one request may name.
|
|
@@ -211,7 +211,7 @@ import { consentGroupForSite, filterSendableForHost, filterSuppressedEmails, fir
|
|
|
211
211
|
* into all three at once, and an agency's client — which declared no group
|
|
212
212
|
* — resolves to itself.
|
|
213
213
|
*/ const group = await consentGroupForSite(input.hostId);
|
|
214
|
-
const [suppression, stored] = await Promise.all([
|
|
214
|
+
const [suppression, { stored, grants }] = await Promise.all([
|
|
215
215
|
suppressionFor(input.hostId, group, addresses),
|
|
216
216
|
storedConsentFor(input.hostId, group, addresses)
|
|
217
217
|
]);
|
|
@@ -252,6 +252,7 @@ import { consentGroupForSite, filterSendableForHost, filterSuppressedEmails, fir
|
|
|
252
252
|
return {
|
|
253
253
|
verdicts,
|
|
254
254
|
stored,
|
|
255
|
+
grants,
|
|
255
256
|
group,
|
|
256
257
|
optedIn: verdicts.filter((verdict)=>!verdict.refusal && !verdict.requiresAttestation).length,
|
|
257
258
|
needAttestation: verdicts.filter((verdict)=>verdict.requiresAttestation).length,
|
|
@@ -310,7 +311,11 @@ import { consentGroupForSite, filterSendableForHost, filterSuppressedEmails, fir
|
|
|
310
311
|
* does not enroll somebody whose stored refusal we simply failed to see.
|
|
311
312
|
*/ async function storedConsentFor(hostId, group, addresses) {
|
|
312
313
|
const found = new Map();
|
|
313
|
-
|
|
314
|
+
const grants = new Map();
|
|
315
|
+
if (!addresses.length) return {
|
|
316
|
+
stored: found,
|
|
317
|
+
grants
|
|
318
|
+
};
|
|
314
319
|
try {
|
|
315
320
|
const contacts = await orgDataCollectionForHost(hostId, 'contacts');
|
|
316
321
|
const chunks = [];
|
|
@@ -322,14 +327,19 @@ import { consentGroupForSite, filterSendableForHost, filterSuppressedEmails, fir
|
|
|
322
327
|
for (const doc of snapshot.docs){
|
|
323
328
|
const email = normalizeContactEmail(doc.get('email'));
|
|
324
329
|
if (!email) continue;
|
|
325
|
-
const
|
|
330
|
+
const data = doc.data();
|
|
331
|
+
const record = readMarketingBasis(data, group);
|
|
326
332
|
const already = found.get(email);
|
|
327
333
|
if ((already == null ? void 0 : already.basis) === 'declined') continue;
|
|
328
334
|
if (already && record.basis !== 'declined') continue;
|
|
329
335
|
found.set(email, record);
|
|
336
|
+
grants.set(email, grantEntriesAsRecorded(data, group));
|
|
330
337
|
}
|
|
331
338
|
}
|
|
332
|
-
return
|
|
339
|
+
return {
|
|
340
|
+
stored: found,
|
|
341
|
+
grants
|
|
342
|
+
};
|
|
333
343
|
} catch (error) {
|
|
334
344
|
console.error('[email] consent lookup failed', error);
|
|
335
345
|
const refused = _extends({}, readMarketingBasis(null, group), {
|
|
@@ -341,10 +351,14 @@ import { consentGroupForSite, filterSendableForHost, filterSuppressedEmails, fir
|
|
|
341
351
|
basisAtMs: null,
|
|
342
352
|
capturedAtMs: null
|
|
343
353
|
});
|
|
344
|
-
return
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
354
|
+
return {
|
|
355
|
+
stored: new Map(addresses.map((email)=>[
|
|
356
|
+
email,
|
|
357
|
+
refused
|
|
358
|
+
])),
|
|
359
|
+
// Nobody is enrollable on a failed read, so nobody's grants travel.
|
|
360
|
+
grants: new Map()
|
|
361
|
+
};
|
|
348
362
|
}
|
|
349
363
|
}
|
|
350
364
|
/** The addresses named by one request, or the refusal to send back. */ export function readAddresses(req) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/server-list-gate.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * THE GATE IN FRONT OF EVERY LIST ENROLLMENT THE CONSOLE MAKES.\n *\n * Who is allowed to change a list's membership, and what is true about each\n * address they name. Three route modules ask it — the audience card's add and\n * preview, the filter search, and the file importer — and they ask this one\n * copy of it.\n *\n * ## Why it is a module of its own\n *\n * It began inside `server-console.ts`, beside its first two callers, and that\n * was right while there were two. The importer made three, and an importer is\n * exactly the surface where a second, laxer idea of \"may this person be\n * enrolled\" is most tempting: a file is bulk, bulk is slow, and skipping the\n * per-address consent read is the obvious saving. A bulk path that reached the\n * membership without the suppression check and the attestation would be a way\n * to enroll precisely the people the one-at-a-time path refuses, which is the\n * defect class `docs/specs/email-competitive-gaps.md` has a closed P1 entry\n * for.\n *\n * Sharing the gate is also what stops the cheaper failure: two modules that\n * merely agree today, with nothing in either one saying the other exists.\n *\n * ## What it decides, and what it deliberately does not\n *\n * {@link resolveAddresses} answers the questions that are FACTS about a\n * person — a stored refusal, a suppression on either list, an opt-in already\n * on record, a line that is not an address. It never consults an attestation,\n * because an attestation is a fact about the operator and folding it in here\n * would make the preview's numbers move as the box was ticked. The basis is\n * decided once, at the write, by `assignmentBasis`.\n */\n\nimport {\n ASSIGNMENT_REFUSAL_MESSAGES,\n assignmentReadout,\n isOrgWideMember,\n normalizeContactEmail,\n readMarketingBasis,\n type AddressRefusal,\n type AssignmentRefusal,\n type ConsentGroup,\n type MarketingConsentRecord,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n filterSendableForHost,\n filterSuppressedEmails,\n firebaseAdmin,\n getOrgForHost,\n orgDataCollectionForHost,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\n\n/**\n * The most addresses one request may name.\n *\n * A ceiling on the WRITE, not on the audience. It bounds the work one request\n * does — the resolution below is a handful of reads per address — and it can\n * never refuse a person already enrolled or make room by removing one, which\n * is the difference between a batch size and a capacity limit. Nothing in the\n * product caps list membership: `contactsPerHost` is the audience band and it\n * is metered against CONTACTS, in `upsert-contact.ts`, and enrolling somebody\n * on a list writes no contact. If a membership ceiling is ever introduced,\n * this function is where it refuses, because this is where something is\n * added.\n */\nexport const LIST_MEMBER_BATCH_MAX = 100\n\n/** How many contact addresses one `in` query may carry. */\nconst CONTACT_LOOKUP_CHUNK = 30\n\n/** Everything a list-membership route needs, or the refusal to send back. */\nexport type ListContext =\n | {\n ok: true\n uid: string\n hostId: string\n orgId: string\n listRef: FirebaseFirestore.DocumentReference\n listName: string\n }\n | { ok: false; status: number; body: Record<string, unknown> }\n\n/**\n * Who is asking, and about which list.\n *\n * ## Two gates, not one — the same two the Inbox assignment route applies\n *\n * A host role is necessary and NOT sufficient. Lists live at\n * `orgs/{orgId}/lists` and their members are contacts, so the rules put both\n * behind `isOrgWideMember()`. An editor invited to ONE site is an org member\n * with `allHosts: false`, and gating an org-wide write on the host role alone\n * would let a single-site collaborator enroll people into an audience every\n * other site in the org can mail — and, through the preview, read the consent\n * record of any address they care to type. The Admin SDK evaluates no rules,\n * so this route is the enforcement rather than an echo of it.\n *\n * The plugin's own gates sit above this and are the dispatcher's: a workspace\n * that has switched the email plugin off, or a site it is disabled for, or a\n * release flag that has not reached this org, all 404 before a handler runs.\n */\nexport async function resolveListContext(\n req: Parameters<PluginApiHandler>[0],\n): Promise<ListContext> {\n const hostId = String(req.body?.hostId ?? '')\n const listId = String(req.body?.listId ?? '')\n if (!hostId || !listId) {\n return {\n ok: false,\n status: 400,\n body: { error: 'Missing hostId or listId' },\n }\n }\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n return { ok: false, status: 401, body: { error: 'Unauthenticated' } }\n }\n\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostSnapshot = await firestore.collection('hosts').doc(hostId).get()\n if (!hostSnapshot.exists) {\n return { ok: false, status: 404, body: { error: 'Unknown site' } }\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return {\n ok: false,\n status: 403,\n body: { error: 'Not a site admin or editor' },\n }\n }\n\n const orgId = String(\n (await getOrgForHost(hostId).catch(() => null))?.orgId ?? '',\n )\n if (!orgId) {\n return {\n ok: false,\n status: 404,\n body: { error: 'This site has no organization, so it has no lists.' },\n }\n }\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(\n () => null,\n )\n const member = membership?.member\n const orgWideWriter =\n isOrgWideMember(member) &&\n (member?.role === 'owner' ||\n member?.role === 'admin' ||\n member?.role === 'editor') &&\n (member as { orgSuspended?: boolean } | undefined)?.orgSuspended !== true\n if (!orgWideWriter) {\n return {\n ok: false,\n status: 403,\n body: {\n error:\n 'Marketing lists belong to the whole organization, so changing who ' +\n 'is on one needs organization-wide access rather than access to ' +\n 'this site.',\n },\n }\n }\n\n const listRef = firestore\n .collection('orgs')\n .doc(orgId)\n .collection('lists')\n .doc(listId)\n const listSnapshot = await listRef.get()\n // A stale or mistyped id must not CREATE a list: a campaign's `list`\n // audience would then read a list nobody set up.\n if (!listSnapshot.exists) {\n return { ok: false, status: 404, body: { error: 'Unknown list' } }\n }\n\n return {\n ok: true,\n uid: decoded.uid,\n hostId,\n orgId,\n listRef,\n listName: String(listSnapshot.get('name') ?? listId),\n }\n}\n\n/** What would happen to one address, and why. */\nexport interface AddressVerdict {\n /** Exactly what the operator typed, so a bad line can be pointed at. */\n input: string\n /** The normalized address, or `null` when there is not one. */\n email: string | null\n /**\n * Why this address can never go on the list, or `null`.\n *\n * A HARD refusal only — a stored `declined`, either suppression list, or a\n * line that is not an address. `no-basis` is deliberately NOT one of them:\n * \"you have not said you have permission yet\" is an unanswered question,\n * not a refusal, and reporting it as one would list the very people an\n * attestation is about to admit under the heading of people it cannot.\n * That distinction is {@link requiresAttestation}.\n */\n refusal: AssignmentRefusal | null\n /** True when only an attestation stands between this address and the list. */\n requiresAttestation: boolean\n /** One sentence of consent facts, in the merchant's terms. */\n summary: string\n}\n\n/** The whole answer, per address and in totals. */\nexport interface AddressResolution {\n verdicts: AddressVerdict[]\n /** Addresses a stored opt-in already covers — no attestation needed. */\n optedIn: number\n /** Addresses that need the operator to state they have permission. */\n needAttestation: number\n /** Addresses nothing can enroll, whatever the operator says. */\n refused: number\n}\n\n/**\n * The resolution plus the consent records it was computed from.\n *\n * The records ride along so the write path can derive the basis it STORES\n * from the same read the verdicts were built on. Re-reading them would be a\n * second answer to \"what does this person's record say\", between which a\n * concurrent edit fits — and the operator would have attested against the\n * first one.\n */\nexport interface ResolvedBatch extends AddressResolution {\n stored: Map<string, MarketingConsentRecord>\n /**\n * The consent group the resolution was made against, so the WRITE records\n * a basis for exactly the controller the verdicts were computed for.\n *\n * Returned rather than re-resolved by the caller for the same reason\n * `stored` is: two resolutions of the same question are two answers a\n * concurrent edit can fit between, and the operator attested against the\n * first one.\n */\n group: ConsentGroup\n}\n\n/**\n * Every address, normalized, deduplicated and put through the policy.\n *\n * ## Deduplicated on the NORMALIZED address, and reported once\n *\n * A pasted column routinely names the same person twice with different\n * casing, and `enrollListMember` keys the membership from the normalized\n * address, so two lines would be one row. Counting them twice would tell the\n * operator they are attesting for more people than they are.\n *\n * ## An unusable line is REPORTED, never dropped\n *\n * A paste that silently discarded its malformed lines would tell an operator\n * that 100 addresses went on the list when 94 did, and the six they never\n * hear about are the six they typed wrong. Every input line comes back with a\n * verdict, including the ones that are not addresses at all.\n *\n * ## Suppression is attributed, and costs nothing when there is none\n *\n * `filterSendableForHost` answers both lists in one pass and fails CLOSED, so\n * it is asked first; only the addresses it refused are put through the\n * platform half again to find out WHICH list holds them. In the ordinary case\n * that second call is never made.\n */\nexport async function resolveAddresses(input: {\n hostId: string\n inputs: readonly string[]\n}): Promise<ResolvedBatch> {\n const seen = new Set<string>()\n const rows: Array<{ input: string; email: string | null }> = []\n for (const raw of input.inputs) {\n const email = normalizeContactEmail(raw)\n if (email && seen.has(email)) continue\n if (email) seen.add(email)\n rows.push({ input: String(raw ?? '').trim(), email })\n }\n\n const addresses = rows\n .map((row) => row.email)\n .filter((email): email is string => Boolean(email))\n\n /*\n * The group first, because every answer below is about a CONTROLLER and\n * not about a site: a business running three sites as one sender enrolls\n * into all three at once, and an agency's client — which declared no group\n * — resolves to itself.\n */\n const group = await consentGroupForSite(input.hostId)\n const [suppression, stored] = await Promise.all([\n suppressionFor(input.hostId, group, addresses),\n storedConsentFor(input.hostId, group, addresses),\n ])\n\n const unrecorded: MarketingConsentRecord = readMarketingBasis(null, group)\n const verdicts = rows.map((row): AddressVerdict => {\n if (!row.email) {\n return {\n input: row.input,\n email: null,\n refusal: 'unroutable-address',\n requiresAttestation: false,\n summary: ASSIGNMENT_REFUSAL_MESSAGES['unroutable-address'],\n }\n }\n const record = stored.get(row.email) ?? unrecorded\n const readout = assignmentReadout({\n stored: record,\n suppression: suppression.get(row.email) ?? null,\n })\n return {\n input: row.input,\n email: row.email,\n /*\n * Straight off the readout, with no attestation in the question.\n *\n * `assignmentReadout` answers what is TRUE about this person — a stored\n * refusal, a suppression, an opt-in, or nothing on record — and none of\n * those changes because the operator did or did not tick a box. Feeding\n * the flag in here would make the preview's answer depend on it, so the\n * count the operator stands behind would move as they answered. The\n * basis itself is decided once, at the write, by `assignmentBasis`.\n */\n refusal: readout.enrollable\n ? null\n : (suppression.get(row.email) ?? 'declined'),\n requiresAttestation: readout.requiresAttestation,\n summary: readout.summary,\n }\n })\n\n return {\n verdicts,\n stored,\n group,\n optedIn: verdicts.filter(\n (verdict) => !verdict.refusal && !verdict.requiresAttestation,\n ).length,\n needAttestation: verdicts.filter((verdict) => verdict.requiresAttestation)\n .length,\n refused: verdicts.filter(\n (verdict) => verdict.refusal && !verdict.requiresAttestation,\n ).length,\n }\n}\n\n/**\n * Which of these addresses is suppressed, and by which list.\n *\n * Composed from the shipped helpers rather than reading the two collections\n * here: normalization, de-duplication and the fail-closed posture live in\n * `email-suppression.ts`, and a second copy of them is a second set of rules\n * for the enrollment check and the send-time check to disagree about — which\n * is exactly the disagreement `an-enrollment-is-not-a-license-to-send.spec.ts`\n * exists to stop.\n *\n * Read across the site's consent group, as the send reads it: somebody who\n * unsubscribed from a sibling of a declared group is refused here too, rather\n * than enrolled onto a list whose campaigns will never reach them.\n */\nasync function suppressionFor(\n hostId: string,\n group: ConsentGroup,\n addresses: readonly string[],\n): Promise<Map<string, AddressRefusal>> {\n const refusals = new Map<string, AddressRefusal>()\n if (!addresses.length) return refusals\n const sendable = new Set(\n await filterSendableForHost(hostId, addresses, undefined, group),\n )\n const blocked = addresses.filter((email) => !sendable.has(email))\n if (!blocked.length) return refusals\n // Only the blocked ones, and only to say WHICH list. Survivors of the\n // platform half are held by this site's own list, or a group sibling's.\n const platformSendable = new Set(await filterSuppressedEmails(blocked))\n for (const email of blocked) {\n refusals.set(\n email,\n platformSendable.has(email) ? 'suppressed-host' : 'suppressed-platform',\n )\n }\n return refusals\n}\n\n/**\n * The stored consent facts for these addresses, off the org's contacts.\n *\n * Read UNSCOPED, deliberately, exactly as the Inbox route reads it:\n * `scopedToHost` narrows an org collection to what one site may see, and a\n * refusal filtered out by that narrowing is a refusal this route would then\n * step over — the failure mode is enrolling somebody who said no. It is safe\n * because the caller has already been proved an org-wide member, which is the\n * tier the rules grant the whole org's contacts to.\n *\n * ## A refusal wins over an opt-in when the CRM holds both\n *\n * Nothing guarantees one contact per address — the collection is keyed by\n * resource id, not by email — so two records for one person can disagree.\n * Taking whichever the query happened to answer first would make the outcome\n * depend on document order. A recorded refusal is the answer whenever one\n * exists, which is the same precedence `assignmentBasis` applies within a\n * single record.\n *\n * A failed read falls to `declined` for the whole batch, for the reason the\n * Inbox route states: a read that throws can neither say the person consented\n * nor that they refused, and the direction that costs a retry is the one that\n * does not enroll somebody whose stored refusal we simply failed to see.\n */\nasync function storedConsentFor(\n hostId: string,\n group: ConsentGroup,\n addresses: readonly string[],\n): Promise<Map<string, MarketingConsentRecord>> {\n const found = new Map<string, MarketingConsentRecord>()\n if (!addresses.length) return found\n try {\n const contacts = await orgDataCollectionForHost(hostId, 'contacts')\n const chunks: string[][] = []\n for (let at = 0; at < addresses.length; at += CONTACT_LOOKUP_CHUNK) {\n chunks.push(addresses.slice(at, at + CONTACT_LOOKUP_CHUNK))\n }\n const snapshots = await Promise.all(\n chunks.map((chunk) => contacts.where('email', 'in', chunk).get()),\n )\n for (const snapshot of snapshots) {\n for (const doc of snapshot.docs) {\n const email = normalizeContactEmail(doc.get('email'))\n if (!email) continue\n const record = readMarketingBasis(\n doc.data() as Record<string, unknown>,\n group,\n )\n const already = found.get(email)\n if (already?.basis === 'declined') continue\n if (already && record.basis !== 'declined') continue\n found.set(email, record)\n }\n }\n return found\n } catch (error) {\n console.error('[email] consent lookup failed', error)\n const refused: MarketingConsentRecord = {\n ...readMarketingBasis(null, group),\n basis: 'declined',\n // Attributed to nobody: this is what a failed read falls back to, not a\n // refusal anybody recorded.\n assertedBy: null,\n source: null,\n basisAtMs: null,\n capturedAtMs: null,\n }\n return new Map(addresses.map((email) => [email, refused]))\n }\n}\n\n/** The addresses named by one request, or the refusal to send back. */\nexport function readAddresses(\n req: Parameters<PluginApiHandler>[0],\n): { emails: string[] } | { error: string } {\n const body = req.body as { emails?: unknown; email?: unknown } | undefined\n const raw = Array.isArray(body?.emails)\n ? body.emails\n : body?.email === undefined\n ? []\n : [body.email]\n const emails = raw\n .map((value) => String(value ?? '').trim())\n .filter((value) => value.length > 0)\n if (!emails.length) return { error: 'No addresses' }\n if (emails.length > LIST_MEMBER_BATCH_MAX) {\n return {\n error:\n `${emails.length} addresses is more than one go can take. Add up to ` +\n `${LIST_MEMBER_BATCH_MAX} at a time.`,\n }\n }\n return { emails }\n}\n"],"names":["ASSIGNMENT_REFUSAL_MESSAGES","assignmentReadout","isOrgWideMember","normalizeContactEmail","readMarketingBasis","consentGroupForSite","filterSendableForHost","filterSuppressedEmails","firebaseAdmin","getOrgForHost","orgDataCollectionForHost","resolveOrgMembership","LIST_MEMBER_BATCH_MAX","CONTACT_LOOKUP_CHUNK","resolveListContext","req","hostSnapshot","listSnapshot","hostId","String","body","listId","ok","status","error","authorization","headers","idToken","startsWith","slice","length","undefined","decoded","app","auth","verifyIdToken","firestore","collection","doc","get","exists","memberRole","uid","orgId","catch","membership","member","orgWideWriter","role","orgSuspended","listRef","listName","resolveAddresses","input","seen","Set","rows","raw","inputs","email","has","add","push","trim","addresses","map","row","filter","Boolean","group","suppression","stored","Promise","all","suppressionFor","storedConsentFor","unrecorded","verdicts","refusal","requiresAttestation","summary","record","readout","enrollable","optedIn","verdict","needAttestation","refused","refusals","Map","sendable","blocked","platformSendable","set","found","contacts","chunks","at","snapshots","chunk","where","snapshot","docs","data","already","basis","console","assertedBy","source","basisAtMs","capturedAtMs","readAddresses","Array","isArray","emails","value"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/server-list-gate.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * THE GATE IN FRONT OF EVERY LIST ENROLLMENT THE CONSOLE MAKES.\n *\n * Who is allowed to change a list's membership, and what is true about each\n * address they name. Three route modules ask it — the audience card's add and\n * preview, the filter search, and the file importer — and they ask this one\n * copy of it.\n *\n * ## Why it is a module of its own\n *\n * It began inside `server-console.ts`, beside its first two callers, and that\n * was right while there were two. The importer made three, and an importer is\n * exactly the surface where a second, laxer idea of \"may this person be\n * enrolled\" is most tempting: a file is bulk, bulk is slow, and skipping the\n * per-address consent read is the obvious saving. A bulk path that reached the\n * membership without the suppression check and the attestation would be a way\n * to enroll precisely the people the one-at-a-time path refuses, which is the\n * defect class `docs/specs/email-competitive-gaps.md` has a closed P1 entry\n * for.\n *\n * Sharing the gate is also what stops the cheaper failure: two modules that\n * merely agree today, with nothing in either one saying the other exists.\n *\n * ## What it decides, and what it deliberately does not\n *\n * {@link resolveAddresses} answers the questions that are FACTS about a\n * person — a stored refusal, a suppression on either list, an opt-in already\n * on record, a line that is not an address. It never consults an attestation,\n * because an attestation is a fact about the operator and folding it in here\n * would make the preview's numbers move as the box was ticked. The basis is\n * decided once, at the write, by `assignmentBasis`.\n */\n\nimport {\n ASSIGNMENT_REFUSAL_MESSAGES,\n assignmentReadout,\n grantEntriesAsRecorded,\n isOrgWideMember,\n normalizeContactEmail,\n readMarketingBasis,\n type AddressRefusal,\n type AssignmentRefusal,\n type ConsentGroup,\n type MarketingConsentRecord,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n filterSendableForHost,\n filterSuppressedEmails,\n firebaseAdmin,\n getOrgForHost,\n orgDataCollectionForHost,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\n\n/**\n * The most addresses one request may name.\n *\n * A ceiling on the WRITE, not on the audience. It bounds the work one request\n * does — the resolution below is a handful of reads per address — and it can\n * never refuse a person already enrolled or make room by removing one, which\n * is the difference between a batch size and a capacity limit. Nothing in the\n * product caps list membership: `contactsPerHost` is the audience band and it\n * is metered against CONTACTS, in `upsert-contact.ts`, and enrolling somebody\n * on a list writes no contact. If a membership ceiling is ever introduced,\n * this function is where it refuses, because this is where something is\n * added.\n */\nexport const LIST_MEMBER_BATCH_MAX = 100\n\n/** How many contact addresses one `in` query may carry. */\nconst CONTACT_LOOKUP_CHUNK = 30\n\n/** Everything a list-membership route needs, or the refusal to send back. */\nexport type ListContext =\n | {\n ok: true\n uid: string\n hostId: string\n orgId: string\n listRef: FirebaseFirestore.DocumentReference\n listName: string\n }\n | { ok: false; status: number; body: Record<string, unknown> }\n\n/**\n * Who is asking, and about which list.\n *\n * ## Two gates, not one — the same two the Inbox assignment route applies\n *\n * A host role is necessary and NOT sufficient. Lists live at\n * `orgs/{orgId}/lists` and their members are contacts, so the rules put both\n * behind `isOrgWideMember()`. An editor invited to ONE site is an org member\n * with `allHosts: false`, and gating an org-wide write on the host role alone\n * would let a single-site collaborator enroll people into an audience every\n * other site in the org can mail — and, through the preview, read the consent\n * record of any address they care to type. The Admin SDK evaluates no rules,\n * so this route is the enforcement rather than an echo of it.\n *\n * The plugin's own gates sit above this and are the dispatcher's: a workspace\n * that has switched the email plugin off, or a site it is disabled for, or a\n * release flag that has not reached this org, all 404 before a handler runs.\n */\nexport async function resolveListContext(\n req: Parameters<PluginApiHandler>[0],\n): Promise<ListContext> {\n const hostId = String(req.body?.hostId ?? '')\n const listId = String(req.body?.listId ?? '')\n if (!hostId || !listId) {\n return {\n ok: false,\n status: 400,\n body: { error: 'Missing hostId or listId' },\n }\n }\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n return { ok: false, status: 401, body: { error: 'Unauthenticated' } }\n }\n\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostSnapshot = await firestore.collection('hosts').doc(hostId).get()\n if (!hostSnapshot.exists) {\n return { ok: false, status: 404, body: { error: 'Unknown site' } }\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return {\n ok: false,\n status: 403,\n body: { error: 'Not a site admin or editor' },\n }\n }\n\n const orgId = String(\n (await getOrgForHost(hostId).catch(() => null))?.orgId ?? '',\n )\n if (!orgId) {\n return {\n ok: false,\n status: 404,\n body: { error: 'This site has no organization, so it has no lists.' },\n }\n }\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(\n () => null,\n )\n const member = membership?.member\n const orgWideWriter =\n isOrgWideMember(member) &&\n (member?.role === 'owner' ||\n member?.role === 'admin' ||\n member?.role === 'editor') &&\n (member as { orgSuspended?: boolean } | undefined)?.orgSuspended !== true\n if (!orgWideWriter) {\n return {\n ok: false,\n status: 403,\n body: {\n error:\n 'Marketing lists belong to the whole organization, so changing who ' +\n 'is on one needs organization-wide access rather than access to ' +\n 'this site.',\n },\n }\n }\n\n const listRef = firestore\n .collection('orgs')\n .doc(orgId)\n .collection('lists')\n .doc(listId)\n const listSnapshot = await listRef.get()\n // A stale or mistyped id must not CREATE a list: a campaign's `list`\n // audience would then read a list nobody set up.\n if (!listSnapshot.exists) {\n return { ok: false, status: 404, body: { error: 'Unknown list' } }\n }\n\n return {\n ok: true,\n uid: decoded.uid,\n hostId,\n orgId,\n listRef,\n listName: String(listSnapshot.get('name') ?? listId),\n }\n}\n\n/** What would happen to one address, and why. */\nexport interface AddressVerdict {\n /** Exactly what the operator typed, so a bad line can be pointed at. */\n input: string\n /** The normalized address, or `null` when there is not one. */\n email: string | null\n /**\n * Why this address can never go on the list, or `null`.\n *\n * A HARD refusal only — a stored `declined`, either suppression list, or a\n * line that is not an address. `no-basis` is deliberately NOT one of them:\n * \"you have not said you have permission yet\" is an unanswered question,\n * not a refusal, and reporting it as one would list the very people an\n * attestation is about to admit under the heading of people it cannot.\n * That distinction is {@link requiresAttestation}.\n */\n refusal: AssignmentRefusal | null\n /** True when only an attestation stands between this address and the list. */\n requiresAttestation: boolean\n /** One sentence of consent facts, in the merchant's terms. */\n summary: string\n}\n\n/** The whole answer, per address and in totals. */\nexport interface AddressResolution {\n verdicts: AddressVerdict[]\n /** Addresses a stored opt-in already covers — no attestation needed. */\n optedIn: number\n /** Addresses that need the operator to state they have permission. */\n needAttestation: number\n /** Addresses nothing can enroll, whatever the operator says. */\n refused: number\n}\n\n/**\n * The resolution plus the consent records it was computed from.\n *\n * The records ride along so the write path can derive the basis it STORES\n * from the same read the verdicts were built on. Re-reading them would be a\n * second answer to \"what does this person's record say\", between which a\n * concurrent edit fits — and the operator would have attested against the\n * first one.\n */\nexport interface ResolvedBatch extends AddressResolution {\n stored: Map<string, MarketingConsentRecord>\n /**\n * Each opted-in person's own grants, as `grantEntriesAsRecorded` read them\n * off the SAME contact document `stored` was read from — what a\n * pass-through enrollment carries onto the membership (AGL-3320).\n *\n * From the same read for the reason `stored` is: the basis decided and the\n * sites it covers must describe one version of the person's record.\n */\n grants: Map<string, Record<string, Record<string, unknown>>>\n /**\n * The consent group the resolution was made against, so the WRITE records\n * a basis for exactly the controller the verdicts were computed for.\n *\n * Returned rather than re-resolved by the caller for the same reason\n * `stored` is: two resolutions of the same question are two answers a\n * concurrent edit can fit between, and the operator attested against the\n * first one.\n */\n group: ConsentGroup\n}\n\n/**\n * Every address, normalized, deduplicated and put through the policy.\n *\n * ## Deduplicated on the NORMALIZED address, and reported once\n *\n * A pasted column routinely names the same person twice with different\n * casing, and `enrollListMember` keys the membership from the normalized\n * address, so two lines would be one row. Counting them twice would tell the\n * operator they are attesting for more people than they are.\n *\n * ## An unusable line is REPORTED, never dropped\n *\n * A paste that silently discarded its malformed lines would tell an operator\n * that 100 addresses went on the list when 94 did, and the six they never\n * hear about are the six they typed wrong. Every input line comes back with a\n * verdict, including the ones that are not addresses at all.\n *\n * ## Suppression is attributed, and costs nothing when there is none\n *\n * `filterSendableForHost` answers both lists in one pass and fails CLOSED, so\n * it is asked first; only the addresses it refused are put through the\n * platform half again to find out WHICH list holds them. In the ordinary case\n * that second call is never made.\n */\nexport async function resolveAddresses(input: {\n hostId: string\n inputs: readonly string[]\n}): Promise<ResolvedBatch> {\n const seen = new Set<string>()\n const rows: Array<{ input: string; email: string | null }> = []\n for (const raw of input.inputs) {\n const email = normalizeContactEmail(raw)\n if (email && seen.has(email)) continue\n if (email) seen.add(email)\n rows.push({ input: String(raw ?? '').trim(), email })\n }\n\n const addresses = rows\n .map((row) => row.email)\n .filter((email): email is string => Boolean(email))\n\n /*\n * The group first, because every answer below is about a CONTROLLER and\n * not about a site: a business running three sites as one sender enrolls\n * into all three at once, and an agency's client — which declared no group\n * — resolves to itself.\n */\n const group = await consentGroupForSite(input.hostId)\n const [suppression, { stored, grants }] = await Promise.all([\n suppressionFor(input.hostId, group, addresses),\n storedConsentFor(input.hostId, group, addresses),\n ])\n\n const unrecorded: MarketingConsentRecord = readMarketingBasis(null, group)\n const verdicts = rows.map((row): AddressVerdict => {\n if (!row.email) {\n return {\n input: row.input,\n email: null,\n refusal: 'unroutable-address',\n requiresAttestation: false,\n summary: ASSIGNMENT_REFUSAL_MESSAGES['unroutable-address'],\n }\n }\n const record = stored.get(row.email) ?? unrecorded\n const readout = assignmentReadout({\n stored: record,\n suppression: suppression.get(row.email) ?? null,\n })\n return {\n input: row.input,\n email: row.email,\n /*\n * Straight off the readout, with no attestation in the question.\n *\n * `assignmentReadout` answers what is TRUE about this person — a stored\n * refusal, a suppression, an opt-in, or nothing on record — and none of\n * those changes because the operator did or did not tick a box. Feeding\n * the flag in here would make the preview's answer depend on it, so the\n * count the operator stands behind would move as they answered. The\n * basis itself is decided once, at the write, by `assignmentBasis`.\n */\n refusal: readout.enrollable\n ? null\n : (suppression.get(row.email) ?? 'declined'),\n requiresAttestation: readout.requiresAttestation,\n summary: readout.summary,\n }\n })\n\n return {\n verdicts,\n stored,\n grants,\n group,\n optedIn: verdicts.filter(\n (verdict) => !verdict.refusal && !verdict.requiresAttestation,\n ).length,\n needAttestation: verdicts.filter((verdict) => verdict.requiresAttestation)\n .length,\n refused: verdicts.filter(\n (verdict) => verdict.refusal && !verdict.requiresAttestation,\n ).length,\n }\n}\n\n/**\n * Which of these addresses is suppressed, and by which list.\n *\n * Composed from the shipped helpers rather than reading the two collections\n * here: normalization, de-duplication and the fail-closed posture live in\n * `email-suppression.ts`, and a second copy of them is a second set of rules\n * for the enrollment check and the send-time check to disagree about — which\n * is exactly the disagreement `an-enrollment-is-not-a-license-to-send.spec.ts`\n * exists to stop.\n *\n * Read across the site's consent group, as the send reads it: somebody who\n * unsubscribed from a sibling of a declared group is refused here too, rather\n * than enrolled onto a list whose campaigns will never reach them.\n */\nasync function suppressionFor(\n hostId: string,\n group: ConsentGroup,\n addresses: readonly string[],\n): Promise<Map<string, AddressRefusal>> {\n const refusals = new Map<string, AddressRefusal>()\n if (!addresses.length) return refusals\n const sendable = new Set(\n await filterSendableForHost(hostId, addresses, undefined, group),\n )\n const blocked = addresses.filter((email) => !sendable.has(email))\n if (!blocked.length) return refusals\n // Only the blocked ones, and only to say WHICH list. Survivors of the\n // platform half are held by this site's own list, or a group sibling's.\n const platformSendable = new Set(await filterSuppressedEmails(blocked))\n for (const email of blocked) {\n refusals.set(\n email,\n platformSendable.has(email) ? 'suppressed-host' : 'suppressed-platform',\n )\n }\n return refusals\n}\n\n/**\n * The stored consent facts for these addresses, off the org's contacts.\n *\n * Read UNSCOPED, deliberately, exactly as the Inbox route reads it:\n * `scopedToHost` narrows an org collection to what one site may see, and a\n * refusal filtered out by that narrowing is a refusal this route would then\n * step over — the failure mode is enrolling somebody who said no. It is safe\n * because the caller has already been proved an org-wide member, which is the\n * tier the rules grant the whole org's contacts to.\n *\n * ## A refusal wins over an opt-in when the CRM holds both\n *\n * Nothing guarantees one contact per address — the collection is keyed by\n * resource id, not by email — so two records for one person can disagree.\n * Taking whichever the query happened to answer first would make the outcome\n * depend on document order. A recorded refusal is the answer whenever one\n * exists, which is the same precedence `assignmentBasis` applies within a\n * single record.\n *\n * A failed read falls to `declined` for the whole batch, for the reason the\n * Inbox route states: a read that throws can neither say the person consented\n * nor that they refused, and the direction that costs a retry is the one that\n * does not enroll somebody whose stored refusal we simply failed to see.\n */\nasync function storedConsentFor(\n hostId: string,\n group: ConsentGroup,\n addresses: readonly string[],\n): Promise<{\n stored: Map<string, MarketingConsentRecord>\n grants: Map<string, Record<string, Record<string, unknown>>>\n}> {\n const found = new Map<string, MarketingConsentRecord>()\n const grants = new Map<string, Record<string, Record<string, unknown>>>()\n if (!addresses.length) return { stored: found, grants }\n try {\n const contacts = await orgDataCollectionForHost(hostId, 'contacts')\n const chunks: string[][] = []\n for (let at = 0; at < addresses.length; at += CONTACT_LOOKUP_CHUNK) {\n chunks.push(addresses.slice(at, at + CONTACT_LOOKUP_CHUNK))\n }\n const snapshots = await Promise.all(\n chunks.map((chunk) => contacts.where('email', 'in', chunk).get()),\n )\n for (const snapshot of snapshots) {\n for (const doc of snapshot.docs) {\n const email = normalizeContactEmail(doc.get('email'))\n if (!email) continue\n const data = doc.data() as Record<string, unknown>\n const record = readMarketingBasis(data, group)\n const already = found.get(email)\n if (already?.basis === 'declined') continue\n if (already && record.basis !== 'declined') continue\n found.set(email, record)\n grants.set(email, grantEntriesAsRecorded(data, group))\n }\n }\n return { stored: found, grants }\n } catch (error) {\n console.error('[email] consent lookup failed', error)\n const refused: MarketingConsentRecord = {\n ...readMarketingBasis(null, group),\n basis: 'declined',\n // Attributed to nobody: this is what a failed read falls back to, not a\n // refusal anybody recorded.\n assertedBy: null,\n source: null,\n basisAtMs: null,\n capturedAtMs: null,\n }\n return {\n stored: new Map(addresses.map((email) => [email, refused])),\n // Nobody is enrollable on a failed read, so nobody's grants travel.\n grants: new Map(),\n }\n }\n}\n\n/** The addresses named by one request, or the refusal to send back. */\nexport function readAddresses(\n req: Parameters<PluginApiHandler>[0],\n): { emails: string[] } | { error: string } {\n const body = req.body as { emails?: unknown; email?: unknown } | undefined\n const raw = Array.isArray(body?.emails)\n ? body.emails\n : body?.email === undefined\n ? []\n : [body.email]\n const emails = raw\n .map((value) => String(value ?? '').trim())\n .filter((value) => value.length > 0)\n if (!emails.length) return { error: 'No addresses' }\n if (emails.length > LIST_MEMBER_BATCH_MAX) {\n return {\n error:\n `${emails.length} addresses is more than one go can take. Add up to ` +\n `${LIST_MEMBER_BATCH_MAX} at a time.`,\n }\n }\n return { emails }\n}\n"],"names":["ASSIGNMENT_REFUSAL_MESSAGES","assignmentReadout","grantEntriesAsRecorded","isOrgWideMember","normalizeContactEmail","readMarketingBasis","consentGroupForSite","filterSendableForHost","filterSuppressedEmails","firebaseAdmin","getOrgForHost","orgDataCollectionForHost","resolveOrgMembership","LIST_MEMBER_BATCH_MAX","CONTACT_LOOKUP_CHUNK","resolveListContext","req","hostSnapshot","listSnapshot","hostId","String","body","listId","ok","status","error","authorization","headers","idToken","startsWith","slice","length","undefined","decoded","app","auth","verifyIdToken","firestore","collection","doc","get","exists","memberRole","uid","orgId","catch","membership","member","orgWideWriter","role","orgSuspended","listRef","listName","resolveAddresses","input","seen","Set","rows","raw","inputs","email","has","add","push","trim","addresses","map","row","filter","Boolean","group","suppression","stored","grants","Promise","all","suppressionFor","storedConsentFor","unrecorded","verdicts","refusal","requiresAttestation","summary","record","readout","enrollable","optedIn","verdict","needAttestation","refused","refusals","Map","sendable","blocked","platformSendable","set","found","contacts","chunks","at","snapshots","chunk","where","snapshot","docs","data","already","basis","console","assertedBy","source","basisAtMs","capturedAtMs","readAddresses","Array","isArray","emails","value"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+BC,GAED,SACEA,2BAA2B,EAC3BC,iBAAiB,EACjBC,sBAAsB,EACtBC,eAAe,EACfC,qBAAqB,EACrBC,kBAAkB,QAMb,sBAAqB;AAC5B,SACEC,mBAAmB,EACnBC,qBAAqB,EACrBC,sBAAsB,EACtBC,aAAa,EACbC,aAAa,EACbC,wBAAwB,EACxBC,oBAAoB,QACf,2BAA0B;AAEjC;;;;;;;;;;;;CAYC,GACD,OAAO,MAAMC,wBAAwB,IAAG;AAExC,yDAAyD,GACzD,MAAMC,uBAAuB;AAc7B;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,eAAeC,mBACpBC,GAAoC;qBAYPA,4BAcTC,0BA4DDC;QApFGF,WACAA,YAiCnB;IAlCH,MAAMG,SAASC,gBAAOJ,YAAAA,IAAIK,IAAI,qBAARL,UAAUG,MAAM,mBAAI;IAC1C,MAAMG,SAASF,iBAAOJ,aAAAA,IAAIK,IAAI,qBAARL,WAAUM,MAAM,oBAAI;IAC1C,IAAI,CAACH,UAAU,CAACG,QAAQ;QACtB,OAAO;YACLC,IAAI;YACJC,QAAQ;YACRH,MAAM;gBAAEI,OAAO;YAA2B;QAC5C;IACF;IAEA,MAAMC,gBAAgBN,QAAOJ,6BAAAA,IAAIW,OAAO,CAACD,aAAa,YAAzBV,6BAA6B;IAC1D,MAAMY,UAAUF,cAAcG,UAAU,CAAC,aACrCH,cAAcI,KAAK,CAAC,UAAUC,MAAM,IACpCC;IACJ,IAAI,CAACJ,SAAS;QACZ,OAAO;YAAEL,IAAI;YAAOC,QAAQ;YAAKH,MAAM;gBAAEI,OAAO;YAAkB;QAAE;IACtE;IAEA,MAAMQ,UAAU,MAAMxB,cAAcyB,GAAG,GAAGC,IAAI,GAAGC,aAAa,CAACR;IAC/D,MAAMS,YAAY5B,cAAcyB,GAAG,GAAGG,SAAS;IAC/C,MAAMpB,eAAe,MAAMoB,UAAUC,UAAU,CAAC,SAASC,GAAG,CAACpB,QAAQqB,GAAG;IACxE,IAAI,CAACvB,aAAawB,MAAM,EAAE;QACxB,OAAO;YAAElB,IAAI;YAAOC,QAAQ;YAAKH,MAAM;gBAAEI,OAAO;YAAe;QAAE;IACnE;IACA,MAAMiB,aAAa,EAACzB,oBAAAA,aAAauB,GAAG,CAAC,0BAAjBvB,oBAAmC,CAAC,EAAE,CAACgB,QAAQU,GAAG,CAAC;IACvE,IAAID,eAAe,WAAWA,eAAe,UAAU;QACrD,OAAO;YACLnB,IAAI;YACJC,QAAQ;YACRH,MAAM;gBAAEI,OAAO;YAA6B;QAC9C;IACF;IAEA,MAAMmB,QAAQxB,iBACX,QAAA,MAAMV,cAAcS,QAAQ0B,KAAK,CAAC,IAAM,0BAAzC,AAAC,MAAgDD,KAAK,oBAAI;IAE5D,IAAI,CAACA,OAAO;QACV,OAAO;YACLrB,IAAI;YACJC,QAAQ;YACRH,MAAM;gBAAEI,OAAO;YAAqD;QACtE;IACF;IACA,MAAMqB,aAAa,MAAMlC,qBAAqBqB,QAAQU,GAAG,EAAEC,OAAOC,KAAK,CACrE,IAAM;IAER,MAAME,SAASD,8BAAAA,WAAYC,MAAM;IACjC,MAAMC,gBACJ7C,gBAAgB4C,WACfA,CAAAA,CAAAA,0BAAAA,OAAQE,IAAI,MAAK,WAChBF,CAAAA,0BAAAA,OAAQE,IAAI,MAAK,WACjBF,CAAAA,0BAAAA,OAAQE,IAAI,MAAK,QAAO,KAC1B,CAACF,0BAAD,AAACA,OAAmDG,YAAY,MAAK;IACvE,IAAI,CAACF,eAAe;QAClB,OAAO;YACLzB,IAAI;YACJC,QAAQ;YACRH,MAAM;gBACJI,OACE,uEACA,oEACA;YACJ;QACF;IACF;IAEA,MAAM0B,UAAUd,UACbC,UAAU,CAAC,QACXC,GAAG,CAACK,OACJN,UAAU,CAAC,SACXC,GAAG,CAACjB;IACP,MAAMJ,eAAe,MAAMiC,QAAQX,GAAG;IACtC,qEAAqE;IACrE,iDAAiD;IACjD,IAAI,CAACtB,aAAauB,MAAM,EAAE;QACxB,OAAO;YAAElB,IAAI;YAAOC,QAAQ;YAAKH,MAAM;gBAAEI,OAAO;YAAe;QAAE;IACnE;IAEA,OAAO;QACLF,IAAI;QACJoB,KAAKV,QAAQU,GAAG;QAChBxB;QACAyB;QACAO;QACAC,UAAUhC,QAAOF,oBAAAA,aAAasB,GAAG,CAAC,mBAAjBtB,oBAA4BI;IAC/C;AACF;AAoEA;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GACD,OAAO,eAAe+B,iBAAiBC,KAGtC;IACC,MAAMC,OAAO,IAAIC;IACjB,MAAMC,OAAuD,EAAE;IAC/D,KAAK,MAAMC,OAAOJ,MAAMK,MAAM,CAAE;QAC9B,MAAMC,QAAQxD,sBAAsBsD;QACpC,IAAIE,SAASL,KAAKM,GAAG,CAACD,QAAQ;QAC9B,IAAIA,OAAOL,KAAKO,GAAG,CAACF;QACpBH,KAAKM,IAAI,CAAC;YAAET,OAAOlC,OAAOsC,cAAAA,MAAO,IAAIM,IAAI;YAAIJ;QAAM;IACrD;IAEA,MAAMK,YAAYR,KACfS,GAAG,CAAC,CAACC,MAAQA,IAAIP,KAAK,EACtBQ,MAAM,CAAC,CAACR,QAA2BS,QAAQT;IAE9C;;;;;GAKC,GACD,MAAMU,QAAQ,MAAMhE,oBAAoBgD,MAAMnC,MAAM;IACpD,MAAM,CAACoD,aAAa,EAAEC,MAAM,EAAEC,MAAM,EAAE,CAAC,GAAG,MAAMC,QAAQC,GAAG,CAAC;QAC1DC,eAAetB,MAAMnC,MAAM,EAAEmD,OAAOL;QACpCY,iBAAiBvB,MAAMnC,MAAM,EAAEmD,OAAOL;KACvC;IAED,MAAMa,aAAqCzE,mBAAmB,MAAMiE;IACpE,MAAMS,WAAWtB,KAAKS,GAAG,CAAC,CAACC;YAUVK,aAGAD,kBAiBRA;QA7BP,IAAI,CAACJ,IAAIP,KAAK,EAAE;YACd,OAAO;gBACLN,OAAOa,IAAIb,KAAK;gBAChBM,OAAO;gBACPoB,SAAS;gBACTC,qBAAqB;gBACrBC,SAASlF,2BAA2B,CAAC,qBAAqB;YAC5D;QACF;QACA,MAAMmF,UAASX,cAAAA,OAAOhC,GAAG,CAAC2B,IAAIP,KAAK,aAApBY,cAAyBM;QACxC,MAAMM,UAAUnF,kBAAkB;YAChCuE,QAAQW;YACRZ,WAAW,GAAEA,mBAAAA,YAAY/B,GAAG,CAAC2B,IAAIP,KAAK,aAAzBW,mBAA8B;QAC7C;QACA,OAAO;YACLjB,OAAOa,IAAIb,KAAK;YAChBM,OAAOO,IAAIP,KAAK;YAChB;;;;;;;;;OASC,GACDoB,SAASI,QAAQC,UAAU,GACvB,QACCd,oBAAAA,YAAY/B,GAAG,CAAC2B,IAAIP,KAAK,aAAzBW,oBAA8B;YACnCU,qBAAqBG,QAAQH,mBAAmB;YAChDC,SAASE,QAAQF,OAAO;QAC1B;IACF;IAEA,OAAO;QACLH;QACAP;QACAC;QACAH;QACAgB,SAASP,SAASX,MAAM,CACtB,CAACmB,UAAY,CAACA,QAAQP,OAAO,IAAI,CAACO,QAAQN,mBAAmB,EAC7DlD,MAAM;QACRyD,iBAAiBT,SAASX,MAAM,CAAC,CAACmB,UAAYA,QAAQN,mBAAmB,EACtElD,MAAM;QACT0D,SAASV,SAASX,MAAM,CACtB,CAACmB,UAAYA,QAAQP,OAAO,IAAI,CAACO,QAAQN,mBAAmB,EAC5DlD,MAAM;IACV;AACF;AAEA;;;;;;;;;;;;;CAaC,GACD,eAAe6C,eACbzD,MAAc,EACdmD,KAAmB,EACnBL,SAA4B;IAE5B,MAAMyB,WAAW,IAAIC;IACrB,IAAI,CAAC1B,UAAUlC,MAAM,EAAE,OAAO2D;IAC9B,MAAME,WAAW,IAAIpC,IACnB,MAAMjD,sBAAsBY,QAAQ8C,WAAWjC,WAAWsC;IAE5D,MAAMuB,UAAU5B,UAAUG,MAAM,CAAC,CAACR,QAAU,CAACgC,SAAS/B,GAAG,CAACD;IAC1D,IAAI,CAACiC,QAAQ9D,MAAM,EAAE,OAAO2D;IAC5B,sEAAsE;IACtE,wEAAwE;IACxE,MAAMI,mBAAmB,IAAItC,IAAI,MAAMhD,uBAAuBqF;IAC9D,KAAK,MAAMjC,SAASiC,QAAS;QAC3BH,SAASK,GAAG,CACVnC,OACAkC,iBAAiBjC,GAAG,CAACD,SAAS,oBAAoB;IAEtD;IACA,OAAO8B;AACT;AAEA;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GACD,eAAeb,iBACb1D,MAAc,EACdmD,KAAmB,EACnBL,SAA4B;IAK5B,MAAM+B,QAAQ,IAAIL;IAClB,MAAMlB,SAAS,IAAIkB;IACnB,IAAI,CAAC1B,UAAUlC,MAAM,EAAE,OAAO;QAAEyC,QAAQwB;QAAOvB;IAAO;IACtD,IAAI;QACF,MAAMwB,WAAW,MAAMtF,yBAAyBQ,QAAQ;QACxD,MAAM+E,SAAqB,EAAE;QAC7B,IAAK,IAAIC,KAAK,GAAGA,KAAKlC,UAAUlC,MAAM,EAAEoE,MAAMrF,qBAAsB;YAClEoF,OAAOnC,IAAI,CAACE,UAAUnC,KAAK,CAACqE,IAAIA,KAAKrF;QACvC;QACA,MAAMsF,YAAY,MAAM1B,QAAQC,GAAG,CACjCuB,OAAOhC,GAAG,CAAC,CAACmC,QAAUJ,SAASK,KAAK,CAAC,SAAS,MAAMD,OAAO7D,GAAG;QAEhE,KAAK,MAAM+D,YAAYH,UAAW;YAChC,KAAK,MAAM7D,OAAOgE,SAASC,IAAI,CAAE;gBAC/B,MAAM5C,QAAQxD,sBAAsBmC,IAAIC,GAAG,CAAC;gBAC5C,IAAI,CAACoB,OAAO;gBACZ,MAAM6C,OAAOlE,IAAIkE,IAAI;gBACrB,MAAMtB,SAAS9E,mBAAmBoG,MAAMnC;gBACxC,MAAMoC,UAAUV,MAAMxD,GAAG,CAACoB;gBAC1B,IAAI8C,CAAAA,2BAAAA,QAASC,KAAK,MAAK,YAAY;gBACnC,IAAID,WAAWvB,OAAOwB,KAAK,KAAK,YAAY;gBAC5CX,MAAMD,GAAG,CAACnC,OAAOuB;gBACjBV,OAAOsB,GAAG,CAACnC,OAAO1D,uBAAuBuG,MAAMnC;YACjD;QACF;QACA,OAAO;YAAEE,QAAQwB;YAAOvB;QAAO;IACjC,EAAE,OAAOhD,OAAO;QACdmF,QAAQnF,KAAK,CAAC,iCAAiCA;QAC/C,MAAMgE,UAAkC,aACnCpF,mBAAmB,MAAMiE;YAC5BqC,OAAO;YACP,wEAAwE;YACxE,4BAA4B;YAC5BE,YAAY;YACZC,QAAQ;YACRC,WAAW;YACXC,cAAc;;QAEhB,OAAO;YACLxC,QAAQ,IAAImB,IAAI1B,UAAUC,GAAG,CAAC,CAACN,QAAU;oBAACA;oBAAO6B;iBAAQ;YACzD,oEAAoE;YACpEhB,QAAQ,IAAIkB;QACd;IACF;AACF;AAEA,qEAAqE,GACrE,OAAO,SAASsB,cACdjG,GAAoC;IAEpC,MAAMK,OAAOL,IAAIK,IAAI;IACrB,MAAMqC,MAAMwD,MAAMC,OAAO,CAAC9F,wBAAAA,KAAM+F,MAAM,IAClC/F,KAAK+F,MAAM,GACX/F,CAAAA,wBAAAA,KAAMuC,KAAK,MAAK5B,YACd,EAAE,GACF;QAACX,KAAKuC,KAAK;KAAC;IAClB,MAAMwD,SAAS1D,IACZQ,GAAG,CAAC,CAACmD,QAAUjG,OAAOiG,gBAAAA,QAAS,IAAIrD,IAAI,IACvCI,MAAM,CAAC,CAACiD,QAAUA,MAAMtF,MAAM,GAAG;IACpC,IAAI,CAACqF,OAAOrF,MAAM,EAAE,OAAO;QAAEN,OAAO;IAAe;IACnD,IAAI2F,OAAOrF,MAAM,GAAGlB,uBAAuB;QACzC,OAAO;YACLY,OACE,GAAG2F,OAAOrF,MAAM,CAAC,mDAAmD,CAAC,GACrE,GAAGlB,sBAAsB,WAAW,CAAC;QACzC;IACF;IACA,OAAO;QAAEuG;IAAO;AAClB"}
|
|
@@ -451,7 +451,7 @@ import { FieldValue } from "firebase-admin/firestore";
|
|
|
451
451
|
});
|
|
452
452
|
};
|
|
453
453
|
for (const verdict of resolution.verdicts){
|
|
454
|
-
var _resolution_stored_get, _row_s, _row_d;
|
|
454
|
+
var _resolution_stored_get, _resolution_grants_get, _row_s, _row_d;
|
|
455
455
|
if (verdict.refusal || !verdict.email) {
|
|
456
456
|
var _verdict_refusal;
|
|
457
457
|
refuse(verdict.email, (_verdict_refusal = verdict.refusal) != null ? _verdict_refusal : 'unroutable-address');
|
|
@@ -479,7 +479,10 @@ import { FieldValue } from "firebase-admin/firestore";
|
|
|
479
479
|
// Never `'rule'`: the dynamic-list materializer reconciles its own
|
|
480
480
|
// rows away when somebody stops matching, and a file a merchant
|
|
481
481
|
// uploaded is not a rule match that can lapse.
|
|
482
|
-
via: 'manual'
|
|
482
|
+
via: 'manual'
|
|
483
|
+
}, decision.basis === 'contact-opt-in' ? {
|
|
484
|
+
grantEntries: (_resolution_grants_get = resolution.grants.get(verdict.email)) != null ? _resolution_grants_get : {}
|
|
485
|
+
} : {}, {
|
|
483
486
|
consent: _extends({}, decision, decision.basis === 'operator-attested' && row ? {
|
|
484
487
|
reason: importedBasisReason({
|
|
485
488
|
declaredSource: (_row_s = row.s) != null ? _row_s : '',
|