@aglyn/plugins-email 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 (144) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +7 -0
  3. package/package.json +58 -0
  4. package/src/index.d.ts +35 -0
  5. package/src/index.js +35 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/campaign-design-create-widget.d.ts +21 -0
  8. package/src/lib/components/campaign-design-create-widget.js +61 -0
  9. package/src/lib/components/campaign-design-create-widget.js.map +1 -0
  10. package/src/lib/components/campaign-sender-editor-widget.d.ts +18 -0
  11. package/src/lib/components/campaign-sender-editor-widget.js +39 -0
  12. package/src/lib/components/campaign-sender-editor-widget.js.map +1 -0
  13. package/src/lib/components/campaign-topic-options-widget.d.ts +19 -0
  14. package/src/lib/components/campaign-topic-options-widget.js +48 -0
  15. package/src/lib/components/campaign-topic-options-widget.js.map +1 -0
  16. package/src/lib/components/campaign-topic-select.d.ts +32 -0
  17. package/src/lib/components/campaign-topic-select.js +87 -0
  18. package/src/lib/components/campaign-topic-select.js.map +1 -0
  19. package/src/lib/components/dynamic-list-rule-fields.d.ts +173 -0
  20. package/src/lib/components/dynamic-list-rule-fields.js +1473 -0
  21. package/src/lib/components/dynamic-list-rule-fields.js.map +1 -0
  22. package/src/lib/components/email-blocks.d.ts +111 -0
  23. package/src/lib/components/email-blocks.js +875 -0
  24. package/src/lib/components/email-blocks.js.map +1 -0
  25. package/src/lib/components/email-design-preview.d.ts +62 -0
  26. package/src/lib/components/email-design-preview.js +174 -0
  27. package/src/lib/components/email-design-preview.js.map +1 -0
  28. package/src/lib/components/email-screens-card.d.ts +42 -0
  29. package/src/lib/components/email-screens-card.js +277 -0
  30. package/src/lib/components/email-screens-card.js.map +1 -0
  31. package/src/lib/components/email-template-detail.d.ts +48 -0
  32. package/src/lib/components/email-template-detail.js +681 -0
  33. package/src/lib/components/email-template-detail.js.map +1 -0
  34. package/src/lib/components/email-topic-detail.d.ts +32 -0
  35. package/src/lib/components/email-topic-detail.js +293 -0
  36. package/src/lib/components/email-topic-detail.js.map +1 -0
  37. package/src/lib/components/email-topics-card.d.ts +46 -0
  38. package/src/lib/components/email-topics-card.js +327 -0
  39. package/src/lib/components/email-topics-card.js.map +1 -0
  40. package/src/lib/components/email-zones.d.ts +28 -0
  41. package/src/lib/components/email-zones.js +20 -0
  42. package/src/lib/components/email-zones.js.map +1 -0
  43. package/src/lib/components/emails-console-page.d.ts +32 -0
  44. package/src/lib/components/emails-console-page.js +229 -0
  45. package/src/lib/components/emails-console-page.js.map +1 -0
  46. package/src/lib/components/emails-console-sections.d.ts +36 -0
  47. package/src/lib/components/emails-console-sections.js +108 -0
  48. package/src/lib/components/emails-console-sections.js.map +1 -0
  49. package/src/lib/components/list-detail-card.d.ts +47 -0
  50. package/src/lib/components/list-detail-card.js +273 -0
  51. package/src/lib/components/list-detail-card.js.map +1 -0
  52. package/src/lib/components/list-edit-card.d.ts +11 -0
  53. package/src/lib/components/list-edit-card.js +287 -0
  54. package/src/lib/components/list-edit-card.js.map +1 -0
  55. package/src/lib/components/list-import-drawer.d.ts +22 -0
  56. package/src/lib/components/list-import-drawer.js +662 -0
  57. package/src/lib/components/list-import-drawer.js.map +1 -0
  58. package/src/lib/components/list-members-panel.d.ts +94 -0
  59. package/src/lib/components/list-members-panel.js +686 -0
  60. package/src/lib/components/list-members-panel.js.map +1 -0
  61. package/src/lib/components/lists-card.d.ts +28 -0
  62. package/src/lib/components/lists-card.js +377 -0
  63. package/src/lib/components/lists-card.js.map +1 -0
  64. package/src/lib/components/sending-domain-detail.d.ts +26 -0
  65. package/src/lib/components/sending-domain-detail.js +496 -0
  66. package/src/lib/components/sending-domain-detail.js.map +1 -0
  67. package/src/lib/components/sending-domains-card.d.ts +33 -0
  68. package/src/lib/components/sending-domains-card.js +962 -0
  69. package/src/lib/components/sending-domains-card.js.map +1 -0
  70. package/src/lib/components/sending-sender-drawer.d.ts +94 -0
  71. package/src/lib/components/sending-sender-drawer.js +543 -0
  72. package/src/lib/components/sending-sender-drawer.js.map +1 -0
  73. package/src/lib/components/suppressions-card.d.ts +49 -0
  74. package/src/lib/components/suppressions-card.js +639 -0
  75. package/src/lib/components/suppressions-card.js.map +1 -0
  76. package/src/lib/components/use-org-email-topics.d.ts +79 -0
  77. package/src/lib/components/use-org-email-topics.js +111 -0
  78. package/src/lib/components/use-org-email-topics.js.map +1 -0
  79. package/src/lib/constants/bundle-common.d.ts +18 -0
  80. package/src/lib/constants/bundle-common.js +18 -0
  81. package/src/lib/constants/bundle-common.js.map +1 -0
  82. package/src/lib/hooks/use-org-company-options.d.ts +20 -0
  83. package/src/lib/hooks/use-org-company-options.js +138 -0
  84. package/src/lib/hooks/use-org-company-options.js.map +1 -0
  85. package/src/lib/hooks/use-org-contact-fields.d.ts +40 -0
  86. package/src/lib/hooks/use-org-contact-fields.js +91 -0
  87. package/src/lib/hooks/use-org-contact-fields.js.map +1 -0
  88. package/src/lib/hooks/use-org-contact-segments.d.ts +16 -0
  89. package/src/lib/hooks/use-org-contact-segments.js +55 -0
  90. package/src/lib/hooks/use-org-contact-segments.js.map +1 -0
  91. package/src/lib/hooks/use-org-crm-views.d.ts +8 -0
  92. package/src/lib/hooks/use-org-crm-views.js +74 -0
  93. package/src/lib/hooks/use-org-crm-views.js.map +1 -0
  94. package/src/lib/hooks/use-org-lists.d.ts +8 -0
  95. package/src/lib/hooks/use-org-lists.js +47 -0
  96. package/src/lib/hooks/use-org-lists.js.map +1 -0
  97. package/src/lib/model/email-design-document.d.ts +52 -0
  98. package/src/lib/model/email-design-document.js +62 -0
  99. package/src/lib/model/email-design-document.js.map +1 -0
  100. package/src/lib/model/index.d.ts +64 -0
  101. package/src/lib/model/index.js +71 -0
  102. package/src/lib/model/index.js.map +1 -0
  103. package/src/lib/model/sending-domain-status.d.ts +99 -0
  104. package/src/lib/model/sending-domain-status.js +196 -0
  105. package/src/lib/model/sending-domain-status.js.map +1 -0
  106. package/src/lib/model/template-provenance.d.ts +113 -0
  107. package/src/lib/model/template-provenance.js +107 -0
  108. package/src/lib/model/template-provenance.js.map +1 -0
  109. package/src/lib/model/template-report.d.ts +158 -0
  110. package/src/lib/model/template-report.js +249 -0
  111. package/src/lib/model/template-report.js.map +1 -0
  112. package/src/lib/plugin.d.ts +27 -0
  113. package/src/lib/plugin.js +163 -0
  114. package/src/lib/plugin.js.map +1 -0
  115. package/src/lib/server-console.d.ts +116 -0
  116. package/src/lib/server-console.js +422 -0
  117. package/src/lib/server-console.js.map +1 -0
  118. package/src/lib/server-email-drafts.d.ts +104 -0
  119. package/src/lib/server-email-drafts.js +381 -0
  120. package/src/lib/server-email-drafts.js.map +1 -0
  121. package/src/lib/server-list-gate.d.ts +183 -0
  122. package/src/lib/server-list-gate.js +365 -0
  123. package/src/lib/server-list-gate.js.map +1 -0
  124. package/src/lib/server-list-import.d.ts +199 -0
  125. package/src/lib/server-list-import.js +632 -0
  126. package/src/lib/server-list-import.js.map +1 -0
  127. package/src/lib/server-suppressions.d.ts +135 -0
  128. package/src/lib/server-suppressions.js +295 -0
  129. package/src/lib/server-suppressions.js.map +1 -0
  130. package/src/lib/server.d.ts +19 -0
  131. package/src/lib/server.js +834 -0
  132. package/src/lib/server.js.map +1 -0
  133. package/src/lib/site.d.ts +26 -0
  134. package/src/lib/site.js +81 -0
  135. package/src/lib/site.js.map +1 -0
  136. package/src/lib/unsubscribe-link.d.ts +311 -0
  137. package/src/lib/unsubscribe-link.js +398 -0
  138. package/src/lib/unsubscribe-link.js.map +1 -0
  139. package/src/lib/utils/create-email-screen.d.ts +59 -0
  140. package/src/lib/utils/create-email-screen.js +59 -0
  141. package/src/lib/utils/create-email-screen.js.map +1 -0
  142. package/src/lib/utils/generate-preset-id.d.ts +19 -0
  143. package/src/lib/utils/generate-preset-id.js +25 -0
  144. package/src/lib/utils/generate-preset-id.js.map +1 -0
@@ -0,0 +1,639 @@
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
+ */ 'use client';
17
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
18
+ import { pluginDocsHelp } from "@aglyn/aglyn";
19
+ import { ICON_VARIANT_CLOSE } from "@aglyn/shared-data-enums";
20
+ import { mdiEmailCheckOutline } from "@aglyn/shared-data-mdi";
21
+ import { CardDisplay, Container, MdiIcon, SrOnly, useConfirmationContext } from "@aglyn/shared-ui-jsx";
22
+ import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
23
+ import { ListRowActions, ListTable, listActionsColumn } from "@aglyn/shared-ui-jsx/components/list-table.component";
24
+ import { TABLE_ROW_HEIGHT } from "@aglyn/shared-ui-jsx/const/table-pagination";
25
+ /*
26
+ * The shared drawer, reached by its own path.
27
+ *
28
+ * `@aglyn/shared-ui-jsx`'s barrel deliberately does not re-export this one, so
29
+ * a deep import is the supported way in rather than an escape hatch. The
30
+ * console's `CreateArtifactDrawer` — what Screens, Components, Layouts and
31
+ * Templates create through — is this same component with a form inside it,
32
+ * and it lives in `apps/console`, which a plugin library may not import. So
33
+ * the chrome is composed from the same primitive rather than duplicated from
34
+ * the wrapper.
35
+ */ import { NavigationDrawerComponent } from "@aglyn/shared-ui-jsx/components/navigation-drawer.component";
36
+ import { useSnackbar } from "@aglyn/shared-ui-snackstack";
37
+ import { Alert, Chip, IconButton, Stack, TextField, Typography } from "@mui/material";
38
+ import Button from "@mui/material/Button";
39
+ import { collection, count, deleteDoc, doc, getAggregateFromServer, limit, orderBy, query, where } from "firebase/firestore";
40
+ import { useCallback, useEffect, useState } from "react";
41
+ import { useFirestore, usePagedCollection, useUser } from "@aglyn/tenant-feature-instance";
42
+ import { authorizedFetch } from "@aglyn/shared-util-http/authorized-token";
43
+ /**
44
+ * What a reason means to a merchant, and how much it should worry them.
45
+ *
46
+ * An ABSENT reason reads as "Unsubscribed", and that is a compatibility rule
47
+ * rather than a guess: until AGL-2408 the unsubscribe handler wrote
48
+ * `{ email, createdAt }` and nothing else, while the Resend webhook has
49
+ * stamped `'bounce'`/`'complaint'` since AGL-1918 — so an entry with no reason
50
+ * can only have come from somebody clicking the link. New unsubscribes write
51
+ * the reason explicitly, so this fallback covers history and nothing else.
52
+ */ const REASONS = {
53
+ unsubscribe: {
54
+ label: 'Unsubscribed',
55
+ color: 'default'
56
+ },
57
+ bounce: {
58
+ label: 'Bounced',
59
+ color: 'warning'
60
+ },
61
+ complaint: {
62
+ label: 'Marked as spam',
63
+ color: 'error'
64
+ },
65
+ /*
66
+ * Recorded by a person, through the Add control.
67
+ *
68
+ * Its OWN value rather than a reuse of `unsubscribe`: an opt-out arriving
69
+ * by reply, phone or in person is not somebody clicking a link, and the
70
+ * difference is exactly what a merchant asked to prove the request was
71
+ * honored has to be able to show.
72
+ */ manual: {
73
+ label: 'Added by hand',
74
+ color: 'default'
75
+ }
76
+ };
77
+ const describeReason = (reason)=>{
78
+ var _REASONS_String;
79
+ return (_REASONS_String = REASONS[String(reason != null ? reason : 'unsubscribe')]) != null ? _REASONS_String : {
80
+ label: String(reason),
81
+ color: 'default'
82
+ };
83
+ };
84
+ /**
85
+ * `YYYY-MM-DD` from a Firestore timestamp shape, or an em dash.
86
+ *
87
+ * `createdAt` first, and that ordering is the column's meaning rather than a
88
+ * preference. Both writers restamp `suppressedAt` on every touch and write
89
+ * `createdAt` only when the document is new, precisely so that a bounce
90
+ * arriving after an unsubscribe does not move the date the person actually
91
+ * unsubscribed. Reading `suppressedAt` first put the restamp on screen under a
92
+ * heading that says "Since", and it is also the field the list is ordered by,
93
+ * so a re-touched row would have sorted by one date and displayed another.
94
+ */ function onDate(row) {
95
+ var _ref;
96
+ var _row_createdAt, _row_suppressedAt;
97
+ const seconds = (_ref = (_row_createdAt = row.createdAt) == null ? void 0 : _row_createdAt.seconds) != null ? _ref : (_row_suppressedAt = row.suppressedAt) == null ? void 0 : _row_suppressedAt.seconds;
98
+ if (!seconds) return '—';
99
+ return new Date(seconds * 1000).toISOString().slice(0, 10);
100
+ }
101
+ /**
102
+ * Suppressions (AGL-2410): who is not being emailed, and why.
103
+ *
104
+ * ## What was missing
105
+ *
106
+ * `hosts/{hostId}/suppressions` was written by two paths — the unsubscribe
107
+ * handler and, since AGL-1918, the Resend webhook on a permanent bounce or a
108
+ * complaint — and read by exactly one: `campaign-send.ts`, to filter an
109
+ * audience. Nothing in the console displayed it. So a merchant could not
110
+ * answer any of:
111
+ *
112
+ * - *"My campaign says 500 recipients and 480 sent — who were the other
113
+ * 20?"* The send returns `{recipients, sent}` and the difference was
114
+ * unexplained.
115
+ * - *"Is my list going stale?"* A bounce rate is the single most useful
116
+ * number about a list and there was nowhere to see it.
117
+ * - *"This address was suppressed by mistake."* There was no way to remove
118
+ * an entry — and a link prescanner unsubscribing someone (AGL-2408 §2) was
119
+ * therefore unrecoverable from inside the product.
120
+ *
121
+ * ## Why a surface and not another counter
122
+ *
123
+ * AGL-1918 deliberately did NOT write a `stats.bounces` counter alongside its
124
+ * fix, because a number with no screen to show it is the written-but-never-
125
+ * read shape this issue is about, one level up. So the fix is the READER, and
126
+ * the breakdown here is derived from the rows on screen rather than from a
127
+ * second stored figure that could disagree with them.
128
+ *
129
+ * ## Removing an entry
130
+ *
131
+ * A plain client `deleteDoc`, and that is a decision. The list belongs to the
132
+ * merchant, host admins already read it through the same rules, and removing
133
+ * a row does nothing except make an address targetable again — there is no
134
+ * counter to launder and no money attached, which is the AGL-1367 test for
135
+ * whether a write has to move server-side.
136
+ *
137
+ * The confirmation is not decoration either: for a `bounce` the address very
138
+ * likely does not exist, and mailing it again is what a provider scores the
139
+ * sending domain on. So the dialog says which reason is being overridden
140
+ * rather than asking a generic "are you sure".
141
+ */ export function SuppressionsCard(props) {
142
+ const { hostId } = props;
143
+ const firestore = useFirestore();
144
+ const { data: user } = useUser();
145
+ const { enqueueSnackbar } = useSnackbar();
146
+ const { confirm } = useConfirmationContext();
147
+ const [adding, setAdding] = useState(false);
148
+ const [addInput, setAddInput] = useState('');
149
+ const [addNote, setAddNote] = useState('');
150
+ const [busy, setBusy] = useState(false);
151
+ /*
152
+ * The window IS the query, ordered by the server (AGL-2501, AGL-2292).
153
+ *
154
+ * This was `limit(500)` with no `orderBy`, sorted by date in the browser.
155
+ * Firestore answers an unordered limit in DOCUMENT-ID order, and an entry
156
+ * here is keyed by `sha256(email)` — so the window was five hundred
157
+ * addresses chosen by the hash of the address, and the client sort dressed
158
+ * that sample up as the newest five hundred. A list past the ceiling
159
+ * therefore hid whoever bounced this morning behind whoever happened to
160
+ * hash low, with no gap on screen to notice and no control asking for more.
161
+ *
162
+ * `createdAt` is the safe field to order on, and that is checked rather
163
+ * than assumed: both writers — the unsubscribe handler and the Resend
164
+ * bounce/complaint webhook — stamp it when the document is created, the
165
+ * pre-AGL-2408 handler wrote `{ email, createdAt }`, and `suppressions` is
166
+ * not in `IMPORTABLE_FIELDS`, so no restore path can produce a row without
167
+ * one. `suppressedAt` would NOT be safe: it is absent on every entry
168
+ * written before AGL-1918, and `orderBy` drops documents that lack the
169
+ * field rather than mis-sorting them.
170
+ */ const { rows: entries, hasMore, page, setPage, pageSize, setPageSize } = usePagedCollection((pageLimit)=>query(collection(firestore, 'hosts', hostId, 'suppressions'), orderBy('createdAt', 'desc'), limit(pageLimit)), [
171
+ firestore,
172
+ hostId
173
+ ], {
174
+ idField: '$id'
175
+ });
176
+ /*==========================================
177
+ * THE BREAKDOWN IS A SERVER AGGREGATE, not a tally of the page.
178
+ *
179
+ * These chips answer "is my list going stale?", and they were a `reduce`
180
+ * over whatever the listener had fetched — so on a site past the old
181
+ * ceiling "Bounced: 140" meant 140 of an arbitrary five hundred, and under
182
+ * a ten-row page it would have meant 140 of ten. A bounce rate computed
183
+ * from a sample is not a bounce rate, and nothing on screen said it was one.
184
+ *
185
+ * Three reads, not one per reason. `where('reason','==','unsubscribe')`
186
+ * cannot be asked, because an entry written before AGL-2408 carries no
187
+ * `reason` at all and an equality filter excludes it — the same
188
+ * field-presence trap as the ordering above. Unsubscribes are therefore the
189
+ * REMAINDER: total minus the two reasons that are always written
190
+ * explicitly, which is exactly the compatibility rule `describeReason`
191
+ * applies row by row.
192
+ *=========================================*/ const [totalsEpoch, setTotalsEpoch] = useState(0);
193
+ const [totals, setTotals] = useState(null);
194
+ useEffect(()=>{
195
+ let active = true;
196
+ const suppressionsRef = collection(firestore, 'hosts', hostId, 'suppressions');
197
+ void Promise.all([
198
+ getAggregateFromServer(suppressionsRef, {
199
+ total: count()
200
+ }),
201
+ getAggregateFromServer(query(suppressionsRef, where('reason', '==', 'bounce')), {
202
+ total: count()
203
+ }),
204
+ getAggregateFromServer(query(suppressionsRef, where('reason', '==', 'complaint')), {
205
+ total: count()
206
+ }),
207
+ // A FOURTH read, and it is not optional. Unsubscribes are the
208
+ // REMAINDER, so every reason that is counted explicitly has to be
209
+ // subtracted — a hand-added entry left out of this list would be
210
+ // reported as somebody who clicked unsubscribe.
211
+ getAggregateFromServer(query(suppressionsRef, where('reason', '==', 'manual')), {
212
+ total: count()
213
+ })
214
+ ]).then(([all, bounced, complained, added])=>{
215
+ var _all_data_total, _bounced_data_total, _complained_data_total, _added_data_total;
216
+ if (!active) return;
217
+ const total = Number((_all_data_total = all.data().total) != null ? _all_data_total : 0);
218
+ const bounce = Number((_bounced_data_total = bounced.data().total) != null ? _bounced_data_total : 0);
219
+ const complaint = Number((_complained_data_total = complained.data().total) != null ? _complained_data_total : 0);
220
+ const manual = Number((_added_data_total = added.data().total) != null ? _added_data_total : 0);
221
+ setTotals({
222
+ unsubscribe: Math.max(0, total - bounce - complaint - manual),
223
+ bounce,
224
+ complaint,
225
+ manual
226
+ });
227
+ }).catch(()=>{
228
+ // Held at null rather than zeroed. "Bounced: 0" is a confident wrong
229
+ // number in the reassuring direction, and this card exists to warn.
230
+ if (active) setTotals(null);
231
+ });
232
+ return ()=>{
233
+ active = false;
234
+ };
235
+ // The list is a live listener and refreshes itself; an aggregate is a
236
+ // one-shot read and would otherwise keep reporting the breakdown from
237
+ // before the address was put back.
238
+ }, [
239
+ firestore,
240
+ hostId,
241
+ totalsEpoch
242
+ ]);
243
+ /*
244
+ * The ADD, through a route rather than a client write.
245
+ *
246
+ * The Remove button below writes straight from the browser, and this does
247
+ * not, which looks inconsistent until the document id is considered: an
248
+ * entry is keyed by `sha256` of the normalized address, and a browser
249
+ * computing that itself would be a second derivation of the key every
250
+ * reader shares. Getting it wrong is silent and one-directional — the
251
+ * merchant is told the person is suppressed and the mail keeps going. A
252
+ * removal has no such hazard: it names a row that is already on screen.
253
+ */ const handleAdd = useCallback(async ()=>{
254
+ const typed = addInput.trim();
255
+ if (!typed || busy) return;
256
+ setBusy(true);
257
+ try {
258
+ var _ref, _ref1;
259
+ const response = await authorizedFetch(user, '/api/email/suppression-add', {
260
+ method: 'POST',
261
+ headers: {
262
+ 'Content-Type': 'application/json'
263
+ },
264
+ body: JSON.stringify({
265
+ hostId,
266
+ emails: typed,
267
+ note: addNote.trim()
268
+ })
269
+ });
270
+ const payload = await response.json().catch(()=>({}));
271
+ if (!response.ok) {
272
+ var _ref2;
273
+ return void enqueueSnackbar((_ref2 = payload == null ? void 0 : payload.error) != null ? _ref2 : 'The address could not be suppressed.', {
274
+ variant: 'error'
275
+ });
276
+ }
277
+ const results = (_ref = payload == null ? void 0 : payload.results) != null ? _ref : [];
278
+ const added = Number((_ref1 = payload == null ? void 0 : payload.added) != null ? _ref1 : 0);
279
+ const rejected = results.filter((result)=>result.refusal === 'not-an-address');
280
+ // The refusals are NAMED, because "3 of 5 added" leaves an operator to
281
+ // work out which two, and the two that failed are the ones somebody
282
+ // asked to stop being emailed.
283
+ if (rejected.length) {
284
+ enqueueSnackbar(`Not an email address: ${rejected.map((result)=>result.input).join(', ')}`, {
285
+ variant: 'warning'
286
+ });
287
+ }
288
+ if (added) {
289
+ enqueueSnackbar(added === 1 ? 'Added to the suppression list' : `${added} addresses added to the suppression list`, {
290
+ variant: 'success',
291
+ persist: false
292
+ });
293
+ } else if (!rejected.length) {
294
+ enqueueSnackbar('Already on the suppression list', {
295
+ variant: 'info'
296
+ });
297
+ }
298
+ if (added) {
299
+ setAddInput('');
300
+ setAddNote('');
301
+ setAdding(false);
302
+ setTotalsEpoch((epoch)=>epoch + 1);
303
+ }
304
+ } catch (error) {
305
+ console.error(error);
306
+ enqueueSnackbar('An error has occurred', {
307
+ variant: 'error'
308
+ });
309
+ } finally{
310
+ setBusy(false);
311
+ }
312
+ }, [
313
+ addInput,
314
+ addNote,
315
+ busy,
316
+ user,
317
+ hostId,
318
+ enqueueSnackbar
319
+ ]);
320
+ /**
321
+ * Whether one address is ALSO on the platform-wide list.
322
+ *
323
+ * The two lists are consulted together at send time and were visible
324
+ * separately, so a merchant who removed their own entry could still find
325
+ * the address was never mailed, with nothing anywhere saying why. The
326
+ * platform entry is invisible to them and cannot be lifted by them, which
327
+ * is precisely why it has to be said before the click rather than
328
+ * discovered from a recipient count that stays short.
329
+ */ const isBlockedPlatformWide = useCallback(async (email)=>{
330
+ try {
331
+ var _ref;
332
+ const response = await authorizedFetch(user, '/api/email/suppression-status', {
333
+ method: 'POST',
334
+ headers: {
335
+ 'Content-Type': 'application/json'
336
+ },
337
+ body: JSON.stringify({
338
+ hostId,
339
+ emails: email
340
+ })
341
+ });
342
+ if (!response.ok) return false;
343
+ const payload = await response.json().catch(()=>({}));
344
+ return ((_ref = payload == null ? void 0 : payload.platform) != null ? _ref : []).length > 0;
345
+ } catch (error) {
346
+ console.error(error);
347
+ return false;
348
+ }
349
+ }, [
350
+ user,
351
+ hostId
352
+ ]);
353
+ const handleRemove = async (row)=>{
354
+ var _row_email, _row_email1;
355
+ const reason = describeReason(row.reason).label.toLowerCase();
356
+ // The platform entry is invisible to a merchant and cannot be lifted by
357
+ // one, so removing the site's row here changes nothing about whether the
358
+ // address is mailed. Saying so BEFORE the click is the whole point: the
359
+ // alternative is a merchant who removes the row, sends again, and sees a
360
+ // recipient count that is still short with nothing explaining it.
361
+ /*
362
+ * ASKED, NOT MOUNTED.
363
+ *
364
+ * One keyed read for the one address the merchant is acting on, at the
365
+ * moment they act. Fetching this for every visible row on mount would be
366
+ * a request per page render for an answer that is only ever needed on a
367
+ * click, and this card is one tab of a page a merchant opens to read.
368
+ *
369
+ * A failed check answers "not blocked", which is the ordinary case: the
370
+ * dialog then reads exactly as it did before, and the removal still
371
+ * works. Refusing to open the dialog because a supplementary lookup
372
+ * failed would make an outage on an explanation into an outage on the
373
+ * control it explains.
374
+ */ const alsoPlatform = row.email ? await isBlockedPlatformWide(row.email) : false;
375
+ const accepted = await confirm({
376
+ title: alsoPlatform ? 'This address will still be skipped' : 'Put this address back on your list?',
377
+ description: alsoPlatform ? `${(_row_email = row.email) != null ? _row_email : 'This address'} bounced permanently or reported ` + 'spam somewhere else in Aglyn, so it is on the platform-wide list ' + 'as well as yours. Removing your entry will not start mail ' + 'reaching it — contact support to have the platform entry lifted.' : `${(_row_email1 = row.email) != null ? _row_email1 : 'This address'} is suppressed because it ` + `${reason === 'bounced' ? 'bounced permanently' : reason === 'marked as spam' ? 'was marked as spam' : reason === 'added by hand' ? 'was added by hand' : 'unsubscribed'}. ` + 'Removing the entry means your next campaign will email it again.',
378
+ confirmationText: 'Remove',
379
+ confirmationButtonProps: {
380
+ color: 'error'
381
+ }
382
+ })// `confirm` resolves with NO VALUE and REJECTS on cancel, so gating on
383
+ // the resolved value alone makes this always return (AGL-950).
384
+ .then(()=>true).catch(()=>false);
385
+ if (!accepted) return;
386
+ try {
387
+ await deleteDoc(doc(firestore, 'hosts', hostId, 'suppressions', row.$id));
388
+ enqueueSnackbar('Removed from the suppression list', {
389
+ variant: 'success',
390
+ persist: false
391
+ });
392
+ setTotalsEpoch((epoch)=>epoch + 1);
393
+ } catch (error) {
394
+ console.error(error);
395
+ enqueueSnackbar('An error has occurred', {
396
+ variant: 'error'
397
+ });
398
+ }
399
+ };
400
+ const columns = [
401
+ {
402
+ field: 'email',
403
+ headerName: 'Address',
404
+ flex: 1,
405
+ minWidth: 240,
406
+ /*
407
+ Entries are keyed by `sha256(email)` because addresses are PII, and the
408
+ address itself is stored in the document. An older row written before
409
+ the address was stored has only its hash — which tells a merchant
410
+ nothing, so it says so rather than displaying 64 hex characters.
411
+ */ renderCell: ({ row })=>row.email || /*#__PURE__*/ _jsx(Typography, {
412
+ variant: "body2",
413
+ color: "text.secondary",
414
+ children: '(address not recorded)'
415
+ })
416
+ },
417
+ {
418
+ field: 'reason',
419
+ headerName: 'Reason',
420
+ width: 170,
421
+ valueGetter: (_value, row)=>describeReason(row.reason).label,
422
+ renderCell: ({ row })=>{
423
+ const described = describeReason(row.reason);
424
+ return /*#__PURE__*/ _jsx(Chip, {
425
+ size: "small",
426
+ color: described.color,
427
+ variant: "outlined",
428
+ label: described.label
429
+ });
430
+ }
431
+ },
432
+ {
433
+ field: 'since',
434
+ headerName: 'Since',
435
+ width: 140,
436
+ valueGetter: (_value, row)=>onDate(row)
437
+ },
438
+ /*
439
+ A SUPPRESSION HAS NO PAGE, so the row does not open one — this is the
440
+ surface's one list whose rows are not a way in to anything. What it does
441
+ have is the one act a merchant performs on an entry, and the trailing
442
+ cluster is where every other list on this surface keeps that: a bare
443
+ `Remove` in the row is a click that puts an address back into mailing
444
+ range, sitting where the reader's eye is running along the row.
445
+ */ listActionsColumn((row)=>/*#__PURE__*/ _jsx(ListRowActions, {
446
+ label: row.email || 'this entry',
447
+ items: [
448
+ {
449
+ key: 'remove',
450
+ label: 'Remove from the list',
451
+ icon: /*#__PURE__*/ _jsx(MdiIcon, {
452
+ path: mdiEmailCheckOutline.path,
453
+ size: 0.8
454
+ }),
455
+ destructive: true,
456
+ onClick: ()=>void handleRemove(row)
457
+ }
458
+ ]
459
+ }), {
460
+ width: 72
461
+ })
462
+ ];
463
+ return /*#__PURE__*/ _jsxs(CardDisplay, {
464
+ header: "Suppressions",
465
+ help: pluginDocsHelp('emailCampaigns', {
466
+ anchor: '#compliance'
467
+ }),
468
+ contentGutterX: true,
469
+ contentGutterY: true,
470
+ contentBordered: "all",
471
+ HeaderProps: {
472
+ action: /*#__PURE__*/ _jsx(Button, {
473
+ size: "small",
474
+ variant: "contained",
475
+ onClick: ()=>setAdding(true),
476
+ children: 'Add'
477
+ })
478
+ },
479
+ children: [
480
+ /*#__PURE__*/ _jsxs(Stack, {
481
+ spacing: 1.5,
482
+ children: [
483
+ /*#__PURE__*/ _jsx(Typography, {
484
+ variant: "body2",
485
+ color: "text.secondary",
486
+ children: 'Addresses this site’s marketing email skips. Someone lands here ' + 'by clicking unsubscribe, by bouncing permanently, by marking a ' + 'message as spam, or because you added them — this is where the ' + 'gap between a campaign’s recipient count and what it actually ' + 'sent comes from.'
487
+ }),
488
+ entries.length === 0 ? /*#__PURE__*/ _jsx(Typography, {
489
+ variant: "body2",
490
+ color: "text.secondary",
491
+ children: 'Nobody is suppressed. Every address in your audiences is ' + 'currently mailable.'
492
+ }) : /*#__PURE__*/ _jsxs(_Fragment, {
493
+ children: [
494
+ /*#__PURE__*/ _jsx(Stack, {
495
+ direction: "row",
496
+ spacing: 1,
497
+ sx: {
498
+ flexWrap: 'wrap'
499
+ },
500
+ children: totals === null ? /*#__PURE__*/ _jsx(Typography, {
501
+ variant: "caption",
502
+ color: "text.secondary",
503
+ children: 'Could not read the breakdown. This is not the same as ' + 'nobody having bounced.'
504
+ }) : Object.entries(totals)// A reason nobody has hit is not news, and three chips
505
+ // reading zero make the two that matter harder to find.
506
+ .filter(([, total])=>total > 0).map(([reason, total])=>{
507
+ const described = describeReason(reason);
508
+ return /*#__PURE__*/ _jsx(Chip, {
509
+ size: "small",
510
+ color: described.color,
511
+ variant: "outlined",
512
+ label: `${described.label}: ${total}`
513
+ }, reason);
514
+ })
515
+ }),
516
+ /*#__PURE__*/ _jsx(ListTable, {
517
+ "aria-label": "Suppressed addresses",
518
+ rows: entries,
519
+ columns: columns,
520
+ rowHeight: TABLE_ROW_HEIGHT,
521
+ // Paged by the footer below, so the grid must not also slice.
522
+ hideFooter: true
523
+ }),
524
+ /*#__PURE__*/ _jsx(ListPagination, {
525
+ page: page,
526
+ pageSize: pageSize,
527
+ rowCount: entries.length,
528
+ hasMore: hasMore,
529
+ // The collection's real size, so the footer's count line says
530
+ // "1–10 of 812" rather than "of more than 10" — the aggregate
531
+ // above already knows it, and it is the same number the chips
532
+ // are a breakdown of.
533
+ count: totals ? Object.values(totals).reduce((a, b)=>a + b, 0) : undefined,
534
+ onPageChange: setPage,
535
+ onPageSizeChange: setPageSize
536
+ })
537
+ ]
538
+ })
539
+ ]
540
+ }),
541
+ /*#__PURE__*/ _jsx(NavigationDrawerComponent, {
542
+ open: adding,
543
+ anchor: "right",
544
+ variant: "temporary",
545
+ onClose: ()=>setAdding(false),
546
+ AppBarProps: {
547
+ color: 'surface'
548
+ },
549
+ appBarLeft: /*#__PURE__*/ _jsxs(_Fragment, {
550
+ children: [
551
+ /*#__PURE__*/ _jsxs(IconButton, {
552
+ color: "inherit",
553
+ edge: "start",
554
+ onClick: ()=>setAdding(false),
555
+ sx: {
556
+ mr: 2
557
+ },
558
+ children: [
559
+ /*#__PURE__*/ _jsx(MdiIcon, {
560
+ path: ICON_VARIANT_CLOSE.path
561
+ }),
562
+ /*#__PURE__*/ _jsx(SrOnly, {
563
+ children: 'close drawer'
564
+ })
565
+ ]
566
+ }),
567
+ /*#__PURE__*/ _jsx(Typography, {
568
+ variant: "h6",
569
+ component: "div",
570
+ children: 'Stop emailing an address'
571
+ })
572
+ ]
573
+ }),
574
+ appBarRight: /*#__PURE__*/ _jsx(Button, {
575
+ variant: "outlined",
576
+ color: "inherit",
577
+ onClick: ()=>setAdding(false),
578
+ children: 'Cancel'
579
+ }),
580
+ children: /*#__PURE__*/ _jsx(Container, {
581
+ gutterY: true,
582
+ children: /*#__PURE__*/ _jsxs(Stack, {
583
+ spacing: 2,
584
+ children: [
585
+ /*#__PURE__*/ _jsx(Typography, {
586
+ variant: "body2",
587
+ color: "text.secondary",
588
+ children: 'Use this when somebody asks you to stop emailing them by ' + 'reply, by phone, or in person. They stay on your audiences ' + 'and keep every record you hold about them — this only stops ' + 'this site’s marketing email reaching them.'
589
+ }),
590
+ /*#__PURE__*/ _jsx(TextField, {
591
+ label: "Email addresses",
592
+ value: addInput,
593
+ onChange: (event)=>setAddInput(event.target.value),
594
+ multiline: true,
595
+ minRows: 3,
596
+ fullWidth: true,
597
+ autoFocus: true,
598
+ helperText: 'One per line, or separated by commas. Up to 50 at a time.'
599
+ }),
600
+ /*#__PURE__*/ _jsx(TextField, {
601
+ label: "Note (optional)",
602
+ value: addNote,
603
+ onChange: (event)=>setAddNote(event.target.value),
604
+ fullWidth: true,
605
+ helperText: 'How the request reached you. Kept with the entry as the ' + 'record that it was honored.',
606
+ slotProps: {
607
+ htmlInput: {
608
+ maxLength: 200
609
+ }
610
+ }
611
+ }),
612
+ /*#__PURE__*/ _jsx(Alert, {
613
+ severity: "info",
614
+ children: 'Order confirmations, booking reminders and password resets ' + 'are unaffected. Somebody who asked to stop hearing from ' + 'your marketing still gets their receipt.'
615
+ }),
616
+ /*#__PURE__*/ _jsx(Stack, {
617
+ direction: "row",
618
+ spacing: 1,
619
+ sx: {
620
+ justifyContent: 'flex-end'
621
+ },
622
+ children: /*#__PURE__*/ _jsx(Button, {
623
+ variant: "contained",
624
+ disabled: busy || !addInput.trim(),
625
+ onClick: ()=>void handleAdd(),
626
+ children: 'Add to suppression list'
627
+ })
628
+ })
629
+ ]
630
+ })
631
+ })
632
+ })
633
+ ]
634
+ });
635
+ }
636
+ SuppressionsCard.displayName = 'SuppressionsCard';
637
+ export default SuppressionsCard;
638
+
639
+ //# sourceMappingURL=suppressions-card.js.map