@aglyn/plugins-inbox 1.0.0-beta.143

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 (48) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +7 -0
  3. package/package.json +51 -0
  4. package/src/index.d.ts +18 -0
  5. package/src/index.js +19 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/contacts-card.component.d.ts +16 -0
  8. package/src/lib/components/contacts-card.component.js +351 -0
  9. package/src/lib/components/contacts-card.component.js.map +1 -0
  10. package/src/lib/components/inbox-attribution-zone.d.ts +52 -0
  11. package/src/lib/components/inbox-attribution-zone.js +53 -0
  12. package/src/lib/components/inbox-attribution-zone.js.map +1 -0
  13. package/src/lib/components/inbox-console-page.d.ts +28 -0
  14. package/src/lib/components/inbox-console-page.js +219 -0
  15. package/src/lib/components/inbox-console-page.js.map +1 -0
  16. package/src/lib/components/inbox-console-sections.d.ts +40 -0
  17. package/src/lib/components/inbox-console-sections.js +66 -0
  18. package/src/lib/components/inbox-console-sections.js.map +1 -0
  19. package/src/lib/components/inbox-glance-card.component.d.ts +26 -0
  20. package/src/lib/components/inbox-glance-card.component.js +215 -0
  21. package/src/lib/components/inbox-glance-card.component.js.map +1 -0
  22. package/src/lib/components/submission-list-assignment.component.d.ts +29 -0
  23. package/src/lib/components/submission-list-assignment.component.js +257 -0
  24. package/src/lib/components/submission-list-assignment.component.js.map +1 -0
  25. package/src/lib/components/submission-reply.component.d.ts +30 -0
  26. package/src/lib/components/submission-reply.component.js +249 -0
  27. package/src/lib/components/submission-reply.component.js.map +1 -0
  28. package/src/lib/components/submissions-card.component.d.ts +35 -0
  29. package/src/lib/components/submissions-card.component.js +577 -0
  30. package/src/lib/components/submissions-card.component.js.map +1 -0
  31. package/src/lib/components/use-record-route-context.d.ts +15 -0
  32. package/src/lib/components/use-record-route-context.js +41 -0
  33. package/src/lib/components/use-record-route-context.js.map +1 -0
  34. package/src/lib/constants/bundle-common.d.ts +8 -0
  35. package/src/lib/constants/bundle-common.js +9 -0
  36. package/src/lib/constants/bundle-common.js.map +1 -0
  37. package/src/lib/model/reply-policy.d.ts +134 -0
  38. package/src/lib/model/reply-policy.js +141 -0
  39. package/src/lib/model/reply-policy.js.map +1 -0
  40. package/src/lib/model/submission-presenter.d.ts +81 -0
  41. package/src/lib/model/submission-presenter.js +152 -0
  42. package/src/lib/model/submission-presenter.js.map +1 -0
  43. package/src/lib/plugin.d.ts +25 -0
  44. package/src/lib/plugin.js +102 -0
  45. package/src/lib/plugin.js.map +1 -0
  46. package/src/lib/server.d.ts +179 -0
  47. package/src/lib/server.js +666 -0
  48. package/src/lib/server.js.map +1 -0
@@ -0,0 +1,666 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ /**
18
+ * The Inbox's two acts on the person who sent a submission: answer them, and
19
+ * put them on a marketing list.
20
+ *
21
+ * `POST inbox/reply` sends one message. `POST inbox/assign-list` enrolls, and
22
+ * `POST inbox/list-options` tells the merchant what they may do before they
23
+ * do it.
24
+ *
25
+ * ## The two acts are kept apart everywhere
26
+ *
27
+ * A reply is TRANSACTIONAL — the person asked to be contacted by submitting
28
+ * the form, and answering them is the transaction they started. A list is
29
+ * MARKETING — a standing invitation to mail them about things they never
30
+ * asked about. So they are separate routes, separate records and separate
31
+ * cards on screen, and neither is a side effect of the other: replying
32
+ * enrolls nobody and writes no consent, and enrolling sends nothing and
33
+ * meters nothing. Folding either into the other would make a merchant's
34
+ * ordinary act of answering a customer into an act with consequences they
35
+ * did not choose. The enrollment rule itself is the framework's
36
+ * `list-assignment-policy`, shared with the Emails console's audience card so
37
+ * that both surfaces refuse the same people for the same stated reasons.
38
+ *
39
+ * ## The boundary this feature sits on, stated because the UI must say it too
40
+ *
41
+ * The Inbox is not a mailbox. Nothing in the INBOX receives mail: no route
42
+ * of its own, no MX record pointed at a submission, no parser. A submission
43
+ * arrived as an HTTP POST, not as a message, so there is no `Message-ID` to
44
+ * thread against and a reply is always the FIRST message in its conversation.
45
+ * (The platform's one receiving door is the CRM's capture address —
46
+ * `POST /api/crm/inbound` in the console app, AGL-2657 — which files a
47
+ * forwarded or copied message on a contact's timeline. It threads nothing
48
+ * here: a submission is not mail, and the Inbox's statement stands.)
49
+ *
50
+ * That decides two things:
51
+ *
52
+ * - **No `In-Reply-To` or `References` header is sent.** There is nothing to
53
+ * put in them. Inventing an identifier would produce headers that reference
54
+ * a message no mail server has ever seen, which threads nothing and makes
55
+ * some filters treat the message as forged.
56
+ * - **`Reply-To` is the sender's own console account address**, so when the
57
+ * recipient answers, the answer reaches a human in a real mailbox. It does
58
+ * not come back here, and the composer says so. A reply that went to the
59
+ * platform's unmonitored address would be a message the merchant never sees
60
+ * and the customer believes was received.
61
+ *
62
+ * The thread the merchant sees is ours, stored under the submission, and it
63
+ * holds what we sent — never what came back, because nothing comes back.
64
+ *
65
+ * ## Sending identity
66
+ *
67
+ * The `From:` address is whatever THIS SITE sends as — `hostSendingIdentity`,
68
+ * the same resolution a campaign gets — so a site with a domain of its own
69
+ * replies on it, and a site without one replies on the shared pool, which is
70
+ * correct for a reply because a reply is transactional.
71
+ *
72
+ * The display name varies separately, through `resolveBrandingProfile`, which
73
+ * is entitlement-gated: a merchant without white-label replies under the
74
+ * platform's name even on their own domain.
75
+ *
76
+ * `Reply-To` stays load-bearing either way, and not as a stopgap. Nothing
77
+ * here receives mail, so the answer has to be routed to a mailbox somebody
78
+ * actually reads — and `Reply-To` is the one header that may name an address
79
+ * on a domain this platform has never verified.
80
+ *
81
+ * ## Consent and suppression
82
+ *
83
+ * A reply is transactional: the recipient asked to be contacted by submitting
84
+ * the form, so no marketing-consent record is required and none is read. The
85
+ * suppression lists still apply — both of them — and this is the first send
86
+ * path in the product to consult BOTH on one address:
87
+ *
88
+ * - the platform list, through `isEmailSuppressed`, which fails closed;
89
+ * - this site's list, under `hosts/{hostId}/suppressions`.
90
+ *
91
+ * Both are keyed with `emailSuppressionKey`, one derivation, so the two reads
92
+ * cannot disagree about which document to look for.
93
+ */ import { ASSIGNMENT_REFUSAL_MESSAGES, assignmentBasis, assignmentReadout, isOrgWideMember, readMarketingBasis, registerPluginApiRoute, resolveBrandingProfile } from "@aglyn/aglyn/server";
94
+ import { emailSuppressionKey, enrollListMember, firebaseAdmin, getOrgForHost, hostSendingIdentity, isEmailSuppressed, meterHostEmail, consentGroupForSite, orgDataCollectionForHost, resolveOrgMembership } from "@aglyn/tenant-data-admin";
95
+ // The leaf, not the barrel: this plugin's specs substitute the barrel
96
+ // wholesale, and the lookup must reach the real index logic under them.
97
+ import { findContactByEmail } from "@aglyn/tenant-data-admin/server/contact-email-index";
98
+ import { sendEmail } from "@aglyn/shared-util-email";
99
+ import { FieldValue } from "firebase-admin/firestore";
100
+ import { REPLY_BODY_MAX, REPLY_SUBJECT_MAX, composeReplyBody, replyRecipient } from "./model/reply-policy.js";
101
+ /** Where a reply is stored, under the submission it answers. */ export const REPLIES_SUBCOLLECTION = 'replies';
102
+ /** `context` on the send: the log label and the Resend attribution tag. */ export const REPLY_CONTEXT = 'inbox-reply';
103
+ /** What a refusal says to the merchant. One line, naming the cause. */ const REFUSAL_MESSAGES = {
104
+ 'no-address': 'This submission has no email field, so there is nobody to reply to.',
105
+ 'unroutable-address': 'The email address on this submission is not a valid address.',
106
+ 'suppressed-platform': 'This address bounced or reported a message as spam, so it cannot be mailed.',
107
+ 'suppressed-host': 'This address unsubscribed from this site, so it cannot be mailed.'
108
+ };
109
+ /**
110
+ * Is this address suppressed, on either list?
111
+ *
112
+ * Order matters only for the reason reported, not the outcome. The platform
113
+ * list is read first because it is the one that carries a bounce learned
114
+ * anywhere — including on a send that named no site — and that is the more
115
+ * useful thing to tell a merchant who is about to retype the message.
116
+ *
117
+ * Named for the ADDRESS rather than for either act, because both use it: a
118
+ * reply must not go to a dead or complaining mailbox, and a list enrollment
119
+ * must not put one on a standing audience. A second copy for the second act
120
+ * would be two answers to "may this address be mailed".
121
+ */ export async function addressSuppression(hostId, email, firestore) {
122
+ const key = emailSuppressionKey(email);
123
+ // `emailSuppressionKey` returns null for an address it cannot key, and
124
+ // `isEmailSuppressed` already answers `true` for that case. Treating it as
125
+ // suppressed here keeps the two halves agreeing: an address we cannot key
126
+ // is an address we cannot prove is safe to mail.
127
+ if (!key) return 'suppressed-platform';
128
+ if (await isEmailSuppressed(email, firestore)) return 'suppressed-platform';
129
+ const db = firestore != null ? firestore : firebaseAdmin.app().firestore();
130
+ const doc = await db.collection('hosts').doc(hostId).collection('suppressions').doc(key).get();
131
+ return doc.exists ? 'suppressed-host' : null;
132
+ }
133
+ /**
134
+ * Reply to one submission.
135
+ *
136
+ * Body: `{ hostId, submissionId, subject, message }`. The recipient is
137
+ * deliberately absent — it is read off the stored submission, so this route
138
+ * cannot be pointed at an address of the caller's choosing.
139
+ */ export const inboxReplyHandler = async (req, res)=>{
140
+ var _ref, _ref1, _ref2, _ref3, _req_headers_authorization;
141
+ var _req_body, _req_body1, _req_body2, _req_body3;
142
+ if (req.method !== 'POST') {
143
+ return res.status(405).json({
144
+ error: 'Method not allowed'
145
+ });
146
+ }
147
+ const hostId = String((_ref = (_req_body = req.body) == null ? void 0 : _req_body.hostId) != null ? _ref : '');
148
+ const submissionId = String((_ref1 = (_req_body1 = req.body) == null ? void 0 : _req_body1.submissionId) != null ? _ref1 : '');
149
+ const subject = String((_ref2 = (_req_body2 = req.body) == null ? void 0 : _req_body2.subject) != null ? _ref2 : '').trim().slice(0, REPLY_SUBJECT_MAX);
150
+ const message = String((_ref3 = (_req_body3 = req.body) == null ? void 0 : _req_body3.message) != null ? _ref3 : '').trim().slice(0, REPLY_BODY_MAX);
151
+ if (!hostId || !submissionId) {
152
+ return res.status(400).json({
153
+ error: 'Missing hostId or submissionId'
154
+ });
155
+ }
156
+ if (!subject || !message) {
157
+ return res.status(400).json({
158
+ error: 'Missing subject or message'
159
+ });
160
+ }
161
+ const authorization = String((_req_headers_authorization = req.headers.authorization) != null ? _req_headers_authorization : '');
162
+ const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined;
163
+ if (!idToken) return res.status(401).json({
164
+ error: 'Unauthenticated'
165
+ });
166
+ try {
167
+ var _hostSnapshot_get, _decoded_email, _submission_get, _ref4, _hostSnapshot_get1, _result_id;
168
+ var _this;
169
+ const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
170
+ const firestore = firebaseAdmin.app().firestore();
171
+ const hostRef = firestore.collection('hosts').doc(hostId);
172
+ const hostSnapshot = await hostRef.get();
173
+ if (!hostSnapshot.exists) {
174
+ return res.status(404).json({
175
+ error: 'Unknown site'
176
+ });
177
+ }
178
+ const memberRole = ((_hostSnapshot_get = hostSnapshot.get('memberRoles')) != null ? _hostSnapshot_get : {})[decoded.uid];
179
+ if (memberRole !== 'admin' && memberRole !== 'editor') {
180
+ return res.status(403).json({
181
+ error: 'Not a site admin or editor'
182
+ });
183
+ }
184
+ // The address the reply goes back to, and the only thing that makes this
185
+ // send worth making. A console account with no address on it would put
186
+ // the customer's answer nowhere, so it refuses rather than sending a
187
+ // message that invites a reply into a void.
188
+ const replyTo = String((_decoded_email = decoded.email) != null ? _decoded_email : '');
189
+ if (!replyTo) {
190
+ return res.status(400).json({
191
+ error: 'Your account has no email address, so a reply would have nowhere to come back to.'
192
+ });
193
+ }
194
+ const submissionRef = hostRef.collection('formSubmissions').doc(submissionId);
195
+ const submission = await submissionRef.get();
196
+ if (!submission.exists) {
197
+ return res.status(404).json({
198
+ error: 'Unknown submission'
199
+ });
200
+ }
201
+ const fields = (_submission_get = submission.get('fields')) != null ? _submission_get : {};
202
+ const recipient = replyRecipient(fields);
203
+ if ('refusal' in recipient) {
204
+ return res.status(422).json({
205
+ error: REFUSAL_MESSAGES[recipient.refusal],
206
+ reason: recipient.refusal
207
+ });
208
+ }
209
+ const suppressed = await addressSuppression(hostId, recipient.email);
210
+ if (suppressed) {
211
+ return res.status(409).json({
212
+ error: REFUSAL_MESSAGES[suppressed],
213
+ reason: suppressed
214
+ });
215
+ }
216
+ const siteName = String((_ref4 = (_hostSnapshot_get1 = hostSnapshot.get('displayName')) != null ? _hostSnapshot_get1 : hostSnapshot.get('subdomain')) != null ? _ref4 : '');
217
+ const branding = resolveBrandingProfile((_this = await getOrgForHost(hostId).catch(()=>null)) == null ? void 0 : _this.org);
218
+ // No `html` is passed on purpose. `sendEmail` synthesizes the HTML part
219
+ // from `text`, which is the single place that guarantee is enforced; a
220
+ // hand-built one here would be a second place it can be forgotten.
221
+ // No `priority` either: absent, it resolves to transactional, which the
222
+ // platform governor may never refuse. That is the correct class — a reply
223
+ // is a person answering a person, and it cannot be retried by a sweep.
224
+ const result = await sendEmail({
225
+ to: recipient.email,
226
+ subject,
227
+ text: composeReplyBody({
228
+ message,
229
+ fields,
230
+ siteName
231
+ }),
232
+ replyTo,
233
+ fromName: branding.fromName,
234
+ sendingIdentity: await hostSendingIdentity(hostId),
235
+ audience: 'tenant',
236
+ context: REPLY_CONTEXT
237
+ });
238
+ if (!result.sent) {
239
+ var _result_reason;
240
+ return res.status(502).json({
241
+ error: 'The reply could not be sent.',
242
+ reason: (_result_reason = result.reason) != null ? _result_reason : 'unknown'
243
+ });
244
+ }
245
+ // Metered as transactional, so it enters the cost meter and never the
246
+ // campaign meter a plan limit can refuse.
247
+ void meterHostEmail(hostId, 1, 'transactional');
248
+ const sentAtMs = Date.now();
249
+ const replyRef = await submissionRef.collection(REPLIES_SUBCOLLECTION).add({
250
+ to: recipient.email,
251
+ subject,
252
+ // The merchant's own words, not the composed wire body. The quote and
253
+ // the attribution line are rendered from the submission every time, so
254
+ // storing them would be storing a copy of a document one field away.
255
+ message,
256
+ replyTo,
257
+ fromName: branding.fromName,
258
+ sentByUid: decoded.uid,
259
+ providerMessageId: (_result_id = result.id) != null ? _result_id : null,
260
+ sentAtMs,
261
+ createdAt: FieldValue.serverTimestamp()
262
+ });
263
+ // A replied submission is a handled one, so it stops being unread in the
264
+ // same write that records the reply. `repliedAtMs` is what the row reads;
265
+ // the count of replies is a subcollection read the list must not make.
266
+ await submissionRef.set({
267
+ read: true,
268
+ repliedAtMs: sentAtMs
269
+ }, {
270
+ merge: true
271
+ });
272
+ return res.status(200).json({
273
+ sent: true,
274
+ replyId: replyRef.id,
275
+ to: recipient.email,
276
+ replyTo,
277
+ sentAtMs
278
+ });
279
+ } catch (error) {
280
+ console.error('[inbox] reply failed', error);
281
+ return res.status(500).json({
282
+ error: 'The reply could not be sent.'
283
+ });
284
+ }
285
+ };
286
+ /** Where an assignment is recorded, under the submission that occasioned it. */ export const LIST_ASSIGNMENTS_SUBCOLLECTION = 'listAssignments';
287
+ /** The `source` stamped on a member enrolled from the Inbox. */ export const ASSIGNMENT_SOURCE = 'inbox';
288
+ /**
289
+ * How many lists the picker offers.
290
+ *
291
+ * A ceiling on the READ, so a merchant with an unusual number of lists costs
292
+ * one bounded query rather than a scan, and the response says when it is a
293
+ * floor rather than presenting a slice as the whole set.
294
+ */ export const LIST_OPTIONS_LIMIT = 100;
295
+ /**
296
+ * Who is asking, about which submission, and for which person.
297
+ *
298
+ * ## Two gates, not one
299
+ *
300
+ * A host role is necessary and NOT sufficient. Lists live at
301
+ * `orgs/{orgId}/lists` and their members are contacts, so the security rules
302
+ * put both behind `isOrgWideMember()` — an editor invited to ONE site is an
303
+ * org member with `allHosts: false`, and gating an org-wide write on the host
304
+ * role alone would let a single-site collaborator enroll people into an
305
+ * audience every other site in the org can mail. The Admin SDK evaluates no
306
+ * rules, so this route is the enforcement rather than an echo of it.
307
+ *
308
+ * ## The address is read off the submission
309
+ *
310
+ * Through `replyRecipient`, the same resolver the reply and the Inbox row use.
311
+ * A `to` in the request body would let a site editor enroll an address of
312
+ * their choosing into a marketing audience, which is the same defect the
313
+ * reply handler refuses for the same reason.
314
+ */ async function resolveAssignmentContext(req) {
315
+ var _ref, _ref1, _req_headers_authorization, _hostSnapshot_get, _ref2, _submission_get;
316
+ var _req_body, _req_body1, _this;
317
+ const hostId = String((_ref = (_req_body = req.body) == null ? void 0 : _req_body.hostId) != null ? _ref : '');
318
+ const submissionId = String((_ref1 = (_req_body1 = req.body) == null ? void 0 : _req_body1.submissionId) != null ? _ref1 : '');
319
+ if (!hostId || !submissionId) {
320
+ return {
321
+ ok: false,
322
+ status: 400,
323
+ body: {
324
+ error: 'Missing hostId or submissionId'
325
+ }
326
+ };
327
+ }
328
+ const authorization = String((_req_headers_authorization = req.headers.authorization) != null ? _req_headers_authorization : '');
329
+ const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined;
330
+ if (!idToken) {
331
+ return {
332
+ ok: false,
333
+ status: 401,
334
+ body: {
335
+ error: 'Unauthenticated'
336
+ }
337
+ };
338
+ }
339
+ const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
340
+ const firestore = firebaseAdmin.app().firestore();
341
+ const hostRef = firestore.collection('hosts').doc(hostId);
342
+ const hostSnapshot = await hostRef.get();
343
+ if (!hostSnapshot.exists) {
344
+ return {
345
+ ok: false,
346
+ status: 404,
347
+ body: {
348
+ error: 'Unknown site'
349
+ }
350
+ };
351
+ }
352
+ const memberRole = ((_hostSnapshot_get = hostSnapshot.get('memberRoles')) != null ? _hostSnapshot_get : {})[decoded.uid];
353
+ if (memberRole !== 'admin' && memberRole !== 'editor') {
354
+ return {
355
+ ok: false,
356
+ status: 403,
357
+ body: {
358
+ error: 'Not a site admin or editor'
359
+ }
360
+ };
361
+ }
362
+ const orgId = String((_ref2 = (_this = await getOrgForHost(hostId).catch(()=>null)) == null ? void 0 : _this.orgId) != null ? _ref2 : '');
363
+ if (!orgId) {
364
+ return {
365
+ ok: false,
366
+ status: 404,
367
+ body: {
368
+ error: 'This site has no organization, so it has no lists.'
369
+ }
370
+ };
371
+ }
372
+ const membership = await resolveOrgMembership(decoded.uid, orgId).catch(()=>null);
373
+ const member = membership == null ? void 0 : membership.member;
374
+ const orgWideWriter = isOrgWideMember(member) && ((member == null ? void 0 : member.role) === 'owner' || (member == null ? void 0 : member.role) === 'admin' || (member == null ? void 0 : member.role) === 'editor') && (member == null ? void 0 : member.orgSuspended) !== true;
375
+ if (!orgWideWriter) {
376
+ return {
377
+ ok: false,
378
+ status: 403,
379
+ body: {
380
+ error: 'Marketing lists belong to the whole organization, so adding ' + 'someone to one needs organization-wide access rather than access ' + 'to this site.'
381
+ }
382
+ };
383
+ }
384
+ const submissionRef = hostRef.collection('formSubmissions').doc(submissionId);
385
+ const submission = await submissionRef.get();
386
+ if (!submission.exists) {
387
+ return {
388
+ ok: false,
389
+ status: 404,
390
+ body: {
391
+ error: 'Unknown submission'
392
+ }
393
+ };
394
+ }
395
+ const recipient = replyRecipient((_submission_get = submission.get('fields')) != null ? _submission_get : {});
396
+ if ('refusal' in recipient) {
397
+ return {
398
+ ok: false,
399
+ status: 422,
400
+ body: {
401
+ error: ASSIGNMENT_REFUSAL_MESSAGES[recipient.refusal],
402
+ reason: recipient.refusal
403
+ }
404
+ };
405
+ }
406
+ return {
407
+ ok: true,
408
+ uid: decoded.uid,
409
+ orgId,
410
+ firestore,
411
+ submissionRef,
412
+ email: recipient.email
413
+ };
414
+ }
415
+ /**
416
+ * The person's own consent facts, read off the org contact for this address.
417
+ *
418
+ * The CRM record is where a refusal lives: `marketingConsent` is written
419
+ * `false` by exactly one path in the product and it writes a contact. So this
420
+ * is the read that makes `declined` mean something at enrollment time, and an
421
+ * absent contact is honestly `unrecorded` rather than a reason to guess.
422
+ *
423
+ * Read UNSCOPED, deliberately. `scopedToHost` narrows an org collection to
424
+ * what one site may see, and a refusal filtered out by that narrowing is a
425
+ * refusal this route would then step over — the failure mode is mailing
426
+ * somebody who said no. It is safe here because the caller has already been
427
+ * proved an org-wide member, which is the tier the rules grant the whole
428
+ * org's contacts to.
429
+ *
430
+ * Through the org's address index (AGL-2633), for the same failure mode
431
+ * from the other side: a refusal recorded on a record that was later
432
+ * merged into another is the survivor's refusal, and the sender's address
433
+ * may be the one that became an alternate.
434
+ */ async function storedConsentForAddress(hostId, group, email) {
435
+ try {
436
+ const contacts = await orgDataCollectionForHost(hostId, 'contacts');
437
+ const found = await findContactByEmail(contacts, email);
438
+ return readMarketingBasis(found ? found.data() : null, group);
439
+ } catch (error) {
440
+ console.error('[inbox] consent lookup failed', error);
441
+ /*
442
+ * FAILS TO `declined`, which reads oddly until you name the alternative.
443
+ * A throwing read cannot say the person consented and cannot say they
444
+ * refused; the question is which way the unknown should fall. `unrecorded`
445
+ * would leave the attestation control on screen and let the merchant add
446
+ * somebody whose stored refusal this route simply failed to see, and no
447
+ * later surface would ever revisit it. A refusal costs a retry.
448
+ */ return _extends({}, readMarketingBasis(null, group), {
449
+ basis: 'declined',
450
+ // Attributed to nobody, because nobody asserted this: it is what a
451
+ // failed read falls back to, not a refusal anyone recorded. Claiming
452
+ // `'person'` here would put a refusal in the audit trail that the
453
+ // person never made.
454
+ assertedBy: null,
455
+ source: null,
456
+ basisAtMs: null,
457
+ capturedAtMs: null
458
+ });
459
+ }
460
+ }
461
+ /**
462
+ * `POST inbox/list-options` — what the merchant may do with this sender.
463
+ *
464
+ * Reads only. It exists because the answer needs three things the browser
465
+ * cannot have: the org's lists (rules put them behind org-wide membership,
466
+ * which the acting console session may not hold), the person's consent record
467
+ * (an org contact, same gate), and both suppression lists. Computing any of
468
+ * it client-side would be a second copy of the rule, on the surface whose
469
+ * whole job is to tell the merchant the truth about what is about to happen.
470
+ *
471
+ * Reached by an explicit expansion in the reader, never on mount: it is three
472
+ * reads and a bounded query, and paying them once per opened submission would
473
+ * charge every merchant who never touches lists.
474
+ */ export const inboxListOptionsHandler = async (req, res)=>{
475
+ if (req.method !== 'POST') {
476
+ return res.status(405).json({
477
+ error: 'Method not allowed'
478
+ });
479
+ }
480
+ try {
481
+ var _ref;
482
+ var _req_body;
483
+ const context = await resolveAssignmentContext(req);
484
+ if (context.ok === false) {
485
+ return res.status(context.status).json(context.body);
486
+ }
487
+ const hostId = String((_ref = (_req_body = req.body) == null ? void 0 : _req_body.hostId) != null ? _ref : '');
488
+ const suppression = await addressSuppression(hostId, context.email);
489
+ // The controller this enrollment is made for — the declared group of
490
+ // sites that are one sender, or this site alone.
491
+ const group = await consentGroupForSite(hostId);
492
+ const stored = await storedConsentForAddress(hostId, group, context.email);
493
+ const readout = assignmentReadout({
494
+ stored,
495
+ suppression
496
+ });
497
+ const listsRef = context.firestore.collection('orgs').doc(context.orgId).collection('lists');
498
+ /*
499
+ * Ordered by document id, which is the one key every list has.
500
+ * `limit()` alone answers in that order anyway but does not SAY so, and a
501
+ * page of results whose order is an implementation detail is the shape
502
+ * that turned an audience into a random sample. Ordering by `name` would
503
+ * be worse than either: `orderBy` drops documents missing the field, so a
504
+ * list created without a name would vanish from its own picker.
505
+ */ const snapshot = await listsRef.orderBy(firebaseAdmin.firestore.FieldPath.documentId()).limit(LIST_OPTIONS_LIMIT + 1).get();
506
+ const lists = snapshot.docs.slice(0, LIST_OPTIONS_LIMIT).map((doc)=>{
507
+ var _doc_get;
508
+ return {
509
+ id: doc.id,
510
+ name: String((_doc_get = doc.get('name')) != null ? _doc_get : doc.id)
511
+ };
512
+ });
513
+ return res.status(200).json(_extends({
514
+ to: context.email,
515
+ lists,
516
+ listsTruncated: snapshot.docs.length > LIST_OPTIONS_LIMIT,
517
+ basis: stored.basis,
518
+ basisAtMs: stored.basisAtMs
519
+ }, readout));
520
+ } catch (error) {
521
+ console.error('[inbox] list options failed', error);
522
+ return res.status(500).json({
523
+ error: 'The lists could not be read.'
524
+ });
525
+ }
526
+ };
527
+ /**
528
+ * `POST inbox/assign-list` — put this sender on a marketing list.
529
+ *
530
+ * Body: `{ hostId, submissionId, listId, attestConsent? }`. `attestConsent` is
531
+ * the merchant STATING that they have this person's permission; it is not a
532
+ * way to name a basis, because the pass-through basis is derived server-side
533
+ * from the person's own record.
534
+ *
535
+ * ## What this act is, and what it is not
536
+ *
537
+ * It is marketing enrollment: the only consumer of a list is a campaign. It
538
+ * is therefore kept entirely separate from the reply — different route,
539
+ * different card in the UI, different record — and replying enrolls nobody.
540
+ * The reverse holds too: enrolling somebody sends them nothing, meters
541
+ * nothing, and is not a promise that they are still mailable when a campaign
542
+ * eventually runs. Suppression is consulted again at send time, in
543
+ * `filterSendableForHost`, because an address can be suppressed the day after
544
+ * it is enrolled and an enrollment-time check that licensed every later send
545
+ * would be a check that passes once and pays out forever.
546
+ */ export const inboxAssignListHandler = async (req, res)=>{
547
+ var _ref;
548
+ var _req_body, _req_body1;
549
+ if (req.method !== 'POST') {
550
+ return res.status(405).json({
551
+ error: 'Method not allowed'
552
+ });
553
+ }
554
+ const listId = String((_ref = (_req_body = req.body) == null ? void 0 : _req_body.listId) != null ? _ref : '');
555
+ if (!listId) return res.status(400).json({
556
+ error: 'Missing listId'
557
+ });
558
+ const attested = ((_req_body1 = req.body) == null ? void 0 : _req_body1.attestConsent) === true;
559
+ try {
560
+ var _ref1, _listSnapshot_get, _listSnapshot_get1;
561
+ var _req_body2;
562
+ const context = await resolveAssignmentContext(req);
563
+ if (context.ok === false) {
564
+ return res.status(context.status).json(context.body);
565
+ }
566
+ const hostId = String((_ref1 = (_req_body2 = req.body) == null ? void 0 : _req_body2.hostId) != null ? _ref1 : '');
567
+ const refuse = (status, reason)=>res.status(status).json({
568
+ error: ASSIGNMENT_REFUSAL_MESSAGES[reason],
569
+ reason
570
+ });
571
+ const suppressed = await addressSuppression(hostId, context.email);
572
+ if (suppressed) return refuse(409, suppressed);
573
+ const listRef = context.firestore.collection('orgs').doc(context.orgId).collection('lists').doc(listId);
574
+ const listSnapshot = await listRef.get();
575
+ // A stale or mistyped id must not CREATE a list: a campaign's `list`
576
+ // audience would then read a list nobody set up.
577
+ if (!listSnapshot.exists) {
578
+ return res.status(404).json({
579
+ error: 'Unknown list'
580
+ });
581
+ }
582
+ // The controller this enrollment is made for — the declared group of
583
+ // sites that are one sender, or this site alone.
584
+ const group = await consentGroupForSite(hostId);
585
+ const stored = await storedConsentForAddress(hostId, group, context.email);
586
+ const nowMs = Date.now();
587
+ const decision = assignmentBasis({
588
+ stored,
589
+ attested,
590
+ actingUid: context.uid,
591
+ nowMs
592
+ });
593
+ if ('refusal' in decision) {
594
+ // 409 for a refusal that no answer can change, 422 for the one the
595
+ // merchant can answer by asserting a basis. The distinction is what
596
+ // lets the UI offer the attestation control on exactly one of them.
597
+ return refuse(decision.refusal === 'declined' ? 409 : 422, decision.refusal);
598
+ }
599
+ const enrollment = await enrollListMember({
600
+ listRef,
601
+ group,
602
+ email: context.email,
603
+ source: ASSIGNMENT_SOURCE,
604
+ // Never `'rule'`: the dynamic-list materializer reconciles its own rows
605
+ // away when a person stops matching, and a decision somebody made by
606
+ // hand is not a rule match that lapsed.
607
+ via: 'manual',
608
+ consent: decision
609
+ });
610
+ if (enrollment.enrolled === false) {
611
+ /*
612
+ * The membership itself records a refusal that the CRM record did not.
613
+ * `enrollListMember` is the only writer of the collection and holds the
614
+ * row, so it is the backstop for every enrollment route; reaching it
615
+ * here means the two records disagree, and the refusal wins.
616
+ */ return refuse(409, enrollment.refusal === 'declined' ? 'declined' : 'no-address');
617
+ }
618
+ /*
619
+ * The attestation trail, beside the reply record and for the same reason:
620
+ * a support question about a marketing list starts with "who put this
621
+ * person on it, and on what basis". The member document carries the basis
622
+ * so the send-time join can read it; this carries the same facts where
623
+ * the act happened, which is the only place that survives the person
624
+ * later being removed from the list.
625
+ */ await context.submissionRef.collection(LIST_ASSIGNMENTS_SUBCOLLECTION).add({
626
+ to: context.email,
627
+ listId,
628
+ listName: String((_listSnapshot_get = listSnapshot.get('name')) != null ? _listSnapshot_get : listId),
629
+ memberId: enrollment.memberId,
630
+ basis: decision.basis,
631
+ basisAtMs: decision.atMs,
632
+ assertedByUid: decision.byUid,
633
+ addedByUid: context.uid,
634
+ addedAtMs: nowMs,
635
+ createdAt: FieldValue.serverTimestamp()
636
+ });
637
+ return res.status(200).json({
638
+ enrolled: true,
639
+ to: context.email,
640
+ listId,
641
+ listName: String((_listSnapshot_get1 = listSnapshot.get('name')) != null ? _listSnapshot_get1 : listId),
642
+ memberId: enrollment.memberId,
643
+ basis: decision.basis
644
+ });
645
+ } catch (error) {
646
+ console.error('[inbox] list assignment failed', error);
647
+ return res.status(500).json({
648
+ error: 'The list assignment failed.'
649
+ });
650
+ }
651
+ };
652
+ /**
653
+ * Console API registration.
654
+ *
655
+ * None of these is on the machine-path exemption list in
656
+ * `plugin-api-rate-limit.ts`. Each is reached by a person pressing a button in
657
+ * a browser, so the visitor limiter's per-(site, IP) budget is far above any
658
+ * real use of them and is the right ceiling for surfaces that put mail on the
659
+ * wire or a person into a marketing audience.
660
+ */ export function registerInboxConsoleApi() {
661
+ registerPluginApiRoute('inbox/reply', inboxReplyHandler);
662
+ registerPluginApiRoute('inbox/list-options', inboxListOptionsHandler);
663
+ registerPluginApiRoute('inbox/assign-list', inboxAssignListHandler);
664
+ }
665
+
666
+ //# sourceMappingURL=server.js.map