@aglyn/plugins-outreach 1.0.0-beta.199 → 1.0.0-beta.201

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 (108) hide show
  1. package/package.json +14 -14
  2. package/src/lib/components/do-not-contact-domains.js +60 -42
  3. package/src/lib/components/do-not-contact-domains.js.map +1 -1
  4. package/src/lib/components/enrollment-actions.d.ts +33 -0
  5. package/src/lib/components/enrollment-actions.js +174 -0
  6. package/src/lib/components/enrollment-actions.js.map +1 -0
  7. package/src/lib/components/enrollment-detail.d.ts +21 -0
  8. package/src/lib/components/enrollment-detail.js +870 -0
  9. package/src/lib/components/enrollment-detail.js.map +1 -0
  10. package/src/lib/components/enrollment-filter-params.d.ts +58 -0
  11. package/src/lib/components/enrollment-filter-params.js +98 -0
  12. package/src/lib/components/enrollment-filter-params.js.map +1 -0
  13. package/src/lib/components/enrollments-table.d.ts +25 -2
  14. package/src/lib/components/enrollments-table.js +303 -283
  15. package/src/lib/components/enrollments-table.js.map +1 -1
  16. package/src/lib/components/outreach-console-page.js +10 -0
  17. package/src/lib/components/outreach-console-page.js.map +1 -1
  18. package/src/lib/components/sequence-detail.d.ts +2 -0
  19. package/src/lib/components/sequence-detail.js +73 -11
  20. package/src/lib/components/sequence-detail.js.map +1 -1
  21. package/src/lib/components/sequence-report-card.d.ts +17 -0
  22. package/src/lib/components/sequence-report-card.js +63 -15
  23. package/src/lib/components/sequence-report-card.js.map +1 -1
  24. package/src/lib/components/sequences-section.d.ts +13 -1
  25. package/src/lib/components/sequences-section.js +108 -83
  26. package/src/lib/components/sequences-section.js.map +1 -1
  27. package/src/lib/components/use-outreach-api.d.ts +3 -1
  28. package/src/lib/components/use-outreach-api.js +6 -0
  29. package/src/lib/components/use-outreach-api.js.map +1 -1
  30. package/src/lib/components/use-outreach-data.d.ts +30 -25
  31. package/src/lib/components/use-outreach-data.js +93 -104
  32. package/src/lib/components/use-outreach-data.js.map +1 -1
  33. package/src/lib/components/use-outreach-enrollment.d.ts +18 -0
  34. package/src/lib/components/use-outreach-enrollment.js +133 -0
  35. package/src/lib/components/use-outreach-enrollment.js.map +1 -0
  36. package/src/lib/constants/api-routes.d.ts +2 -0
  37. package/src/lib/constants/api-routes.js +1 -0
  38. package/src/lib/constants/api-routes.js.map +1 -1
  39. package/src/lib/engine/gates.d.ts +4 -3
  40. package/src/lib/engine/gates.js.map +1 -1
  41. package/src/lib/engine/gateway-block.d.ts +4 -8
  42. package/src/lib/engine/gateway-block.js +6 -26
  43. package/src/lib/engine/gateway-block.js.map +1 -1
  44. package/src/lib/engine/mail-gateway.d.ts +18 -73
  45. package/src/lib/engine/mail-gateway.js +32 -206
  46. package/src/lib/engine/mail-gateway.js.map +1 -1
  47. package/src/lib/enrollment/enrollment-search.d.ts +32 -0
  48. package/src/lib/enrollment/enrollment-search.js +40 -0
  49. package/src/lib/enrollment/enrollment-search.js.map +1 -0
  50. package/src/lib/model/do-not-contact-domain-list-query.d.ts +32 -0
  51. package/src/lib/model/do-not-contact-domain-list-query.js +107 -0
  52. package/src/lib/model/do-not-contact-domain-list-query.js.map +1 -0
  53. package/src/lib/model/enrollment-engagement.d.ts +26 -2
  54. package/src/lib/model/enrollment-engagement.js +40 -3
  55. package/src/lib/model/enrollment-engagement.js.map +1 -1
  56. package/src/lib/model/enrollment-history.d.ts +40 -0
  57. package/src/lib/model/enrollment-history.js +95 -0
  58. package/src/lib/model/enrollment-history.js.map +1 -0
  59. package/src/lib/model/enrollment-list-query.d.ts +28 -0
  60. package/src/lib/model/enrollment-list-query.js +151 -0
  61. package/src/lib/model/enrollment-list-query.js.map +1 -0
  62. package/src/lib/model/enrollment-timeline.d.ts +78 -0
  63. package/src/lib/model/enrollment-timeline.js +430 -0
  64. package/src/lib/model/enrollment-timeline.js.map +1 -0
  65. package/src/lib/model/outreach-api.d.ts +24 -0
  66. package/src/lib/model/outreach-api.js.map +1 -1
  67. package/src/lib/model/outreach.types.d.ts +90 -29
  68. package/src/lib/model/outreach.types.js +33 -8
  69. package/src/lib/model/outreach.types.js.map +1 -1
  70. package/src/lib/model/sequence-list-query.d.ts +22 -0
  71. package/src/lib/model/sequence-list-query.js +99 -0
  72. package/src/lib/model/sequence-list-query.js.map +1 -0
  73. package/src/lib/routes/enroll-routes.js +34 -3
  74. package/src/lib/routes/enroll-routes.js.map +1 -1
  75. package/src/lib/routes/enrollment-routes.d.ts +10 -0
  76. package/src/lib/routes/enrollment-routes.js +82 -1
  77. package/src/lib/routes/enrollment-routes.js.map +1 -1
  78. package/src/lib/routes/register-routes.js +14 -4
  79. package/src/lib/routes/register-routes.js.map +1 -1
  80. package/src/lib/routes/route-deps.d.ts +3 -1
  81. package/src/lib/routes/route-deps.js.map +1 -1
  82. package/src/lib/routes/sequence-routes.js +6 -1
  83. package/src/lib/routes/sequence-routes.js.map +1 -1
  84. package/src/lib/runtime/click-events.d.ts +10 -5
  85. package/src/lib/runtime/click-events.js +45 -6
  86. package/src/lib/runtime/click-events.js.map +1 -1
  87. package/src/lib/runtime/gateway-ledger.d.ts +6 -0
  88. package/src/lib/runtime/gateway-ledger.js +9 -3
  89. package/src/lib/runtime/gateway-ledger.js.map +1 -1
  90. package/src/lib/runtime/host-suspension.d.ts +30 -0
  91. package/src/lib/runtime/host-suspension.js +35 -0
  92. package/src/lib/runtime/host-suspension.js.map +1 -0
  93. package/src/lib/runtime/person-erasure.d.ts +4 -1
  94. package/src/lib/runtime/person-erasure.js +14 -3
  95. package/src/lib/runtime/person-erasure.js.map +1 -1
  96. package/src/lib/runtime/platform-runtime-deps.js +34 -3
  97. package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
  98. package/src/lib/runtime/runtime-deps.d.ts +27 -1
  99. package/src/lib/runtime/runtime-deps.js.map +1 -1
  100. package/src/lib/runtime/send-job.js +46 -6
  101. package/src/lib/runtime/send-job.js.map +1 -1
  102. package/src/lib/runtime/sync-job.js +4 -0
  103. package/src/lib/runtime/sync-job.js.map +1 -1
  104. package/src/lib/storage/do-not-contact-store.js +6 -1
  105. package/src/lib/storage/do-not-contact-store.js.map +1 -1
  106. package/src/lib/storage/domain-intel-store.d.ts +61 -43
  107. package/src/lib/storage/domain-intel-store.js +138 -226
  108. package/src/lib/storage/domain-intel-store.js.map +1 -1
@@ -14,84 +14,29 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
- export type OutreachMailGateway = 'barracuda' | 'proofpoint' | 'mimecast' | 'google' | 'microsoft' | 'other' | 'none';
18
- export declare const OUTREACH_MAIL_GATEWAYS: readonly OutreachMailGateway[];
17
+ import { classifyMailGateway, isMailGatewayFronted, type MailGateway, mailGatewayChip, type MailGatewayChip, type MailGatewayChipTone, mailGatewayDay, type MailGatewayDayCounts, mailGatewayHolds, mailGatewayOfHost, type MailGatewayStanding, mailGatewayWindow, pruneMailGatewayDays } from '@aglyn/shared-util-email/mail-gateway';
18
+ export type OutreachMailGateway = MailGateway;
19
+ export type OutreachGatewayDayCounts = MailGatewayDayCounts;
20
+ export type OutreachGatewayStanding = MailGatewayStanding;
21
+ export type OutreachGatewayChipTone = MailGatewayChipTone;
22
+ export type OutreachGatewayChip = MailGatewayChip;
23
+ export declare const OUTREACH_MAIL_GATEWAYS: readonly MailGateway[];
19
24
  /** The gateway as the Check-people chip and the enrollment row name it. */
20
- export declare const OUTREACH_MAIL_GATEWAY_LABELS: Record<OutreachMailGateway, 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: the ones the enroll dialog
24
- * counts as "gateway-fronted".
25
- */
26
- export declare const OUTREACH_GATEWAY_FRONTED: readonly OutreachMailGateway[];
27
- /** Blocks in the window, with no delivery beside them, that hold the next send. */
25
+ export declare const OUTREACH_MAIL_GATEWAY_LABELS: Record<MailGateway, string>;
26
+ /** The security gateways the enroll dialog counts as "gateway-fronted". */
27
+ export declare const OUTREACH_GATEWAY_FRONTED: readonly MailGateway[];
28
28
  export declare const OUTREACH_GATEWAY_HOLD_BLOCKS = 2;
29
- /** The days the hold is judged over. */
30
29
  export declare const OUTREACH_GATEWAY_WINDOW_DAYS = 30;
31
- /** The days the Check-people chip counts. */
32
30
  export declare const OUTREACH_GATEWAY_CHIP_DAYS = 7;
33
- /** How long a domain's MX answer is trusted before it is looked up again. */
34
31
  export declare const OUTREACH_DOMAIN_INTEL_TTL_MS: number;
35
- /** A send with no bounce this long after it counts as delivered. */
36
32
  export declare const OUTREACH_GATEWAY_DELIVERED_AFTER_MS: number;
37
- /**
38
- * The gateway ONE host names, or `null` when it names none of the known
39
- * ones — a Remote-MTA in a bounce, or one MX exchange.
40
- */
41
- export declare function outreachMailGatewayOfHost(host: unknown): OutreachMailGateway | null;
42
- /**
43
- * The gateway a domain's MX records name — see the module note. A domain
44
- * whose exchanges name more than one is read as the first named one in MX
45
- * order (the list is handed over lowest-priority-first); one that names
46
- * none is `other`; an empty list is `none`.
47
- */
48
- export declare function classifyOutreachMailGateway(mxHosts: readonly string[]): OutreachMailGateway;
49
- /** Whether the gateway is one of the security gateways that refuse a sender for a whole company. */
50
- export declare function isOutreachGatewayFronted(gateway: OutreachMailGateway | null | undefined): boolean;
51
- /** The counts one UTC day holds on a gateway's ledger. */
52
- export interface OutreachGatewayDayCounts {
53
- sent: number;
54
- delivered: number;
55
- blocked: number;
56
- }
57
- /** `YYYY-MM-DD` in UTC: the day a ledger entry is filed under. */
58
- export declare function outreachGatewayDay(atMs: number): string;
59
- /** The ledger's counts summed over the last `days` UTC days, today included. */
60
- export declare function outreachGatewayWindow(days: Record<string, Partial<OutreachGatewayDayCounts>> | null | undefined, nowMs: number, windowDays: number): OutreachGatewayDayCounts;
61
- /** The ledger's days with everything older than `keepDays` dropped. */
62
- export declare function pruneOutreachGatewayDays<T>(days: Record<string, T> | null | undefined, nowMs: number, keepDays: number): Record<string, T>;
63
- /**
64
- * What the gates and the enroll dialog know about the gateway in front of
65
- * one address: which it is, and what it did with this organization's mail
66
- * over the chip's week and the hold's month.
67
- */
68
- export interface OutreachGatewayStanding {
69
- gateway: OutreachMailGateway;
70
- /** Blocks and deliveries in the last {@link OUTREACH_GATEWAY_CHIP_DAYS} days. */
71
- blocked7: number;
72
- delivered7: number;
73
- /** Blocks and deliveries in the last {@link OUTREACH_GATEWAY_WINDOW_DAYS} days. */
74
- blocked30: number;
75
- delivered30: number;
76
- }
77
- /**
78
- * Whether the gateway holds the next send — see the module note. A named
79
- * gateway only: `other` is many exchanges, and `none` blocks instead.
80
- */
81
- export declare function outreachGatewayHolds(standing: Pick<OutreachGatewayStanding, 'gateway' | 'blocked30' | 'delivered30'>): boolean;
33
+ export declare const outreachMailGatewayOfHost: typeof mailGatewayOfHost;
34
+ export declare const classifyOutreachMailGateway: typeof classifyMailGateway;
35
+ export declare const isOutreachGatewayFronted: typeof isMailGatewayFronted;
36
+ export declare const outreachGatewayDay: typeof mailGatewayDay;
37
+ export declare const outreachGatewayWindow: typeof mailGatewayWindow;
38
+ export declare const pruneOutreachGatewayDays: typeof pruneMailGatewayDays;
39
+ export declare const outreachGatewayHolds: typeof mailGatewayHolds;
40
+ export declare const outreachGatewayChip: typeof mailGatewayChip;
82
41
  /** The sentence the hold is written with, on the enrollment and in the engine's reason. */
83
42
  export declare function outreachGatewayHoldReason(gateway: OutreachMailGateway, blocked30: number): string;
84
- /** The chip's tone: what color the Check-people step gives it. */
85
- export type OutreachGatewayChipTone = 'refused' | 'delivered' | 'neutral' | 'blocked';
86
- export interface OutreachGatewayChip {
87
- label: string;
88
- tone: OutreachGatewayChipTone;
89
- }
90
- /**
91
- * The Check-people chip for one person, or `null` when there is nothing
92
- * worth a chip: an unrecognized exchange with no verdict this week. Red for
93
- * a gateway that refused this week, green for one that delivered, neutral
94
- * for the hosted providers and the security gateways with no verdict yet,
95
- * and blocked for a domain with no MX.
96
- */
97
- export declare function outreachGatewayChip(standing: OutreachGatewayStanding | null | undefined): OutreachGatewayChip | null;
@@ -14,213 +14,39 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */ /*==========================================
17
- * THE MX RECORD PREDICTS THE GATEWAY, AND THE GATEWAY'S HISTORY PREDICTS
18
- * THE BLOCK (AGL-3326).
19
- *
20
- * Two Barracuda gateways refused the same sender three days apart, and each
21
- * refusal was read only after the email had left: a hard bounce, a paused
22
- * mailbox, ten first emails still queued. Both domains had Barracuda ESS as
23
- * their MX, and a third enrolled domain with the same MX was stopped by hand
24
- * before it sent. So the signal is cheap and it is available BEFORE a send:
25
- * the domain's MX names the gateway in front of it, and what that gateway
26
- * has done with this organization's mail lately says what it will do next.
27
- *
28
- * ## The kinds
29
- *
30
- * A gateway is named by the MX host's suffix. `barracuda`, `proofpoint`
31
- * and `mimecast` are the security gateways that refuse a SENDER on policy
32
- * and reputation; `google` and `microsoft` are the two hosted providers,
33
- * which judge mail one message at a time; `other` is every self-hosted or
34
- * unrecognized exchange; `none` is a domain with no MX at all, which cannot
35
- * receive mail and blocks the enrollment outright.
36
- *
37
- * ## The ledger and the hold
38
- *
39
- * Every send, every delivery and every block is counted per gateway per
40
- * organization, by UTC day, and a gateway that has REFUSED this
41
- * organization twice in the last {@link OUTREACH_GATEWAY_WINDOW_DAYS} days
42
- * without delivering once holds the next send into it: nothing goes into a
43
- * gateway that has refused us twice unless a member says so. `other` never
44
- * holds, because it is a bucket of unrelated exchanges rather than one
45
- * gateway; `none` blocks instead.
46
- *==========================================*/ export const OUTREACH_MAIL_GATEWAYS = [
47
- 'barracuda',
48
- 'proofpoint',
49
- 'mimecast',
50
- 'google',
51
- 'microsoft',
52
- 'other',
53
- 'none'
54
- ];
55
- /** The gateway as the Check-people chip and the enrollment row name it. */ export const OUTREACH_MAIL_GATEWAY_LABELS = {
56
- barracuda: 'Barracuda',
57
- proofpoint: 'Proofpoint',
58
- mimecast: 'Mimecast',
59
- google: 'Google Workspace',
60
- microsoft: 'Microsoft 365',
61
- other: 'Other mail gateway',
62
- none: 'No MX record'
63
- };
64
- /**
65
- * The security gateways that stand in front of a whole company's mail and
66
- * refuse a sender for everyone behind them: the ones the enroll dialog
67
- * counts as "gateway-fronted".
68
- */ export const OUTREACH_GATEWAY_FRONTED = [
69
- 'barracuda',
70
- 'proofpoint',
71
- 'mimecast'
72
- ];
73
- /** Blocks in the window, with no delivery beside them, that hold the next send. */ export const OUTREACH_GATEWAY_HOLD_BLOCKS = 2;
74
- /** The days the hold is judged over. */ export const OUTREACH_GATEWAY_WINDOW_DAYS = 30;
75
- /** The days the Check-people chip counts. */ export const OUTREACH_GATEWAY_CHIP_DAYS = 7;
76
- /** How long a domain's MX answer is trusted before it is looked up again. */ export const OUTREACH_DOMAIN_INTEL_TTL_MS = 7 * 24 * 60 * 60 * 1000;
77
- /** A send with no bounce this long after it counts as delivered. */ export const OUTREACH_GATEWAY_DELIVERED_AFTER_MS = 24 * 60 * 60 * 1000;
78
- /** The MX host suffixes each named gateway answers as, lowercase, no trailing dot. */ const GATEWAY_HOST_SUFFIXES = [
79
- [
80
- 'barracuda',
81
- [
82
- '.ess.barracudanetworks.com',
83
- '.barracudanetworks.com'
84
- ]
85
- ],
86
- [
87
- 'proofpoint',
88
- [
89
- '.pphosted.com',
90
- '.ppe-hosted.com',
91
- '.proofpoint.com'
92
- ]
93
- ],
94
- [
95
- 'mimecast',
96
- [
97
- '.mimecast.com',
98
- '.mimecast.co.za',
99
- '.mimecast-offshore.com'
100
- ]
101
- ],
102
- [
103
- 'google',
104
- [
105
- '.google.com',
106
- '.googlemail.com',
107
- '.gmail.com'
108
- ]
109
- ],
110
- [
111
- 'microsoft',
112
- [
113
- '.mail.protection.outlook.com',
114
- '.outlook.com',
115
- '.protection.outlook.com'
116
- ]
117
- ]
118
- ];
119
- /** A host as the suffix table reads it: lowercase, trimmed, no trailing dot. */ function normalizeHost(host) {
120
- return String(host != null ? host : '').trim().toLowerCase().replace(/\.$/, '');
121
- }
122
- /**
123
- * The gateway ONE host names, or `null` when it names none of the known
124
- * ones — a Remote-MTA in a bounce, or one MX exchange.
125
- */ export function outreachMailGatewayOfHost(host) {
126
- const name = normalizeHost(host);
127
- if (!name) return null;
128
- for (const [gateway, suffixes] of GATEWAY_HOST_SUFFIXES){
129
- if (suffixes.some((suffix)=>name === suffix.slice(1) || name.endsWith(suffix))) return gateway;
130
- }
131
- return null;
132
- }
133
- /**
134
- * The gateway a domain's MX records name — see the module note. A domain
135
- * whose exchanges name more than one is read as the first named one in MX
136
- * order (the list is handed over lowest-priority-first); one that names
137
- * none is `other`; an empty list is `none`.
138
- */ export function classifyOutreachMailGateway(mxHosts) {
139
- const hosts = mxHosts.map(normalizeHost).filter(Boolean);
140
- // A lone "." (or an empty exchange) is RFC 7505's null MX: the domain
141
- // says outright that it takes no mail.
142
- if (!hosts.length) return 'none';
143
- for (const host of hosts){
144
- const gateway = outreachMailGatewayOfHost(host);
145
- if (gateway) return gateway;
146
- }
147
- return 'other';
148
- }
149
- /** Whether the gateway is one of the security gateways that refuse a sender for a whole company. */ export function isOutreachGatewayFronted(gateway) {
150
- return gateway !== null && gateway !== undefined && OUTREACH_GATEWAY_FRONTED.includes(gateway);
151
- }
152
- /** `YYYY-MM-DD` in UTC: the day a ledger entry is filed under. */ export function outreachGatewayDay(atMs) {
153
- return new Date(atMs).toISOString().slice(0, 10);
154
- }
155
- const count = (value)=>{
156
- const number = Number(value);
157
- return Number.isFinite(number) ? Math.max(0, Math.floor(number)) : 0;
158
- };
159
- /** The ledger's counts summed over the last `days` UTC days, today included. */ export function outreachGatewayWindow(days, nowMs, windowDays) {
160
- const summed = {
161
- sent: 0,
162
- delivered: 0,
163
- blocked: 0
164
- };
165
- if (!days) return summed;
166
- const oldest = outreachGatewayDay(nowMs - (windowDays - 1) * 86400000);
167
- const newest = outreachGatewayDay(nowMs);
168
- for (const [day, counts] of Object.entries(days)){
169
- if (day < oldest || day > newest) continue;
170
- summed.sent += count(counts == null ? void 0 : counts.sent);
171
- summed.delivered += count(counts == null ? void 0 : counts.delivered);
172
- summed.blocked += count(counts == null ? void 0 : counts.blocked);
173
- }
174
- return summed;
175
- }
176
- /** The ledger's days with everything older than `keepDays` dropped. */ export function pruneOutreachGatewayDays(days, nowMs, keepDays) {
177
- const oldest = outreachGatewayDay(nowMs - (keepDays - 1) * 86400000);
178
- return Object.fromEntries(Object.entries(days != null ? days : {}).filter(([day])=>day >= oldest));
179
- }
180
- /**
181
- * Whether the gateway holds the next send — see the module note. A named
182
- * gateway only: `other` is many exchanges, and `none` blocks instead.
183
- */ export function outreachGatewayHolds(standing) {
184
- if (standing.gateway === 'other' || standing.gateway === 'none') return false;
185
- return count(standing.blocked30) >= OUTREACH_GATEWAY_HOLD_BLOCKS && count(standing.delivered30) === 0;
186
- }
17
+ * THE MAIL GATEWAY, AS SEQUENCES NAMES IT (AGL-3326).
18
+ *
19
+ * The engine is the platform's — `@aglyn/shared-util-email`'s
20
+ * `mail-gateway` (AGL-3328), which the Resend path reads too, so one table
21
+ * names a gateway and one rule holds a send for both. What stays here is
22
+ * the vocabulary Sequences already speaks, and the one sentence it adds: a
23
+ * held enrollment is resumed by a member.
24
+ *
25
+ * ## The hold, as Sequences applies it
26
+ *
27
+ * A gateway that REFUSED the mailbox's sending domain twice in the last
28
+ * {@link OUTREACH_GATEWAY_WINDOW_DAYS} days without delivering once holds
29
+ * the next send into it: nothing goes into a gateway that has refused us
30
+ * twice unless a member says so. `other` never holds; `none` blocks instead.
31
+ *==========================================*/ import { classifyMailGateway, isMailGatewayFronted, MAIL_DOMAIN_INTEL_TTL_MS, MAIL_GATEWAY_CHIP_DAYS, MAIL_GATEWAY_DELIVERED_AFTER_MS, MAIL_GATEWAY_HOLD_BLOCKS, MAIL_GATEWAY_LABELS, MAIL_GATEWAY_WINDOW_DAYS, MAIL_GATEWAYS, MAIL_GATEWAYS_FRONTED, mailGatewayChip, mailGatewayDay, mailGatewayHolds, mailGatewayHoldSentence, mailGatewayOfHost, mailGatewayWindow, pruneMailGatewayDays } from "@aglyn/shared-util-email/mail-gateway";
32
+ export const OUTREACH_MAIL_GATEWAYS = MAIL_GATEWAYS;
33
+ /** The gateway as the Check-people chip and the enrollment row name it. */ export const OUTREACH_MAIL_GATEWAY_LABELS = MAIL_GATEWAY_LABELS;
34
+ /** The security gateways the enroll dialog counts as "gateway-fronted". */ export const OUTREACH_GATEWAY_FRONTED = MAIL_GATEWAYS_FRONTED;
35
+ export const OUTREACH_GATEWAY_HOLD_BLOCKS = MAIL_GATEWAY_HOLD_BLOCKS;
36
+ export const OUTREACH_GATEWAY_WINDOW_DAYS = MAIL_GATEWAY_WINDOW_DAYS;
37
+ export const OUTREACH_GATEWAY_CHIP_DAYS = MAIL_GATEWAY_CHIP_DAYS;
38
+ export const OUTREACH_DOMAIN_INTEL_TTL_MS = MAIL_DOMAIN_INTEL_TTL_MS;
39
+ export const OUTREACH_GATEWAY_DELIVERED_AFTER_MS = MAIL_GATEWAY_DELIVERED_AFTER_MS;
40
+ export const outreachMailGatewayOfHost = mailGatewayOfHost;
41
+ export const classifyOutreachMailGateway = classifyMailGateway;
42
+ export const isOutreachGatewayFronted = isMailGatewayFronted;
43
+ export const outreachGatewayDay = mailGatewayDay;
44
+ export const outreachGatewayWindow = mailGatewayWindow;
45
+ export const pruneOutreachGatewayDays = pruneMailGatewayDays;
46
+ export const outreachGatewayHolds = mailGatewayHolds;
47
+ export const outreachGatewayChip = mailGatewayChip;
187
48
  /** The sentence the hold is written with, on the enrollment and in the engine's reason. */ export function outreachGatewayHoldReason(gateway, blocked30) {
188
- const name = OUTREACH_MAIL_GATEWAY_LABELS[gateway];
189
- return `${name} refused this sender ${blocked30 === 2 ? 'twice' : `${blocked30} times`} in the last ` + `${OUTREACH_GATEWAY_WINDOW_DAYS} days and delivered nothing, so this email is held. ` + 'Resume the enrollment to send it anyway.';
190
- }
191
- /**
192
- * The Check-people chip for one person, or `null` when there is nothing
193
- * worth a chip: an unrecognized exchange with no verdict this week. Red for
194
- * a gateway that refused this week, green for one that delivered, neutral
195
- * for the hosted providers and the security gateways with no verdict yet,
196
- * and blocked for a domain with no MX.
197
- */ export function outreachGatewayChip(standing) {
198
- if (!standing) return null;
199
- const name = OUTREACH_MAIL_GATEWAY_LABELS[standing.gateway];
200
- if (standing.gateway === 'none') return {
201
- label: 'No MX record — cannot receive mail',
202
- tone: 'blocked'
203
- };
204
- const blocked = count(standing.blocked7);
205
- const delivered = count(standing.delivered7);
206
- const settled = blocked + delivered;
207
- if (blocked > 0) {
208
- return {
209
- label: `${name} · ${blocked} of ${settled} ${settled === 1 ? 'send' : 'sends'} refused this week`,
210
- tone: 'refused'
211
- };
212
- }
213
- if (delivered > 0) {
214
- return {
215
- label: `${name} · ${delivered} of ${settled} delivered this week`,
216
- tone: 'delivered'
217
- };
218
- }
219
- if (standing.gateway === 'other') return null;
220
- return {
221
- label: name,
222
- tone: 'neutral'
223
- };
49
+ return `${mailGatewayHoldSentence(gateway, blocked30)}, so this email is held. Resume the enrollment to send it anyway.`;
224
50
  }
225
51
 
226
52
  //# sourceMappingURL=mail-gateway.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/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 PREDICTS\n * THE BLOCK (AGL-3326).\n *\n * Two Barracuda gateways refused the same sender three days apart, and each\n * refusal was read only after the email had left: a hard bounce, a paused\n * mailbox, ten first emails still queued. Both domains had Barracuda ESS as\n * their MX, and a third enrolled domain with the same MX was stopped by hand\n * before it sent. So the signal is cheap and it is available BEFORE a send:\n * the domain's MX names the gateway in front of it, and what that gateway\n * has done with this organization's mail lately says what it will do next.\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 with no MX at all, which cannot\n * receive mail and blocks the enrollment outright.\n *\n * ## The ledger and the hold\n *\n * Every send, every delivery and every block is counted per gateway per\n * organization, by UTC day, and a gateway that has REFUSED this\n * organization twice in the last {@link OUTREACH_GATEWAY_WINDOW_DAYS} days\n * without delivering once holds the next send into it: nothing goes into a\n * gateway that has refused us twice unless a member says so. `other` never\n * holds, because it is a bucket of unrelated exchanges rather than one\n * gateway; `none` blocks instead.\n *==========================================*/\n\nexport type OutreachMailGateway =\n | 'barracuda'\n | 'proofpoint'\n | 'mimecast'\n | 'google'\n | 'microsoft'\n | 'other'\n | 'none'\n\nexport const OUTREACH_MAIL_GATEWAYS: readonly OutreachMailGateway[] = [\n 'barracuda',\n 'proofpoint',\n 'mimecast',\n 'google',\n 'microsoft',\n 'other',\n 'none',\n]\n\n/** The gateway as the Check-people chip and the enrollment row name it. */\nexport const OUTREACH_MAIL_GATEWAY_LABELS: Record<OutreachMailGateway, 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: the ones the enroll dialog\n * counts as \"gateway-fronted\".\n */\nexport const OUTREACH_GATEWAY_FRONTED: readonly OutreachMailGateway[] = ['barracuda', 'proofpoint', 'mimecast']\n\n/** Blocks in the window, with no delivery beside them, that hold the next send. */\nexport const OUTREACH_GATEWAY_HOLD_BLOCKS = 2\n\n/** The days the hold is judged over. */\nexport const OUTREACH_GATEWAY_WINDOW_DAYS = 30\n\n/** The days the Check-people chip counts. */\nexport const OUTREACH_GATEWAY_CHIP_DAYS = 7\n\n/** How long a domain's MX answer is trusted before it is looked up again. */\nexport const OUTREACH_DOMAIN_INTEL_TTL_MS = 7 * 24 * 60 * 60 * 1000\n\n/** A send with no bounce this long after it counts as delivered. */\nexport const OUTREACH_GATEWAY_DELIVERED_AFTER_MS = 24 * 60 * 60 * 1000\n\n/** The MX host suffixes each named gateway answers as, lowercase, no trailing dot. */\nconst GATEWAY_HOST_SUFFIXES: ReadonlyArray<readonly [OutreachMailGateway, 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. */\nfunction normalizeHost(host: unknown): string {\n return String(host ?? '')\n .trim()\n .toLowerCase()\n .replace(/\\.$/, '')\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 outreachMailGatewayOfHost(host: unknown): OutreachMailGateway | null {\n const name = normalizeHost(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 — see the module note. A domain\n * whose exchanges name more than one is read as the first named one in MX\n * order (the list is handed over lowest-priority-first); one that names\n * none is `other`; an empty list is `none`.\n */\nexport function classifyOutreachMailGateway(mxHosts: readonly string[]): OutreachMailGateway {\n const hosts = mxHosts.map(normalizeHost).filter(Boolean)\n // A lone \".\" (or an empty exchange) is RFC 7505's null MX: the domain\n // says outright that it takes no mail.\n if (!hosts.length) return 'none'\n for (const host of hosts) {\n const gateway = outreachMailGatewayOfHost(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 isOutreachGatewayFronted(gateway: OutreachMailGateway | null | undefined): boolean {\n return gateway !== null && gateway !== undefined && OUTREACH_GATEWAY_FRONTED.includes(gateway)\n}\n\n/** The counts one UTC day holds on a gateway's ledger. */\nexport interface OutreachGatewayDayCounts {\n sent: number\n delivered: number\n blocked: number\n}\n\n/** `YYYY-MM-DD` in UTC: the day a ledger entry is filed under. */\nexport function outreachGatewayDay(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 `days` UTC days, today included. */\nexport function outreachGatewayWindow(\n days: Record<string, Partial<OutreachGatewayDayCounts>> | null | undefined,\n nowMs: number,\n windowDays: number,\n): OutreachGatewayDayCounts {\n const summed: OutreachGatewayDayCounts = { sent: 0, delivered: 0, blocked: 0 }\n if (!days) return summed\n const oldest = outreachGatewayDay(nowMs - (windowDays - 1) * 86_400_000)\n const newest = outreachGatewayDay(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 pruneOutreachGatewayDays<T>(\n days: Record<string, T> | null | undefined,\n nowMs: number,\n keepDays: number,\n): Record<string, T> {\n const oldest = outreachGatewayDay(nowMs - (keepDays - 1) * 86_400_000)\n return Object.fromEntries(Object.entries(days ?? {}).filter(([day]) => day >= oldest))\n}\n\n/**\n * What the gates and the enroll dialog know about the gateway in front of\n * one address: which it is, and what it did with this organization's mail\n * over the chip's week and the hold's month.\n */\nexport interface OutreachGatewayStanding {\n gateway: OutreachMailGateway\n /** Blocks and deliveries in the last {@link OUTREACH_GATEWAY_CHIP_DAYS} days. */\n blocked7: number\n delivered7: number\n /** Blocks and deliveries in the last {@link OUTREACH_GATEWAY_WINDOW_DAYS} days. */\n blocked30: number\n delivered30: number\n}\n\n/**\n * Whether the gateway holds the next send — see the module note. A named\n * gateway only: `other` is many exchanges, and `none` blocks instead.\n */\nexport function outreachGatewayHolds(standing: Pick<OutreachGatewayStanding, 'gateway' | 'blocked30' | 'delivered30'>): boolean {\n if (standing.gateway === 'other' || standing.gateway === 'none') return false\n return count(standing.blocked30) >= OUTREACH_GATEWAY_HOLD_BLOCKS && count(standing.delivered30) === 0\n}\n\n/** The sentence the hold is written with, on the enrollment and in the engine's reason. */\nexport function outreachGatewayHoldReason(gateway: OutreachMailGateway, blocked30: number): string {\n const name = OUTREACH_MAIL_GATEWAY_LABELS[gateway]\n return (\n `${name} refused this sender ${blocked30 === 2 ? 'twice' : `${blocked30} times`} in the last ` +\n `${OUTREACH_GATEWAY_WINDOW_DAYS} days and delivered nothing, so this email is held. ` +\n 'Resume the enrollment to send it anyway.'\n )\n}\n\n/** The chip's tone: what color the Check-people step gives it. */\nexport type OutreachGatewayChipTone = 'refused' | 'delivered' | 'neutral' | 'blocked'\n\nexport interface OutreachGatewayChip {\n label: string\n tone: OutreachGatewayChipTone\n}\n\n/**\n * The Check-people chip for one person, or `null` when there is nothing\n * worth a chip: an unrecognized exchange with no verdict this week. Red for\n * a gateway that refused this week, green for one that delivered, neutral\n * for the hosted providers and the security gateways with no verdict yet,\n * and blocked for a domain with no MX.\n */\nexport function outreachGatewayChip(standing: OutreachGatewayStanding | null | undefined): OutreachGatewayChip | null {\n if (!standing) return null\n const name = OUTREACH_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":["OUTREACH_MAIL_GATEWAYS","OUTREACH_MAIL_GATEWAY_LABELS","barracuda","proofpoint","mimecast","google","microsoft","other","none","OUTREACH_GATEWAY_FRONTED","OUTREACH_GATEWAY_HOLD_BLOCKS","OUTREACH_GATEWAY_WINDOW_DAYS","OUTREACH_GATEWAY_CHIP_DAYS","OUTREACH_DOMAIN_INTEL_TTL_MS","OUTREACH_GATEWAY_DELIVERED_AFTER_MS","GATEWAY_HOST_SUFFIXES","normalizeHost","host","String","trim","toLowerCase","replace","outreachMailGatewayOfHost","name","gateway","suffixes","some","suffix","slice","endsWith","classifyOutreachMailGateway","mxHosts","hosts","map","filter","Boolean","length","isOutreachGatewayFronted","undefined","includes","outreachGatewayDay","atMs","Date","toISOString","count","value","number","Number","isFinite","Math","max","floor","outreachGatewayWindow","days","nowMs","windowDays","summed","sent","delivered","blocked","oldest","newest","day","counts","Object","entries","pruneOutreachGatewayDays","keepDays","fromEntries","outreachGatewayHolds","standing","blocked30","delivered30","outreachGatewayHoldReason","outreachGatewayChip","label","tone","blocked7","delivered7","settled"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/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 MAIL GATEWAY, AS SEQUENCES NAMES IT (AGL-3326).\n *\n * The engine is the platform's — `@aglyn/shared-util-email`'s\n * `mail-gateway` (AGL-3328), which the Resend path reads too, so one table\n * names a gateway and one rule holds a send for both. What stays here is\n * the vocabulary Sequences already speaks, and the one sentence it adds: a\n * held enrollment is resumed by a member.\n *\n * ## The hold, as Sequences applies it\n *\n * A gateway that REFUSED the mailbox's sending domain twice in the last\n * {@link OUTREACH_GATEWAY_WINDOW_DAYS} days without delivering once holds\n * the next send into it: nothing goes into a gateway that has refused us\n * twice unless a member says so. `other` never holds; `none` blocks instead.\n *==========================================*/\n\nimport {\n classifyMailGateway,\n isMailGatewayFronted,\n MAIL_DOMAIN_INTEL_TTL_MS,\n MAIL_GATEWAY_CHIP_DAYS,\n MAIL_GATEWAY_DELIVERED_AFTER_MS,\n MAIL_GATEWAY_HOLD_BLOCKS,\n MAIL_GATEWAY_LABELS,\n MAIL_GATEWAY_WINDOW_DAYS,\n MAIL_GATEWAYS,\n MAIL_GATEWAYS_FRONTED,\n type MailGateway,\n mailGatewayChip,\n type MailGatewayChip,\n type MailGatewayChipTone,\n mailGatewayDay,\n type MailGatewayDayCounts,\n mailGatewayHolds,\n mailGatewayHoldSentence,\n mailGatewayOfHost,\n type MailGatewayStanding,\n mailGatewayWindow,\n pruneMailGatewayDays,\n} from '@aglyn/shared-util-email/mail-gateway'\n\nexport type OutreachMailGateway = MailGateway\nexport type OutreachGatewayDayCounts = MailGatewayDayCounts\nexport type OutreachGatewayStanding = MailGatewayStanding\nexport type OutreachGatewayChipTone = MailGatewayChipTone\nexport type OutreachGatewayChip = MailGatewayChip\n\nexport const OUTREACH_MAIL_GATEWAYS = MAIL_GATEWAYS\n/** The gateway as the Check-people chip and the enrollment row name it. */\nexport const OUTREACH_MAIL_GATEWAY_LABELS = MAIL_GATEWAY_LABELS\n/** The security gateways the enroll dialog counts as \"gateway-fronted\". */\nexport const OUTREACH_GATEWAY_FRONTED = MAIL_GATEWAYS_FRONTED\nexport const OUTREACH_GATEWAY_HOLD_BLOCKS = MAIL_GATEWAY_HOLD_BLOCKS\nexport const OUTREACH_GATEWAY_WINDOW_DAYS = MAIL_GATEWAY_WINDOW_DAYS\nexport const OUTREACH_GATEWAY_CHIP_DAYS = MAIL_GATEWAY_CHIP_DAYS\nexport const OUTREACH_DOMAIN_INTEL_TTL_MS = MAIL_DOMAIN_INTEL_TTL_MS\nexport const OUTREACH_GATEWAY_DELIVERED_AFTER_MS = MAIL_GATEWAY_DELIVERED_AFTER_MS\n\nexport const outreachMailGatewayOfHost = mailGatewayOfHost\nexport const classifyOutreachMailGateway = classifyMailGateway\nexport const isOutreachGatewayFronted = isMailGatewayFronted\nexport const outreachGatewayDay = mailGatewayDay\nexport const outreachGatewayWindow = mailGatewayWindow\nexport const pruneOutreachGatewayDays = pruneMailGatewayDays\nexport const outreachGatewayHolds = mailGatewayHolds\nexport const outreachGatewayChip = mailGatewayChip\n\n/** The sentence the hold is written with, on the enrollment and in the engine's reason. */\nexport function outreachGatewayHoldReason(gateway: OutreachMailGateway, blocked30: number): string {\n return `${mailGatewayHoldSentence(gateway, blocked30)}, so this email is held. Resume the enrollment to send it anyway.`\n}\n"],"names":["classifyMailGateway","isMailGatewayFronted","MAIL_DOMAIN_INTEL_TTL_MS","MAIL_GATEWAY_CHIP_DAYS","MAIL_GATEWAY_DELIVERED_AFTER_MS","MAIL_GATEWAY_HOLD_BLOCKS","MAIL_GATEWAY_LABELS","MAIL_GATEWAY_WINDOW_DAYS","MAIL_GATEWAYS","MAIL_GATEWAYS_FRONTED","mailGatewayChip","mailGatewayDay","mailGatewayHolds","mailGatewayHoldSentence","mailGatewayOfHost","mailGatewayWindow","pruneMailGatewayDays","OUTREACH_MAIL_GATEWAYS","OUTREACH_MAIL_GATEWAY_LABELS","OUTREACH_GATEWAY_FRONTED","OUTREACH_GATEWAY_HOLD_BLOCKS","OUTREACH_GATEWAY_WINDOW_DAYS","OUTREACH_GATEWAY_CHIP_DAYS","OUTREACH_DOMAIN_INTEL_TTL_MS","OUTREACH_GATEWAY_DELIVERED_AFTER_MS","outreachMailGatewayOfHost","classifyOutreachMailGateway","isOutreachGatewayFronted","outreachGatewayDay","outreachGatewayWindow","pruneOutreachGatewayDays","outreachGatewayHolds","outreachGatewayChip","outreachGatewayHoldReason","gateway","blocked30"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;4CAe4C,GAE5C,SACEA,mBAAmB,EACnBC,oBAAoB,EACpBC,wBAAwB,EACxBC,sBAAsB,EACtBC,+BAA+B,EAC/BC,wBAAwB,EACxBC,mBAAmB,EACnBC,wBAAwB,EACxBC,aAAa,EACbC,qBAAqB,EAErBC,eAAe,EAGfC,cAAc,EAEdC,gBAAgB,EAChBC,uBAAuB,EACvBC,iBAAiB,EAEjBC,iBAAiB,EACjBC,oBAAoB,QACf,wCAAuC;AAQ9C,OAAO,MAAMC,yBAAyBT,cAAa;AACnD,yEAAyE,GACzE,OAAO,MAAMU,+BAA+BZ,oBAAmB;AAC/D,yEAAyE,GACzE,OAAO,MAAMa,2BAA2BV,sBAAqB;AAC7D,OAAO,MAAMW,+BAA+Bf,yBAAwB;AACpE,OAAO,MAAMgB,+BAA+Bd,yBAAwB;AACpE,OAAO,MAAMe,6BAA6BnB,uBAAsB;AAChE,OAAO,MAAMoB,+BAA+BrB,yBAAwB;AACpE,OAAO,MAAMsB,sCAAsCpB,gCAA+B;AAElF,OAAO,MAAMqB,4BAA4BX,kBAAiB;AAC1D,OAAO,MAAMY,8BAA8B1B,oBAAmB;AAC9D,OAAO,MAAM2B,2BAA2B1B,qBAAoB;AAC5D,OAAO,MAAM2B,qBAAqBjB,eAAc;AAChD,OAAO,MAAMkB,wBAAwBd,kBAAiB;AACtD,OAAO,MAAMe,2BAA2Bd,qBAAoB;AAC5D,OAAO,MAAMe,uBAAuBnB,iBAAgB;AACpD,OAAO,MAAMoB,sBAAsBtB,gBAAe;AAElD,yFAAyF,GACzF,OAAO,SAASuB,0BAA0BC,OAA4B,EAAEC,SAAiB;IACvF,OAAO,GAAGtB,wBAAwBqB,SAASC,WAAW,iEAAiE,CAAC;AAC1H"}
@@ -0,0 +1,32 @@
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
+ import type { OutreachEnrollment } from '../model/outreach.types';
18
+ /**
19
+ * What a sequence's enrollments table searches an enrollment by (AGL-3321):
20
+ * every prefix of each word of the person's name, and of their address, its
21
+ * domain and each piece of either — the same address derivation the
22
+ * platform's suppression search uses — so "casey", "morgan" and "example"
23
+ * all find Casey Morgan at casey.morgan@example.com.
24
+ *
25
+ * Both halves are captured once, at enrollment, and never rewritten (an
26
+ * erasure deletes the enrollment rather than editing it), so the enroll
27
+ * route stamps this on the document it creates and nothing else has to.
28
+ * `tools/scripts/backfill-outreach-list-search.mjs` restates it for the
29
+ * enrollments made before it; both are held to
30
+ * `tools/scripts/lib/outreach-search-tokens.fixtures.json`.
31
+ */
32
+ export declare function outreachEnrollmentSearchTokens(enrollment: Pick<OutreachEnrollment, 'contactName' | 'email'>): string[];
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { nameSearchTokens } from "@aglyn/aglyn/app-utils/name-search";
17
+ import { emailSearchTokens } from "@aglyn/tenant-data-admin/server/email-suppression";
18
+ /**
19
+ * What a sequence's enrollments table searches an enrollment by (AGL-3321):
20
+ * every prefix of each word of the person's name, and of their address, its
21
+ * domain and each piece of either — the same address derivation the
22
+ * platform's suppression search uses — so "casey", "morgan" and "example"
23
+ * all find Casey Morgan at casey.morgan@example.com.
24
+ *
25
+ * Both halves are captured once, at enrollment, and never rewritten (an
26
+ * erasure deletes the enrollment rather than editing it), so the enroll
27
+ * route stamps this on the document it creates and nothing else has to.
28
+ * `tools/scripts/backfill-outreach-list-search.mjs` restates it for the
29
+ * enrollments made before it; both are held to
30
+ * `tools/scripts/lib/outreach-search-tokens.fixtures.json`.
31
+ */ export function outreachEnrollmentSearchTokens(enrollment) {
32
+ return [
33
+ ...new Set([
34
+ ...nameSearchTokens(enrollment.contactName),
35
+ ...emailSearchTokens(enrollment.email)
36
+ ])
37
+ ];
38
+ }
39
+
40
+ //# sourceMappingURL=enrollment-search.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/enrollment/enrollment-search.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { nameSearchTokens } from '@aglyn/aglyn/app-utils/name-search'\nimport { emailSearchTokens } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport type { OutreachEnrollment } from '../model/outreach.types'\n\n/**\n * What a sequence's enrollments table searches an enrollment by (AGL-3321):\n * every prefix of each word of the person's name, and of their address, its\n * domain and each piece of either — the same address derivation the\n * platform's suppression search uses — so \"casey\", \"morgan\" and \"example\"\n * all find Casey Morgan at casey.morgan@example.com.\n *\n * Both halves are captured once, at enrollment, and never rewritten (an\n * erasure deletes the enrollment rather than editing it), so the enroll\n * route stamps this on the document it creates and nothing else has to.\n * `tools/scripts/backfill-outreach-list-search.mjs` restates it for the\n * enrollments made before it; both are held to\n * `tools/scripts/lib/outreach-search-tokens.fixtures.json`.\n */\nexport function outreachEnrollmentSearchTokens(\n enrollment: Pick<OutreachEnrollment, 'contactName' | 'email'>,\n): string[] {\n return [\n ...new Set([\n ...nameSearchTokens(enrollment.contactName),\n ...emailSearchTokens(enrollment.email),\n ]),\n ]\n}\n"],"names":["nameSearchTokens","emailSearchTokens","outreachEnrollmentSearchTokens","enrollment","Set","contactName","email"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,gBAAgB,QAAQ,qCAAoC;AACrE,SAASC,iBAAiB,QAAQ,oDAAmD;AAGrF;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASC,+BACdC,UAA6D;IAE7D,OAAO;WACF,IAAIC,IAAI;eACNJ,iBAAiBG,WAAWE,WAAW;eACvCJ,kBAAkBE,WAAWG,KAAK;SACtC;KACF;AACH"}
@@ -0,0 +1,32 @@
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
+ import { type ListQueryDeclaration } from '@aglyn/shared-ui-jsx/const/list-query-plan';
18
+ import type { OutreachDoNotContactDomainEntry } from './outreach.types';
19
+ /** The array the search box reads, stamped by the one writer that adds a domain. */
20
+ export declare const OUTREACH_DOMAIN_SEARCH_TOKENS = "searchTokens";
21
+ export declare const OUTREACH_DO_NOT_CONTACT_DOMAIN_LIST_QUERY: ListQueryDeclaration;
22
+ /**
23
+ * What the domains list searches an entry by: every prefix of the domain
24
+ * whole and of each shorter domain inside it, of each of its labels, and of
25
+ * each word of its detail — so "acme", "acme.com" and "mail.ac" all find
26
+ * `mail.acme.com`, and "barracuda" finds the domain a gateway block filed.
27
+ * An entry is created once and never edited, so its one writer stamps this
28
+ * and `tools/scripts/backfill-outreach-list-search.mjs` restates it for the
29
+ * entries made before it; both are held to
30
+ * `tools/scripts/lib/outreach-search-tokens.fixtures.json`.
31
+ */
32
+ export declare function outreachDomainSearchTokens(entry: Pick<OutreachDoNotContactDomainEntry, 'domain' | 'detail'>): string[];
@@ -0,0 +1,107 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { nameSearchTokens } from "@aglyn/aglyn/app-utils/name-search";
17
+ import { LIST_QUERY_ID_PATH } from "@aglyn/shared-ui-jsx/const/list-query-plan";
18
+ /*
19
+ * WHAT THE DO-NOT-CONTACT DOMAINS LIST ASKS FIRESTORE (AGL-3321).
20
+ *
21
+ * One query over `orgs/{orgId}/outreachDoNotContactDomains`, alphabetical —
22
+ * the document id IS the domain — paged by that query:
23
+ *
24
+ * domain `equals` one domain, on the `domain` field every entry carries
25
+ * beside its id, lower-cased the way every domain is written; a
26
+ * prefix is the search box's job, which reads every label;
27
+ * reason the stored reason, which every entry carries;
28
+ * added the list's ONE range: when the entry was added, which every
29
+ * entry carries; a range leads the order, so while it applies
30
+ * the list is newest added first;
31
+ * search `searchTokens`: the domain, each shorter domain inside it and
32
+ * each of its labels, and the words of its detail, stamped when
33
+ * the entry is created.
34
+ *
35
+ * Without the range every shape is equalities and the one array clause,
36
+ * ordered by the document id, which Firestore serves by merging
37
+ * single-field indexes. The range's order needs one composite per
38
+ * equality field beside it: `reason` and `searchTokens`.
39
+ */ /** The array the search box reads, stamped by the one writer that adds a domain. */ export const OUTREACH_DOMAIN_SEARCH_TOKENS = 'searchTokens';
40
+ export const OUTREACH_DO_NOT_CONTACT_DOMAIN_LIST_QUERY = {
41
+ fields: [
42
+ {
43
+ column: 'domain',
44
+ kind: 'text',
45
+ path: 'domain',
46
+ lowerPath: 'domain',
47
+ operators: [
48
+ 'equals'
49
+ ]
50
+ },
51
+ {
52
+ column: 'reason',
53
+ kind: 'exact',
54
+ path: 'reason',
55
+ presence: 'always',
56
+ operators: [
57
+ 'equals',
58
+ 'isAnyOf'
59
+ ]
60
+ },
61
+ {
62
+ column: 'addedAtMs',
63
+ kind: 'date',
64
+ path: 'addedAtMs',
65
+ storedAs: 'millis',
66
+ presence: 'always',
67
+ operators: [
68
+ 'is',
69
+ 'after',
70
+ 'onOrAfter',
71
+ 'before',
72
+ 'onOrBefore'
73
+ ]
74
+ }
75
+ ],
76
+ sorts: [
77
+ {
78
+ path: LIST_QUERY_ID_PATH,
79
+ direction: 'asc'
80
+ }
81
+ ],
82
+ search: {
83
+ tokensPath: OUTREACH_DOMAIN_SEARCH_TOKENS
84
+ }
85
+ };
86
+ /**
87
+ * What the domains list searches an entry by: every prefix of the domain
88
+ * whole and of each shorter domain inside it, of each of its labels, and of
89
+ * each word of its detail — so "acme", "acme.com" and "mail.ac" all find
90
+ * `mail.acme.com`, and "barracuda" finds the domain a gateway block filed.
91
+ * An entry is created once and never edited, so its one writer stamps this
92
+ * and `tools/scripts/backfill-outreach-list-search.mjs` restates it for the
93
+ * entries made before it; both are held to
94
+ * `tools/scripts/lib/outreach-search-tokens.fixtures.json`.
95
+ */ export function outreachDomainSearchTokens(entry) {
96
+ var _entry_domain, _entry_detail;
97
+ const domain = String((_entry_domain = entry.domain) != null ? _entry_domain : '');
98
+ const labels = domain.split('.');
99
+ const within = labels.map((_, at)=>labels.slice(at).join('.'));
100
+ return nameSearchTokens([
101
+ ...within,
102
+ ...domain.split(/[.-]+/),
103
+ (_entry_detail = entry.detail) != null ? _entry_detail : ''
104
+ ].join(' '));
105
+ }
106
+
107
+ //# sourceMappingURL=do-not-contact-domain-list-query.js.map