@aglyn/aglyn 1.0.0-beta.180 → 1.0.0-beta.182

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.
@@ -14,51 +14,43 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
+ import { type MarketingConsentSource } from './marketing-consent';
17
18
  /**
18
- * Marketing consent for the PLATFORM's own product email (AGL-3185).
19
- *
20
- * The strict policy (`marketing-consent.ts`) means a person with no declared
21
- * basis is unsendable, and until this module the only doors that declared
22
- * one were the marketing site's forms. Somebody who created a console account
23
- * — the warmest audience the platform has — was never asked. Three console
24
- * doors ask now: a checkbox on the sign-up form, a switch in the account
25
- * preferences, and a one-time prompt for accounts that predate the checkbox.
26
- *
27
- * ## One wording, versioned
28
- *
29
- * Every door shows {@link PLATFORM_MARKETING_CONSENT_TEXT} and every record
30
- * stores {@link PLATFORM_MARKETING_CONSENT_TEXT_VERSION} beside the exact
31
- * timestamp, so "what did this person agree to" is answered by a version and
32
- * not by whatever the checkbox says today. Changing the sentence is a bump of
33
- * the version in the same edit.
34
- *
35
- * ## The record lives in two places, and the two do not disagree
36
- *
37
- * The decision is written by the server onto the person's own `users/{uid}`
38
- * document, so the console can show a preference without reading the
39
- * operator's CRM, and onto the operator's marketing contact for the same
40
- * person, so the send path sees a declared basis with provenance. The
41
- * contact half is written only when the deployment names a marketing host
42
- * (`PLATFORM_MARKETING_HOST_ID`, read in `@aglyn/tenant-data-admin`); a
43
- * self-hosted install with no audience of its own records the preference
44
- * and skips the contact.
45
- *
46
- * ## Absence is not refusal
47
- *
48
- * An unticked sign-up checkbox records nothing. A switch turned off and a
49
- * prompt answered "no" record a refusal, which the strict policy already
50
- * honors. A prompt dismissed without an answer records only the dismissal,
51
- * and the prompt stays away for {@link PLATFORM_MARKETING_PROMPT_SNOOZE_MS}.
52
- *
53
- * Pure and dependency-light on purpose: the sign-up page renders the text,
54
- * the console route validates the body, and the server library writes the
55
- * record, all from this one module.
19
+ * The list the account's answer stands for on the marketing site (AGL-3305):
20
+ * the built-in "Product updates" topic, which is what the platform's own
21
+ * announcements are sent on.
22
+ *
23
+ * Named so that "which unsubscribe is about this answer" has one spelling.
24
+ * Leaving this list, or leaving everything the site sends, is a No to the
25
+ * question the console asked. Leaving any other list is not, and must not
26
+ * touch the answer: someone who stops the newsletter has said nothing about
27
+ * product updates.
56
28
  */
57
- import { type MarketingConsentSource } from './marketing-consent';
29
+ export declare const PLATFORM_MARKETING_TOPIC_ID = "product-updates";
58
30
  /** The console doors a decision can come through, as the record names them. */
59
- export declare const PLATFORM_MARKETING_CONSENT_SOURCE_KINDS: readonly ["console-signup", "console-preferences", "console-prompt"];
31
+ export declare const PLATFORM_MARKETING_CONSOLE_SOURCE_KINDS: readonly ["console-signup", "console-preferences", "console-prompt"];
32
+ export type PlatformMarketingConsoleSourceKind = (typeof PLATFORM_MARKETING_CONSOLE_SOURCE_KINDS)[number];
33
+ /**
34
+ * The email doors (AGL-3305): the marketing site's own unsubscribe,
35
+ * preference and resubscribe pages, when what the person changed there was
36
+ * {@link PLATFORM_MARKETING_TOPIC_ID} or everything.
37
+ *
38
+ * Kept apart from the console doors because nothing the console sends may
39
+ * claim one. The server records these from the signed link a recipient
40
+ * followed; a console request naming one is refused, because a click in the
41
+ * console is not a click in a mailbox.
42
+ */
43
+ export declare const PLATFORM_MARKETING_EMAIL_SOURCE_KINDS: readonly ["email-unsubscribe", "email-preferences", "email-resubscribe"];
44
+ export type PlatformMarketingEmailSourceKind = (typeof PLATFORM_MARKETING_EMAIL_SOURCE_KINDS)[number];
45
+ /** Every door a stored decision can name. */
46
+ export declare const PLATFORM_MARKETING_CONSENT_SOURCE_KINDS: readonly ["console-signup", "console-preferences", "console-prompt", "email-unsubscribe", "email-preferences", "email-resubscribe"];
60
47
  export type PlatformMarketingConsentSourceKind = (typeof PLATFORM_MARKETING_CONSENT_SOURCE_KINDS)[number];
48
+ /** Any door, for READING a stored decision. */
61
49
  export declare function isPlatformMarketingConsentSourceKind(value: unknown): value is PlatformMarketingConsentSourceKind;
50
+ /** A console door: the only kind a console request may record. */
51
+ export declare function isPlatformMarketingConsoleSourceKind(value: unknown): value is PlatformMarketingConsoleSourceKind;
52
+ /** An email door: a decision made from a link in one of the site's emails. */
53
+ export declare function isPlatformMarketingEmailSourceKind(value: unknown): value is PlatformMarketingEmailSourceKind;
62
54
  /** What a door records: a grant or a refusal. Absence is never recorded. */
63
55
  export declare const PLATFORM_MARKETING_CONSENT_DECISIONS: readonly ["granted", "declined"];
64
56
  export type PlatformMarketingConsentDecision = (typeof PLATFORM_MARKETING_CONSENT_DECISIONS)[number];
@@ -101,7 +93,11 @@ export interface PlatformMarketingConsentState {
101
93
  atMs: number | null;
102
94
  /** The wording version the decision was made under. */
103
95
  textVersion: string | null;
104
- /** Which console door recorded it; `null` for a record another writer left. */
96
+ /**
97
+ * Which door recorded it — a console door, or an email door for a decision
98
+ * mirrored from the marketing site's own pages. `null` for a record another
99
+ * writer left.
100
+ */
105
101
  sourceKind: PlatformMarketingConsentSourceKind | null;
106
102
  /** When the prompt was last dismissed without an answer. */
107
103
  promptDismissedAtMs: number | null;
@@ -132,12 +128,43 @@ export declare function platformMarketingPromptDue(state: Pick<PlatformMarketing
132
128
  * actor are the same human, which is the fact the field records.
133
129
  */
134
130
  export declare function platformMarketingConsentSource(input: {
135
- kind: PlatformMarketingConsentSourceKind;
131
+ kind: PlatformMarketingConsoleSourceKind;
136
132
  decision: PlatformMarketingConsentDecision;
137
133
  uid: string;
138
134
  atMs: number;
139
135
  textVersion: string;
140
136
  }): MarketingConsentSource;
137
+ /**
138
+ * What an email door did, and which decisions each door can make.
139
+ *
140
+ * The pairs are the whole vocabulary: an unsubscribe only ever refuses, a
141
+ * resubscribe only ever restores, and only the preference page does both.
142
+ * Typed as the union rather than a free kind and decision, so an
143
+ * "unsubscribe that granted" cannot be written down at all.
144
+ */
145
+ export type PlatformMarketingEmailDecision = {
146
+ kind: 'email-unsubscribe';
147
+ decision: 'declined';
148
+ } | {
149
+ kind: 'email-preferences';
150
+ decision: PlatformMarketingConsentDecision;
151
+ } | {
152
+ kind: 'email-resubscribe';
153
+ decision: 'granted';
154
+ };
155
+ /**
156
+ * The provenance stored with a decision mirrored from the marketing site's
157
+ * own pages (AGL-3305), on the account only.
158
+ *
159
+ * Still `actor: 'person'`: the click was the recipient's, on a link signed
160
+ * for their address. No `textVersion`, because those pages do not version
161
+ * their wording; the field is absent rather than borrowing the console's
162
+ * version for a sentence the person never saw.
163
+ */
164
+ export declare function platformMarketingEmailSource(input: PlatformMarketingEmailDecision & {
165
+ uid: string;
166
+ atMs: number;
167
+ }): MarketingConsentSource;
141
168
  /**
142
169
  * The `users/{uid}` fields a decision writes, as one merge-able object.
143
170
  *
@@ -40,7 +40,19 @@
40
40
  * contact half is written only when the deployment names a marketing host
41
41
  * (`PLATFORM_MARKETING_HOST_ID`, read in `@aglyn/tenant-data-admin`); a
42
42
  * self-hosted install with no audience of its own records the preference
43
- * and skips the contact.
43
+ * and skips the contact — and keeps asking, so the answers are already on
44
+ * file the day the operator names a marketing host.
45
+ *
46
+ * ## An unsubscribe reaches the account, for its own list only (AGL-3305)
47
+ *
48
+ * The marketing site's emails carry their own way out, and a person who uses
49
+ * it has answered the console's question too — but only if what they left
50
+ * is the list the question was about. Leaving
51
+ * {@link PLATFORM_MARKETING_TOPIC_ID}, or everything the site sends, turns
52
+ * the account's answer to No through an email door; leaving the newsletter
53
+ * leaves it alone. The email doors write the account and nothing else: the
54
+ * site's lists already say what was left, and declining the contact's basis
55
+ * would stop every list, not the one the person chose.
44
56
  *
45
57
  * ## Absence is not refusal
46
58
  *
@@ -52,16 +64,52 @@
52
64
  * Pure and dependency-light on purpose: the sign-up page renders the text,
53
65
  * the console route validates the body, and the server library writes the
54
66
  * record, all from this one module.
55
- */ import { MARKETING_CONSENT_FIELD, MARKETING_CONSENT_SOURCE_FIELD } from "./marketing-consent.js";
67
+ */ import { EMAIL_TOPIC_PRODUCT_UPDATES } from "./email-topics.js";
68
+ import { MARKETING_CONSENT_FIELD, MARKETING_CONSENT_SOURCE_FIELD } from "./marketing-consent.js";
56
69
  import { PLATFORM_BRAND_NAME } from "./platform-brand.js";
57
- /** The console doors a decision can come through, as the record names them. */ export const PLATFORM_MARKETING_CONSENT_SOURCE_KINDS = [
70
+ /**
71
+ * The list the account's answer stands for on the marketing site (AGL-3305):
72
+ * the built-in "Product updates" topic, which is what the platform's own
73
+ * announcements are sent on.
74
+ *
75
+ * Named so that "which unsubscribe is about this answer" has one spelling.
76
+ * Leaving this list, or leaving everything the site sends, is a No to the
77
+ * question the console asked. Leaving any other list is not, and must not
78
+ * touch the answer: someone who stops the newsletter has said nothing about
79
+ * product updates.
80
+ */ export const PLATFORM_MARKETING_TOPIC_ID = EMAIL_TOPIC_PRODUCT_UPDATES;
81
+ /** The console doors a decision can come through, as the record names them. */ export const PLATFORM_MARKETING_CONSOLE_SOURCE_KINDS = [
58
82
  'console-signup',
59
83
  'console-preferences',
60
84
  'console-prompt'
61
85
  ];
62
- export function isPlatformMarketingConsentSourceKind(value) {
86
+ /**
87
+ * The email doors (AGL-3305): the marketing site's own unsubscribe,
88
+ * preference and resubscribe pages, when what the person changed there was
89
+ * {@link PLATFORM_MARKETING_TOPIC_ID} or everything.
90
+ *
91
+ * Kept apart from the console doors because nothing the console sends may
92
+ * claim one. The server records these from the signed link a recipient
93
+ * followed; a console request naming one is refused, because a click in the
94
+ * console is not a click in a mailbox.
95
+ */ export const PLATFORM_MARKETING_EMAIL_SOURCE_KINDS = [
96
+ 'email-unsubscribe',
97
+ 'email-preferences',
98
+ 'email-resubscribe'
99
+ ];
100
+ /** Every door a stored decision can name. */ export const PLATFORM_MARKETING_CONSENT_SOURCE_KINDS = [
101
+ ...PLATFORM_MARKETING_CONSOLE_SOURCE_KINDS,
102
+ ...PLATFORM_MARKETING_EMAIL_SOURCE_KINDS
103
+ ];
104
+ /** Any door, for READING a stored decision. */ export function isPlatformMarketingConsentSourceKind(value) {
63
105
  return PLATFORM_MARKETING_CONSENT_SOURCE_KINDS.includes(value);
64
106
  }
107
+ /** A console door: the only kind a console request may record. */ export function isPlatformMarketingConsoleSourceKind(value) {
108
+ return PLATFORM_MARKETING_CONSOLE_SOURCE_KINDS.includes(value);
109
+ }
110
+ /** An email door: a decision made from a link in one of the site's emails. */ export function isPlatformMarketingEmailSourceKind(value) {
111
+ return PLATFORM_MARKETING_EMAIL_SOURCE_KINDS.includes(value);
112
+ }
65
113
  /** What a door records: a grant or a refusal. Absence is never recorded. */ export const PLATFORM_MARKETING_CONSENT_DECISIONS = [
66
114
  'granted',
67
115
  'declined'
@@ -166,6 +214,31 @@ function finiteOrNull(value) {
166
214
  actor: 'person'
167
215
  };
168
216
  }
217
+ const EMAIL_PROVENANCE_REASONS = {
218
+ 'email-unsubscribe:declined': 'Unsubscribed from every email the marketing site sends, from a link in one of its emails.',
219
+ 'email-preferences:declined': 'Left product updates on the marketing site’s email preference page.',
220
+ 'email-preferences:granted': 'Chose product updates again on the marketing site’s email preference page.',
221
+ 'email-resubscribe:granted': 'Resubscribed from the link in one of the marketing site’s emails.'
222
+ };
223
+ /**
224
+ * The provenance stored with a decision mirrored from the marketing site's
225
+ * own pages (AGL-3305), on the account only.
226
+ *
227
+ * Still `actor: 'person'`: the click was the recipient's, on a link signed
228
+ * for their address. No `textVersion`, because those pages do not version
229
+ * their wording; the field is absent rather than borrowing the console's
230
+ * version for a sentence the person never saw.
231
+ */ export function platformMarketingEmailSource(input) {
232
+ return {
233
+ kind: input.kind,
234
+ by: input.uid,
235
+ atMs: input.atMs,
236
+ // The union narrows the pair; the template type cannot see that, so the
237
+ // key is asserted to the table's own keys rather than widened to string.
238
+ reason: EMAIL_PROVENANCE_REASONS[`${input.kind}:${input.decision}`],
239
+ actor: 'person'
240
+ };
241
+ }
169
242
  /**
170
243
  * The `users/{uid}` fields a decision writes, as one merge-able object.
171
244
  *
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/platform-marketing-consent.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 * Marketing consent for the PLATFORM's own product email (AGL-3185).\n *\n * The strict policy (`marketing-consent.ts`) means a person with no declared\n * basis is unsendable, and until this module the only doors that declared\n * one were the marketing site's forms. Somebody who created a console account\n * — the warmest audience the platform has — was never asked. Three console\n * doors ask now: a checkbox on the sign-up form, a switch in the account\n * preferences, and a one-time prompt for accounts that predate the checkbox.\n *\n * ## One wording, versioned\n *\n * Every door shows {@link PLATFORM_MARKETING_CONSENT_TEXT} and every record\n * stores {@link PLATFORM_MARKETING_CONSENT_TEXT_VERSION} beside the exact\n * timestamp, so \"what did this person agree to\" is answered by a version and\n * not by whatever the checkbox says today. Changing the sentence is a bump of\n * the version in the same edit.\n *\n * ## The record lives in two places, and the two do not disagree\n *\n * The decision is written by the server onto the person's own `users/{uid}`\n * document, so the console can show a preference without reading the\n * operator's CRM, and onto the operator's marketing contact for the same\n * person, so the send path sees a declared basis with provenance. The\n * contact half is written only when the deployment names a marketing host\n * (`PLATFORM_MARKETING_HOST_ID`, read in `@aglyn/tenant-data-admin`); a\n * self-hosted install with no audience of its own records the preference\n * and skips the contact.\n *\n * ## Absence is not refusal\n *\n * An unticked sign-up checkbox records nothing. A switch turned off and a\n * prompt answered \"no\" record a refusal, which the strict policy already\n * honors. A prompt dismissed without an answer records only the dismissal,\n * and the prompt stays away for {@link PLATFORM_MARKETING_PROMPT_SNOOZE_MS}.\n *\n * Pure and dependency-light on purpose: the sign-up page renders the text,\n * the console route validates the body, and the server library writes the\n * record, all from this one module.\n */\n\nimport {\n MARKETING_CONSENT_FIELD,\n MARKETING_CONSENT_SOURCE_FIELD,\n type MarketingConsentSource,\n} from './marketing-consent'\nimport { PLATFORM_BRAND_NAME } from './platform-brand'\n\n/** The console doors a decision can come through, as the record names them. */\nexport const PLATFORM_MARKETING_CONSENT_SOURCE_KINDS = [\n 'console-signup',\n 'console-preferences',\n 'console-prompt',\n] as const\n\nexport type PlatformMarketingConsentSourceKind =\n (typeof PLATFORM_MARKETING_CONSENT_SOURCE_KINDS)[number]\n\nexport function isPlatformMarketingConsentSourceKind(\n value: unknown,\n): value is PlatformMarketingConsentSourceKind {\n return (PLATFORM_MARKETING_CONSENT_SOURCE_KINDS as readonly unknown[]).includes(\n value,\n )\n}\n\n/** What a door records: a grant or a refusal. Absence is never recorded. */\nexport const PLATFORM_MARKETING_CONSENT_DECISIONS = ['granted', 'declined'] as const\n\nexport type PlatformMarketingConsentDecision =\n (typeof PLATFORM_MARKETING_CONSENT_DECISIONS)[number]\n\nexport function isPlatformMarketingConsentDecision(\n value: unknown,\n): value is PlatformMarketingConsentDecision {\n return (PLATFORM_MARKETING_CONSENT_DECISIONS as readonly unknown[]).includes(\n value,\n )\n}\n\n/**\n * The version of the consent wording every door shows. Bumped in the same\n * edit as {@link platformMarketingConsentText}; a record stores the version\n * it was shown under, so the two must move together.\n */\nexport const PLATFORM_MARKETING_CONSENT_TEXT_VERSION = '2026-09-20'\n\n/**\n * The sentence the person agrees to, with the deployment's brand in it.\n *\n * Affirmative and standalone: it names what is sent, who sends it, and that\n * it can be stopped — never bundled with the terms acceptance, and never\n * pre-ticked anywhere it is rendered.\n */\nexport function platformMarketingConsentText(brandName: string): string {\n return `Send me product updates from ${brandName}. You can opt out any time.`\n}\n\n/** {@link platformMarketingConsentText} for the brand this deployment wears. */\nexport const PLATFORM_MARKETING_CONSENT_TEXT = platformMarketingConsentText(\n PLATFORM_BRAND_NAME,\n)\n\n/**\n * The `users/{uid}` fields the decision is mirrored onto.\n *\n * The same names the contact record uses for the same facts, so one reader\n * can parse either document. The prompt-dismissal stamp has no contact\n * counterpart: it is about what the console asked, not what the person\n * agreed to.\n */\nexport const USER_MARKETING_CONSENT_FIELD = MARKETING_CONSENT_FIELD\nexport const USER_MARKETING_CONSENT_AT_FIELD = 'marketingConsentAtMs'\nexport const USER_MARKETING_CONSENT_SOURCE_FIELD = MARKETING_CONSENT_SOURCE_FIELD\nexport const USER_MARKETING_PROMPT_DISMISSED_AT_FIELD =\n 'marketingConsentPromptDismissedAtMs'\n\n/** How long a dismissed prompt stays away: ninety days. */\nexport const PLATFORM_MARKETING_PROMPT_SNOOZE_MS = 90 * 24 * 60 * 60 * 1000\n\n/** What the person's own document says about product email. */\nexport interface PlatformMarketingConsentState {\n /** `null` when nothing has been recorded — asked, but not answered. */\n decision: PlatformMarketingConsentDecision | null\n /** When the decision was recorded. */\n atMs: number | null\n /** The wording version the decision was made under. */\n textVersion: string | null\n /** Which console door recorded it; `null` for a record another writer left. */\n sourceKind: PlatformMarketingConsentSourceKind | null\n /** When the prompt was last dismissed without an answer. */\n promptDismissedAtMs: number | null\n}\n\nfunction finiteOrNull(value: unknown): number | null {\n return typeof value === 'number' && Number.isFinite(value) ? value : null\n}\n\n/**\n * Reads the decision off a `users/{uid}` document, or off no document.\n *\n * Every unusable value reads as its absent case rather than throwing: a\n * malformed field must degrade to \"nothing recorded\", which asks again,\n * never to a grant.\n */\nexport function readPlatformMarketingConsent(\n document: Record<string, unknown> | null | undefined,\n): PlatformMarketingConsentState {\n const consent = document?.[USER_MARKETING_CONSENT_FIELD]\n const rawSource = document?.[USER_MARKETING_CONSENT_SOURCE_FIELD]\n const source =\n rawSource && typeof rawSource === 'object' && !Array.isArray(rawSource)\n ? (rawSource as Record<string, unknown>)\n : null\n const textVersion = source?.['textVersion']\n return {\n decision: consent === true ? 'granted' : consent === false ? 'declined' : null,\n atMs: finiteOrNull(document?.[USER_MARKETING_CONSENT_AT_FIELD]),\n textVersion:\n typeof textVersion === 'string' && textVersion ? textVersion : null,\n sourceKind: isPlatformMarketingConsentSourceKind(source?.['kind'])\n ? source['kind']\n : null,\n promptDismissedAtMs: finiteOrNull(\n document?.[USER_MARKETING_PROMPT_DISMISSED_AT_FIELD],\n ),\n }\n}\n\n/**\n * Whether the console should ask this person now.\n *\n * Never after a decision, in either direction: a \"no\" is an answer and a\n * \"yes\" needs no repeating. A dismissal is not an answer, so the prompt\n * returns once the snooze has run out.\n */\nexport function platformMarketingPromptDue(\n state: Pick<PlatformMarketingConsentState, 'decision' | 'promptDismissedAtMs'>,\n nowMs: number,\n): boolean {\n if (state.decision !== null) return false\n if (state.promptDismissedAtMs === null) return true\n return nowMs - state.promptDismissedAtMs >= PLATFORM_MARKETING_PROMPT_SNOOZE_MS\n}\n\n/**\n * What each door did, in prose, for whoever audits the record later.\n *\n * Keyed by door and decision so the sentence is decided once, here, and the\n * three doors cannot describe the same act three different ways.\n */\nconst PROVENANCE_REASONS: Record<\n PlatformMarketingConsentSourceKind,\n Record<PlatformMarketingConsentDecision, string>\n> = {\n 'console-signup': {\n granted: 'Ticked the product-updates checkbox on the console sign-up form.',\n declined: 'Declined product updates on the console sign-up form.',\n },\n 'console-preferences': {\n granted: 'Turned product updates on in the console account preferences.',\n declined: 'Turned product updates off in the console account preferences.',\n },\n 'console-prompt': {\n granted: 'Answered yes to the product-updates prompt in the console.',\n declined: 'Answered no to the product-updates prompt in the console.',\n },\n}\n\n/**\n * The provenance stored with a console decision, on both documents.\n *\n * `actor: 'person'` is what keeps {@link MarketingConsentSource} honest here:\n * the reader treats provenance as an operator's assertion unless it says\n * otherwise, and this is the person's own click, carrying which door and\n * which wording. `by` is the account that clicked — the subject and the\n * actor are the same human, which is the fact the field records.\n */\nexport function platformMarketingConsentSource(input: {\n kind: PlatformMarketingConsentSourceKind\n decision: PlatformMarketingConsentDecision\n uid: string\n atMs: number\n textVersion: string\n}): MarketingConsentSource {\n return {\n kind: input.kind,\n by: input.uid,\n atMs: input.atMs,\n reason: PROVENANCE_REASONS[input.kind][input.decision],\n textVersion: input.textVersion,\n actor: 'person',\n }\n}\n\n/**\n * The `users/{uid}` fields a decision writes, as one merge-able object.\n *\n * `false` for a refusal, not a deleted field: absence is the third state and\n * means \"never asked\", which a person who said no must never read as again.\n */\nexport function platformMarketingUserFields(input: {\n decision: PlatformMarketingConsentDecision\n atMs: number\n source: MarketingConsentSource\n}): Record<string, unknown> {\n return {\n [USER_MARKETING_CONSENT_FIELD]: input.decision === 'granted',\n [USER_MARKETING_CONSENT_AT_FIELD]: input.atMs,\n [USER_MARKETING_CONSENT_SOURCE_FIELD]: input.source,\n }\n}\n"],"names":["MARKETING_CONSENT_FIELD","MARKETING_CONSENT_SOURCE_FIELD","PLATFORM_BRAND_NAME","PLATFORM_MARKETING_CONSENT_SOURCE_KINDS","isPlatformMarketingConsentSourceKind","value","includes","PLATFORM_MARKETING_CONSENT_DECISIONS","isPlatformMarketingConsentDecision","PLATFORM_MARKETING_CONSENT_TEXT_VERSION","platformMarketingConsentText","brandName","PLATFORM_MARKETING_CONSENT_TEXT","USER_MARKETING_CONSENT_FIELD","USER_MARKETING_CONSENT_AT_FIELD","USER_MARKETING_CONSENT_SOURCE_FIELD","USER_MARKETING_PROMPT_DISMISSED_AT_FIELD","PLATFORM_MARKETING_PROMPT_SNOOZE_MS","finiteOrNull","Number","isFinite","readPlatformMarketingConsent","document","consent","rawSource","source","Array","isArray","textVersion","decision","atMs","sourceKind","promptDismissedAtMs","platformMarketingPromptDue","state","nowMs","PROVENANCE_REASONS","granted","declined","platformMarketingConsentSource","input","kind","by","uid","reason","actor","platformMarketingUserFields"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/platform-marketing-consent.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 * Marketing consent for the PLATFORM's own product email (AGL-3185).\n *\n * The strict policy (`marketing-consent.ts`) means a person with no declared\n * basis is unsendable, and until this module the only doors that declared\n * one were the marketing site's forms. Somebody who created a console account\n * — the warmest audience the platform has — was never asked. Three console\n * doors ask now: a checkbox on the sign-up form, a switch in the account\n * preferences, and a one-time prompt for accounts that predate the checkbox.\n *\n * ## One wording, versioned\n *\n * Every door shows {@link PLATFORM_MARKETING_CONSENT_TEXT} and every record\n * stores {@link PLATFORM_MARKETING_CONSENT_TEXT_VERSION} beside the exact\n * timestamp, so \"what did this person agree to\" is answered by a version and\n * not by whatever the checkbox says today. Changing the sentence is a bump of\n * the version in the same edit.\n *\n * ## The record lives in two places, and the two do not disagree\n *\n * The decision is written by the server onto the person's own `users/{uid}`\n * document, so the console can show a preference without reading the\n * operator's CRM, and onto the operator's marketing contact for the same\n * person, so the send path sees a declared basis with provenance. The\n * contact half is written only when the deployment names a marketing host\n * (`PLATFORM_MARKETING_HOST_ID`, read in `@aglyn/tenant-data-admin`); a\n * self-hosted install with no audience of its own records the preference\n * and skips the contact — and keeps asking, so the answers are already on\n * file the day the operator names a marketing host.\n *\n * ## An unsubscribe reaches the account, for its own list only (AGL-3305)\n *\n * The marketing site's emails carry their own way out, and a person who uses\n * it has answered the console's question too — but only if what they left\n * is the list the question was about. Leaving\n * {@link PLATFORM_MARKETING_TOPIC_ID}, or everything the site sends, turns\n * the account's answer to No through an email door; leaving the newsletter\n * leaves it alone. The email doors write the account and nothing else: the\n * site's lists already say what was left, and declining the contact's basis\n * would stop every list, not the one the person chose.\n *\n * ## Absence is not refusal\n *\n * An unticked sign-up checkbox records nothing. A switch turned off and a\n * prompt answered \"no\" record a refusal, which the strict policy already\n * honors. A prompt dismissed without an answer records only the dismissal,\n * and the prompt stays away for {@link PLATFORM_MARKETING_PROMPT_SNOOZE_MS}.\n *\n * Pure and dependency-light on purpose: the sign-up page renders the text,\n * the console route validates the body, and the server library writes the\n * record, all from this one module.\n */\n\nimport { EMAIL_TOPIC_PRODUCT_UPDATES } from './email-topics'\nimport {\n MARKETING_CONSENT_FIELD,\n MARKETING_CONSENT_SOURCE_FIELD,\n type MarketingConsentSource,\n} from './marketing-consent'\nimport { PLATFORM_BRAND_NAME } from './platform-brand'\n\n/**\n * The list the account's answer stands for on the marketing site (AGL-3305):\n * the built-in \"Product updates\" topic, which is what the platform's own\n * announcements are sent on.\n *\n * Named so that \"which unsubscribe is about this answer\" has one spelling.\n * Leaving this list, or leaving everything the site sends, is a No to the\n * question the console asked. Leaving any other list is not, and must not\n * touch the answer: someone who stops the newsletter has said nothing about\n * product updates.\n */\nexport const PLATFORM_MARKETING_TOPIC_ID = EMAIL_TOPIC_PRODUCT_UPDATES\n\n/** The console doors a decision can come through, as the record names them. */\nexport const PLATFORM_MARKETING_CONSOLE_SOURCE_KINDS = [\n 'console-signup',\n 'console-preferences',\n 'console-prompt',\n] as const\n\nexport type PlatformMarketingConsoleSourceKind =\n (typeof PLATFORM_MARKETING_CONSOLE_SOURCE_KINDS)[number]\n\n/**\n * The email doors (AGL-3305): the marketing site's own unsubscribe,\n * preference and resubscribe pages, when what the person changed there was\n * {@link PLATFORM_MARKETING_TOPIC_ID} or everything.\n *\n * Kept apart from the console doors because nothing the console sends may\n * claim one. The server records these from the signed link a recipient\n * followed; a console request naming one is refused, because a click in the\n * console is not a click in a mailbox.\n */\nexport const PLATFORM_MARKETING_EMAIL_SOURCE_KINDS = [\n 'email-unsubscribe',\n 'email-preferences',\n 'email-resubscribe',\n] as const\n\nexport type PlatformMarketingEmailSourceKind =\n (typeof PLATFORM_MARKETING_EMAIL_SOURCE_KINDS)[number]\n\n/** Every door a stored decision can name. */\nexport const PLATFORM_MARKETING_CONSENT_SOURCE_KINDS = [\n ...PLATFORM_MARKETING_CONSOLE_SOURCE_KINDS,\n ...PLATFORM_MARKETING_EMAIL_SOURCE_KINDS,\n] as const\n\nexport type PlatformMarketingConsentSourceKind =\n (typeof PLATFORM_MARKETING_CONSENT_SOURCE_KINDS)[number]\n\n/** Any door, for READING a stored decision. */\nexport function isPlatformMarketingConsentSourceKind(\n value: unknown,\n): value is PlatformMarketingConsentSourceKind {\n return (PLATFORM_MARKETING_CONSENT_SOURCE_KINDS as readonly unknown[]).includes(\n value,\n )\n}\n\n/** A console door: the only kind a console request may record. */\nexport function isPlatformMarketingConsoleSourceKind(\n value: unknown,\n): value is PlatformMarketingConsoleSourceKind {\n return (PLATFORM_MARKETING_CONSOLE_SOURCE_KINDS as readonly unknown[]).includes(\n value,\n )\n}\n\n/** An email door: a decision made from a link in one of the site's emails. */\nexport function isPlatformMarketingEmailSourceKind(\n value: unknown,\n): value is PlatformMarketingEmailSourceKind {\n return (PLATFORM_MARKETING_EMAIL_SOURCE_KINDS as readonly unknown[]).includes(\n value,\n )\n}\n\n/** What a door records: a grant or a refusal. Absence is never recorded. */\nexport const PLATFORM_MARKETING_CONSENT_DECISIONS = ['granted', 'declined'] as const\n\nexport type PlatformMarketingConsentDecision =\n (typeof PLATFORM_MARKETING_CONSENT_DECISIONS)[number]\n\nexport function isPlatformMarketingConsentDecision(\n value: unknown,\n): value is PlatformMarketingConsentDecision {\n return (PLATFORM_MARKETING_CONSENT_DECISIONS as readonly unknown[]).includes(\n value,\n )\n}\n\n/**\n * The version of the consent wording every door shows. Bumped in the same\n * edit as {@link platformMarketingConsentText}; a record stores the version\n * it was shown under, so the two must move together.\n */\nexport const PLATFORM_MARKETING_CONSENT_TEXT_VERSION = '2026-09-20'\n\n/**\n * The sentence the person agrees to, with the deployment's brand in it.\n *\n * Affirmative and standalone: it names what is sent, who sends it, and that\n * it can be stopped — never bundled with the terms acceptance, and never\n * pre-ticked anywhere it is rendered.\n */\nexport function platformMarketingConsentText(brandName: string): string {\n return `Send me product updates from ${brandName}. You can opt out any time.`\n}\n\n/** {@link platformMarketingConsentText} for the brand this deployment wears. */\nexport const PLATFORM_MARKETING_CONSENT_TEXT = platformMarketingConsentText(\n PLATFORM_BRAND_NAME,\n)\n\n/**\n * The `users/{uid}` fields the decision is mirrored onto.\n *\n * The same names the contact record uses for the same facts, so one reader\n * can parse either document. The prompt-dismissal stamp has no contact\n * counterpart: it is about what the console asked, not what the person\n * agreed to.\n */\nexport const USER_MARKETING_CONSENT_FIELD = MARKETING_CONSENT_FIELD\nexport const USER_MARKETING_CONSENT_AT_FIELD = 'marketingConsentAtMs'\nexport const USER_MARKETING_CONSENT_SOURCE_FIELD = MARKETING_CONSENT_SOURCE_FIELD\nexport const USER_MARKETING_PROMPT_DISMISSED_AT_FIELD =\n 'marketingConsentPromptDismissedAtMs'\n\n/** How long a dismissed prompt stays away: ninety days. */\nexport const PLATFORM_MARKETING_PROMPT_SNOOZE_MS = 90 * 24 * 60 * 60 * 1000\n\n/** What the person's own document says about product email. */\nexport interface PlatformMarketingConsentState {\n /** `null` when nothing has been recorded — asked, but not answered. */\n decision: PlatformMarketingConsentDecision | null\n /** When the decision was recorded. */\n atMs: number | null\n /** The wording version the decision was made under. */\n textVersion: string | null\n /**\n * Which door recorded it — a console door, or an email door for a decision\n * mirrored from the marketing site's own pages. `null` for a record another\n * writer left.\n */\n sourceKind: PlatformMarketingConsentSourceKind | null\n /** When the prompt was last dismissed without an answer. */\n promptDismissedAtMs: number | null\n}\n\nfunction finiteOrNull(value: unknown): number | null {\n return typeof value === 'number' && Number.isFinite(value) ? value : null\n}\n\n/**\n * Reads the decision off a `users/{uid}` document, or off no document.\n *\n * Every unusable value reads as its absent case rather than throwing: a\n * malformed field must degrade to \"nothing recorded\", which asks again,\n * never to a grant.\n */\nexport function readPlatformMarketingConsent(\n document: Record<string, unknown> | null | undefined,\n): PlatformMarketingConsentState {\n const consent = document?.[USER_MARKETING_CONSENT_FIELD]\n const rawSource = document?.[USER_MARKETING_CONSENT_SOURCE_FIELD]\n const source =\n rawSource && typeof rawSource === 'object' && !Array.isArray(rawSource)\n ? (rawSource as Record<string, unknown>)\n : null\n const textVersion = source?.['textVersion']\n return {\n decision: consent === true ? 'granted' : consent === false ? 'declined' : null,\n atMs: finiteOrNull(document?.[USER_MARKETING_CONSENT_AT_FIELD]),\n textVersion:\n typeof textVersion === 'string' && textVersion ? textVersion : null,\n sourceKind: isPlatformMarketingConsentSourceKind(source?.['kind'])\n ? source['kind']\n : null,\n promptDismissedAtMs: finiteOrNull(\n document?.[USER_MARKETING_PROMPT_DISMISSED_AT_FIELD],\n ),\n }\n}\n\n/**\n * Whether the console should ask this person now.\n *\n * Never after a decision, in either direction: a \"no\" is an answer and a\n * \"yes\" needs no repeating. A dismissal is not an answer, so the prompt\n * returns once the snooze has run out.\n */\nexport function platformMarketingPromptDue(\n state: Pick<PlatformMarketingConsentState, 'decision' | 'promptDismissedAtMs'>,\n nowMs: number,\n): boolean {\n if (state.decision !== null) return false\n if (state.promptDismissedAtMs === null) return true\n return nowMs - state.promptDismissedAtMs >= PLATFORM_MARKETING_PROMPT_SNOOZE_MS\n}\n\n/**\n * What each door did, in prose, for whoever audits the record later.\n *\n * Keyed by door and decision so the sentence is decided once, here, and the\n * three doors cannot describe the same act three different ways.\n */\nconst PROVENANCE_REASONS: Record<\n PlatformMarketingConsoleSourceKind,\n Record<PlatformMarketingConsentDecision, string>\n> = {\n 'console-signup': {\n granted: 'Ticked the product-updates checkbox on the console sign-up form.',\n declined: 'Declined product updates on the console sign-up form.',\n },\n 'console-preferences': {\n granted: 'Turned product updates on in the console account preferences.',\n declined: 'Turned product updates off in the console account preferences.',\n },\n 'console-prompt': {\n granted: 'Answered yes to the product-updates prompt in the console.',\n declined: 'Answered no to the product-updates prompt in the console.',\n },\n}\n\n/**\n * The provenance stored with a console decision, on both documents.\n *\n * `actor: 'person'` is what keeps {@link MarketingConsentSource} honest here:\n * the reader treats provenance as an operator's assertion unless it says\n * otherwise, and this is the person's own click, carrying which door and\n * which wording. `by` is the account that clicked — the subject and the\n * actor are the same human, which is the fact the field records.\n */\nexport function platformMarketingConsentSource(input: {\n kind: PlatformMarketingConsoleSourceKind\n decision: PlatformMarketingConsentDecision\n uid: string\n atMs: number\n textVersion: string\n}): MarketingConsentSource {\n return {\n kind: input.kind,\n by: input.uid,\n atMs: input.atMs,\n reason: PROVENANCE_REASONS[input.kind][input.decision],\n textVersion: input.textVersion,\n actor: 'person',\n }\n}\n\n/**\n * What an email door did, and which decisions each door can make.\n *\n * The pairs are the whole vocabulary: an unsubscribe only ever refuses, a\n * resubscribe only ever restores, and only the preference page does both.\n * Typed as the union rather than a free kind and decision, so an\n * \"unsubscribe that granted\" cannot be written down at all.\n */\nexport type PlatformMarketingEmailDecision =\n | { kind: 'email-unsubscribe'; decision: 'declined' }\n | { kind: 'email-preferences'; decision: PlatformMarketingConsentDecision }\n | { kind: 'email-resubscribe'; decision: 'granted' }\n\nconst EMAIL_PROVENANCE_REASONS: {\n [K in PlatformMarketingEmailDecision as `${K['kind']}:${K['decision']}`]: string\n} = {\n 'email-unsubscribe:declined':\n 'Unsubscribed from every email the marketing site sends, from a link in one of its emails.',\n 'email-preferences:declined':\n 'Left product updates on the marketing site’s email preference page.',\n 'email-preferences:granted':\n 'Chose product updates again on the marketing site’s email preference page.',\n 'email-resubscribe:granted':\n 'Resubscribed from the link in one of the marketing site’s emails.',\n}\n\n/**\n * The provenance stored with a decision mirrored from the marketing site's\n * own pages (AGL-3305), on the account only.\n *\n * Still `actor: 'person'`: the click was the recipient's, on a link signed\n * for their address. No `textVersion`, because those pages do not version\n * their wording; the field is absent rather than borrowing the console's\n * version for a sentence the person never saw.\n */\nexport function platformMarketingEmailSource(\n input: PlatformMarketingEmailDecision & { uid: string; atMs: number },\n): MarketingConsentSource {\n return {\n kind: input.kind,\n by: input.uid,\n atMs: input.atMs,\n // The union narrows the pair; the template type cannot see that, so the\n // key is asserted to the table's own keys rather than widened to string.\n reason: EMAIL_PROVENANCE_REASONS[\n `${input.kind}:${input.decision}` as keyof typeof EMAIL_PROVENANCE_REASONS\n ],\n actor: 'person',\n }\n}\n\n/**\n * The `users/{uid}` fields a decision writes, as one merge-able object.\n *\n * `false` for a refusal, not a deleted field: absence is the third state and\n * means \"never asked\", which a person who said no must never read as again.\n */\nexport function platformMarketingUserFields(input: {\n decision: PlatformMarketingConsentDecision\n atMs: number\n source: MarketingConsentSource\n}): Record<string, unknown> {\n return {\n [USER_MARKETING_CONSENT_FIELD]: input.decision === 'granted',\n [USER_MARKETING_CONSENT_AT_FIELD]: input.atMs,\n [USER_MARKETING_CONSENT_SOURCE_FIELD]: input.source,\n }\n}\n"],"names":["EMAIL_TOPIC_PRODUCT_UPDATES","MARKETING_CONSENT_FIELD","MARKETING_CONSENT_SOURCE_FIELD","PLATFORM_BRAND_NAME","PLATFORM_MARKETING_TOPIC_ID","PLATFORM_MARKETING_CONSOLE_SOURCE_KINDS","PLATFORM_MARKETING_EMAIL_SOURCE_KINDS","PLATFORM_MARKETING_CONSENT_SOURCE_KINDS","isPlatformMarketingConsentSourceKind","value","includes","isPlatformMarketingConsoleSourceKind","isPlatformMarketingEmailSourceKind","PLATFORM_MARKETING_CONSENT_DECISIONS","isPlatformMarketingConsentDecision","PLATFORM_MARKETING_CONSENT_TEXT_VERSION","platformMarketingConsentText","brandName","PLATFORM_MARKETING_CONSENT_TEXT","USER_MARKETING_CONSENT_FIELD","USER_MARKETING_CONSENT_AT_FIELD","USER_MARKETING_CONSENT_SOURCE_FIELD","USER_MARKETING_PROMPT_DISMISSED_AT_FIELD","PLATFORM_MARKETING_PROMPT_SNOOZE_MS","finiteOrNull","Number","isFinite","readPlatformMarketingConsent","document","consent","rawSource","source","Array","isArray","textVersion","decision","atMs","sourceKind","promptDismissedAtMs","platformMarketingPromptDue","state","nowMs","PROVENANCE_REASONS","granted","declined","platformMarketingConsentSource","input","kind","by","uid","reason","actor","EMAIL_PROVENANCE_REASONS","platformMarketingEmailSource","platformMarketingUserFields"],"mappings":"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"}
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /** Which stream, for whom, on which site. */
18
+ export interface PluginEmailStreamRejoinRequest {
19
+ /** The site whose lists change. */
20
+ hostId: string;
21
+ /** The address, as a verified account carries it. */
22
+ email: string;
23
+ /** The one stream to reopen. */
24
+ topicId: string;
25
+ }
26
+ /** What reopening did. */
27
+ export type PluginEmailStreamRejoinResult =
28
+ /**
29
+ * The address may be mailed about the stream again. When it had left
30
+ * everything, `keptLeft` streams became opt-outs before the suppression
31
+ * was lifted, so they stay left.
32
+ */
33
+ {
34
+ status: 'rejoined';
35
+ releasedSuppression: boolean;
36
+ keptLeft: number;
37
+ }
38
+ /**
39
+ * A record the person may not lift from here — a bounce, a complaint, an
40
+ * erasure, a staff hold — so nothing changed.
41
+ */
42
+ | {
43
+ status: 'held';
44
+ reason: string;
45
+ };
46
+ export interface PluginEmailStreams {
47
+ /**
48
+ * Reopen one stream: lift its opt-out, and turn a whole-site unsubscribe
49
+ * into opt-outs of every OTHER stream before lifting it. Idempotent.
50
+ */
51
+ rejoin(request: PluginEmailStreamRejoinRequest): Promise<PluginEmailStreamRejoinResult>;
52
+ }
53
+ export declare const PLUGIN_EMAIL_STREAMS: import("./plugin-services").PluginServiceContract<PluginEmailStreams>;
54
+ /**
55
+ * Fills the slot. The owner is the loader's marker when a register fn is
56
+ * running, else `options.pluginId`; with neither the registration throws, and
57
+ * a second plugin's implementation is refused naming both.
58
+ */
59
+ export declare function registerPluginEmailStreams(impl: PluginEmailStreams, options?: {
60
+ pluginId?: string;
61
+ }): void;
62
+ /** What a caller learns: the plugin's answer, or why there was none. */
63
+ export type EmailStreamRejoinOutcome = PluginEmailStreamRejoinResult
64
+ /** No plugin keeps email preferences in this process. */
65
+ | {
66
+ status: 'unavailable';
67
+ }
68
+ /** The plugin threw. Logged; the caller's own write stands. */
69
+ | {
70
+ status: 'failed';
71
+ };
72
+ /**
73
+ * Reopens the stream through whichever plugin keeps email preferences. Never
74
+ * throws: every caller has already recorded the person's answer, and a
75
+ * stream that could not reopen is a thing to report, not a reason to lose it.
76
+ */
77
+ export declare function rejoinEmailStream(request: PluginEmailStreamRejoinRequest): Promise<EmailStreamRejoinOutcome>;
@@ -0,0 +1,77 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ /**
18
+ * A site's email streams, reopened for a signed-in person (AGL-3305).
19
+ *
20
+ * A site keeps what a recipient left in two places: its suppression list for
21
+ * "everything", and a per-stream opt-out for one topic. Both are written from
22
+ * the site's own pages by whoever holds a link signed for the address, over
23
+ * the topic catalog the plugin that keeps email preferences owns.
24
+ *
25
+ * The console has a second door onto ONE of those streams: the account's
26
+ * answer about product updates, which stands for a topic on the operator's
27
+ * marketing site. When that answer turns back to yes the stream has to
28
+ * reopen — and reopening it for somebody who left everything needs the
29
+ * catalog, because they asked for product updates, not for the newsletter
30
+ * and the promotions they also left. Core does not read another plugin's
31
+ * storage, so it asks through this slot.
32
+ *
33
+ * One implementation, from the plugin that keeps the site's email
34
+ * preferences, registered from its `consoleServerDeclarations`: the only
35
+ * caller is a console route, and the tenant has no reason to hold it.
36
+ *
37
+ * ## The caller proves who is asking
38
+ *
39
+ * Like every seam here, the slot authenticates nobody. Reopening a stream
40
+ * undoes something a recipient did from their mailbox, so the caller asks
41
+ * only for the address of a signed-in account whose email is VERIFIED — the
42
+ * same proof of mailbox the signed link carried. An unverified account could
43
+ * otherwise type a stranger's address and reverse the stranger's unsubscribe.
44
+ */ import { definePluginServiceContract, registerPluginService, resolvePluginService } from "./plugin-services.js";
45
+ export const PLUGIN_EMAIL_STREAMS = definePluginServiceContract('core.email-streams', {
46
+ multiple: false
47
+ });
48
+ /**
49
+ * Fills the slot. The owner is the loader's marker when a register fn is
50
+ * running, else `options.pluginId`; with neither the registration throws, and
51
+ * a second plugin's implementation is refused naming both.
52
+ */ export function registerPluginEmailStreams(impl, options) {
53
+ registerPluginService(PLUGIN_EMAIL_STREAMS, impl, _extends({}, (options == null ? void 0 : options.pluginId) ? {
54
+ pluginId: options.pluginId
55
+ } : {}));
56
+ }
57
+ /**
58
+ * Reopens the stream through whichever plugin keeps email preferences. Never
59
+ * throws: every caller has already recorded the person's answer, and a
60
+ * stream that could not reopen is a thing to report, not a reason to lose it.
61
+ */ export async function rejoinEmailStream(request) {
62
+ const impl = resolvePluginService(PLUGIN_EMAIL_STREAMS);
63
+ if (!impl) return {
64
+ status: 'unavailable'
65
+ };
66
+ try {
67
+ return await impl.rejoin(request);
68
+ } catch (error) {
69
+ // The site, never the address.
70
+ console.error(`[email-streams] could not reopen a stream on ${request.hostId}`, error);
71
+ return {
72
+ status: 'failed'
73
+ };
74
+ }
75
+ }
76
+
77
+ //# sourceMappingURL=plugin-email-streams.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-email-streams.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A site's email streams, reopened for a signed-in person (AGL-3305).\n *\n * A site keeps what a recipient left in two places: its suppression list for\n * \"everything\", and a per-stream opt-out for one topic. Both are written from\n * the site's own pages by whoever holds a link signed for the address, over\n * the topic catalog the plugin that keeps email preferences owns.\n *\n * The console has a second door onto ONE of those streams: the account's\n * answer about product updates, which stands for a topic on the operator's\n * marketing site. When that answer turns back to yes the stream has to\n * reopen — and reopening it for somebody who left everything needs the\n * catalog, because they asked for product updates, not for the newsletter\n * and the promotions they also left. Core does not read another plugin's\n * storage, so it asks through this slot.\n *\n * One implementation, from the plugin that keeps the site's email\n * preferences, registered from its `consoleServerDeclarations`: the only\n * caller is a console route, and the tenant has no reason to hold it.\n *\n * ## The caller proves who is asking\n *\n * Like every seam here, the slot authenticates nobody. Reopening a stream\n * undoes something a recipient did from their mailbox, so the caller asks\n * only for the address of a signed-in account whose email is VERIFIED — the\n * same proof of mailbox the signed link carried. An unverified account could\n * otherwise type a stranger's address and reverse the stranger's unsubscribe.\n */\n\nimport {\n definePluginServiceContract,\n registerPluginService,\n resolvePluginService,\n} from './plugin-services'\n\n/** Which stream, for whom, on which site. */\nexport interface PluginEmailStreamRejoinRequest {\n /** The site whose lists change. */\n hostId: string\n /** The address, as a verified account carries it. */\n email: string\n /** The one stream to reopen. */\n topicId: string\n}\n\n/** What reopening did. */\nexport type PluginEmailStreamRejoinResult =\n /**\n * The address may be mailed about the stream again. When it had left\n * everything, `keptLeft` streams became opt-outs before the suppression\n * was lifted, so they stay left.\n */\n | { status: 'rejoined'; releasedSuppression: boolean; keptLeft: number }\n /**\n * A record the person may not lift from here — a bounce, a complaint, an\n * erasure, a staff hold — so nothing changed.\n */\n | { status: 'held'; reason: string }\n\nexport interface PluginEmailStreams {\n /**\n * Reopen one stream: lift its opt-out, and turn a whole-site unsubscribe\n * into opt-outs of every OTHER stream before lifting it. Idempotent.\n */\n rejoin(request: PluginEmailStreamRejoinRequest): Promise<PluginEmailStreamRejoinResult>\n}\n\nexport const PLUGIN_EMAIL_STREAMS = definePluginServiceContract<PluginEmailStreams>(\n 'core.email-streams',\n { multiple: false },\n)\n\n/**\n * Fills the slot. The owner is the loader's marker when a register fn is\n * running, else `options.pluginId`; with neither the registration throws, and\n * a second plugin's implementation is refused naming both.\n */\nexport function registerPluginEmailStreams(\n impl: PluginEmailStreams,\n options?: { pluginId?: string },\n): void {\n registerPluginService(PLUGIN_EMAIL_STREAMS, impl, {\n ...(options?.pluginId ? { pluginId: options.pluginId } : {}),\n })\n}\n\n/** What a caller learns: the plugin's answer, or why there was none. */\nexport type EmailStreamRejoinOutcome =\n | PluginEmailStreamRejoinResult\n /** No plugin keeps email preferences in this process. */\n | { status: 'unavailable' }\n /** The plugin threw. Logged; the caller's own write stands. */\n | { status: 'failed' }\n\n/**\n * Reopens the stream through whichever plugin keeps email preferences. Never\n * throws: every caller has already recorded the person's answer, and a\n * stream that could not reopen is a thing to report, not a reason to lose it.\n */\nexport async function rejoinEmailStream(\n request: PluginEmailStreamRejoinRequest,\n): Promise<EmailStreamRejoinOutcome> {\n const impl = resolvePluginService(PLUGIN_EMAIL_STREAMS)\n if (!impl) return { status: 'unavailable' }\n try {\n return await impl.rejoin(request)\n } catch (error) {\n // The site, never the address.\n console.error(`[email-streams] could not reopen a stream on ${request.hostId}`, error)\n return { status: 'failed' }\n }\n}\n"],"names":["definePluginServiceContract","registerPluginService","resolvePluginService","PLUGIN_EMAIL_STREAMS","multiple","registerPluginEmailStreams","impl","options","pluginId","rejoinEmailStream","request","status","rejoin","error","console","hostId"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2BC,GAED,SACEA,2BAA2B,EAC3BC,qBAAqB,EACrBC,oBAAoB,QACf,uBAAmB;AAkC1B,OAAO,MAAMC,uBAAuBH,4BAClC,sBACA;IAAEI,UAAU;AAAM,GACnB;AAED;;;;CAIC,GACD,OAAO,SAASC,2BACdC,IAAwB,EACxBC,OAA+B;IAE/BN,sBAAsBE,sBAAsBG,MAAM,aAC5CC,CAAAA,2BAAAA,QAASC,QAAQ,IAAG;QAAEA,UAAUD,QAAQC,QAAQ;IAAC,IAAI,CAAC;AAE9D;AAUA;;;;CAIC,GACD,OAAO,eAAeC,kBACpBC,OAAuC;IAEvC,MAAMJ,OAAOJ,qBAAqBC;IAClC,IAAI,CAACG,MAAM,OAAO;QAAEK,QAAQ;IAAc;IAC1C,IAAI;QACF,OAAO,MAAML,KAAKM,MAAM,CAACF;IAC3B,EAAE,OAAOG,OAAO;QACd,+BAA+B;QAC/BC,QAAQD,KAAK,CAAC,CAAC,6CAA6C,EAAEH,QAAQK,MAAM,EAAE,EAAEF;QAChF,OAAO;YAAEF,QAAQ;QAAS;IAC5B;AACF"}