@aglyn/shared-util-email 1.0.0-beta.200 → 1.0.0-beta.202

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 (41) hide show
  1. package/package.json +3 -3
  2. package/src/index.d.ts +6 -0
  3. package/src/index.js +16 -0
  4. package/src/index.js.map +1 -1
  5. package/src/lib/email-deliverability.d.ts +200 -0
  6. package/src/lib/email-deliverability.js +461 -0
  7. package/src/lib/email-deliverability.js.map +1 -0
  8. package/src/lib/email-delivery-events.d.ts +16 -39
  9. package/src/lib/email-delivery-events.js +28 -12
  10. package/src/lib/email-delivery-events.js.map +1 -1
  11. package/src/lib/email-linkify.d.ts +24 -0
  12. package/src/lib/email-linkify.js +73 -0
  13. package/src/lib/email-linkify.js.map +1 -0
  14. package/src/lib/email-render.js +6 -1
  15. package/src/lib/email-render.js.map +1 -1
  16. package/src/lib/hosted-page-screen.d.ts +60 -0
  17. package/src/lib/hosted-page-screen.js +314 -0
  18. package/src/lib/hosted-page-screen.js.map +1 -0
  19. package/src/lib/mail-bounce.d.ts +42 -0
  20. package/src/lib/mail-bounce.js +108 -0
  21. package/src/lib/mail-bounce.js.map +1 -0
  22. package/src/lib/mail-gateway.d.ts +161 -0
  23. package/src/lib/mail-gateway.js +330 -0
  24. package/src/lib/mail-gateway.js.map +1 -0
  25. package/src/lib/outbound-phishing-screen.d.ts +248 -0
  26. package/src/lib/outbound-phishing-screen.js +769 -0
  27. package/src/lib/outbound-phishing-screen.js.map +1 -0
  28. package/src/lib/outbound-screen-gate.d.ts +95 -0
  29. package/src/lib/outbound-screen-gate.js +109 -0
  30. package/src/lib/outbound-screen-gate.js.map +1 -0
  31. package/src/lib/send-email.d.ts +79 -1
  32. package/src/lib/send-email.js +153 -10
  33. package/src/lib/send-email.js.map +1 -1
  34. package/src/lib/sending-domain.d.ts +22 -1
  35. package/src/lib/sending-domain.js +0 -95
  36. package/src/lib/sending-domain.js.map +1 -1
  37. package/src/lib/system-email-catalog.js +400 -0
  38. package/src/lib/system-email-catalog.js.map +1 -1
  39. package/src/lib/text-email-html.d.ts +38 -0
  40. package/src/lib/text-email-html.js +3 -44
  41. package/src/lib/text-email-html.js.map +1 -1
@@ -0,0 +1,161 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ export type MailGateway = 'barracuda' | 'proofpoint' | 'mimecast' | 'google' | 'microsoft' | 'other' | 'none';
18
+ export declare const MAIL_GATEWAYS: readonly MailGateway[];
19
+ /** The gateway as a chip, a table row or a sentence names it. */
20
+ export declare const MAIL_GATEWAY_LABELS: Record<MailGateway, string>;
21
+ /**
22
+ * The security gateways that stand in front of a whole company's mail and
23
+ * refuse a sender for everyone behind them.
24
+ */
25
+ export declare const MAIL_GATEWAYS_FRONTED: readonly MailGateway[];
26
+ /** Refusals in the window, with no delivery beside them, that hold the next bulk send. */
27
+ export declare const MAIL_GATEWAY_HOLD_BLOCKS = 2;
28
+ /** The days the hold is judged over. */
29
+ export declare const MAIL_GATEWAY_WINDOW_DAYS = 30;
30
+ /** The days a gateway chip counts. */
31
+ export declare const MAIL_GATEWAY_CHIP_DAYS = 7;
32
+ /** How long a domain's MX answer is trusted before it is looked up again. */
33
+ export declare const MAIL_DOMAIN_INTEL_TTL_MS: number;
34
+ /**
35
+ * How long an answer that the domain takes NO mail is trusted: a day, so a
36
+ * domain whose owner is still setting up its mail is looked at again soon.
37
+ */
38
+ export declare const MAIL_DOMAIN_NEGATIVE_TTL_MS: number;
39
+ /** A send with no bounce this long after it counts as delivered, where no delivery event is reported. */
40
+ export declare const MAIL_GATEWAY_DELIVERED_AFTER_MS: number;
41
+ /**
42
+ * The deliveries one ledger counts per UTC day at most.
43
+ *
44
+ * The hold asks whether a gateway delivered ANYTHING in the window, and the
45
+ * chip reads a week of counts; neither needs the ten-thousandth delivery of
46
+ * a campaign to Gmail, and a ledger document written once per delivery
47
+ * event would be the hottest document in the database. A day that reached
48
+ * the cap reads "at least".
49
+ */
50
+ export declare const MAIL_GATEWAY_DAILY_DELIVERY_CAP = 50;
51
+ /** A host as the suffix table reads it: lowercase, trimmed, no trailing dot. */
52
+ export declare function normalizeMailHost(host: unknown): string;
53
+ /** Whether a value names one of the gateways. */
54
+ export declare function isMailGateway(value: unknown): value is MailGateway;
55
+ /**
56
+ * The gateway ONE host names, or `null` when it names none of the known
57
+ * ones — a Remote-MTA in a bounce, or one MX exchange.
58
+ */
59
+ export declare function mailGatewayOfHost(host: unknown): MailGateway | null;
60
+ /**
61
+ * The gateway a domain's MX records name. A domain whose exchanges name more
62
+ * than one is read as the first named one in MX order (the list is handed
63
+ * over lowest-preference-first); one that names none is `other`; an empty
64
+ * list, or RFC 7505's lone ".", is `none`.
65
+ */
66
+ export declare function classifyMailGateway(mxHosts: readonly string[]): MailGateway;
67
+ /** Whether the gateway is one of the security gateways that refuse a sender for a whole company. */
68
+ export declare function isMailGatewayFronted(gateway: MailGateway | null | undefined): boolean;
69
+ /** The counts one UTC day holds on a gateway's ledger. */
70
+ export interface MailGatewayDayCounts {
71
+ sent: number;
72
+ delivered: number;
73
+ blocked: number;
74
+ }
75
+ /** What a send met at a gateway, as the ledger counts it. */
76
+ export type MailGatewayOutcome = keyof MailGatewayDayCounts;
77
+ /** `YYYY-MM-DD` in UTC: the day a ledger entry is filed under. */
78
+ export declare function mailGatewayDay(atMs: number): string;
79
+ /** The ledger's counts summed over the last `windowDays` UTC days, today included. */
80
+ export declare function mailGatewayWindow(days: Record<string, Partial<MailGatewayDayCounts>> | null | undefined, nowMs: number, windowDays: number): MailGatewayDayCounts;
81
+ /** The ledger's days with everything older than `keepDays` dropped. */
82
+ export declare function pruneMailGatewayDays<T>(days: Record<string, T> | null | undefined, nowMs: number, keepDays: number): Record<string, T>;
83
+ /**
84
+ * One sending domain's record with one gateway: totals, and the same counts
85
+ * by UTC day for the windows the hold and the chip read. Stored by the
86
+ * server layer under {@link mailGatewayLedgerKey}.
87
+ */
88
+ export interface MailGatewayLedger {
89
+ /** The domain the mail left from — the `From:` domain, or a mailbox's. */
90
+ sendingDomain: string;
91
+ gateway: MailGateway;
92
+ sent: number;
93
+ delivered: number;
94
+ blocked: number;
95
+ lastBlockedAtMs: number | null;
96
+ /** The last refusal's diagnostic, scrubbed of addresses, for the staff table. */
97
+ lastBlockedDetail: string | null;
98
+ /** `YYYY-MM-DD` (UTC) → that day's counts, the last thirty days kept. */
99
+ days: Record<string, MailGatewayDayCounts>;
100
+ updatedAtMs: number;
101
+ }
102
+ /** A sending domain as the ledger spells it: lowercase, no trailing dot, `null` for none. */
103
+ export declare function normalizeLedgerSendingDomain(value: unknown): string | null;
104
+ /**
105
+ * The document id a ledger is stored under: `aglyn.io~barracuda`. A `~`
106
+ * because a domain never holds one and a Firestore id may.
107
+ */
108
+ export declare function mailGatewayLedgerKey(sendingDomain: string, gateway: MailGateway): string;
109
+ /** A stored ledger in its model shape, or `null` for no document or an unreadable one. */
110
+ export declare function readStoredMailGatewayLedger(id: string, data: Record<string, unknown> | null | undefined): MailGatewayLedger | null;
111
+ /**
112
+ * The ledger after one more outcome — the pure half of the write, which the
113
+ * store runs inside its transaction. Deliveries stop counting for the day at
114
+ * {@link MAIL_GATEWAY_DAILY_DELIVERY_CAP}; answers `null` when the write
115
+ * would change nothing, so the store can skip it.
116
+ */
117
+ export declare function applyMailGatewayOutcome(ledger: MailGatewayLedger | null, input: {
118
+ sendingDomain: string;
119
+ gateway: MailGateway;
120
+ outcome: MailGatewayOutcome;
121
+ count?: number;
122
+ atMs: number;
123
+ detail?: string | null;
124
+ }): MailGatewayLedger | null;
125
+ /**
126
+ * What a surface knows about the gateway in front of one address: which it
127
+ * is, and what it did with this sending domain's mail over the chip's week
128
+ * and the hold's month.
129
+ */
130
+ export interface MailGatewayStanding {
131
+ gateway: MailGateway;
132
+ /** Refusals and deliveries in the last {@link MAIL_GATEWAY_CHIP_DAYS} days. */
133
+ blocked7: number;
134
+ delivered7: number;
135
+ /** Refusals and deliveries in the last {@link MAIL_GATEWAY_WINDOW_DAYS} days. */
136
+ blocked30: number;
137
+ delivered30: number;
138
+ }
139
+ /** A gateway's standing with a sending domain, read off its ledger (or none yet). */
140
+ export declare function mailGatewayStanding(gateway: MailGateway, ledger: Pick<MailGatewayLedger, 'days'> | null | undefined, nowMs: number): MailGatewayStanding;
141
+ /**
142
+ * Whether the gateway holds the next bulk send — see the module note. A
143
+ * named gateway only: `other` is many exchanges, and `none` refuses instead.
144
+ */
145
+ export declare function mailGatewayHolds(standing: Pick<MailGatewayStanding, 'gateway' | 'blocked30' | 'delivered30'>): boolean;
146
+ /** Why a gateway holds, in one sentence a surface or a send record can carry. */
147
+ export declare function mailGatewayHoldSentence(gateway: MailGateway, blocked30: number): string;
148
+ /** The chip's tone: what color a surface gives it. */
149
+ export type MailGatewayChipTone = 'refused' | 'delivered' | 'neutral' | 'blocked';
150
+ export interface MailGatewayChip {
151
+ label: string;
152
+ tone: MailGatewayChipTone;
153
+ }
154
+ /**
155
+ * The chip for one address, or `null` when there is nothing worth a chip: an
156
+ * unrecognized exchange with no verdict this week. Red for a gateway that
157
+ * refused this week, green for one that delivered, neutral for the hosted
158
+ * providers and the security gateways with no verdict yet, and blocked for a
159
+ * domain with no MX.
160
+ */
161
+ export declare function mailGatewayChip(standing: MailGatewayStanding | null | undefined): MailGatewayChip | null;
@@ -0,0 +1,330 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /*==========================================
17
+ * THE MX RECORD PREDICTS THE GATEWAY, AND THE GATEWAY'S HISTORY WITH A
18
+ * SENDING DOMAIN PREDICTS THE BLOCK (AGL-3326, AGL-3328).
19
+ *
20
+ * A security gateway refuses a SENDER, not an address: a Barracuda that
21
+ * refused `aglyn.io` on reputation refuses it for every company behind a
22
+ * Barracuda, and the next address there bounces the same way. So the signal
23
+ * is cheap and it is available BEFORE a send: the recipient domain's MX
24
+ * names the gateway in front of it, and what that gateway did with this
25
+ * sending domain's mail lately says what it will do next.
26
+ *
27
+ * Pure, for the reason every module in this library is: the barrel reaches
28
+ * the browser, so the DNS lookup and the ledger's store are handed in by
29
+ * the server layer that owns them.
30
+ *
31
+ * ## The kinds
32
+ *
33
+ * A gateway is named by the MX host's suffix. `barracuda`, `proofpoint`
34
+ * and `mimecast` are the security gateways that refuse a sender on policy
35
+ * and reputation; `google` and `microsoft` are the two hosted providers,
36
+ * which judge mail one message at a time; `other` is every self-hosted or
37
+ * unrecognized exchange; `none` is a domain that takes no mail at all.
38
+ *
39
+ * ## The ledger and the hold
40
+ *
41
+ * Every send, every delivery and every refusal is counted per SENDING DOMAIN
42
+ * per gateway, by UTC day. A gateway that REFUSED a sending domain twice in
43
+ * the last {@link MAIL_GATEWAY_WINDOW_DAYS} days without delivering once
44
+ * holds the next bulk send from that domain into it. `other` never holds,
45
+ * because it is a bucket of unrelated exchanges rather than one gateway;
46
+ * `none` refuses instead. Which sends a hold may touch is the caller's
47
+ * question — see `email-deliverability.ts`: mail the recipient asked for is
48
+ * never held.
49
+ *==========================================*/ import { _ as _extends } from "@swc/helpers/_/_extends";
50
+ export const MAIL_GATEWAYS = [
51
+ 'barracuda',
52
+ 'proofpoint',
53
+ 'mimecast',
54
+ 'google',
55
+ 'microsoft',
56
+ 'other',
57
+ 'none'
58
+ ];
59
+ /** The gateway as a chip, a table row or a sentence names it. */ export const MAIL_GATEWAY_LABELS = {
60
+ barracuda: 'Barracuda',
61
+ proofpoint: 'Proofpoint',
62
+ mimecast: 'Mimecast',
63
+ google: 'Google Workspace',
64
+ microsoft: 'Microsoft 365',
65
+ other: 'Other mail gateway',
66
+ none: 'No MX record'
67
+ };
68
+ /**
69
+ * The security gateways that stand in front of a whole company's mail and
70
+ * refuse a sender for everyone behind them.
71
+ */ export const MAIL_GATEWAYS_FRONTED = [
72
+ 'barracuda',
73
+ 'proofpoint',
74
+ 'mimecast'
75
+ ];
76
+ /** Refusals in the window, with no delivery beside them, that hold the next bulk send. */ export const MAIL_GATEWAY_HOLD_BLOCKS = 2;
77
+ /** The days the hold is judged over. */ export const MAIL_GATEWAY_WINDOW_DAYS = 30;
78
+ /** The days a gateway chip counts. */ export const MAIL_GATEWAY_CHIP_DAYS = 7;
79
+ /** How long a domain's MX answer is trusted before it is looked up again. */ export const MAIL_DOMAIN_INTEL_TTL_MS = 7 * 24 * 60 * 60 * 1000;
80
+ /**
81
+ * How long an answer that the domain takes NO mail is trusted: a day, so a
82
+ * domain whose owner is still setting up its mail is looked at again soon.
83
+ */ export const MAIL_DOMAIN_NEGATIVE_TTL_MS = 24 * 60 * 60 * 1000;
84
+ /** A send with no bounce this long after it counts as delivered, where no delivery event is reported. */ export const MAIL_GATEWAY_DELIVERED_AFTER_MS = 24 * 60 * 60 * 1000;
85
+ /**
86
+ * The deliveries one ledger counts per UTC day at most.
87
+ *
88
+ * The hold asks whether a gateway delivered ANYTHING in the window, and the
89
+ * chip reads a week of counts; neither needs the ten-thousandth delivery of
90
+ * a campaign to Gmail, and a ledger document written once per delivery
91
+ * event would be the hottest document in the database. A day that reached
92
+ * the cap reads "at least".
93
+ */ export const MAIL_GATEWAY_DAILY_DELIVERY_CAP = 50;
94
+ /** The MX host suffixes each named gateway answers as, lowercase, no trailing dot. */ const GATEWAY_HOST_SUFFIXES = [
95
+ [
96
+ 'barracuda',
97
+ [
98
+ '.ess.barracudanetworks.com',
99
+ '.barracudanetworks.com'
100
+ ]
101
+ ],
102
+ [
103
+ 'proofpoint',
104
+ [
105
+ '.pphosted.com',
106
+ '.ppe-hosted.com',
107
+ '.proofpoint.com'
108
+ ]
109
+ ],
110
+ [
111
+ 'mimecast',
112
+ [
113
+ '.mimecast.com',
114
+ '.mimecast.co.za',
115
+ '.mimecast-offshore.com'
116
+ ]
117
+ ],
118
+ [
119
+ 'google',
120
+ [
121
+ '.google.com',
122
+ '.googlemail.com',
123
+ '.gmail.com'
124
+ ]
125
+ ],
126
+ [
127
+ 'microsoft',
128
+ [
129
+ '.mail.protection.outlook.com',
130
+ '.outlook.com',
131
+ '.protection.outlook.com'
132
+ ]
133
+ ]
134
+ ];
135
+ /** A host as the suffix table reads it: lowercase, trimmed, no trailing dot. */ export function normalizeMailHost(host) {
136
+ return String(host != null ? host : '').trim().toLowerCase().replace(/\.$/, '');
137
+ }
138
+ /** Whether a value names one of the gateways. */ export function isMailGateway(value) {
139
+ return MAIL_GATEWAYS.includes(value);
140
+ }
141
+ /**
142
+ * The gateway ONE host names, or `null` when it names none of the known
143
+ * ones — a Remote-MTA in a bounce, or one MX exchange.
144
+ */ export function mailGatewayOfHost(host) {
145
+ const name = normalizeMailHost(host);
146
+ if (!name) return null;
147
+ for (const [gateway, suffixes] of GATEWAY_HOST_SUFFIXES){
148
+ if (suffixes.some((suffix)=>name === suffix.slice(1) || name.endsWith(suffix))) return gateway;
149
+ }
150
+ return null;
151
+ }
152
+ /**
153
+ * The gateway a domain's MX records name. A domain whose exchanges name more
154
+ * than one is read as the first named one in MX order (the list is handed
155
+ * over lowest-preference-first); one that names none is `other`; an empty
156
+ * list, or RFC 7505's lone ".", is `none`.
157
+ */ export function classifyMailGateway(mxHosts) {
158
+ const hosts = mxHosts.map(normalizeMailHost).filter(Boolean);
159
+ if (!hosts.length) return 'none';
160
+ for (const host of hosts){
161
+ const gateway = mailGatewayOfHost(host);
162
+ if (gateway) return gateway;
163
+ }
164
+ return 'other';
165
+ }
166
+ /** Whether the gateway is one of the security gateways that refuse a sender for a whole company. */ export function isMailGatewayFronted(gateway) {
167
+ return gateway !== null && gateway !== undefined && MAIL_GATEWAYS_FRONTED.includes(gateway);
168
+ }
169
+ /** `YYYY-MM-DD` in UTC: the day a ledger entry is filed under. */ export function mailGatewayDay(atMs) {
170
+ return new Date(atMs).toISOString().slice(0, 10);
171
+ }
172
+ const count = (value)=>{
173
+ const number = Number(value);
174
+ return Number.isFinite(number) ? Math.max(0, Math.floor(number)) : 0;
175
+ };
176
+ /** The ledger's counts summed over the last `windowDays` UTC days, today included. */ export function mailGatewayWindow(days, nowMs, windowDays) {
177
+ const summed = {
178
+ sent: 0,
179
+ delivered: 0,
180
+ blocked: 0
181
+ };
182
+ if (!days) return summed;
183
+ const oldest = mailGatewayDay(nowMs - (windowDays - 1) * 86400000);
184
+ const newest = mailGatewayDay(nowMs);
185
+ for (const [day, counts] of Object.entries(days)){
186
+ if (day < oldest || day > newest) continue;
187
+ summed.sent += count(counts == null ? void 0 : counts.sent);
188
+ summed.delivered += count(counts == null ? void 0 : counts.delivered);
189
+ summed.blocked += count(counts == null ? void 0 : counts.blocked);
190
+ }
191
+ return summed;
192
+ }
193
+ /** The ledger's days with everything older than `keepDays` dropped. */ export function pruneMailGatewayDays(days, nowMs, keepDays) {
194
+ const oldest = mailGatewayDay(nowMs - (keepDays - 1) * 86400000);
195
+ return Object.fromEntries(Object.entries(days != null ? days : {}).filter(([day])=>day >= oldest));
196
+ }
197
+ /** A sending domain as the ledger spells it: lowercase, no trailing dot, `null` for none. */ export function normalizeLedgerSendingDomain(value) {
198
+ const raw = String(value != null ? value : '').trim().toLowerCase();
199
+ const domain = (raw.includes('@') ? raw.slice(raw.lastIndexOf('@') + 1) : raw).replace(/[>\s]+$/, '').replace(/\.$/, '');
200
+ return /^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(domain) ? domain : null;
201
+ }
202
+ /**
203
+ * The document id a ledger is stored under: `aglyn.io~barracuda`. A `~`
204
+ * because a domain never holds one and a Firestore id may.
205
+ */ export function mailGatewayLedgerKey(sendingDomain, gateway) {
206
+ return `${sendingDomain}~${gateway}`;
207
+ }
208
+ /** A stored ledger in its model shape, or `null` for no document or an unreadable one. */ export function readStoredMailGatewayLedger(id, data) {
209
+ var _data_sendingDomain, _ms;
210
+ if (!data) return null;
211
+ const [idDomain, idGateway] = String(id != null ? id : '').split('~');
212
+ const gateway = isMailGateway(data['gateway']) ? data['gateway'] : isMailGateway(idGateway) ? idGateway : null;
213
+ const sendingDomain = normalizeLedgerSendingDomain((_data_sendingDomain = data['sendingDomain']) != null ? _data_sendingDomain : idDomain);
214
+ if (!gateway || !sendingDomain) return null;
215
+ const days = {};
216
+ const raw = data['days'];
217
+ if (raw && typeof raw === 'object') {
218
+ for (const [day, counts] of Object.entries(raw)){
219
+ const entry = counts && typeof counts === 'object' ? counts : {};
220
+ days[day] = {
221
+ sent: count(entry['sent']),
222
+ delivered: count(entry['delivered']),
223
+ blocked: count(entry['blocked'])
224
+ };
225
+ }
226
+ }
227
+ const ms = (value)=>typeof value === 'number' && Number.isFinite(value) ? value : null;
228
+ return {
229
+ sendingDomain,
230
+ gateway,
231
+ sent: count(data['sent']),
232
+ delivered: count(data['delivered']),
233
+ blocked: count(data['blocked']),
234
+ lastBlockedAtMs: ms(data['lastBlockedAtMs']),
235
+ lastBlockedDetail: typeof data['lastBlockedDetail'] === 'string' ? data['lastBlockedDetail'] : null,
236
+ days,
237
+ updatedAtMs: (_ms = ms(data['updatedAtMs'])) != null ? _ms : 0
238
+ };
239
+ }
240
+ /**
241
+ * The ledger after one more outcome — the pure half of the write, which the
242
+ * store runs inside its transaction. Deliveries stop counting for the day at
243
+ * {@link MAIL_GATEWAY_DAILY_DELIVERY_CAP}; answers `null` when the write
244
+ * would change nothing, so the store can skip it.
245
+ */ export function applyMailGatewayOutcome(ledger, input) {
246
+ var _days_day, _input_count, _ref, _ref1, _ref2, _ref3, _ref4, _input_detail, _ref5, _ref6;
247
+ const day = mailGatewayDay(input.atMs);
248
+ const days = pruneMailGatewayDays(ledger == null ? void 0 : ledger.days, input.atMs, MAIL_GATEWAY_WINDOW_DAYS);
249
+ const today = (_days_day = days[day]) != null ? _days_day : {
250
+ sent: 0,
251
+ delivered: 0,
252
+ blocked: 0
253
+ };
254
+ let by = count((_input_count = input.count) != null ? _input_count : 1);
255
+ if (input.outcome === 'delivered') by = Math.min(by, Math.max(0, MAIL_GATEWAY_DAILY_DELIVERY_CAP - today.delivered));
256
+ if (!by) return null;
257
+ days[day] = _extends({}, today, {
258
+ [input.outcome]: today[input.outcome] + by
259
+ });
260
+ const blocked = input.outcome === 'blocked';
261
+ return {
262
+ sendingDomain: input.sendingDomain,
263
+ gateway: input.gateway,
264
+ sent: ((_ref = ledger == null ? void 0 : ledger.sent) != null ? _ref : 0) + (input.outcome === 'sent' ? by : 0),
265
+ delivered: ((_ref1 = ledger == null ? void 0 : ledger.delivered) != null ? _ref1 : 0) + (input.outcome === 'delivered' ? by : 0),
266
+ blocked: ((_ref2 = ledger == null ? void 0 : ledger.blocked) != null ? _ref2 : 0) + (blocked ? by : 0),
267
+ lastBlockedAtMs: blocked ? Math.max((_ref3 = ledger == null ? void 0 : ledger.lastBlockedAtMs) != null ? _ref3 : 0, input.atMs) : (_ref4 = ledger == null ? void 0 : ledger.lastBlockedAtMs) != null ? _ref4 : null,
268
+ lastBlockedDetail: blocked ? String((_input_detail = input.detail) != null ? _input_detail : '').trim().slice(0, 300) || ((_ref5 = ledger == null ? void 0 : ledger.lastBlockedDetail) != null ? _ref5 : null) : (_ref6 = ledger == null ? void 0 : ledger.lastBlockedDetail) != null ? _ref6 : null,
269
+ days,
270
+ updatedAtMs: input.atMs
271
+ };
272
+ }
273
+ /** A gateway's standing with a sending domain, read off its ledger (or none yet). */ export function mailGatewayStanding(gateway, ledger, nowMs) {
274
+ const week = mailGatewayWindow(ledger == null ? void 0 : ledger.days, nowMs, MAIL_GATEWAY_CHIP_DAYS);
275
+ const month = mailGatewayWindow(ledger == null ? void 0 : ledger.days, nowMs, MAIL_GATEWAY_WINDOW_DAYS);
276
+ return {
277
+ gateway,
278
+ blocked7: week.blocked,
279
+ delivered7: week.delivered,
280
+ blocked30: month.blocked,
281
+ delivered30: month.delivered
282
+ };
283
+ }
284
+ /**
285
+ * Whether the gateway holds the next bulk send — see the module note. A
286
+ * named gateway only: `other` is many exchanges, and `none` refuses instead.
287
+ */ export function mailGatewayHolds(standing) {
288
+ if (standing.gateway === 'other' || standing.gateway === 'none') return false;
289
+ return count(standing.blocked30) >= MAIL_GATEWAY_HOLD_BLOCKS && count(standing.delivered30) === 0;
290
+ }
291
+ /** Why a gateway holds, in one sentence a surface or a send record can carry. */ export function mailGatewayHoldSentence(gateway, blocked30) {
292
+ const name = MAIL_GATEWAY_LABELS[gateway];
293
+ return `${name} refused this sender ${blocked30 === 2 ? 'twice' : `${blocked30} times`} in the last ` + `${MAIL_GATEWAY_WINDOW_DAYS} days and delivered nothing`;
294
+ }
295
+ /**
296
+ * The chip for one address, or `null` when there is nothing worth a chip: an
297
+ * unrecognized exchange with no verdict this week. Red for a gateway that
298
+ * refused this week, green for one that delivered, neutral for the hosted
299
+ * providers and the security gateways with no verdict yet, and blocked for a
300
+ * domain with no MX.
301
+ */ export function mailGatewayChip(standing) {
302
+ if (!standing) return null;
303
+ const name = MAIL_GATEWAY_LABELS[standing.gateway];
304
+ if (standing.gateway === 'none') return {
305
+ label: 'No MX record — cannot receive mail',
306
+ tone: 'blocked'
307
+ };
308
+ const blocked = count(standing.blocked7);
309
+ const delivered = count(standing.delivered7);
310
+ const settled = blocked + delivered;
311
+ if (blocked > 0) {
312
+ return {
313
+ label: `${name} · ${blocked} of ${settled} ${settled === 1 ? 'send' : 'sends'} refused this week`,
314
+ tone: 'refused'
315
+ };
316
+ }
317
+ if (delivered > 0) {
318
+ return {
319
+ label: `${name} · ${delivered} of ${settled} delivered this week`,
320
+ tone: 'delivered'
321
+ };
322
+ }
323
+ if (standing.gateway === 'other') return null;
324
+ return {
325
+ label: name,
326
+ tone: 'neutral'
327
+ };
328
+ }
329
+
330
+ //# sourceMappingURL=mail-gateway.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/mail-gateway.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * THE MX RECORD PREDICTS THE GATEWAY, AND THE GATEWAY'S HISTORY WITH A\n * SENDING DOMAIN PREDICTS THE BLOCK (AGL-3326, AGL-3328).\n *\n * A security gateway refuses a SENDER, not an address: a Barracuda that\n * refused `aglyn.io` on reputation refuses it for every company behind a\n * Barracuda, and the next address there bounces the same way. So the signal\n * is cheap and it is available BEFORE a send: the recipient domain's MX\n * names the gateway in front of it, and what that gateway did with this\n * sending domain's mail lately says what it will do next.\n *\n * Pure, for the reason every module in this library is: the barrel reaches\n * the browser, so the DNS lookup and the ledger's store are handed in by\n * the server layer that owns them.\n *\n * ## The kinds\n *\n * A gateway is named by the MX host's suffix. `barracuda`, `proofpoint`\n * and `mimecast` are the security gateways that refuse a sender on policy\n * and reputation; `google` and `microsoft` are the two hosted providers,\n * which judge mail one message at a time; `other` is every self-hosted or\n * unrecognized exchange; `none` is a domain that takes no mail at all.\n *\n * ## The ledger and the hold\n *\n * Every send, every delivery and every refusal is counted per SENDING DOMAIN\n * per gateway, by UTC day. A gateway that REFUSED a sending domain twice in\n * the last {@link MAIL_GATEWAY_WINDOW_DAYS} days without delivering once\n * holds the next bulk send from that domain into it. `other` never holds,\n * because it is a bucket of unrelated exchanges rather than one gateway;\n * `none` refuses instead. Which sends a hold may touch is the caller's\n * question — see `email-deliverability.ts`: mail the recipient asked for is\n * never held.\n *==========================================*/\n\nexport type MailGateway =\n | 'barracuda'\n | 'proofpoint'\n | 'mimecast'\n | 'google'\n | 'microsoft'\n | 'other'\n | 'none'\n\nexport const MAIL_GATEWAYS: readonly MailGateway[] = [\n 'barracuda',\n 'proofpoint',\n 'mimecast',\n 'google',\n 'microsoft',\n 'other',\n 'none',\n]\n\n/** The gateway as a chip, a table row or a sentence names it. */\nexport const MAIL_GATEWAY_LABELS: Record<MailGateway, string> = {\n barracuda: 'Barracuda',\n proofpoint: 'Proofpoint',\n mimecast: 'Mimecast',\n google: 'Google Workspace',\n microsoft: 'Microsoft 365',\n other: 'Other mail gateway',\n none: 'No MX record',\n}\n\n/**\n * The security gateways that stand in front of a whole company's mail and\n * refuse a sender for everyone behind them.\n */\nexport const MAIL_GATEWAYS_FRONTED: readonly MailGateway[] = ['barracuda', 'proofpoint', 'mimecast']\n\n/** Refusals in the window, with no delivery beside them, that hold the next bulk send. */\nexport const MAIL_GATEWAY_HOLD_BLOCKS = 2\n\n/** The days the hold is judged over. */\nexport const MAIL_GATEWAY_WINDOW_DAYS = 30\n\n/** The days a gateway chip counts. */\nexport const MAIL_GATEWAY_CHIP_DAYS = 7\n\n/** How long a domain's MX answer is trusted before it is looked up again. */\nexport const MAIL_DOMAIN_INTEL_TTL_MS = 7 * 24 * 60 * 60 * 1000\n\n/**\n * How long an answer that the domain takes NO mail is trusted: a day, so a\n * domain whose owner is still setting up its mail is looked at again soon.\n */\nexport const MAIL_DOMAIN_NEGATIVE_TTL_MS = 24 * 60 * 60 * 1000\n\n/** A send with no bounce this long after it counts as delivered, where no delivery event is reported. */\nexport const MAIL_GATEWAY_DELIVERED_AFTER_MS = 24 * 60 * 60 * 1000\n\n/**\n * The deliveries one ledger counts per UTC day at most.\n *\n * The hold asks whether a gateway delivered ANYTHING in the window, and the\n * chip reads a week of counts; neither needs the ten-thousandth delivery of\n * a campaign to Gmail, and a ledger document written once per delivery\n * event would be the hottest document in the database. A day that reached\n * the cap reads \"at least\".\n */\nexport const MAIL_GATEWAY_DAILY_DELIVERY_CAP = 50\n\n/** The MX host suffixes each named gateway answers as, lowercase, no trailing dot. */\nconst GATEWAY_HOST_SUFFIXES: ReadonlyArray<readonly [MailGateway, readonly string[]]> = [\n ['barracuda', ['.ess.barracudanetworks.com', '.barracudanetworks.com']],\n ['proofpoint', ['.pphosted.com', '.ppe-hosted.com', '.proofpoint.com']],\n ['mimecast', ['.mimecast.com', '.mimecast.co.za', '.mimecast-offshore.com']],\n ['google', ['.google.com', '.googlemail.com', '.gmail.com']],\n ['microsoft', ['.mail.protection.outlook.com', '.outlook.com', '.protection.outlook.com']],\n]\n\n/** A host as the suffix table reads it: lowercase, trimmed, no trailing dot. */\nexport function normalizeMailHost(host: unknown): string {\n return String(host ?? '')\n .trim()\n .toLowerCase()\n .replace(/\\.$/, '')\n}\n\n/** Whether a value names one of the gateways. */\nexport function isMailGateway(value: unknown): value is MailGateway {\n return (MAIL_GATEWAYS as readonly unknown[]).includes(value)\n}\n\n/**\n * The gateway ONE host names, or `null` when it names none of the known\n * ones — a Remote-MTA in a bounce, or one MX exchange.\n */\nexport function mailGatewayOfHost(host: unknown): MailGateway | null {\n const name = normalizeMailHost(host)\n if (!name) return null\n for (const [gateway, suffixes] of GATEWAY_HOST_SUFFIXES) {\n if (suffixes.some((suffix) => name === suffix.slice(1) || name.endsWith(suffix))) return gateway\n }\n return null\n}\n\n/**\n * The gateway a domain's MX records name. A domain whose exchanges name more\n * than one is read as the first named one in MX order (the list is handed\n * over lowest-preference-first); one that names none is `other`; an empty\n * list, or RFC 7505's lone \".\", is `none`.\n */\nexport function classifyMailGateway(mxHosts: readonly string[]): MailGateway {\n const hosts = mxHosts.map(normalizeMailHost).filter(Boolean)\n if (!hosts.length) return 'none'\n for (const host of hosts) {\n const gateway = mailGatewayOfHost(host)\n if (gateway) return gateway\n }\n return 'other'\n}\n\n/** Whether the gateway is one of the security gateways that refuse a sender for a whole company. */\nexport function isMailGatewayFronted(gateway: MailGateway | null | undefined): boolean {\n return gateway !== null && gateway !== undefined && MAIL_GATEWAYS_FRONTED.includes(gateway)\n}\n\n/** The counts one UTC day holds on a gateway's ledger. */\nexport interface MailGatewayDayCounts {\n sent: number\n delivered: number\n blocked: number\n}\n\n/** What a send met at a gateway, as the ledger counts it. */\nexport type MailGatewayOutcome = keyof MailGatewayDayCounts\n\n/** `YYYY-MM-DD` in UTC: the day a ledger entry is filed under. */\nexport function mailGatewayDay(atMs: number): string {\n return new Date(atMs).toISOString().slice(0, 10)\n}\n\nconst count = (value: unknown): number => {\n const number = Number(value)\n return Number.isFinite(number) ? Math.max(0, Math.floor(number)) : 0\n}\n\n/** The ledger's counts summed over the last `windowDays` UTC days, today included. */\nexport function mailGatewayWindow(\n days: Record<string, Partial<MailGatewayDayCounts>> | null | undefined,\n nowMs: number,\n windowDays: number,\n): MailGatewayDayCounts {\n const summed: MailGatewayDayCounts = { sent: 0, delivered: 0, blocked: 0 }\n if (!days) return summed\n const oldest = mailGatewayDay(nowMs - (windowDays - 1) * 86_400_000)\n const newest = mailGatewayDay(nowMs)\n for (const [day, counts] of Object.entries(days)) {\n if (day < oldest || day > newest) continue\n summed.sent += count(counts?.sent)\n summed.delivered += count(counts?.delivered)\n summed.blocked += count(counts?.blocked)\n }\n return summed\n}\n\n/** The ledger's days with everything older than `keepDays` dropped. */\nexport function pruneMailGatewayDays<T>(\n days: Record<string, T> | null | undefined,\n nowMs: number,\n keepDays: number,\n): Record<string, T> {\n const oldest = mailGatewayDay(nowMs - (keepDays - 1) * 86_400_000)\n return Object.fromEntries(Object.entries(days ?? {}).filter(([day]) => day >= oldest))\n}\n\n/*==========================================\n * THE LEDGER, keyed SENDING DOMAIN × GATEWAY.\n *==========================================*/\n\n/**\n * One sending domain's record with one gateway: totals, and the same counts\n * by UTC day for the windows the hold and the chip read. Stored by the\n * server layer under {@link mailGatewayLedgerKey}.\n */\nexport interface MailGatewayLedger {\n /** The domain the mail left from — the `From:` domain, or a mailbox's. */\n sendingDomain: string\n gateway: MailGateway\n sent: number\n delivered: number\n blocked: number\n lastBlockedAtMs: number | null\n /** The last refusal's diagnostic, scrubbed of addresses, for the staff table. */\n lastBlockedDetail: string | null\n /** `YYYY-MM-DD` (UTC) → that day's counts, the last thirty days kept. */\n days: Record<string, MailGatewayDayCounts>\n updatedAtMs: number\n}\n\n/** A sending domain as the ledger spells it: lowercase, no trailing dot, `null` for none. */\nexport function normalizeLedgerSendingDomain(value: unknown): string | null {\n const raw = String(value ?? '')\n .trim()\n .toLowerCase()\n const domain = (raw.includes('@') ? raw.slice(raw.lastIndexOf('@') + 1) : raw).replace(/[>\\s]+$/, '').replace(/\\.$/, '')\n return /^[a-z0-9-]+(\\.[a-z0-9-]+)+$/.test(domain) ? domain : null\n}\n\n/**\n * The document id a ledger is stored under: `aglyn.io~barracuda`. A `~`\n * because a domain never holds one and a Firestore id may.\n */\nexport function mailGatewayLedgerKey(sendingDomain: string, gateway: MailGateway): string {\n return `${sendingDomain}~${gateway}`\n}\n\n/** A stored ledger in its model shape, or `null` for no document or an unreadable one. */\nexport function readStoredMailGatewayLedger(\n id: string,\n data: Record<string, unknown> | null | undefined,\n): MailGatewayLedger | null {\n if (!data) return null\n const [idDomain, idGateway] = String(id ?? '').split('~')\n const gateway = isMailGateway(data['gateway']) ? data['gateway'] : isMailGateway(idGateway) ? idGateway : null\n const sendingDomain = normalizeLedgerSendingDomain(data['sendingDomain'] ?? idDomain)\n if (!gateway || !sendingDomain) return null\n const days: MailGatewayLedger['days'] = {}\n const raw = data['days']\n if (raw && typeof raw === 'object') {\n for (const [day, counts] of Object.entries(raw as Record<string, unknown>)) {\n const entry = (counts && typeof counts === 'object' ? counts : {}) as Record<string, unknown>\n days[day] = { sent: count(entry['sent']), delivered: count(entry['delivered']), blocked: count(entry['blocked']) }\n }\n }\n const ms = (value: unknown): number | null => (typeof value === 'number' && Number.isFinite(value) ? value : null)\n return {\n sendingDomain,\n gateway,\n sent: count(data['sent']),\n delivered: count(data['delivered']),\n blocked: count(data['blocked']),\n lastBlockedAtMs: ms(data['lastBlockedAtMs']),\n lastBlockedDetail: typeof data['lastBlockedDetail'] === 'string' ? data['lastBlockedDetail'] : null,\n days,\n updatedAtMs: ms(data['updatedAtMs']) ?? 0,\n }\n}\n\n/**\n * The ledger after one more outcome — the pure half of the write, which the\n * store runs inside its transaction. Deliveries stop counting for the day at\n * {@link MAIL_GATEWAY_DAILY_DELIVERY_CAP}; answers `null` when the write\n * would change nothing, so the store can skip it.\n */\nexport function applyMailGatewayOutcome(\n ledger: MailGatewayLedger | null,\n input: {\n sendingDomain: string\n gateway: MailGateway\n outcome: MailGatewayOutcome\n count?: number\n atMs: number\n detail?: string | null\n },\n): MailGatewayLedger | null {\n const day = mailGatewayDay(input.atMs)\n const days = pruneMailGatewayDays(ledger?.days, input.atMs, MAIL_GATEWAY_WINDOW_DAYS)\n const today = days[day] ?? { sent: 0, delivered: 0, blocked: 0 }\n let by = count(input.count ?? 1)\n if (input.outcome === 'delivered') by = Math.min(by, Math.max(0, MAIL_GATEWAY_DAILY_DELIVERY_CAP - today.delivered))\n if (!by) return null\n days[day] = { ...today, [input.outcome]: today[input.outcome] + by }\n const blocked = input.outcome === 'blocked'\n return {\n sendingDomain: input.sendingDomain,\n gateway: input.gateway,\n sent: (ledger?.sent ?? 0) + (input.outcome === 'sent' ? by : 0),\n delivered: (ledger?.delivered ?? 0) + (input.outcome === 'delivered' ? by : 0),\n blocked: (ledger?.blocked ?? 0) + (blocked ? by : 0),\n lastBlockedAtMs: blocked ? Math.max(ledger?.lastBlockedAtMs ?? 0, input.atMs) : (ledger?.lastBlockedAtMs ?? null),\n lastBlockedDetail: blocked\n ? String(input.detail ?? '').trim().slice(0, 300) || (ledger?.lastBlockedDetail ?? null)\n : (ledger?.lastBlockedDetail ?? null),\n days,\n updatedAtMs: input.atMs,\n }\n}\n\n/**\n * What a surface knows about the gateway in front of one address: which it\n * is, and what it did with this sending domain's mail over the chip's week\n * and the hold's month.\n */\nexport interface MailGatewayStanding {\n gateway: MailGateway\n /** Refusals and deliveries in the last {@link MAIL_GATEWAY_CHIP_DAYS} days. */\n blocked7: number\n delivered7: number\n /** Refusals and deliveries in the last {@link MAIL_GATEWAY_WINDOW_DAYS} days. */\n blocked30: number\n delivered30: number\n}\n\n/** A gateway's standing with a sending domain, read off its ledger (or none yet). */\nexport function mailGatewayStanding(\n gateway: MailGateway,\n ledger: Pick<MailGatewayLedger, 'days'> | null | undefined,\n nowMs: number,\n): MailGatewayStanding {\n const week = mailGatewayWindow(ledger?.days, nowMs, MAIL_GATEWAY_CHIP_DAYS)\n const month = mailGatewayWindow(ledger?.days, nowMs, MAIL_GATEWAY_WINDOW_DAYS)\n return {\n gateway,\n blocked7: week.blocked,\n delivered7: week.delivered,\n blocked30: month.blocked,\n delivered30: month.delivered,\n }\n}\n\n/**\n * Whether the gateway holds the next bulk send — see the module note. A\n * named gateway only: `other` is many exchanges, and `none` refuses instead.\n */\nexport function mailGatewayHolds(standing: Pick<MailGatewayStanding, 'gateway' | 'blocked30' | 'delivered30'>): boolean {\n if (standing.gateway === 'other' || standing.gateway === 'none') return false\n return count(standing.blocked30) >= MAIL_GATEWAY_HOLD_BLOCKS && count(standing.delivered30) === 0\n}\n\n/** Why a gateway holds, in one sentence a surface or a send record can carry. */\nexport function mailGatewayHoldSentence(gateway: MailGateway, blocked30: number): string {\n const name = MAIL_GATEWAY_LABELS[gateway]\n return (\n `${name} refused this sender ${blocked30 === 2 ? 'twice' : `${blocked30} times`} in the last ` +\n `${MAIL_GATEWAY_WINDOW_DAYS} days and delivered nothing`\n )\n}\n\n/** The chip's tone: what color a surface gives it. */\nexport type MailGatewayChipTone = 'refused' | 'delivered' | 'neutral' | 'blocked'\n\nexport interface MailGatewayChip {\n label: string\n tone: MailGatewayChipTone\n}\n\n/**\n * The chip for one address, or `null` when there is nothing worth a chip: an\n * unrecognized exchange with no verdict this week. Red for a gateway that\n * refused this week, green for one that delivered, neutral for the hosted\n * providers and the security gateways with no verdict yet, and blocked for a\n * domain with no MX.\n */\nexport function mailGatewayChip(standing: MailGatewayStanding | null | undefined): MailGatewayChip | null {\n if (!standing) return null\n const name = MAIL_GATEWAY_LABELS[standing.gateway]\n if (standing.gateway === 'none') return { label: 'No MX record — cannot receive mail', tone: 'blocked' }\n const blocked = count(standing.blocked7)\n const delivered = count(standing.delivered7)\n const settled = blocked + delivered\n if (blocked > 0) {\n return {\n label: `${name} · ${blocked} of ${settled} ${settled === 1 ? 'send' : 'sends'} refused this week`,\n tone: 'refused',\n }\n }\n if (delivered > 0) {\n return { label: `${name} · ${delivered} of ${settled} delivered this week`, tone: 'delivered' }\n }\n if (standing.gateway === 'other') return null\n return { label: name, tone: 'neutral' }\n}\n"],"names":["MAIL_GATEWAYS","MAIL_GATEWAY_LABELS","barracuda","proofpoint","mimecast","google","microsoft","other","none","MAIL_GATEWAYS_FRONTED","MAIL_GATEWAY_HOLD_BLOCKS","MAIL_GATEWAY_WINDOW_DAYS","MAIL_GATEWAY_CHIP_DAYS","MAIL_DOMAIN_INTEL_TTL_MS","MAIL_DOMAIN_NEGATIVE_TTL_MS","MAIL_GATEWAY_DELIVERED_AFTER_MS","MAIL_GATEWAY_DAILY_DELIVERY_CAP","GATEWAY_HOST_SUFFIXES","normalizeMailHost","host","String","trim","toLowerCase","replace","isMailGateway","value","includes","mailGatewayOfHost","name","gateway","suffixes","some","suffix","slice","endsWith","classifyMailGateway","mxHosts","hosts","map","filter","Boolean","length","isMailGatewayFronted","undefined","mailGatewayDay","atMs","Date","toISOString","count","number","Number","isFinite","Math","max","floor","mailGatewayWindow","days","nowMs","windowDays","summed","sent","delivered","blocked","oldest","newest","day","counts","Object","entries","pruneMailGatewayDays","keepDays","fromEntries","normalizeLedgerSendingDomain","raw","domain","lastIndexOf","test","mailGatewayLedgerKey","sendingDomain","readStoredMailGatewayLedger","id","data","ms","idDomain","idGateway","split","entry","lastBlockedAtMs","lastBlockedDetail","updatedAtMs","applyMailGatewayOutcome","ledger","input","today","by","outcome","min","detail","mailGatewayStanding","week","month","blocked7","delivered7","blocked30","delivered30","mailGatewayHolds","standing","mailGatewayHoldSentence","mailGatewayChip","label","tone","settled"],"mappings":"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"}