@aglyn/plugins-email 1.0.0-beta.218 → 1.0.0-beta.219

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,324 @@
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
+ * READING A MERCHANT'S CONTACT FILE, and saying what is in it.
18
+ *
19
+ * `docs/specs/email-competitive-gaps.md` G5 is the product gap — a customer
20
+ * arriving with a list has no way to bring it — and P4 is its condition: an
21
+ * importer is simultaneously the biggest onboarding blocker and the biggest
22
+ * abuse vector, so it ships WITH its controls or not at all. This module is
23
+ * the pure half of those controls. It parses, it de-duplicates, and it
24
+ * SCREENS. It decides nothing about consent and it enrolls nobody: the
25
+ * consent question belongs to `list-assignment-policy`, which the one-address
26
+ * add path already asks, and an import that answered it a second way would be
27
+ * exactly the defect class the register has a P1 entry for.
28
+ *
29
+ * ## The screening reports, it never refuses
30
+ *
31
+ * M3AAWG's Vetting BCP supplies two cheap mechanical checks and both are
32
+ * signals rather than verdicts. A role account (`sales@`, `info@`) is, in
33
+ * M3AAWG's words, indicative of poor acquisition practice — it is not proof
34
+ * of one, and plenty of legitimate B2B lists carry a handful. A column named
35
+ * `jigsaw` or `append` is a purchase tell, and again a merchant may simply
36
+ * have named a column badly.
37
+ *
38
+ * So neither one drops an address. Both are put in front of the operator
39
+ * BEFORE they attest, because the attestation is the thing with teeth: an
40
+ * operator who states they have permission for a file whose headers say it
41
+ * was appended has made a claim their own console showed them the evidence
42
+ * against. That is what makes the record worth keeping.
43
+ *
44
+ * ## A declared basis per address, and why it is still an assertion
45
+ *
46
+ * P4 asks for "a declared basis per address, not one checkbox over the file".
47
+ * A file may carry an opt-in source and an opt-in date per row, and when it
48
+ * does they are read here and carried onto the import as evidence. They do
49
+ * NOT become the person's own opt-in. Nothing arriving in a spreadsheet is a
50
+ * checkbox somebody ticked; it is the merchant telling us about one, which is
51
+ * an operator assertion however many columns it arrives in. The basis stays
52
+ * `operator-attested` and the declared source rides along as the reason.
53
+ */ import { normalizeContactEmail } from "@aglyn/aglyn/app-utils/contacts";
54
+ import { parseCsv } from "@aglyn/aglyn/app-utils/dataset-csv";
55
+ /**
56
+ * The most addresses one uploaded file may name.
57
+ *
58
+ * A bound on the WORK an import represents, not a capacity limit: it refuses
59
+ * a FILE, before anything is written, and it can never drop a person already
60
+ * on a list or trim one to make room. A merchant with more than this splits
61
+ * the file, which is a nuisance; a merchant whose audience got silently
62
+ * truncated has an audience they cannot reason about.
63
+ *
64
+ * 50,000 is Klaviyo's shape rather than a number of our own — its only
65
+ * published hard import limit is a 50 MB CSV, which is the same statement in
66
+ * bytes. Above this the parse itself is the cost, and it happens in one
67
+ * request.
68
+ */ export const LIST_IMPORT_MAX_ADDRESSES = 50000;
69
+ /**
70
+ * Local parts that make an address a ROLE account.
71
+ *
72
+ * A mailbox several people read, or none. M3AAWG's Vetting BCP: their
73
+ * appearance on a customer list "may be indicative of poor acquisition
74
+ * practices" — a list built by scraping a website collects these, a list
75
+ * built from signups does not.
76
+ *
77
+ * Reported, never dropped. A merchant importing their own supplier contacts
78
+ * legitimately has `orders@`, and an importer that quietly removed addresses
79
+ * would be telling the operator a different number went on the list than did.
80
+ */ export const ROLE_ACCOUNT_LOCAL_PARTS = [
81
+ 'abuse',
82
+ 'admin',
83
+ 'billing',
84
+ 'contact',
85
+ 'enquiries',
86
+ 'help',
87
+ 'hello',
88
+ 'hr',
89
+ 'info',
90
+ 'inquiries',
91
+ 'mail',
92
+ 'marketing',
93
+ 'noreply',
94
+ 'no-reply',
95
+ 'office',
96
+ 'orders',
97
+ 'postmaster',
98
+ 'sales',
99
+ 'security',
100
+ 'staff',
101
+ 'support',
102
+ 'team',
103
+ 'webmaster'
104
+ ];
105
+ /**
106
+ * Column names that say where the file came from, when the answer is "not
107
+ * from people who asked".
108
+ *
109
+ * M3AAWG names `jigsaw` and `append` specifically. The rest are the same tell
110
+ * in the vocabulary the data brokers actually use, and they are matched as
111
+ * substrings of a normalized header so `Appended Email` and `append_date`
112
+ * both hit.
113
+ *
114
+ * Every vendor surveyed prohibits purchased lists outright, Apple bans
115
+ * purchased, rented and appended lists, and M3AAWG calls appending "a direct
116
+ * violation of core M3AAWG values". This is not a policy we invented and the
117
+ * screening exists so that a merchant cannot attest their way past it without
118
+ * having been shown it.
119
+ */ export const PURCHASE_TELL_COLUMNS = [
120
+ 'jigsaw',
121
+ 'append',
122
+ 'purchased',
123
+ 'rented',
124
+ 'databroker',
125
+ 'data broker',
126
+ 'leadgen',
127
+ 'lead gen',
128
+ 'scraped'
129
+ ];
130
+ /** Header aliases that name the address column. */ const EMAIL_COLUMNS = [
131
+ 'email',
132
+ 'emailaddress',
133
+ 'email address',
134
+ 'e-mail',
135
+ 'e-mail address',
136
+ 'mail',
137
+ 'contact email',
138
+ 'primary email'
139
+ ];
140
+ /** Header aliases that name a display name. */ const NAME_COLUMNS = [
141
+ 'name',
142
+ 'full name',
143
+ 'fullname',
144
+ 'display name',
145
+ 'contact name',
146
+ 'first name',
147
+ 'firstname',
148
+ 'given name'
149
+ ];
150
+ /** Header aliases carrying the merchant's declared opt-in source. */ const OPT_IN_SOURCE_COLUMNS = [
151
+ 'opt-in source',
152
+ 'optin source',
153
+ 'opt in source',
154
+ 'consent source',
155
+ 'signup source',
156
+ 'subscription source',
157
+ 'source'
158
+ ];
159
+ /** Header aliases carrying the merchant's declared opt-in date. */ const OPT_IN_DATE_COLUMNS = [
160
+ 'opt-in date',
161
+ 'optin date',
162
+ 'opt in date',
163
+ 'consent date',
164
+ 'signup date',
165
+ 'subscribed at',
166
+ 'date subscribed',
167
+ 'confirmed at'
168
+ ];
169
+ /** Normalizes a header cell for alias matching. */ function headerKey(value) {
170
+ return String(value != null ? value : '').trim().toLowerCase().replace(/[_]+/g, ' ').replace(/\s+/g, ' ');
171
+ }
172
+ /** The index of the first column matching one of `aliases`, or -1. */ function columnIndex(columns, aliases) {
173
+ const keys = columns.map(headerKey);
174
+ for (const alias of aliases){
175
+ const at = keys.indexOf(alias);
176
+ if (at !== -1) return at;
177
+ }
178
+ return -1;
179
+ }
180
+ /**
181
+ * Whether a parsed first row is a HEADER rather than a person.
182
+ *
183
+ * A header is a row with no address in it. That test rather than an alias
184
+ * match, because the common shape of a merchant's export is a header whose
185
+ * address column is called something we have never seen — and treating that
186
+ * file as headerless would import the word "Email Address" as a line that is
187
+ * not an address, and shift every subsequent column read by one.
188
+ *
189
+ * A one-column file of bare addresses therefore has no header, correctly:
190
+ * every row including the first carries an address.
191
+ */ function looksLikeHeader(cells) {
192
+ return !cells.some((cell)=>normalizeContactEmail(cell));
193
+ }
194
+ /**
195
+ * Reads an uploaded or pasted file into rows.
196
+ *
197
+ * Handles the two shapes a merchant's list actually arrives in: a CSV with a
198
+ * header, and a bare column of addresses with no header at all. Both go
199
+ * through one parser — `parseCsv` — because a newline-separated list of
200
+ * addresses IS a single-column CSV, and a second code path for it would be a
201
+ * second place for the quoting rules to differ.
202
+ *
203
+ * ## Every line comes back, including the bad ones
204
+ *
205
+ * A file that silently discarded its malformed lines would tell an operator
206
+ * that 100 addresses went on the list when 94 did, and the six they never
207
+ * hear about are the six they typed wrong. This is the rule `resolveAddresses`
208
+ * already applies to a paste, restated for a file.
209
+ */ export function parseListImport(text) {
210
+ const table = parseCsv(String(text != null ? text : ''));
211
+ if (!table.length) {
212
+ return {
213
+ columns: [],
214
+ rows: [],
215
+ usable: 0,
216
+ unusable: 0,
217
+ duplicates: 0,
218
+ overCeiling: false
219
+ };
220
+ }
221
+ const headed = table.length > 1 && looksLikeHeader(table[0]);
222
+ const columns = headed ? table[0].map((cell)=>String(cell != null ? cell : '').trim()) : [];
223
+ const body = headed ? table.slice(1) : table;
224
+ const emailAt = headed ? columnIndex(columns, EMAIL_COLUMNS) : 0;
225
+ const nameAt = headed ? columnIndex(columns, NAME_COLUMNS) : -1;
226
+ const sourceAt = headed ? columnIndex(columns, OPT_IN_SOURCE_COLUMNS) : -1;
227
+ const dateAt = headed ? columnIndex(columns, OPT_IN_DATE_COLUMNS) : -1;
228
+ const seen = new Set();
229
+ const rows = [];
230
+ let usable = 0;
231
+ let unusable = 0;
232
+ let duplicates = 0;
233
+ let overCeiling = false;
234
+ for (const cells of body){
235
+ var _cells_emailAt, _cells_find, _cells_nameAt, _cells_sourceAt, _cells_dateAt;
236
+ /*
237
+ * The named column when the header named one, and otherwise the first
238
+ * cell in the row that IS an address.
239
+ *
240
+ * The fallback is what makes an unrecognized header survivable: an
241
+ * export whose address column is called `Primary contact e-mail (work)`
242
+ * matches no alias, and refusing the whole file over a column name would
243
+ * be refusing the customer's migration over our vocabulary.
244
+ */ const raw = emailAt >= 0 && emailAt < cells.length ? String((_cells_emailAt = cells[emailAt]) != null ? _cells_emailAt : '') : (_cells_find = cells.find((cell)=>normalizeContactEmail(cell))) != null ? _cells_find : '';
245
+ const email = normalizeContactEmail(raw);
246
+ const input = String(raw != null ? raw : '').trim() || cells.join(', ').trim();
247
+ if (!email) {
248
+ unusable += 1;
249
+ rows.push({
250
+ input,
251
+ email: null,
252
+ name: '',
253
+ declaredSource: '',
254
+ declaredAt: ''
255
+ });
256
+ continue;
257
+ }
258
+ if (seen.has(email)) {
259
+ duplicates += 1;
260
+ continue;
261
+ }
262
+ if (seen.size >= LIST_IMPORT_MAX_ADDRESSES) {
263
+ overCeiling = true;
264
+ break;
265
+ }
266
+ seen.add(email);
267
+ usable += 1;
268
+ rows.push({
269
+ input,
270
+ email,
271
+ name: nameAt >= 0 ? String((_cells_nameAt = cells[nameAt]) != null ? _cells_nameAt : '').trim() : '',
272
+ declaredSource: sourceAt >= 0 ? String((_cells_sourceAt = cells[sourceAt]) != null ? _cells_sourceAt : '').trim() : '',
273
+ declaredAt: dateAt >= 0 ? String((_cells_dateAt = cells[dateAt]) != null ? _cells_dateAt : '').trim() : ''
274
+ });
275
+ }
276
+ return {
277
+ columns,
278
+ rows,
279
+ usable,
280
+ unusable,
281
+ duplicates,
282
+ overCeiling
283
+ };
284
+ }
285
+ /**
286
+ * The mechanical checks, over a parsed file.
287
+ *
288
+ * Cheap by construction: two substring passes and one set membership per
289
+ * address. It is deliberately not a deliverability service — Omnisend's own
290
+ * caveat on the one that exists is that "list cleaning confirms
291
+ * deliverability only and does not establish recipient consent", and consent
292
+ * is the only question this import actually turns on.
293
+ */ export function screenListImport(parsed) {
294
+ const roleAccounts = [];
295
+ for (const row of parsed.rows){
296
+ if (!row.email) continue;
297
+ const local = row.email.slice(0, row.email.indexOf('@'));
298
+ if (ROLE_ACCOUNT_LOCAL_PARTS.includes(local)) roleAccounts.push(row.email);
299
+ }
300
+ const purchaseTellColumns = parsed.columns.filter((column)=>{
301
+ const key = headerKey(column);
302
+ return PURCHASE_TELL_COLUMNS.some((tell)=>key.includes(tell));
303
+ });
304
+ return {
305
+ roleAccounts,
306
+ purchaseTellColumns,
307
+ declaresBasis: parsed.rows.some((row)=>!!row.declaredSource || !!row.declaredAt)
308
+ };
309
+ }
310
+ /**
311
+ * The sentence recorded as the REASON behind an imported person's basis.
312
+ *
313
+ * One line, built from what the file actually declared, so a compliance
314
+ * question about one address gets an answer about that address rather than
315
+ * "somebody ticked a box on an import once". Empty declarations produce the
316
+ * plain sentence rather than a sentence with holes in it.
317
+ */ export function importedBasisReason(row) {
318
+ const parts = [];
319
+ if (row.declaredSource) parts.push(`declared source: ${row.declaredSource}`);
320
+ if (row.declaredAt) parts.push(`declared opt-in: ${row.declaredAt}`);
321
+ return parts.length ? `Imported from a file, attested by the operator (${parts.join('; ')}).` : 'Imported from a file, attested by the operator.';
322
+ }
323
+
324
+ //# sourceMappingURL=list-import.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/list-import.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 * READING A MERCHANT'S CONTACT FILE, and saying what is in it.\n *\n * `docs/specs/email-competitive-gaps.md` G5 is the product gap — a customer\n * arriving with a list has no way to bring it — and P4 is its condition: an\n * importer is simultaneously the biggest onboarding blocker and the biggest\n * abuse vector, so it ships WITH its controls or not at all. This module is\n * the pure half of those controls. It parses, it de-duplicates, and it\n * SCREENS. It decides nothing about consent and it enrolls nobody: the\n * consent question belongs to `list-assignment-policy`, which the one-address\n * add path already asks, and an import that answered it a second way would be\n * exactly the defect class the register has a P1 entry for.\n *\n * ## The screening reports, it never refuses\n *\n * M3AAWG's Vetting BCP supplies two cheap mechanical checks and both are\n * signals rather than verdicts. A role account (`sales@`, `info@`) is, in\n * M3AAWG's words, indicative of poor acquisition practice — it is not proof\n * of one, and plenty of legitimate B2B lists carry a handful. A column named\n * `jigsaw` or `append` is a purchase tell, and again a merchant may simply\n * have named a column badly.\n *\n * So neither one drops an address. Both are put in front of the operator\n * BEFORE they attest, because the attestation is the thing with teeth: an\n * operator who states they have permission for a file whose headers say it\n * was appended has made a claim their own console showed them the evidence\n * against. That is what makes the record worth keeping.\n *\n * ## A declared basis per address, and why it is still an assertion\n *\n * P4 asks for \"a declared basis per address, not one checkbox over the file\".\n * A file may carry an opt-in source and an opt-in date per row, and when it\n * does they are read here and carried onto the import as evidence. They do\n * NOT become the person's own opt-in. Nothing arriving in a spreadsheet is a\n * checkbox somebody ticked; it is the merchant telling us about one, which is\n * an operator assertion however many columns it arrives in. The basis stays\n * `operator-attested` and the declared source rides along as the reason.\n */\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { parseCsv } from '@aglyn/aglyn/app-utils/dataset-csv'\n\n/**\n * The most addresses one uploaded file may name.\n *\n * A bound on the WORK an import represents, not a capacity limit: it refuses\n * a FILE, before anything is written, and it can never drop a person already\n * on a list or trim one to make room. A merchant with more than this splits\n * the file, which is a nuisance; a merchant whose audience got silently\n * truncated has an audience they cannot reason about.\n *\n * 50,000 is Klaviyo's shape rather than a number of our own — its only\n * published hard import limit is a 50 MB CSV, which is the same statement in\n * bytes. Above this the parse itself is the cost, and it happens in one\n * request.\n */\nexport const LIST_IMPORT_MAX_ADDRESSES = 50_000\n\n/**\n * Local parts that make an address a ROLE account.\n *\n * A mailbox several people read, or none. M3AAWG's Vetting BCP: their\n * appearance on a customer list \"may be indicative of poor acquisition\n * practices\" — a list built by scraping a website collects these, a list\n * built from signups does not.\n *\n * Reported, never dropped. A merchant importing their own supplier contacts\n * legitimately has `orders@`, and an importer that quietly removed addresses\n * would be telling the operator a different number went on the list than did.\n */\nexport const ROLE_ACCOUNT_LOCAL_PARTS: readonly string[] = [\n 'abuse',\n 'admin',\n 'billing',\n 'contact',\n 'enquiries',\n 'help',\n 'hello',\n 'hr',\n 'info',\n 'inquiries',\n 'mail',\n 'marketing',\n 'noreply',\n 'no-reply',\n 'office',\n 'orders',\n 'postmaster',\n 'sales',\n 'security',\n 'staff',\n 'support',\n 'team',\n 'webmaster',\n]\n\n/**\n * Column names that say where the file came from, when the answer is \"not\n * from people who asked\".\n *\n * M3AAWG names `jigsaw` and `append` specifically. The rest are the same tell\n * in the vocabulary the data brokers actually use, and they are matched as\n * substrings of a normalized header so `Appended Email` and `append_date`\n * both hit.\n *\n * Every vendor surveyed prohibits purchased lists outright, Apple bans\n * purchased, rented and appended lists, and M3AAWG calls appending \"a direct\n * violation of core M3AAWG values\". This is not a policy we invented and the\n * screening exists so that a merchant cannot attest their way past it without\n * having been shown it.\n */\nexport const PURCHASE_TELL_COLUMNS: readonly string[] = [\n 'jigsaw',\n 'append',\n 'purchased',\n 'rented',\n 'databroker',\n 'data broker',\n 'leadgen',\n 'lead gen',\n 'scraped',\n]\n\n/** Header aliases that name the address column. */\nconst EMAIL_COLUMNS = [\n 'email',\n 'emailaddress',\n 'email address',\n 'e-mail',\n 'e-mail address',\n 'mail',\n 'contact email',\n 'primary email',\n]\n\n/** Header aliases that name a display name. */\nconst NAME_COLUMNS = [\n 'name',\n 'full name',\n 'fullname',\n 'display name',\n 'contact name',\n 'first name',\n 'firstname',\n 'given name',\n]\n\n/** Header aliases carrying the merchant's declared opt-in source. */\nconst OPT_IN_SOURCE_COLUMNS = [\n 'opt-in source',\n 'optin source',\n 'opt in source',\n 'consent source',\n 'signup source',\n 'subscription source',\n 'source',\n]\n\n/** Header aliases carrying the merchant's declared opt-in date. */\nconst OPT_IN_DATE_COLUMNS = [\n 'opt-in date',\n 'optin date',\n 'opt in date',\n 'consent date',\n 'signup date',\n 'subscribed at',\n 'date subscribed',\n 'confirmed at',\n]\n\n/** What the merchant's file says about one person. */\nexport interface ListImportRow {\n /** The source line, verbatim, so a bad one can be pointed at on screen. */\n input: string\n /** The normalized address, or `null` when the line does not carry one. */\n email: string | null\n /** A display name from the file, or `''`. */\n name: string\n /**\n * Where the file says this person opted in, or `''`.\n *\n * Evidence for the operator's assertion and never a basis of its own — see\n * the module note.\n */\n declaredSource: string\n /** When the file says they opted in, or `''`, kept as written. */\n declaredAt: string\n}\n\n/** What one file turned out to be. */\nexport interface ParsedListImport {\n /** The header row's column names, or `[]` for a bare list of addresses. */\n columns: string[]\n /**\n * One row per usable, first-seen address, plus one per unusable line.\n *\n * A repeat of an address already seen is collapsed and counted in\n * {@link duplicates} rather than carried: the membership is keyed by the\n * normalized address, so two lines would be one row and reporting them as\n * two would tell the operator they are attesting for more people than they\n * are. That is exactly the rule `resolveAddresses` applies to a paste.\n */\n rows: ListImportRow[]\n /** Rows carrying a usable address. */\n usable: number\n /** Lines that are not addresses. Reported, never dropped. */\n unusable: number\n /** Repeat appearances of an address already counted. */\n duplicates: number\n /** True when the file names more than {@link LIST_IMPORT_MAX_ADDRESSES}. */\n overCeiling: boolean\n}\n\n/** What the mechanical screening found. Signals, not verdicts. */\nexport interface ListImportScreening {\n /** Addresses at a shared or unattended mailbox. */\n roleAccounts: string[]\n /** Column names in the file that read as purchase or append tells. */\n purchaseTellColumns: string[]\n /** True when the file declares an opt-in source or date per row. */\n declaresBasis: boolean\n}\n\n/** Normalizes a header cell for alias matching. */\nfunction headerKey(value: string): string {\n return String(value ?? '')\n .trim()\n .toLowerCase()\n .replace(/[_]+/g, ' ')\n .replace(/\\s+/g, ' ')\n}\n\n/** The index of the first column matching one of `aliases`, or -1. */\nfunction columnIndex(columns: string[], aliases: readonly string[]): number {\n const keys = columns.map(headerKey)\n for (const alias of aliases) {\n const at = keys.indexOf(alias)\n if (at !== -1) return at\n }\n return -1\n}\n\n/**\n * Whether a parsed first row is a HEADER rather than a person.\n *\n * A header is a row with no address in it. That test rather than an alias\n * match, because the common shape of a merchant's export is a header whose\n * address column is called something we have never seen — and treating that\n * file as headerless would import the word \"Email Address\" as a line that is\n * not an address, and shift every subsequent column read by one.\n *\n * A one-column file of bare addresses therefore has no header, correctly:\n * every row including the first carries an address.\n */\nfunction looksLikeHeader(cells: readonly string[]): boolean {\n return !cells.some((cell) => normalizeContactEmail(cell))\n}\n\n/**\n * Reads an uploaded or pasted file into rows.\n *\n * Handles the two shapes a merchant's list actually arrives in: a CSV with a\n * header, and a bare column of addresses with no header at all. Both go\n * through one parser — `parseCsv` — because a newline-separated list of\n * addresses IS a single-column CSV, and a second code path for it would be a\n * second place for the quoting rules to differ.\n *\n * ## Every line comes back, including the bad ones\n *\n * A file 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. This is the rule `resolveAddresses`\n * already applies to a paste, restated for a file.\n */\nexport function parseListImport(text: string): ParsedListImport {\n const table = parseCsv(String(text ?? ''))\n if (!table.length) {\n return {\n columns: [],\n rows: [],\n usable: 0,\n unusable: 0,\n duplicates: 0,\n overCeiling: false,\n }\n }\n const headed = table.length > 1 && looksLikeHeader(table[0])\n const columns = headed ? table[0].map((cell) => String(cell ?? '').trim()) : []\n const body = headed ? table.slice(1) : table\n\n const emailAt = headed ? columnIndex(columns, EMAIL_COLUMNS) : 0\n const nameAt = headed ? columnIndex(columns, NAME_COLUMNS) : -1\n const sourceAt = headed ? columnIndex(columns, OPT_IN_SOURCE_COLUMNS) : -1\n const dateAt = headed ? columnIndex(columns, OPT_IN_DATE_COLUMNS) : -1\n\n const seen = new Set<string>()\n const rows: ListImportRow[] = []\n let usable = 0\n let unusable = 0\n let duplicates = 0\n let overCeiling = false\n\n for (const cells of body) {\n /*\n * The named column when the header named one, and otherwise the first\n * cell in the row that IS an address.\n *\n * The fallback is what makes an unrecognized header survivable: an\n * export whose address column is called `Primary contact e-mail (work)`\n * matches no alias, and refusing the whole file over a column name would\n * be refusing the customer's migration over our vocabulary.\n */\n const raw =\n emailAt >= 0 && emailAt < cells.length\n ? String(cells[emailAt] ?? '')\n : (cells.find((cell) => normalizeContactEmail(cell)) ?? '')\n const email = normalizeContactEmail(raw)\n const input = String(raw ?? '').trim() || cells.join(', ').trim()\n if (!email) {\n unusable += 1\n rows.push({\n input,\n email: null,\n name: '',\n declaredSource: '',\n declaredAt: '',\n })\n continue\n }\n if (seen.has(email)) {\n duplicates += 1\n continue\n }\n if (seen.size >= LIST_IMPORT_MAX_ADDRESSES) {\n overCeiling = true\n break\n }\n seen.add(email)\n usable += 1\n rows.push({\n input,\n email,\n name: nameAt >= 0 ? String(cells[nameAt] ?? '').trim() : '',\n declaredSource: sourceAt >= 0 ? String(cells[sourceAt] ?? '').trim() : '',\n declaredAt: dateAt >= 0 ? String(cells[dateAt] ?? '').trim() : '',\n })\n }\n\n return { columns, rows, usable, unusable, duplicates, overCeiling }\n}\n\n/**\n * The mechanical checks, over a parsed file.\n *\n * Cheap by construction: two substring passes and one set membership per\n * address. It is deliberately not a deliverability service — Omnisend's own\n * caveat on the one that exists is that \"list cleaning confirms\n * deliverability only and does not establish recipient consent\", and consent\n * is the only question this import actually turns on.\n */\nexport function screenListImport(\n parsed: Pick<ParsedListImport, 'columns' | 'rows'>,\n): ListImportScreening {\n const roleAccounts: string[] = []\n for (const row of parsed.rows) {\n if (!row.email) continue\n const local = row.email.slice(0, row.email.indexOf('@'))\n if (ROLE_ACCOUNT_LOCAL_PARTS.includes(local)) roleAccounts.push(row.email)\n }\n const purchaseTellColumns = parsed.columns.filter((column) => {\n const key = headerKey(column)\n return PURCHASE_TELL_COLUMNS.some((tell) => key.includes(tell))\n })\n return {\n roleAccounts,\n purchaseTellColumns,\n declaresBasis: parsed.rows.some(\n (row) => !!row.declaredSource || !!row.declaredAt,\n ),\n }\n}\n\n/**\n * The sentence recorded as the REASON behind an imported person's basis.\n *\n * One line, built from what the file actually declared, so a compliance\n * question about one address gets an answer about that address rather than\n * \"somebody ticked a box on an import once\". Empty declarations produce the\n * plain sentence rather than a sentence with holes in it.\n */\nexport function importedBasisReason(row: {\n declaredSource: string\n declaredAt: string\n}): string {\n const parts: string[] = []\n if (row.declaredSource) parts.push(`declared source: ${row.declaredSource}`)\n if (row.declaredAt) parts.push(`declared opt-in: ${row.declaredAt}`)\n return parts.length\n ? `Imported from a file, attested by the operator (${parts.join('; ')}).`\n : 'Imported from a file, attested by the operator.'\n}\n"],"names":["normalizeContactEmail","parseCsv","LIST_IMPORT_MAX_ADDRESSES","ROLE_ACCOUNT_LOCAL_PARTS","PURCHASE_TELL_COLUMNS","EMAIL_COLUMNS","NAME_COLUMNS","OPT_IN_SOURCE_COLUMNS","OPT_IN_DATE_COLUMNS","headerKey","value","String","trim","toLowerCase","replace","columnIndex","columns","aliases","keys","map","alias","at","indexOf","looksLikeHeader","cells","some","cell","parseListImport","text","table","length","rows","usable","unusable","duplicates","overCeiling","headed","body","slice","emailAt","nameAt","sourceAt","dateAt","seen","Set","raw","find","email","input","join","push","name","declaredSource","declaredAt","has","size","add","screenListImport","parsed","roleAccounts","row","local","includes","purchaseTellColumns","filter","column","key","tell","declaresBasis","importedBasisReason","parts"],"mappings":"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"}
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * Called from the console registrar. The owner is named rather than read off
19
+ * the loader's marker, so the routes register the same way under a spec that
20
+ * calls the registrar directly.
21
+ */
22
+ export declare function registerEmailRecordRoutes(): void;
@@ -0,0 +1,75 @@
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
+ */ import { buildRoute, Route } from "@aglyn/aglyn/app-utils/console-routes";
17
+ import { registerPluginRecordRoute } from "@aglyn/aglyn/plugin-manager/plugin-record-routes";
18
+ import { BUNDLE_ID } from "../constants/bundle-common.js";
19
+ /**
20
+ * Where the Emails page's records are read, published for every other
21
+ * surface (AGL-3080).
22
+ *
23
+ * A contact's timeline links a campaign email to its report, and a composer
24
+ * refused for an unverified sender links to where a sender is set up. Both
25
+ * pages are this plugin's — the Emails page under a site, and the same page
26
+ * over every site at the organization level — so a surface elsewhere asks
27
+ * the record-route registry for the kind rather than spelling this plugin's
28
+ * nav slug, and gets `null` (text instead of a link) where it is not loaded.
29
+ *
30
+ * - `emailMessage`: one send, at `messages/{id}`; the list is the Messages
31
+ * section. What draws a message's report is a zone the plugin that owns
32
+ * campaigns fills, but the address is this page's.
33
+ * - `sendingIdentity`: who the mail comes from, set up in the Sending
34
+ * section. An identity has no page of its own, so its record answers the
35
+ * section it is kept in.
36
+ * - `emailTemplate`: a site's email design, at `templates/{id}` (the
37
+ * screen's id). A design is a site's, so the organization level has no
38
+ * address for one: a caller at the org names the site the send used.
39
+ */ /** The nav slug the shell resolves this plugin's page by. */ const EMAILS_SLUG = 'emails';
40
+ function hub({ orgSlug, host }) {
41
+ return host ? buildRoute(Route.HOST_PLUGIN, {
42
+ orgSlug,
43
+ host,
44
+ pluginSlug: EMAILS_SLUG
45
+ }) : buildRoute(Route.ORG_PLUGIN, {
46
+ orgSlug,
47
+ pluginSlug: EMAILS_SLUG
48
+ });
49
+ }
50
+ /**
51
+ * Called from the console registrar. The owner is named rather than read off
52
+ * the loader's marker, so the routes register the same way under a spec that
53
+ * calls the registrar directly.
54
+ */ export function registerEmailRecordRoutes() {
55
+ registerPluginRecordRoute('emailMessage', {
56
+ list: (context)=>`${hub(context)}/messages`,
57
+ record: (context, id)=>`${hub(context)}/messages/${encodeURIComponent(id)}`
58
+ }, {
59
+ pluginId: BUNDLE_ID
60
+ });
61
+ registerPluginRecordRoute('emailTemplate', {
62
+ list: (context)=>context.host ? `${hub(context)}/templates` : null,
63
+ record: (context, id)=>context.host ? `${hub(context)}/templates/${encodeURIComponent(id)}` : null
64
+ }, {
65
+ pluginId: BUNDLE_ID
66
+ });
67
+ registerPluginRecordRoute('sendingIdentity', {
68
+ list: (context)=>`${hub(context)}/sending`,
69
+ record: (context)=>`${hub(context)}/sending`
70
+ }, {
71
+ pluginId: BUNDLE_ID
72
+ });
73
+ }
74
+
75
+ //# sourceMappingURL=email-record-routes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/model/email-record-routes.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { buildRoute, Route } from '@aglyn/aglyn/app-utils/console-routes'\nimport {\n registerPluginRecordRoute,\n type PluginRecordRouteContext,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport { BUNDLE_ID } from '../constants/bundle-common'\n\n/**\n * Where the Emails page's records are read, published for every other\n * surface (AGL-3080).\n *\n * A contact's timeline links a campaign email to its report, and a composer\n * refused for an unverified sender links to where a sender is set up. Both\n * pages are this plugin's — the Emails page under a site, and the same page\n * over every site at the organization level — so a surface elsewhere asks\n * the record-route registry for the kind rather than spelling this plugin's\n * nav slug, and gets `null` (text instead of a link) where it is not loaded.\n *\n * - `emailMessage`: one send, at `messages/{id}`; the list is the Messages\n * section. What draws a message's report is a zone the plugin that owns\n * campaigns fills, but the address is this page's.\n * - `sendingIdentity`: who the mail comes from, set up in the Sending\n * section. An identity has no page of its own, so its record answers the\n * section it is kept in.\n * - `emailTemplate`: a site's email design, at `templates/{id}` (the\n * screen's id). A design is a site's, so the organization level has no\n * address for one: a caller at the org names the site the send used.\n */\n\n/** The nav slug the shell resolves this plugin's page by. */\nconst EMAILS_SLUG = 'emails'\n\nfunction hub({ orgSlug, host }: PluginRecordRouteContext): string {\n return host\n ? buildRoute(Route.HOST_PLUGIN, { orgSlug, host, pluginSlug: EMAILS_SLUG })\n : buildRoute(Route.ORG_PLUGIN, { orgSlug, pluginSlug: EMAILS_SLUG })\n}\n\n/**\n * Called from the console registrar. The owner is named rather than read off\n * the loader's marker, so the routes register the same way under a spec that\n * calls the registrar directly.\n */\nexport function registerEmailRecordRoutes(): void {\n registerPluginRecordRoute(\n 'emailMessage',\n {\n list: (context) => `${hub(context)}/messages`,\n record: (context, id) => `${hub(context)}/messages/${encodeURIComponent(id)}`,\n },\n { pluginId: BUNDLE_ID },\n )\n registerPluginRecordRoute(\n 'emailTemplate',\n {\n list: (context) => (context.host ? `${hub(context)}/templates` : null),\n record: (context, id) =>\n context.host ? `${hub(context)}/templates/${encodeURIComponent(id)}` : null,\n },\n { pluginId: BUNDLE_ID },\n )\n registerPluginRecordRoute(\n 'sendingIdentity',\n {\n list: (context) => `${hub(context)}/sending`,\n record: (context) => `${hub(context)}/sending`,\n },\n { pluginId: BUNDLE_ID },\n )\n}\n"],"names":["buildRoute","Route","registerPluginRecordRoute","BUNDLE_ID","EMAILS_SLUG","hub","orgSlug","host","HOST_PLUGIN","pluginSlug","ORG_PLUGIN","registerEmailRecordRoutes","list","context","record","id","encodeURIComponent","pluginId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,EAAEC,KAAK,QAAQ,wCAAuC;AACzE,SACEC,yBAAyB,QAEpB,mDAAkD;AACzD,SAASC,SAAS,QAAQ,gCAA4B;AAEtD;;;;;;;;;;;;;;;;;;;;CAoBC,GAED,2DAA2D,GAC3D,MAAMC,cAAc;AAEpB,SAASC,IAAI,EAAEC,OAAO,EAAEC,IAAI,EAA4B;IACtD,OAAOA,OACHP,WAAWC,MAAMO,WAAW,EAAE;QAAEF;QAASC;QAAME,YAAYL;IAAY,KACvEJ,WAAWC,MAAMS,UAAU,EAAE;QAAEJ;QAASG,YAAYL;IAAY;AACtE;AAEA;;;;CAIC,GACD,OAAO,SAASO;IACdT,0BACE,gBACA;QACEU,MAAM,CAACC,UAAY,GAAGR,IAAIQ,SAAS,SAAS,CAAC;QAC7CC,QAAQ,CAACD,SAASE,KAAO,GAAGV,IAAIQ,SAAS,UAAU,EAAEG,mBAAmBD,KAAK;IAC/E,GACA;QAAEE,UAAUd;IAAU;IAExBD,0BACE,iBACA;QACEU,MAAM,CAACC,UAAaA,QAAQN,IAAI,GAAG,GAAGF,IAAIQ,SAAS,UAAU,CAAC,GAAG;QACjEC,QAAQ,CAACD,SAASE,KAChBF,QAAQN,IAAI,GAAG,GAAGF,IAAIQ,SAAS,WAAW,EAAEG,mBAAmBD,KAAK,GAAG;IAC3E,GACA;QAAEE,UAAUd;IAAU;IAExBD,0BACE,mBACA;QACEU,MAAM,CAACC,UAAY,GAAGR,IAAIQ,SAAS,QAAQ,CAAC;QAC5CC,QAAQ,CAACD,UAAY,GAAGR,IAAIQ,SAAS,QAAQ,CAAC;IAChD,GACA;QAAEI,UAAUd;IAAU;AAE1B"}
@@ -58,7 +58,7 @@
58
58
  * computes standing it correctly declines to claim one. Do not go looking for
59
59
  * the writer — there is none yet.
60
60
  */
61
- import { type ResolvedProvenance } from '@aglyn/aglyn/app-utils/marketplace-provenance';
61
+ import { type ResolvedProvenance } from '@aglyn/aglyn/app-utils/artifact-provenance';
62
62
  /** Where a template's content comes from. */
63
63
  export type TemplateOrigin = 'local' | 'installed';
64
64
  /**
@@ -62,7 +62,7 @@
62
62
  * model is imported by `email-events.ts`, which runs in the plugin API route's
63
63
  * SERVER graph. Through the barrel that is a client-only module pulled into a
64
64
  * server bundle, which `app-router-graph.spec.ts` refuses.
65
- */ import { resolveProvenance } from "@aglyn/aglyn/app-utils/marketplace-provenance";
65
+ */ import { resolveProvenance } from "@aglyn/aglyn/app-utils/artifact-provenance";
66
66
  /** The stamped standing values this reads, mapped to the states above. */ const STAMPED_STANDING = {
67
67
  offered: 'offered',
68
68
  listed: 'offered',
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/model/template-provenance.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 * WHOSE TEMPLATE IS THIS, AND IS IT STILL ANY GOOD.\n *\n * An email template is a besigner document, and the org reading it is not\n * necessarily the org that wrote it: a template can be installed from a\n * marketplace listing, in which case its content is versioned by somebody\n * else and can be updated, rejected or withdrawn by a party outside this\n * workspace. A surface that assumes every template is locally authored and\n * permanently valid reads an installed one as if the org owned it.\n *\n * ## Provenance is not re-derived here\n *\n * {@link resolveProvenance} in core is the ONE reader of where an installed\n * artifact came from, and it already tolerates every shape the install routes\n * have written. This module asks it and adds nothing to it; a second reading\n * of `installedFrom` would be a second answer to the same question.\n *\n * ## Standing is a fact about the LISTING, not about the install\n *\n * Whether a publisher has withdrawn a template, or had it rejected on\n * re-review, is recorded on the marketplace listing. This page does not read\n * listings — that would be a marketplace read on every template opened, for a\n * fact that is usually \"fine\" — so an installed template's standing is\n * `unread` unless the install path stamped an answer onto the document.\n *\n * `unread` is deliberately not `ok`. The difference is the whole reason this\n * type has four states rather than a boolean: \"we checked and it is fine\" and\n * \"we did not check\" lead a reader to the same action only when they are\n * lucky, and a template that was killed upstream is exactly the unlucky case.\n *\n * ## Today every installed template reads `unread`, and that is not a bug\n *\n * Nothing writes `installedFrom.standing`. The install stamps\n * `installedFrom.assurance` — whether that VERSION carried a review verdict —\n * which is a different fact and is knowable at install time. Standing is not:\n * a publisher withdraws or is killed AFTER somebody installed, so recording it\n * needs a later read or a fan-out write that does not exist.\n *\n * What actually protects a tenant is the SEND: `loadEmailTemplate` consults\n * the marketplace revocation record and refuses to mail from a killed\n * template. This badge is the softer, earlier warning, and until something\n * computes standing it correctly declines to claim one. Do not go looking for\n * the writer — there is none yet.\n */\n\n/*\n * The MODULE, not the barrel. `@aglyn/aglyn` re-exports the app-utils index,\n * which reaches `enabled-plugins-context` and therefore React — and this\n * model is imported by `email-events.ts`, which runs in the plugin API route's\n * SERVER graph. Through the barrel that is a client-only module pulled into a\n * server bundle, which `app-router-graph.spec.ts` refuses.\n */\nimport {\n resolveProvenance,\n type ResolvedProvenance,\n} from '@aglyn/aglyn/app-utils/marketplace-provenance'\n\n/** Where a template's content comes from. */\nexport type TemplateOrigin = 'local' | 'installed'\n\n/**\n * How this template stands with whoever publishes it.\n *\n * The three non-`local` values are read from what the install path stamped;\n * none of them is computed here, because computing any of them means reading\n * the listing.\n */\nexport type TemplateStanding =\n /** Authored in this org. There is no publisher to stand with. */\n | 'local'\n /** Installed, and the publisher's current standing has not been read. */\n | 'unread'\n /** Installed, and recorded as still offered by its publisher. */\n | 'offered'\n /** Installed, and recorded as withdrawn, killed or rejected upstream. */\n | 'withdrawn'\n\nexport interface TemplateProvenance {\n origin: TemplateOrigin\n standing: TemplateStanding\n /** The core resolver's answer, verbatim. Null for a local template. */\n installed: ResolvedProvenance | null\n /**\n * One line for the reader, or null when there is nothing worth saying.\n *\n * A local template gets `null`: \"you wrote this\" is the default assumption\n * and repeating it on every page is noise. Everything else says what is\n * known and, where it matters, what is not.\n */\n note: string | null\n /**\n * This template should not be sent without checking with its publisher\n * first. Only ever true for `withdrawn`, and the surface renders it as a\n * warning rather than hiding the template: a message already scheduled\n * against it is a fact the reader has to be able to see.\n */\n warn: boolean\n}\n\n/** The stamped standing values this reads, mapped to the states above. */\nconst STAMPED_STANDING: Record<string, TemplateStanding> = {\n offered: 'offered',\n listed: 'offered',\n verified: 'offered',\n withdrawn: 'withdrawn',\n rejected: 'withdrawn',\n killed: 'withdrawn',\n revoked: 'withdrawn',\n}\n\n/**\n * Reads a template screen document's provenance and standing.\n *\n * Takes the document rather than ids so it costs nothing: everything here is\n * already on the screen document the detail page reads to render anything at\n * all. A template with no install stamp is local, which is the correct\n * reading — every template predating the marketplace was authored here.\n */\nexport function templateProvenance(\n screen:\n | (Record<string, unknown> & {\n /*\n * The install stamp, as a bag of fields with ONE of them named.\n *\n * `Record<string, unknown>` on the inner shape as well as the outer,\n * and it is load-bearing rather than decorative: a type whose every\n * property is optional is a WEAK type, and TypeScript refuses an\n * argument that shares none of its properties. `installedFrom` as\n * written by the install path carries `listingId`, `version`,\n * `sha256`, `artifactType` and `publisherOrgId` and — since nothing\n * writes it yet — never `standing`, so a real stamp had no property\n * in common with `{ standing?: unknown }` and was rejected at the\n * call site while being exactly what this function reads.\n */\n installedFrom?: (Record<string, unknown> & { standing?: unknown }) | null\n })\n | null\n | undefined,\n): TemplateProvenance {\n const installed = resolveProvenance(screen as any, 'emailTemplate')\n if (installed.state === 'unknown' || !installed.listingId) {\n return {\n origin: 'local',\n standing: 'local',\n installed: null,\n note: null,\n warn: false,\n }\n }\n\n const stamped = String(screen?.installedFrom?.standing ?? '')\n const standing: TemplateStanding = STAMPED_STANDING[stamped] ?? 'unread'\n const version = installed.version ? ` version ${installed.version}` : ''\n return {\n origin: 'installed',\n standing,\n installed,\n note:\n standing === 'withdrawn'\n ? `Installed from a marketplace listing${version}, which its ` +\n 'publisher no longer offers. It still sends exactly as it is here, ' +\n 'but it will not receive updates and cannot be reinstalled.'\n : standing === 'offered'\n ? `Installed from a marketplace listing${version}. Its content is ` +\n 'versioned by its publisher, so an update can change what this ' +\n 'template looks like.'\n : `Installed from a marketplace listing${version}. Whether its ` +\n 'publisher still offers it has not been checked here — this page ' +\n 'reads the template, not the listing.',\n warn: standing === 'withdrawn',\n }\n}\n"],"names":["resolveProvenance","STAMPED_STANDING","offered","listed","verified","withdrawn","rejected","killed","revoked","templateProvenance","screen","installed","state","listingId","origin","standing","note","warn","stamped","String","installedFrom","version"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2CC,GAED;;;;;;CAMC,GACD,SACEA,iBAAiB,QAEZ,gDAA+C;AA4CtD,wEAAwE,GACxE,MAAMC,mBAAqD;IACzDC,SAAS;IACTC,QAAQ;IACRC,UAAU;IACVC,WAAW;IACXC,UAAU;IACVC,QAAQ;IACRC,SAAS;AACX;AAEA;;;;;;;CAOC,GACD,OAAO,SAASC,mBACdC,MAkBa;cAcsBT;QADZS;IAXvB,MAAMC,YAAYX,kBAAkBU,QAAe;IACnD,IAAIC,UAAUC,KAAK,KAAK,aAAa,CAACD,UAAUE,SAAS,EAAE;QACzD,OAAO;YACLC,QAAQ;YACRC,UAAU;YACVJ,WAAW;YACXK,MAAM;YACNC,MAAM;QACR;IACF;IAEA,MAAMC,UAAUC,eAAOT,2BAAAA,wBAAAA,OAAQU,aAAa,qBAArBV,sBAAuBK,QAAQ,mBAAI;IAC1D,MAAMA,YAA6Bd,4BAAAA,gBAAgB,CAACiB,QAAQ,YAAzBjB,4BAA6B;IAChE,MAAMoB,UAAUV,UAAUU,OAAO,GAAG,CAAC,SAAS,EAAEV,UAAUU,OAAO,EAAE,GAAG;IACtE,OAAO;QACLP,QAAQ;QACRC;QACAJ;QACAK,MACED,aAAa,cACT,CAAC,oCAAoC,EAAEM,QAAQ,YAAY,CAAC,GAC5D,uEACA,+DACAN,aAAa,YACX,CAAC,oCAAoC,EAAEM,QAAQ,iBAAiB,CAAC,GACjE,mEACA,yBACA,CAAC,oCAAoC,EAAEA,QAAQ,cAAc,CAAC,GAC9D,qEACA;QACRJ,MAAMF,aAAa;IACrB;AACF"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/model/template-provenance.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 * WHOSE TEMPLATE IS THIS, AND IS IT STILL ANY GOOD.\n *\n * An email template is a besigner document, and the org reading it is not\n * necessarily the org that wrote it: a template can be installed from a\n * marketplace listing, in which case its content is versioned by somebody\n * else and can be updated, rejected or withdrawn by a party outside this\n * workspace. A surface that assumes every template is locally authored and\n * permanently valid reads an installed one as if the org owned it.\n *\n * ## Provenance is not re-derived here\n *\n * {@link resolveProvenance} in core is the ONE reader of where an installed\n * artifact came from, and it already tolerates every shape the install routes\n * have written. This module asks it and adds nothing to it; a second reading\n * of `installedFrom` would be a second answer to the same question.\n *\n * ## Standing is a fact about the LISTING, not about the install\n *\n * Whether a publisher has withdrawn a template, or had it rejected on\n * re-review, is recorded on the marketplace listing. This page does not read\n * listings — that would be a marketplace read on every template opened, for a\n * fact that is usually \"fine\" — so an installed template's standing is\n * `unread` unless the install path stamped an answer onto the document.\n *\n * `unread` is deliberately not `ok`. The difference is the whole reason this\n * type has four states rather than a boolean: \"we checked and it is fine\" and\n * \"we did not check\" lead a reader to the same action only when they are\n * lucky, and a template that was killed upstream is exactly the unlucky case.\n *\n * ## Today every installed template reads `unread`, and that is not a bug\n *\n * Nothing writes `installedFrom.standing`. The install stamps\n * `installedFrom.assurance` — whether that VERSION carried a review verdict —\n * which is a different fact and is knowable at install time. Standing is not:\n * a publisher withdraws or is killed AFTER somebody installed, so recording it\n * needs a later read or a fan-out write that does not exist.\n *\n * What actually protects a tenant is the SEND: `loadEmailTemplate` consults\n * the marketplace revocation record and refuses to mail from a killed\n * template. This badge is the softer, earlier warning, and until something\n * computes standing it correctly declines to claim one. Do not go looking for\n * the writer — there is none yet.\n */\n\n/*\n * The MODULE, not the barrel. `@aglyn/aglyn` re-exports the app-utils index,\n * which reaches `enabled-plugins-context` and therefore React — and this\n * model is imported by `email-events.ts`, which runs in the plugin API route's\n * SERVER graph. Through the barrel that is a client-only module pulled into a\n * server bundle, which `app-router-graph.spec.ts` refuses.\n */\nimport {\n resolveProvenance,\n type ResolvedProvenance,\n} from '@aglyn/aglyn/app-utils/artifact-provenance'\n\n/** Where a template's content comes from. */\nexport type TemplateOrigin = 'local' | 'installed'\n\n/**\n * How this template stands with whoever publishes it.\n *\n * The three non-`local` values are read from what the install path stamped;\n * none of them is computed here, because computing any of them means reading\n * the listing.\n */\nexport type TemplateStanding =\n /** Authored in this org. There is no publisher to stand with. */\n | 'local'\n /** Installed, and the publisher's current standing has not been read. */\n | 'unread'\n /** Installed, and recorded as still offered by its publisher. */\n | 'offered'\n /** Installed, and recorded as withdrawn, killed or rejected upstream. */\n | 'withdrawn'\n\nexport interface TemplateProvenance {\n origin: TemplateOrigin\n standing: TemplateStanding\n /** The core resolver's answer, verbatim. Null for a local template. */\n installed: ResolvedProvenance | null\n /**\n * One line for the reader, or null when there is nothing worth saying.\n *\n * A local template gets `null`: \"you wrote this\" is the default assumption\n * and repeating it on every page is noise. Everything else says what is\n * known and, where it matters, what is not.\n */\n note: string | null\n /**\n * This template should not be sent without checking with its publisher\n * first. Only ever true for `withdrawn`, and the surface renders it as a\n * warning rather than hiding the template: a message already scheduled\n * against it is a fact the reader has to be able to see.\n */\n warn: boolean\n}\n\n/** The stamped standing values this reads, mapped to the states above. */\nconst STAMPED_STANDING: Record<string, TemplateStanding> = {\n offered: 'offered',\n listed: 'offered',\n verified: 'offered',\n withdrawn: 'withdrawn',\n rejected: 'withdrawn',\n killed: 'withdrawn',\n revoked: 'withdrawn',\n}\n\n/**\n * Reads a template screen document's provenance and standing.\n *\n * Takes the document rather than ids so it costs nothing: everything here is\n * already on the screen document the detail page reads to render anything at\n * all. A template with no install stamp is local, which is the correct\n * reading — every template predating the marketplace was authored here.\n */\nexport function templateProvenance(\n screen:\n | (Record<string, unknown> & {\n /*\n * The install stamp, as a bag of fields with ONE of them named.\n *\n * `Record<string, unknown>` on the inner shape as well as the outer,\n * and it is load-bearing rather than decorative: a type whose every\n * property is optional is a WEAK type, and TypeScript refuses an\n * argument that shares none of its properties. `installedFrom` as\n * written by the install path carries `listingId`, `version`,\n * `sha256`, `artifactType` and `publisherOrgId` and — since nothing\n * writes it yet — never `standing`, so a real stamp had no property\n * in common with `{ standing?: unknown }` and was rejected at the\n * call site while being exactly what this function reads.\n */\n installedFrom?: (Record<string, unknown> & { standing?: unknown }) | null\n })\n | null\n | undefined,\n): TemplateProvenance {\n const installed = resolveProvenance(screen as any, 'emailTemplate')\n if (installed.state === 'unknown' || !installed.listingId) {\n return {\n origin: 'local',\n standing: 'local',\n installed: null,\n note: null,\n warn: false,\n }\n }\n\n const stamped = String(screen?.installedFrom?.standing ?? '')\n const standing: TemplateStanding = STAMPED_STANDING[stamped] ?? 'unread'\n const version = installed.version ? ` version ${installed.version}` : ''\n return {\n origin: 'installed',\n standing,\n installed,\n note:\n standing === 'withdrawn'\n ? `Installed from a marketplace listing${version}, which its ` +\n 'publisher no longer offers. It still sends exactly as it is here, ' +\n 'but it will not receive updates and cannot be reinstalled.'\n : standing === 'offered'\n ? `Installed from a marketplace listing${version}. Its content is ` +\n 'versioned by its publisher, so an update can change what this ' +\n 'template looks like.'\n : `Installed from a marketplace listing${version}. Whether its ` +\n 'publisher still offers it has not been checked here — this page ' +\n 'reads the template, not the listing.',\n warn: standing === 'withdrawn',\n }\n}\n"],"names":["resolveProvenance","STAMPED_STANDING","offered","listed","verified","withdrawn","rejected","killed","revoked","templateProvenance","screen","installed","state","listingId","origin","standing","note","warn","stamped","String","installedFrom","version"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2CC,GAED;;;;;;CAMC,GACD,SACEA,iBAAiB,QAEZ,6CAA4C;AA4CnD,wEAAwE,GACxE,MAAMC,mBAAqD;IACzDC,SAAS;IACTC,QAAQ;IACRC,UAAU;IACVC,WAAW;IACXC,UAAU;IACVC,QAAQ;IACRC,SAAS;AACX;AAEA;;;;;;;CAOC,GACD,OAAO,SAASC,mBACdC,MAkBa;cAcsBT;QADZS;IAXvB,MAAMC,YAAYX,kBAAkBU,QAAe;IACnD,IAAIC,UAAUC,KAAK,KAAK,aAAa,CAACD,UAAUE,SAAS,EAAE;QACzD,OAAO;YACLC,QAAQ;YACRC,UAAU;YACVJ,WAAW;YACXK,MAAM;YACNC,MAAM;QACR;IACF;IAEA,MAAMC,UAAUC,eAAOT,2BAAAA,wBAAAA,OAAQU,aAAa,qBAArBV,sBAAuBK,QAAQ,mBAAI;IAC1D,MAAMA,YAA6Bd,4BAAAA,gBAAgB,CAACiB,QAAQ,YAAzBjB,4BAA6B;IAChE,MAAMoB,UAAUV,UAAUU,OAAO,GAAG,CAAC,SAAS,EAAEV,UAAUU,OAAO,EAAE,GAAG;IACtE,OAAO;QACLP,QAAQ;QACRC;QACAJ;QACAK,MACED,aAAa,cACT,CAAC,oCAAoC,EAAEM,QAAQ,YAAY,CAAC,GAC5D,uEACA,+DACAN,aAAa,YACX,CAAC,oCAAoC,EAAEM,QAAQ,iBAAiB,CAAC,GACjE,mEACA,yBACA,CAAC,oCAAoC,EAAEA,QAAQ,cAAc,CAAC,GAC9D,qEACA;QACRJ,MAAMF,aAAa;IACrB;AACF"}
package/src/lib/plugin.js CHANGED
@@ -20,6 +20,7 @@ import { lazy } from "react";
20
20
  import { EMAIL_MESSAGES_ZONE, EMAIL_TEMPLATE_RECIPIENTS_ZONE } from "./components/email-zones.js";
21
21
  import { EMAILS_CONSOLE_SECTIONS } from "./components/emails-console-sections.js";
22
22
  import { BUNDLE_ID } from "./constants/bundle-common.js";
23
+ import { registerEmailRecordRoutes } from "./model/email-record-routes.js";
23
24
  /** Code-split: the Emails console page only loads when opened. */ const EmailsConsolePage = lazy(()=>import("./components/emails-console-page.js"));
24
25
  /*
25
26
  * What this plugin draws inside a campaign's own pages, each loaded only where
@@ -38,6 +39,9 @@ const EmailDesignPreview = lazy(()=>import("./components/email-design-preview.js
38
39
  * identities and the suppression list) — with no edit to the console's own
39
40
  * nav or page files.
40
41
  */ export function registerEmailConsole() {
42
+ // Where a message and a sending identity are read (AGL-3080), for the
43
+ // surfaces that link to them without knowing this page's address.
44
+ registerEmailRecordRoutes();
41
45
  /*
42
46
  * The two places this plugin's pages hand over to whichever plugin owns
43
47
  * campaigns. A message is one send of a campaign and every action on it is
@@ -58,7 +62,7 @@ const EmailDesignPreview = lazy(()=>import("./components/email-design-preview.js
58
62
  zone: EMAIL_TEMPLATE_RECIPIENTS_ZONE,
59
63
  label: 'Who received a template’s emails',
60
64
  surface: 'console',
61
- description: 'On one template’s page, under its report. A widget here lists the recipients of every send built from that template; it is handed the site and the template’s screen id.'
65
+ description: 'On one template’s page, under its report. A widget here lists the recipients of every send built from that template; it is handed the site and the template’s id.'
62
66
  }, {
63
67
  pluginId: BUNDLE_ID
64
68
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/plugin.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport { registerPluginZone } from '@aglyn/aglyn/plugin-manager/plugin-zones'\nimport { mdiEmailOutline } from '@aglyn/shared-data-mdi'\nimport { lazy } from 'react'\nimport {\n EMAIL_MESSAGES_ZONE,\n EMAIL_TEMPLATE_RECIPIENTS_ZONE,\n} from './components/email-zones'\nimport { EMAILS_CONSOLE_SECTIONS } from './components/emails-console-sections'\nimport { BUNDLE_ID } from './constants/bundle-common'\n\n/** Code-split: the Emails console page only loads when opened. */\nconst EmailsConsolePage = lazy(() => import('./components/emails-console-page'))\n\n/*\n * What this plugin draws inside a campaign's own pages, each loaded only where\n * the campaign owner's zone is on screen.\n */\nconst CampaignTopicSelect = lazy(\n () => import('./components/campaign-topic-select'),\n)\nconst CampaignTopicOptionsWidget = lazy(\n () => import('./components/campaign-topic-options-widget'),\n)\nconst CampaignSenderEditorWidget = lazy(\n () => import('./components/campaign-sender-editor-widget'),\n)\nconst CampaignDesignCreateWidget = lazy(\n () => import('./components/campaign-design-create-widget'),\n)\nconst EmailDesignPreview = lazy(\n () => import('./components/email-design-preview'),\n)\n\n/**\n * Console half (AGL-395): registers the Emails nav item + page in the\n * ConsoleExtension registry. Safe to call at console app load — the page is\n * lazy, so no besigner/canvas code loads. The shell renders the Emails nav\n * item and, via its generic plugin route, the page (the messages and their\n * composer, the templates, the audience lists, the topic catalog, the sending\n * identities and the suppression list) — with no edit to the console's own\n * nav or page files.\n */\nexport function registerEmailConsole(): void {\n /*\n * The two places this plugin's pages hand over to whichever plugin owns\n * campaigns. A message is one send of a campaign and every action on it is\n * that plugin's route, so the Messages section is a zone this page hosts\n * rather than pages this plugin imports; the same holds for the recipients\n * table under a template's report. Named, because a spec calls this\n * registrar without the loader.\n */\n registerPluginZone(\n {\n zone: EMAIL_MESSAGES_ZONE,\n label: 'The Emails page’s Messages section',\n surface: 'console',\n layout: 'bare',\n description:\n 'The whole body of `/emails/messages` and the routes under it, under a site and on the organization’s Emails page. A widget here is handed the site (or `null` and the org mount at the organization level), the Emails page’s base path and the segments under `messages`, and draws the list, one message’s report or its composer.',\n },\n { pluginId: BUNDLE_ID },\n )\n registerPluginZone(\n {\n zone: EMAIL_TEMPLATE_RECIPIENTS_ZONE,\n label: 'Who received a template’s emails',\n surface: 'console',\n description:\n 'On one template’s page, under its report. A widget here lists the recipients of every send built from that template; it is handed the site and the template’s screen id.',\n },\n { pluginId: BUNDLE_ID },\n )\n Aglyn.registerConsoleExtension({\n pluginId: BUNDLE_ID,\n displayName: 'Email',\n /*\n * WHO may open the email console, declared so the shell enforces it.\n *\n * The audiences section reads `orgs/{orgId}/lists/{listId}/members`, and\n * those members are enrolled CONTACTS — an address, a name, and the\n * consent basis recording why the person may be mailed. That is the same\n * org-shared people data the CRM holds, reached from a different page.\n *\n * The rules gate those reads on `isOrgWideMember()` ALONE, with no role\n * condition, so org-wide membership of any role is enough to list every\n * audience the organization has and everybody on it. An org VIEWER — the\n * role that exists to read and change nothing — therefore reads the whole\n * marketing audience today. `data.manage` is what closes that: it\n * defaults to owner, admin and editor, so the population it admits is\n * exactly the one `server-list-gate.ts` accepts a list write from, and\n * the viewer it excludes is the reader the rules never excluded.\n *\n * `data.manage` rather than a key of this plugin's own for the reason the\n * catalog gives for refusing a `marketing.manage`: campaigns are written\n * client-direct against rules that gate on the HOST role, so a new\n * org-level key would name an action with no org-level boundary under it.\n * `data.manage` is not in that position — it already governs the\n * org-shared data this surface exposes, and the list gate already reads\n * the roles it defaults to.\n *\n * A SITE COLLABORATOR holding it opens the page, and the answer is NOT\n * the one Contacts reached. There the listener is scoped and the rules\n * prove the same predicate per document; here the audiences read demands\n * `isOrgWideMember()`, which a collaborator is not, so the org-shared\n * half is already refused beneath the console and the half that remains —\n * this site's own messages, templates and sending identities — is theirs.\n * Refusing the surface outright would take that away to close nothing.\n */\n permission: 'data.manage',\n /*\n * The mail a campaign rides on is this plugin's — the topic catalog, the\n * sending identities, the design document and its renderer — so each is\n * drawn here, in a zone the campaign owner's composer and message page\n * host. Every one reports through a callback; none writes a campaign.\n */\n widgets: [\n {\n slot: 'campaignTopicSelect',\n widgetId: 'email-campaign-topic-select',\n title: 'Topic',\n Component: CampaignTopicSelect,\n },\n {\n slot: 'campaignTopicOptions',\n widgetId: 'email-campaign-topic-options',\n title: 'Topics',\n Component: CampaignTopicOptionsWidget,\n },\n {\n slot: 'campaignSenderEditor',\n widgetId: 'email-campaign-sender-editor',\n title: 'Add a sender',\n Component: CampaignSenderEditorWidget,\n },\n {\n slot: 'campaignDesignCreate',\n widgetId: 'email-campaign-design-create',\n title: 'Design this email',\n Component: CampaignDesignCreateWidget,\n },\n {\n slot: 'campaignDesignPreview',\n widgetId: 'email-campaign-design-preview',\n title: 'Preview',\n Component: EmailDesignPreview,\n },\n ],\n navItems: [\n {\n label: 'Emails',\n href: '/emails',\n icon: { path: mdiEmailOutline.path },\n // Sections as ROUTES (AGL-2501): `/emails/messages` and friends are\n // real URLs the shell resolves and gates, so the page mounts the one\n // being read instead of subscribing all six.\n sections: EMAILS_CONSOLE_SECTIONS,\n header: {\n title: 'Emails',\n icon: { path: mdiEmailOutline.path },\n docsTopic: 'emailCampaigns',\n },\n Component: EmailsConsolePage,\n },\n ],\n /*\n * The ORGANIZATION's Emails page, at `/[orgSlug]/emails`: the same six\n * sections over every site. The audiences and the topics are the org's\n * already; the messages, the templates, the sending identities and the\n * suppression lists are read site by site and link into the site that\n * holds them. The page is the same component — handed no site, it renders\n * the org sections.\n *\n * No `navTabId`, the same as the site tab: this plugin's release flag\n * gates the plugin itself, so one flag already holds both levels.\n */\n orgNavItems: [\n {\n label: 'Emails',\n href: '/emails',\n icon: { path: mdiEmailOutline.path },\n sections: EMAILS_CONSOLE_SECTIONS,\n header: {\n title: 'Emails',\n icon: { path: mdiEmailOutline.path },\n docsTopic: 'emailCampaigns',\n },\n Component: EmailsConsolePage,\n },\n ],\n })\n}\n\nexport * from './site'\n"],"names":["Aglyn","registerPluginZone","mdiEmailOutline","lazy","EMAIL_MESSAGES_ZONE","EMAIL_TEMPLATE_RECIPIENTS_ZONE","EMAILS_CONSOLE_SECTIONS","BUNDLE_ID","EmailsConsolePage","CampaignTopicSelect","CampaignTopicOptionsWidget","CampaignSenderEditorWidget","CampaignDesignCreateWidget","EmailDesignPreview","registerEmailConsole","zone","label","surface","layout","description","pluginId","registerConsoleExtension","displayName","permission","widgets","slot","widgetId","title","Component","navItems","href","icon","path","sections","header","docsTopic","orgNavItems"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,YAAYA,WAAW,eAAc;AACrC,SAASC,kBAAkB,QAAQ,2CAA0C;AAC7E,SAASC,eAAe,QAAQ,yBAAwB;AACxD,SAASC,IAAI,QAAQ,QAAO;AAC5B,SACEC,mBAAmB,EACnBC,8BAA8B,QACzB,8BAA0B;AACjC,SAASC,uBAAuB,QAAQ,0CAAsC;AAC9E,SAASC,SAAS,QAAQ,+BAA2B;AAErD,gEAAgE,GAChE,MAAMC,oBAAoBL,KAAK,IAAM,MAAM,CAAC;AAE5C;;;CAGC,GACD,MAAMM,sBAAsBN,KAC1B,IAAM,MAAM,CAAC;AAEf,MAAMO,6BAA6BP,KACjC,IAAM,MAAM,CAAC;AAEf,MAAMQ,6BAA6BR,KACjC,IAAM,MAAM,CAAC;AAEf,MAAMS,6BAA6BT,KACjC,IAAM,MAAM,CAAC;AAEf,MAAMU,qBAAqBV,KACzB,IAAM,MAAM,CAAC;AAGf;;;;;;;;CAQC,GACD,OAAO,SAASW;IACd;;;;;;;GAOC,GACDb,mBACE;QACEc,MAAMX;QACNY,OAAO;QACPC,SAAS;QACTC,QAAQ;QACRC,aACE;IACJ,GACA;QAAEC,UAAUb;IAAU;IAExBN,mBACE;QACEc,MAAMV;QACNW,OAAO;QACPC,SAAS;QACTE,aACE;IACJ,GACA;QAAEC,UAAUb;IAAU;IAExBP,MAAMqB,wBAAwB,CAAC;QAC7BD,UAAUb;QACVe,aAAa;QACb;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAgCC,GACDC,YAAY;QACZ;;;;;KAKC,GACDC,SAAS;YACP;gBACEC,MAAM;gBACNC,UAAU;gBACVC,OAAO;gBACPC,WAAWnB;YACb;YACA;gBACEgB,MAAM;gBACNC,UAAU;gBACVC,OAAO;gBACPC,WAAWlB;YACb;YACA;gBACEe,MAAM;gBACNC,UAAU;gBACVC,OAAO;gBACPC,WAAWjB;YACb;YACA;gBACEc,MAAM;gBACNC,UAAU;gBACVC,OAAO;gBACPC,WAAWhB;YACb;YACA;gBACEa,MAAM;gBACNC,UAAU;gBACVC,OAAO;gBACPC,WAAWf;YACb;SACD;QACDgB,UAAU;YACR;gBACEb,OAAO;gBACPc,MAAM;gBACNC,MAAM;oBAAEC,MAAM9B,gBAAgB8B,IAAI;gBAAC;gBACnC,oEAAoE;gBACpE,qEAAqE;gBACrE,6CAA6C;gBAC7CC,UAAU3B;gBACV4B,QAAQ;oBACNP,OAAO;oBACPI,MAAM;wBAAEC,MAAM9B,gBAAgB8B,IAAI;oBAAC;oBACnCG,WAAW;gBACb;gBACAP,WAAWpB;YACb;SACD;QACD;;;;;;;;;;KAUC,GACD4B,aAAa;YACX;gBACEpB,OAAO;gBACPc,MAAM;gBACNC,MAAM;oBAAEC,MAAM9B,gBAAgB8B,IAAI;gBAAC;gBACnCC,UAAU3B;gBACV4B,QAAQ;oBACNP,OAAO;oBACPI,MAAM;wBAAEC,MAAM9B,gBAAgB8B,IAAI;oBAAC;oBACnCG,WAAW;gBACb;gBACAP,WAAWpB;YACb;SACD;IACH;AACF;AAEA,cAAc,YAAQ"}
1
+ {"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/plugin.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport { registerPluginZone } from '@aglyn/aglyn/plugin-manager/plugin-zones'\nimport { mdiEmailOutline } from '@aglyn/shared-data-mdi'\nimport { lazy } from 'react'\nimport {\n EMAIL_MESSAGES_ZONE,\n EMAIL_TEMPLATE_RECIPIENTS_ZONE,\n} from './components/email-zones'\nimport { EMAILS_CONSOLE_SECTIONS } from './components/emails-console-sections'\nimport { BUNDLE_ID } from './constants/bundle-common'\nimport { registerEmailRecordRoutes } from './model/email-record-routes'\n\n/** Code-split: the Emails console page only loads when opened. */\nconst EmailsConsolePage = lazy(() => import('./components/emails-console-page'))\n\n/*\n * What this plugin draws inside a campaign's own pages, each loaded only where\n * the campaign owner's zone is on screen.\n */\nconst CampaignTopicSelect = lazy(\n () => import('./components/campaign-topic-select'),\n)\nconst CampaignTopicOptionsWidget = lazy(\n () => import('./components/campaign-topic-options-widget'),\n)\nconst CampaignSenderEditorWidget = lazy(\n () => import('./components/campaign-sender-editor-widget'),\n)\nconst CampaignDesignCreateWidget = lazy(\n () => import('./components/campaign-design-create-widget'),\n)\nconst EmailDesignPreview = lazy(\n () => import('./components/email-design-preview'),\n)\n\n/**\n * Console half (AGL-395): registers the Emails nav item + page in the\n * ConsoleExtension registry. Safe to call at console app load — the page is\n * lazy, so no besigner/canvas code loads. The shell renders the Emails nav\n * item and, via its generic plugin route, the page (the messages and their\n * composer, the templates, the audience lists, the topic catalog, the sending\n * identities and the suppression list) — with no edit to the console's own\n * nav or page files.\n */\nexport function registerEmailConsole(): void {\n // Where a message and a sending identity are read (AGL-3080), for the\n // surfaces that link to them without knowing this page's address.\n registerEmailRecordRoutes()\n /*\n * The two places this plugin's pages hand over to whichever plugin owns\n * campaigns. A message is one send of a campaign and every action on it is\n * that plugin's route, so the Messages section is a zone this page hosts\n * rather than pages this plugin imports; the same holds for the recipients\n * table under a template's report. Named, because a spec calls this\n * registrar without the loader.\n */\n registerPluginZone(\n {\n zone: EMAIL_MESSAGES_ZONE,\n label: 'The Emails page’s Messages section',\n surface: 'console',\n layout: 'bare',\n description:\n 'The whole body of `/emails/messages` and the routes under it, under a site and on the organization’s Emails page. A widget here is handed the site (or `null` and the org mount at the organization level), the Emails page’s base path and the segments under `messages`, and draws the list, one message’s report or its composer.',\n },\n { pluginId: BUNDLE_ID },\n )\n registerPluginZone(\n {\n zone: EMAIL_TEMPLATE_RECIPIENTS_ZONE,\n label: 'Who received a template’s emails',\n surface: 'console',\n description:\n 'On one template’s page, under its report. A widget here lists the recipients of every send built from that template; it is handed the site and the template’s id.',\n },\n { pluginId: BUNDLE_ID },\n )\n Aglyn.registerConsoleExtension({\n pluginId: BUNDLE_ID,\n displayName: 'Email',\n /*\n * WHO may open the email console, declared so the shell enforces it.\n *\n * The audiences section reads `orgs/{orgId}/lists/{listId}/members`, and\n * those members are enrolled CONTACTS — an address, a name, and the\n * consent basis recording why the person may be mailed. That is the same\n * org-shared people data the CRM holds, reached from a different page.\n *\n * The rules gate those reads on `isOrgWideMember()` ALONE, with no role\n * condition, so org-wide membership of any role is enough to list every\n * audience the organization has and everybody on it. An org VIEWER — the\n * role that exists to read and change nothing — therefore reads the whole\n * marketing audience today. `data.manage` is what closes that: it\n * defaults to owner, admin and editor, so the population it admits is\n * exactly the one `server-list-gate.ts` accepts a list write from, and\n * the viewer it excludes is the reader the rules never excluded.\n *\n * `data.manage` rather than a key of this plugin's own for the reason the\n * catalog gives for refusing a `marketing.manage`: campaigns are written\n * client-direct against rules that gate on the HOST role, so a new\n * org-level key would name an action with no org-level boundary under it.\n * `data.manage` is not in that position — it already governs the\n * org-shared data this surface exposes, and the list gate already reads\n * the roles it defaults to.\n *\n * A SITE COLLABORATOR holding it opens the page, and the answer is NOT\n * the one Contacts reached. There the listener is scoped and the rules\n * prove the same predicate per document; here the audiences read demands\n * `isOrgWideMember()`, which a collaborator is not, so the org-shared\n * half is already refused beneath the console and the half that remains —\n * this site's own messages, templates and sending identities — is theirs.\n * Refusing the surface outright would take that away to close nothing.\n */\n permission: 'data.manage',\n /*\n * The mail a campaign rides on is this plugin's — the topic catalog, the\n * sending identities, the design document and its renderer — so each is\n * drawn here, in a zone the campaign owner's composer and message page\n * host. Every one reports through a callback; none writes a campaign.\n */\n widgets: [\n {\n slot: 'campaignTopicSelect',\n widgetId: 'email-campaign-topic-select',\n title: 'Topic',\n Component: CampaignTopicSelect,\n },\n {\n slot: 'campaignTopicOptions',\n widgetId: 'email-campaign-topic-options',\n title: 'Topics',\n Component: CampaignTopicOptionsWidget,\n },\n {\n slot: 'campaignSenderEditor',\n widgetId: 'email-campaign-sender-editor',\n title: 'Add a sender',\n Component: CampaignSenderEditorWidget,\n },\n {\n slot: 'campaignDesignCreate',\n widgetId: 'email-campaign-design-create',\n title: 'Design this email',\n Component: CampaignDesignCreateWidget,\n },\n {\n slot: 'campaignDesignPreview',\n widgetId: 'email-campaign-design-preview',\n title: 'Preview',\n Component: EmailDesignPreview,\n },\n ],\n navItems: [\n {\n label: 'Emails',\n href: '/emails',\n icon: { path: mdiEmailOutline.path },\n // Sections as ROUTES (AGL-2501): `/emails/messages` and friends are\n // real URLs the shell resolves and gates, so the page mounts the one\n // being read instead of subscribing all six.\n sections: EMAILS_CONSOLE_SECTIONS,\n header: {\n title: 'Emails',\n icon: { path: mdiEmailOutline.path },\n docsTopic: 'emailCampaigns',\n },\n Component: EmailsConsolePage,\n },\n ],\n /*\n * The ORGANIZATION's Emails page, at `/[orgSlug]/emails`: the same six\n * sections over every site. The audiences and the topics are the org's\n * already; the messages, the templates, the sending identities and the\n * suppression lists are read site by site and link into the site that\n * holds them. The page is the same component — handed no site, it renders\n * the org sections.\n *\n * No `navTabId`, the same as the site tab: this plugin's release flag\n * gates the plugin itself, so one flag already holds both levels.\n */\n orgNavItems: [\n {\n label: 'Emails',\n href: '/emails',\n icon: { path: mdiEmailOutline.path },\n sections: EMAILS_CONSOLE_SECTIONS,\n header: {\n title: 'Emails',\n icon: { path: mdiEmailOutline.path },\n docsTopic: 'emailCampaigns',\n },\n Component: EmailsConsolePage,\n },\n ],\n })\n}\n\nexport * from './site'\n"],"names":["Aglyn","registerPluginZone","mdiEmailOutline","lazy","EMAIL_MESSAGES_ZONE","EMAIL_TEMPLATE_RECIPIENTS_ZONE","EMAILS_CONSOLE_SECTIONS","BUNDLE_ID","registerEmailRecordRoutes","EmailsConsolePage","CampaignTopicSelect","CampaignTopicOptionsWidget","CampaignSenderEditorWidget","CampaignDesignCreateWidget","EmailDesignPreview","registerEmailConsole","zone","label","surface","layout","description","pluginId","registerConsoleExtension","displayName","permission","widgets","slot","widgetId","title","Component","navItems","href","icon","path","sections","header","docsTopic","orgNavItems"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,YAAYA,WAAW,eAAc;AACrC,SAASC,kBAAkB,QAAQ,2CAA0C;AAC7E,SAASC,eAAe,QAAQ,yBAAwB;AACxD,SAASC,IAAI,QAAQ,QAAO;AAC5B,SACEC,mBAAmB,EACnBC,8BAA8B,QACzB,8BAA0B;AACjC,SAASC,uBAAuB,QAAQ,0CAAsC;AAC9E,SAASC,SAAS,QAAQ,+BAA2B;AACrD,SAASC,yBAAyB,QAAQ,iCAA6B;AAEvE,gEAAgE,GAChE,MAAMC,oBAAoBN,KAAK,IAAM,MAAM,CAAC;AAE5C;;;CAGC,GACD,MAAMO,sBAAsBP,KAC1B,IAAM,MAAM,CAAC;AAEf,MAAMQ,6BAA6BR,KACjC,IAAM,MAAM,CAAC;AAEf,MAAMS,6BAA6BT,KACjC,IAAM,MAAM,CAAC;AAEf,MAAMU,6BAA6BV,KACjC,IAAM,MAAM,CAAC;AAEf,MAAMW,qBAAqBX,KACzB,IAAM,MAAM,CAAC;AAGf;;;;;;;;CAQC,GACD,OAAO,SAASY;IACd,sEAAsE;IACtE,kEAAkE;IAClEP;IACA;;;;;;;GAOC,GACDP,mBACE;QACEe,MAAMZ;QACNa,OAAO;QACPC,SAAS;QACTC,QAAQ;QACRC,aACE;IACJ,GACA;QAAEC,UAAUd;IAAU;IAExBN,mBACE;QACEe,MAAMX;QACNY,OAAO;QACPC,SAAS;QACTE,aACE;IACJ,GACA;QAAEC,UAAUd;IAAU;IAExBP,MAAMsB,wBAAwB,CAAC;QAC7BD,UAAUd;QACVgB,aAAa;QACb;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAgCC,GACDC,YAAY;QACZ;;;;;KAKC,GACDC,SAAS;YACP;gBACEC,MAAM;gBACNC,UAAU;gBACVC,OAAO;gBACPC,WAAWnB;YACb;YACA;gBACEgB,MAAM;gBACNC,UAAU;gBACVC,OAAO;gBACPC,WAAWlB;YACb;YACA;gBACEe,MAAM;gBACNC,UAAU;gBACVC,OAAO;gBACPC,WAAWjB;YACb;YACA;gBACEc,MAAM;gBACNC,UAAU;gBACVC,OAAO;gBACPC,WAAWhB;YACb;YACA;gBACEa,MAAM;gBACNC,UAAU;gBACVC,OAAO;gBACPC,WAAWf;YACb;SACD;QACDgB,UAAU;YACR;gBACEb,OAAO;gBACPc,MAAM;gBACNC,MAAM;oBAAEC,MAAM/B,gBAAgB+B,IAAI;gBAAC;gBACnC,oEAAoE;gBACpE,qEAAqE;gBACrE,6CAA6C;gBAC7CC,UAAU5B;gBACV6B,QAAQ;oBACNP,OAAO;oBACPI,MAAM;wBAAEC,MAAM/B,gBAAgB+B,IAAI;oBAAC;oBACnCG,WAAW;gBACb;gBACAP,WAAWpB;YACb;SACD;QACD;;;;;;;;;;KAUC,GACD4B,aAAa;YACX;gBACEpB,OAAO;gBACPc,MAAM;gBACNC,MAAM;oBAAEC,MAAM/B,gBAAgB+B,IAAI;gBAAC;gBACnCC,UAAU5B;gBACV6B,QAAQ;oBACNP,OAAO;oBACPI,MAAM;wBAAEC,MAAM/B,gBAAgB+B,IAAI;oBAAC;oBACnCG,WAAW;gBACb;gBACAP,WAAWpB;YACb;SACD;IACH;AACF;AAEA,cAAc,YAAQ"}