@aglyn/plugins-email 1.0.0-beta.221 → 1.0.0-beta.222

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.
Files changed (82) hide show
  1. package/README.md +5 -4
  2. package/package.json +16 -17
  3. package/src/lib/components/email-template-detail.d.ts +5 -13
  4. package/src/lib/components/email-template-detail.js +29 -426
  5. package/src/lib/components/email-template-detail.js.map +1 -1
  6. package/src/lib/components/email-zones.d.ts +15 -0
  7. package/src/lib/components/email-zones.js +1 -0
  8. package/src/lib/components/email-zones.js.map +1 -1
  9. package/src/lib/components/list-detail-card.d.ts +0 -36
  10. package/src/lib/components/list-detail-card.js +125 -84
  11. package/src/lib/components/list-detail-card.js.map +1 -1
  12. package/src/lib/components/list-members-panel.d.ts +17 -0
  13. package/src/lib/components/list-members-panel.js +14 -0
  14. package/src/lib/components/list-members-panel.js.map +1 -1
  15. package/src/lib/components/site-package-email-preview.d.ts +13 -0
  16. package/src/lib/components/site-package-email-preview.js +47 -0
  17. package/src/lib/components/site-package-email-preview.js.map +1 -0
  18. package/src/lib/components/suppressions-card.js +75 -6
  19. package/src/lib/components/suppressions-card.js.map +1 -1
  20. package/src/lib/components/use-org-email-topics.js +2 -9
  21. package/src/lib/components/use-org-email-topics.js.map +1 -1
  22. package/src/lib/constants/site-package.d.ts +21 -0
  23. package/src/lib/constants/site-package.js +21 -0
  24. package/src/lib/constants/site-package.js.map +1 -0
  25. package/src/lib/declarations.console-server.d.ts +12 -0
  26. package/src/lib/declarations.console-server.js +73 -0
  27. package/src/lib/declarations.console-server.js.map +1 -1
  28. package/src/lib/list-import.d.ts +64 -75
  29. package/src/lib/list-import.js +43 -158
  30. package/src/lib/list-import.js.map +1 -1
  31. package/src/lib/model/email-topic-catalog.d.ts +24 -0
  32. package/src/lib/model/email-topic-catalog.js +19 -0
  33. package/src/lib/model/email-topic-catalog.js.map +1 -1
  34. package/src/lib/model/index.d.ts +0 -25
  35. package/src/lib/model/index.js +0 -27
  36. package/src/lib/model/index.js.map +1 -1
  37. package/src/lib/plugin.d.ts +8 -0
  38. package/src/lib/plugin.js +76 -2
  39. package/src/lib/plugin.js.map +1 -1
  40. package/src/lib/server/person-eraser.d.ts +39 -0
  41. package/src/lib/server/person-eraser.js +44 -0
  42. package/src/lib/server/person-eraser.js.map +1 -0
  43. package/src/lib/server-console.js +9 -9
  44. package/src/lib/server-console.js.map +1 -1
  45. package/src/lib/server-list-gate.d.ts +7 -0
  46. package/src/lib/server-list-gate.js +10 -3
  47. package/src/lib/server-list-gate.js.map +1 -1
  48. package/src/lib/server-list-import.d.ts +20 -177
  49. package/src/lib/server-list-import.js +34 -594
  50. package/src/lib/server-list-import.js.map +1 -1
  51. package/src/lib/server.js +16 -29
  52. package/src/lib/server.js.map +1 -1
  53. package/src/lib/testing/stand-in-send-tally.d.ts +29 -0
  54. package/src/lib/testing/stand-in-send-tally.js +52 -0
  55. package/src/lib/testing/stand-in-send-tally.js.map +1 -0
  56. package/src/lib/transfer/email-transfer-catalog.d.ts +151 -0
  57. package/src/lib/transfer/email-transfer-catalog.js +536 -0
  58. package/src/lib/transfer/email-transfer-catalog.js.map +1 -0
  59. package/src/lib/transfer/list-import-steps.d.ts +44 -0
  60. package/src/lib/transfer/list-import-steps.js +201 -0
  61. package/src/lib/transfer/list-import-steps.js.map +1 -0
  62. package/src/lib/transfer/list-members.server.d.ts +165 -0
  63. package/src/lib/transfer/list-members.server.js +911 -0
  64. package/src/lib/transfer/list-members.server.js.map +1 -0
  65. package/src/lib/transfer/suppressions.server.d.ts +69 -0
  66. package/src/lib/transfer/suppressions.server.js +282 -0
  67. package/src/lib/transfer/suppressions.server.js.map +1 -0
  68. package/src/lib/transfer/topics-package.d.ts +56 -0
  69. package/src/lib/transfer/topics-package.js +87 -0
  70. package/src/lib/transfer/topics-package.js.map +1 -0
  71. package/src/lib/transfer/topics-package.server.d.ts +33 -0
  72. package/src/lib/transfer/topics-package.server.js +171 -0
  73. package/src/lib/transfer/topics-package.server.js.map +1 -0
  74. package/src/lib/transfer/transfer-reads.server.d.ts +25 -0
  75. package/src/lib/transfer/transfer-reads.server.js +34 -0
  76. package/src/lib/transfer/transfer-reads.server.js.map +1 -0
  77. package/src/lib/components/list-import-drawer.d.ts +0 -22
  78. package/src/lib/components/list-import-drawer.js +0 -662
  79. package/src/lib/components/list-import-drawer.js.map +0 -1
  80. package/src/lib/model/template-report.d.ts +0 -158
  81. package/src/lib/model/template-report.js +0 -249
  82. package/src/lib/model/template-report.js.map +0 -1
@@ -1,4 +1,3 @@
1
- import { _ as _extends } from "@swc/helpers/_/_extends";
2
1
  /**
3
2
  * @license
4
3
  * Copyright 2026 Aglyn LLC
@@ -15,622 +14,63 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
15
14
  * See the License for the specific language governing permissions and
16
15
  * limitations under the License.
17
16
  */ /**
18
- * BRINGING AN EXISTING LIST IN — the four routes an import is made of.
19
- *
20
- * `docs/specs/email-competitive-gaps.md` G5: export works, import does not
21
- * exist, and every customer arriving from another product has a list and no
22
- * way to bring it. P4 is the condition attached to closing that: a bulk
23
- * import is the fastest way to destroy a shared sending domain, so it ships
24
- * WITH its controls.
25
- *
26
- * ## Every imported address goes through the checks a typed one does
27
- *
28
- * Not a similar set — the same functions. {@link resolveAddresses} is the
29
- * resolution `email/list-members-preview` runs, `assignmentBasis` is the
30
- * policy `email/list-members-add` applies, and `enrollListMember` is the one
31
- * writer of the membership collection. A second bulk path with its own idea of
32
- * suppression and its own idea of consent is exactly the defect class this
33
- * register has a P1 entry for, and that entry is closed.
34
- *
35
- * What this module adds is everything ABOVE that gate: reading a file,
36
- * screening it, holding the operator's attestation, and metering the work out
37
- * over as many requests as it takes.
38
- *
39
- * ## Four routes, because an import is four separate acts
40
- *
41
- * - `email/list-import-preview` — reads the file and says what is in it.
42
- * Writes nothing, enrolls nobody, and resolves a BOUNDED SAMPLE through the
43
- * consent gate so the numbers the operator attests against are real numbers
44
- * from real records rather than a promise.
45
- * - `email/list-import-start` — records the attestation and stages the
46
- * addresses. Still enrolls nobody: the act of saying "I have permission for
47
- * these people" is separated from the act of adding them so that the
48
- * attestation has a moment of its own.
49
- * - `email/list-import-run` — enrolls up to {@link LIST_IMPORT_RUN_BUDGET}
50
- * addresses from the cursor and moves it. Called until it answers
51
- * `complete`.
52
- * - `email/list-import-status` — the unfinished import on a list, if there is
53
- * one, so a merchant who closed the tab is not left with a half-added
54
- * audience and no way to see it.
55
- *
56
- * ## A budget and a cursor, not one request and not one transaction
57
- *
58
- * The shape `dynamic-list-materialize.ts` already uses: a per-run bound on
59
- * work, a cursor recording where the run stopped, and a next run that resumes
60
- * rather than restarts. A 50,000-address file is not a request that times out
61
- * halfway with no record of what it did; it is 500 bounded requests over one
62
- * durable job, and stopping in the middle of it leaves the addresses already
63
- * enrolled enrolled and the rest staged.
64
- *
65
- * The run budget is deliberately {@link LIST_MEMBER_BATCH_MAX} — the same
66
- * number of addresses one hand-typed add already resolves in one request — so
67
- * an import run costs exactly what an add costs and no new cost profile is
68
- * introduced to discover in production.
69
- *
70
- * ## ⛔ Nothing here is a capacity limit
71
- *
72
- * {@link LIST_IMPORT_MAX_ADDRESSES} refuses a FILE before anything is written.
73
- * It never trims a staged import, never drops an address to fit, and never
74
- * removes anybody already on the list. A ceiling in this product is enforced
75
- * at the reduction, and the reduction here is refusing the upload — which the
76
- * operator sees, can argue with, and can act on by splitting the file.
77
- *
78
- * ## Who the attester is, and why a resumer does not become one
79
- *
80
- * The attestation is one person's claim about where a file came from. It is
81
- * recorded on the job with the account that made it, and every run reads the
82
- * basis from THAT account, not from whoever pressed Resume. A colleague who
83
- * finishes somebody else's import has asserted nothing, and the consent
84
- * records the run writes must not say they did.
85
- */ import { ASSIGNMENT_REFUSAL_MESSAGES, assignmentBasis, createResourceUid, CSV_UPLOAD_MAX_CHARACTERS, readMarketingBasis, registerPluginApiRoute } from "@aglyn/aglyn/server";
86
- import { enrollListMember } from "@aglyn/tenant-data-admin";
87
- import { FieldValue } from "firebase-admin/firestore";
88
- import { importedBasisReason, LIST_IMPORT_MAX_ADDRESSES, parseListImport, screenListImport } from "./list-import.js";
89
- /*
90
- * The gate module directly, never `server-console.ts`'s re-export of it.
91
- *
92
- * That file imports THIS one to register these routes, so reaching its
93
- * re-export would close a cycle — and the constant below is evaluated at
94
- * module load, which is precisely where a cycle stops being harmless: the
95
- * binding is still in its temporal dead zone when the loader arrives.
96
- */ import { LIST_MEMBER_BATCH_MAX, resolveAddresses, resolveListContext } from "./server-list-gate.js";
97
- /** `source` stamped on every membership an import writes. */ export const CONSOLE_IMPORT_SOURCE = 'console:list-import';
98
- /** Where a list's import jobs live: `orgs/{orgId}/lists/{listId}/imports`. */ export const LIST_IMPORTS_SUBCOLLECTION = 'imports';
17
+ * WHAT THE PERMISSION STEP SHOWS — `POST email/list-import-screening`.
18
+ *
19
+ * A list import runs on the platform's transfer framework (AGL-3529; the
20
+ * server half is `transfer/list-members.server.ts`). Its "Permission" step is
21
+ * where the operator states they have these people's permission, and the
22
+ * statement is only worth recording if it is made in front of the evidence:
23
+ * the shared mailboxes, the column names that read as a bought list, and
24
+ * what the consent gate says about a sample of the addresses. The dry run
25
+ * that runs as the operator reaches the step computes exactly that and keeps
26
+ * it on the list's import ledger (`orgs/{orgId}/lists/{listId}/imports/{jobId}`);
27
+ * this route reads it back for the step to draw. It writes nothing and
28
+ * enrolls nobody.
29
+ *
30
+ * Behind the list gate (`resolveListContext`): an org-wide owner, admin or
31
+ * editor, on a site of the organization, about a list that exists — the
32
+ * population that may run the import in the first place.
33
+ */ import { registerPluginApiRoute } from "@aglyn/aglyn/server";
34
+ import { resolveListContext } from "./server-list-gate.js";
35
+ /** A list's import ledger, beside its members. */ const LIST_IMPORTS = 'imports';
99
36
  /**
100
- * Addresses one run enrolls before answering and handing back the cursor.
101
- *
102
- * The same number as {@link LIST_MEMBER_BATCH_MAX} on purpose — see the module
103
- * note. It is a bound on WORK: it can never refuse a person, and the addresses
104
- * it does not reach in this run are reached by the next one.
105
- */ export const LIST_IMPORT_RUN_BUDGET = LIST_MEMBER_BATCH_MAX;
106
- /**
107
- * Staged addresses per chunk document.
108
- *
109
- * Comfortably inside Firestore's one-megabyte document limit at the widths a
110
- * contact file actually carries, and a multiple of the run budget so a run
111
- * reads exactly one chunk. Reading two would be the common case at any size
112
- * that is not a multiple, which is a round trip paid on every run to save
113
- * nothing.
114
- */ export const LIST_IMPORT_CHUNK_SIZE = 500;
115
- /** Unusable lines kept verbatim on the job, so the result names some of them. */ const UNUSABLE_SAMPLE_MAX = 25;
116
- /** Role accounts kept verbatim on the job, for the same reason. */ const ROLE_ACCOUNT_SAMPLE_MAX = 25;
117
- /** Reads the request's file text, or the refusal to send back. */ function readImportText(req) {
37
+ * Body: `{ hostId, listId, jobId }`. Answers `{ listName, screening }`, the
38
+ * screening `null` until a dry run has made one.
39
+ */ export const emailListImportScreeningHandler = async (req, res)=>{
118
40
  var _ref;
119
41
  var _req_body;
120
- const text = String((_ref = (_req_body = req.body) == null ? void 0 : _req_body.text) != null ? _ref : '');
121
- if (!text.trim()) return {
122
- error: 'The file is empty.'
123
- };
124
- if (text.length > CSV_UPLOAD_MAX_CHARACTERS) {
125
- return {
126
- error: 'That file is too large to read in one go. Split it and import the ' + 'pieces — nothing is added until you do, and nothing already on the ' + 'list is affected.'
127
- };
128
- }
129
- return {
130
- text
131
- };
132
- }
133
- /**
134
- * The screening report for a parsed file.
135
- *
136
- * Counts plus a bounded sample rather than every offending address. The point
137
- * of the warning is that the operator SEES the shape of what they are about to
138
- * attest to; a list of four thousand role accounts is a scroll, not a warning,
139
- * and it would put four thousand addresses into a document whose reason for
140
- * existing is bookkeeping.
141
- */ function screeningReport(parsed) {
142
- const screening = screenListImport(parsed);
143
- return {
144
- roleAccounts: screening.roleAccounts.length,
145
- roleAccountSamples: screening.roleAccounts.slice(0, ROLE_ACCOUNT_SAMPLE_MAX),
146
- purchaseTellColumns: screening.purchaseTellColumns,
147
- declaresBasis: screening.declaresBasis
148
- };
149
- }
150
- /**
151
- * `POST email/list-import-preview` — what is in this file.
152
- *
153
- * Reads only. It answers three separate questions and keeps them separate,
154
- * because collapsing them is how an import gets attested to on a number that
155
- * is not the number:
156
- *
157
- * - what the FILE contains: usable addresses, unusable lines, duplicates
158
- * collapsed, and the columns it carries;
159
- * - what the SCREENING found, which decides nothing and is shown anyway;
160
- * - what the CONSENT GATE says about a bounded sample of the addresses.
161
- *
162
- * The sample is the honest shape rather than a shortcut. Resolving fifty
163
- * thousand addresses against the contacts collection and both suppression
164
- * lists is the same scan the import itself performs, so a preview that did it
165
- * would be the import minus the writes — twice the cost, and a request that
166
- * times out on exactly the files this feature exists for. So the sample size
167
- * is reported beside the total and the run reports the real figures as they
168
- * become true, which is the same distinction `email/list-rule-preview` draws
169
- * between `matched` and the batch it hands back.
170
- */ export const emailListImportPreviewHandler = async (req, res)=>{
171
42
  if (req.method !== 'POST') {
172
43
  return res.status(405).json({
173
44
  error: 'Method not allowed'
174
45
  });
175
46
  }
176
- const file = readImportText(req);
177
- if ('error' in file) return res.status(400).json({
178
- error: file.error
179
- });
180
- try {
181
- const context = await resolveListContext(req);
182
- if (context.ok === false) {
183
- return res.status(context.status).json(context.body);
184
- }
185
- const parsed = parseListImport(file.text);
186
- const sample = parsed.rows.map((row)=>row.email).filter((email)=>!!email).slice(0, LIST_MEMBER_BATCH_MAX);
187
- const resolution = await resolveAddresses({
188
- hostId: context.hostId,
189
- inputs: sample
190
- });
191
- return res.status(200).json({
192
- listName: context.listName,
193
- columns: parsed.columns,
194
- usable: parsed.usable,
195
- unusable: parsed.unusable,
196
- duplicates: parsed.duplicates,
197
- overCeiling: parsed.overCeiling,
198
- ceiling: LIST_IMPORT_MAX_ADDRESSES,
199
- unusableSamples: parsed.rows.filter((row)=>!row.email).slice(0, UNUSABLE_SAMPLE_MAX).map((row)=>row.input),
200
- screening: screeningReport(parsed),
201
- /*
202
- * The sample's verdicts, in the shape the panel's consent readout
203
- * already draws, and its SIZE beside them. A count with no denominator
204
- * next to it is the thing an operator misreads as the whole file.
205
- */ sampleSize: sample.length,
206
- verdicts: resolution.verdicts,
207
- optedIn: resolution.optedIn,
208
- needAttestation: resolution.needAttestation,
209
- refused: resolution.refused
210
- });
211
- } catch (error) {
212
- console.error('[email] list import preview failed', error);
213
- return res.status(500).json({
214
- error: 'The file could not be read.'
215
- });
216
- }
217
- };
218
- /**
219
- * `POST email/list-import-start` — record the attestation, stage the file.
220
- *
221
- * Body: `{ hostId, listId, text, attestConsent }`. Enrolls nobody. It writes
222
- * the job document that every subsequent run reads, and the chunks holding
223
- * the addresses, and then stops — so the moment the operator makes their
224
- * claim is a moment of its own, with a record of who made it and when, rather
225
- * than a flag riding along on the request that also did the work.
226
- *
227
- * `attestConsent` is the operator STATING they have these people's
228
- * permission. It is not a way to name a basis: the basis is derived per
229
- * address at run time from that person's own record, exactly as the
230
- * one-address add path derives it, and this flag can only ever produce the
231
- * attributable kind.
232
- */ export const emailListImportStartHandler = async (req, res)=>{
233
- var _req_body;
234
- if (req.method !== 'POST') {
235
- return res.status(405).json({
236
- error: 'Method not allowed'
47
+ const jobId = String((_ref = (_req_body = req.body) == null ? void 0 : _req_body.jobId) != null ? _ref : '').trim();
48
+ if (!jobId || jobId.includes('/')) {
49
+ return res.status(400).json({
50
+ error: 'Missing jobId'
237
51
  });
238
52
  }
239
- const file = readImportText(req);
240
- if ('error' in file) return res.status(400).json({
241
- error: file.error
242
- });
243
- const attested = ((_req_body = req.body) == null ? void 0 : _req_body.attestConsent) === true;
244
53
  try {
54
+ var _ledger_get, _ledger_get1;
245
55
  const context = await resolveListContext(req);
246
56
  if (context.ok === false) {
247
57
  return res.status(context.status).json(context.body);
248
58
  }
249
- const parsed = parseListImport(file.text);
250
- const staged = parsed.rows.filter((row)=>!!row.email).map((row)=>_extends({
251
- e: row.email
252
- }, row.name ? {
253
- n: row.name
254
- } : {}, row.declaredSource ? {
255
- s: row.declaredSource
256
- } : {}, row.declaredAt ? {
257
- d: row.declaredAt
258
- } : {}));
259
- if (!staged.length) {
260
- return res.status(400).json({
261
- error: 'No usable email addresses were found in that file. Check that it ' + 'has an address column, or paste one address per line.'
262
- });
263
- }
264
- const importId = createResourceUid();
265
- const importRef = context.listRef.collection(LIST_IMPORTS_SUBCOLLECTION).doc(importId);
266
- const chunks = importRef.collection('chunks');
267
- /*
268
- * The staging area first, the job document last.
269
- *
270
- * A job whose chunks are not all written yet is a job a run would read
271
- * past the end of, and the run is driven by a client that starts
272
- * immediately. Writing the job last means the only state anybody can
273
- * observe is a complete one.
274
- *
275
- * One document per chunk and not one batch over all of them: a batch is
276
- * capped at 500 writes and, more to the point, is a transaction — the
277
- * whole reason this is a staged job rather than one request is that a
278
- * fifty-thousand-address import must not be a single atomic thing that
279
- * either lands or does not.
280
- */ for(let at = 0; at < staged.length; at += LIST_IMPORT_CHUNK_SIZE){
281
- await chunks.doc(String(at / LIST_IMPORT_CHUNK_SIZE)).set({
282
- rows: staged.slice(at, at + LIST_IMPORT_CHUNK_SIZE)
283
- });
284
- }
285
- await importRef.set({
286
- listName: context.listName,
287
- status: 'running',
288
- total: staged.length,
289
- cursor: 0,
290
- enrolled: 0,
291
- refused: 0,
292
- refusals: {},
293
- unusable: parsed.unusable,
294
- duplicates: parsed.duplicates,
295
- overCeiling: parsed.overCeiling,
296
- unusableSamples: parsed.rows.filter((row)=>!row.email).slice(0, UNUSABLE_SAMPLE_MAX).map((row)=>row.input),
297
- columns: parsed.columns,
298
- screening: screeningReport(parsed),
299
- /*
300
- * WHO attested, stored beside WHETHER. A flag on its own is an
301
- * unattributed claim, which is the one thing `enrollment-basis`
302
- * refuses to let an attestation be — and every run reads the account
303
- * from here rather than from the session that triggered it.
304
- */ attested,
305
- attestedByUid: attested ? context.uid : null,
306
- attestedAtMs: attested ? Date.now() : null,
307
- startedByUid: context.uid,
308
- createdAt: FieldValue.serverTimestamp(),
309
- updatedAt: FieldValue.serverTimestamp()
310
- });
59
+ const ledger = await context.listRef.collection(LIST_IMPORTS).doc(jobId).get();
311
60
  return res.status(200).json({
312
- importId,
313
61
  listName: context.listName,
314
- total: staged.length,
315
- attested,
316
- runBudget: LIST_IMPORT_RUN_BUDGET
62
+ screening: ledger.exists ? (_ledger_get = ledger.get('screening')) != null ? _ledger_get : null : null,
63
+ total: ledger.exists ? Number((_ledger_get1 = ledger.get('total')) != null ? _ledger_get1 : 0) : null
317
64
  });
318
65
  } catch (error) {
319
- console.error('[email] list import start failed', error);
66
+ console.error('[email] list import screening failed', error);
320
67
  return res.status(500).json({
321
- error: 'The import could not be started.'
68
+ error: 'What the file holds could not be read.'
322
69
  });
323
70
  }
324
71
  };
325
- /** The staged addresses a run will work on, from the cursor. */ async function readStaged(importRef, cursor, total) {
326
- const take = Math.min(LIST_IMPORT_RUN_BUDGET, Math.max(total - cursor, 0));
327
- if (take <= 0) return [];
328
- const rows = [];
329
- let at = cursor;
330
- /*
331
- * A loop rather than one read.
332
- *
333
- * `LIST_IMPORT_CHUNK_SIZE` is a multiple of `LIST_IMPORT_RUN_BUDGET`, so as
334
- * those two constants stand a run reads exactly one chunk and this turns
335
- * once. That relationship is a PERFORMANCE choice — one round trip per run
336
- * — and the loop is what keeps it from also being a correctness
337
- * requirement: change either number to something that does not divide, or
338
- * resume a job whose cursor came from an older budget, and a run's batch
339
- * straddles a boundary. Reading one chunk and truncating would silently
340
- * import a short batch and advance the cursor past the rest.
341
- */ while(rows.length < take){
342
- const index = Math.floor(at / LIST_IMPORT_CHUNK_SIZE);
343
- const snapshot = await importRef.collection('chunks').doc(String(index)).get();
344
- const stored = snapshot.exists ? snapshot.get('rows') : null;
345
- if (!Array.isArray(stored) || !stored.length) break;
346
- const offset = at - index * LIST_IMPORT_CHUNK_SIZE;
347
- const slice = stored.slice(offset, offset + (take - rows.length));
348
- if (!slice.length) break;
349
- rows.push(...slice);
350
- at += slice.length;
351
- }
352
- return rows;
353
- }
354
- /**
355
- * `POST email/list-import-run` — enroll the next batch.
356
- *
357
- * Body: `{ hostId, listId, importId }`. Answers `complete` when the cursor
358
- * has reached the total, so the caller's loop is "call until complete" and
359
- * nothing has to guess how many runs a file needs.
360
- *
361
- * ## The cursor moves for every address, enrolled or refused
362
- *
363
- * A refusal is a finished address. Advancing only on success would put a
364
- * suppressed address at the head of the queue forever and turn the import
365
- * into a loop that never terminates on exactly the files that most need to
366
- * terminate.
367
- *
368
- * ## The counters are incremented, not recomputed
369
- *
370
- * `FieldValue.increment` rather than a read-modify-write, so two runs racing
371
- * on one job — a merchant with the drawer open in two tabs — cannot lose a
372
- * batch's worth of tally. The cursor is written as an absolute value because
373
- * it is the position the NEXT run reads from, and two racing runs that both
374
- * incremented it would skip a batch rather than repeat one; repeating is safe
375
- * (`enrollListMember` is keyed by the person), skipping is not.
376
- */ export const emailListImportRunHandler = async (req, res)=>{
377
- var _ref;
378
- var _req_body;
379
- if (req.method !== 'POST') {
380
- return res.status(405).json({
381
- error: 'Method not allowed'
382
- });
383
- }
384
- const importId = String((_ref = (_req_body = req.body) == null ? void 0 : _req_body.importId) != null ? _ref : '').trim();
385
- if (!importId) return res.status(400).json({
386
- error: 'Missing importId'
387
- });
388
- try {
389
- var _job_get, _job_get1, _job_get2;
390
- const context = await resolveListContext(req);
391
- if (context.ok === false) {
392
- return res.status(context.status).json(context.body);
393
- }
394
- const importRef = context.listRef.collection(LIST_IMPORTS_SUBCOLLECTION).doc(importId);
395
- const job = await importRef.get();
396
- if (!job.exists) {
397
- return res.status(404).json({
398
- error: 'Unknown import'
399
- });
400
- }
401
- const total = Number((_job_get = job.get('total')) != null ? _job_get : 0);
402
- const cursor = Number((_job_get1 = job.get('cursor')) != null ? _job_get1 : 0);
403
- if (cursor >= total) {
404
- return res.status(200).json(finishedPayload(job, context));
405
- }
406
- const staged = await readStaged(importRef, cursor, total);
407
- if (!staged.length) {
408
- /*
409
- * The staging area is short of what the job claims. Recorded as
410
- * complete rather than retried forever: the addresses that were
411
- * enrolled stay enrolled, and a job that cannot be finished is more
412
- * useful marked finished with its real numbers than left as a
413
- * permanently unfinished import a merchant is told to resume.
414
- */ await importRef.set({
415
- status: 'complete',
416
- cursor: total,
417
- updatedAt: FieldValue.serverTimestamp()
418
- }, {
419
- merge: true
420
- });
421
- return res.status(200).json(_extends({}, finishedPayload(job, context), {
422
- complete: true,
423
- cursor: total
424
- }));
425
- }
426
- const attested = job.get('attested') === true;
427
- /*
428
- * The ATTESTER's account, not the caller's. See the module note: a
429
- * colleague who resumes somebody else's import has asserted nothing, and
430
- * a consent record naming them would be a claim nobody made.
431
- */ const attestingUid = String((_job_get2 = job.get('attestedByUid')) != null ? _job_get2 : '');
432
- const nowMs = Date.now();
433
- const resolution = await resolveAddresses({
434
- hostId: context.hostId,
435
- inputs: staged.map((row)=>row.e)
436
- });
437
- const byEmail = new Map(staged.map((row)=>[
438
- row.e,
439
- row
440
- ]));
441
- let enrolled = 0;
442
- const refusals = {};
443
- const results = [];
444
- const refuse = (email, reason)=>{
445
- var _refusals_reason;
446
- refusals[reason] = ((_refusals_reason = refusals[reason]) != null ? _refusals_reason : 0) + 1;
447
- results.push({
448
- email,
449
- enrolled: false,
450
- reason,
451
- error: ASSIGNMENT_REFUSAL_MESSAGES[reason]
452
- });
453
- };
454
- for (const verdict of resolution.verdicts){
455
- var _resolution_stored_get, _resolution_grants_get, _row_s, _row_d;
456
- if (verdict.refusal || !verdict.email) {
457
- var _verdict_refusal;
458
- refuse(verdict.email, (_verdict_refusal = verdict.refusal) != null ? _verdict_refusal : 'unroutable-address');
459
- continue;
460
- }
461
- const decision = assignmentBasis({
462
- stored: (_resolution_stored_get = resolution.stored.get(verdict.email)) != null ? _resolution_stored_get : readMarketingBasis(null, resolution.group),
463
- attested,
464
- actingUid: attestingUid,
465
- nowMs
466
- });
467
- if ('refusal' in decision) {
468
- refuse(verdict.email, decision.refusal);
469
- continue;
470
- }
471
- const row = byEmail.get(verdict.email);
472
- const enrollment = await enrollListMember(_extends({
473
- listRef: context.listRef,
474
- group: resolution.group,
475
- email: verdict.email
476
- }, (row == null ? void 0 : row.n) ? {
477
- name: row.n
478
- } : {}, {
479
- source: CONSOLE_IMPORT_SOURCE,
480
- // Never `'rule'`: the dynamic-list materializer reconciles its own
481
- // rows away when somebody stops matching, and a file a merchant
482
- // uploaded is not a rule match that can lapse.
483
- via: 'manual'
484
- }, decision.basis === 'contact-opt-in' ? {
485
- grantEntries: (_resolution_grants_get = resolution.grants.get(verdict.email)) != null ? _resolution_grants_get : {}
486
- } : {}, {
487
- consent: _extends({}, decision, decision.basis === 'operator-attested' && row ? {
488
- reason: importedBasisReason({
489
- declaredSource: (_row_s = row.s) != null ? _row_s : '',
490
- declaredAt: (_row_d = row.d) != null ? _row_d : ''
491
- })
492
- } : {})
493
- }));
494
- if (enrollment.enrolled === false) {
495
- refuse(verdict.email, enrollment.refusal === 'declined' ? 'declined' : 'unroutable-address');
496
- continue;
497
- }
498
- enrolled += 1;
499
- results.push({
500
- email: verdict.email,
501
- enrolled: true
502
- });
503
- }
504
- const nextCursor = cursor + staged.length;
505
- const complete = nextCursor >= total;
506
- await importRef.set({
507
- cursor: nextCursor,
508
- status: complete ? 'complete' : 'running',
509
- enrolled: FieldValue.increment(enrolled),
510
- refused: FieldValue.increment(staged.length - enrolled),
511
- /*
512
- * A NESTED map, not dotted keys. `set({merge:true})` reads its keys
513
- * as literal field names — only `update()` expands a dot into a field
514
- * path — so `refusals.declined` here would create a top-level field
515
- * with a dot in its name and leave the map it was meant to update
516
- * empty. A deep merge over a nested map does what is wanted and
517
- * honors the increments inside it.
518
- */ refusals: Object.fromEntries(Object.entries(refusals).map(([reason, count])=>[
519
- reason,
520
- FieldValue.increment(count)
521
- ])),
522
- updatedAt: FieldValue.serverTimestamp()
523
- }, {
524
- merge: true
525
- });
526
- return res.status(200).json({
527
- importId,
528
- listName: context.listName,
529
- complete,
530
- total,
531
- cursor: nextCursor,
532
- /*
533
- * This RUN's numbers, named as this run's. The job's running totals are
534
- * read back by `email/list-import-status`; reporting an increment as a
535
- * total is how a progress readout comes to disagree with the record.
536
- */ ranEnrolled: enrolled,
537
- ranRefused: staged.length - enrolled,
538
- refusals,
539
- results
540
- });
541
- } catch (error) {
542
- console.error('[email] list import run failed', error);
543
- return res.status(500).json({
544
- error: 'The import could not continue.'
545
- });
546
- }
547
- };
548
- /** The payload for a job that has nothing left to do. */ function finishedPayload(job, context) {
549
- var _job_get, _job_get1;
550
- return {
551
- importId: job.id,
552
- listName: context.listName,
553
- complete: true,
554
- total: Number((_job_get = job.get('total')) != null ? _job_get : 0),
555
- cursor: Number((_job_get1 = job.get('cursor')) != null ? _job_get1 : 0),
556
- ranEnrolled: 0,
557
- ranRefused: 0,
558
- refusals: {},
559
- results: []
560
- };
561
- }
562
- /**
563
- * `POST email/list-import-status` — the import on this list, if there is one.
564
- *
565
- * Reached when the import drawer opens, and at no other time. It exists
566
- * because a browser is not a durable thing: a merchant who closed the tab
567
- * during a large import has an audience that is part-way filled and, without
568
- * this, no way to see that or to finish it. What they must never be offered
569
- * instead is a fresh import of the same file, which would re-run the whole
570
- * gate over addresses already enrolled.
571
- *
572
- * Ordered on `createdAt`, which every job document written by
573
- * `email/list-import-start` carries — a `limit()` with no `orderBy` answers in
574
- * document-id order, and the ids come from `createResourceUid()`, so the
575
- * "latest" import would be an arbitrary one.
576
- */ export const emailListImportStatusHandler = async (req, res)=>{
577
- if (req.method !== 'POST') {
578
- return res.status(405).json({
579
- error: 'Method not allowed'
580
- });
581
- }
582
- try {
583
- var _job_get, _job_get1, _job_get2, _job_get3, _job_get4, _job_get5, _job_get6, _job_get7, _job_get8, _job_get9;
584
- const context = await resolveListContext(req);
585
- if (context.ok === false) {
586
- return res.status(context.status).json(context.body);
587
- }
588
- const snapshot = await context.listRef.collection(LIST_IMPORTS_SUBCOLLECTION).orderBy('createdAt', 'desc').limit(1).get();
589
- const job = snapshot.docs[0];
590
- if (!job) return res.status(200).json({
591
- listName: context.listName,
592
- job: null
593
- });
594
- return res.status(200).json({
595
- listName: context.listName,
596
- job: {
597
- importId: job.id,
598
- status: String((_job_get = job.get('status')) != null ? _job_get : 'running'),
599
- total: Number((_job_get1 = job.get('total')) != null ? _job_get1 : 0),
600
- cursor: Number((_job_get2 = job.get('cursor')) != null ? _job_get2 : 0),
601
- enrolled: Number((_job_get3 = job.get('enrolled')) != null ? _job_get3 : 0),
602
- refused: Number((_job_get4 = job.get('refused')) != null ? _job_get4 : 0),
603
- refusals: (_job_get5 = job.get('refusals')) != null ? _job_get5 : {},
604
- attested: job.get('attested') === true,
605
- unusable: Number((_job_get6 = job.get('unusable')) != null ? _job_get6 : 0),
606
- duplicates: Number((_job_get7 = job.get('duplicates')) != null ? _job_get7 : 0),
607
- unusableSamples: (_job_get8 = job.get('unusableSamples')) != null ? _job_get8 : [],
608
- screening: (_job_get9 = job.get('screening')) != null ? _job_get9 : null
609
- }
610
- });
611
- } catch (error) {
612
- console.error('[email] list import status failed', error);
613
- return res.status(500).json({
614
- error: 'The import could not be looked up.'
615
- });
616
- }
617
- };
618
- /**
619
- * Import route registration.
620
- *
621
- * Reached by a person pressing a button in a browser, like the rest of the
622
- * console half, so none of these is on the machine-path exemption list in
623
- * `plugin-api-rate-limit.ts` — with one consequence worth stating: the RUN
624
- * route is called repeatedly by design, once per {@link
625
- * LIST_IMPORT_RUN_BUDGET} addresses, so the visitor limiter's per-(site, IP)
626
- * budget is the ceiling on how fast a large import can proceed. That is the
627
- * correct ceiling for a path that enrolls people into a marketing audience,
628
- * and it degrades into a slower import rather than a failed one.
629
- */ export function registerEmailListImportApi() {
630
- registerPluginApiRoute('email/list-import-preview', emailListImportPreviewHandler);
631
- registerPluginApiRoute('email/list-import-start', emailListImportStartHandler);
632
- registerPluginApiRoute('email/list-import-run', emailListImportRunHandler);
633
- registerPluginApiRoute('email/list-import-status', emailListImportStatusHandler);
72
+ /** Registration: one console route, reached by a person opening a step. */ export function registerEmailListImportApi() {
73
+ registerPluginApiRoute('email/list-import-screening', emailListImportScreeningHandler);
634
74
  }
635
75
 
636
76
  //# sourceMappingURL=server-list-import.js.map