@aglyn/plugins-outreach 1.0.0-beta.196 → 1.0.0-beta.198
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.
- package/package.json +14 -14
- package/src/lib/components/compliance-section.d.ts +3 -1
- package/src/lib/components/compliance-section.js +110 -45
- package/src/lib/components/compliance-section.js.map +1 -1
- package/src/lib/components/enroll-dialog.d.ts +7 -1
- package/src/lib/components/enroll-dialog.js +106 -3
- package/src/lib/components/enroll-dialog.js.map +1 -1
- package/src/lib/components/mailbox-card.js +11 -2
- package/src/lib/components/mailbox-card.js.map +1 -1
- package/src/lib/components/outreach-ui.js +1 -0
- package/src/lib/components/outreach-ui.js.map +1 -1
- package/src/lib/components/sequence-detail.js +1 -0
- package/src/lib/components/sequence-detail.js.map +1 -1
- package/src/lib/components/sequence-editor.d.ts +2 -0
- package/src/lib/components/sequence-editor.js +51 -26
- package/src/lib/components/sequence-editor.js.map +1 -1
- package/src/lib/components/sequence-step-card.d.ts +7 -0
- package/src/lib/components/sequence-step-card.js +37 -14
- package/src/lib/components/sequence-step-card.js.map +1 -1
- package/src/lib/components/sequences-section.js +12 -3
- package/src/lib/components/sequences-section.js.map +1 -1
- package/src/lib/components/step-test-dialog.d.ts +31 -0
- package/src/lib/components/step-test-dialog.js +247 -0
- package/src/lib/components/step-test-dialog.js.map +1 -0
- package/src/lib/components/use-outreach-api.d.ts +8 -1
- package/src/lib/components/use-outreach-api.js +4 -0
- package/src/lib/components/use-outreach-api.js.map +1 -1
- package/src/lib/components/use-outreach-data.d.ts +6 -1
- package/src/lib/components/use-outreach-data.js +6 -3
- package/src/lib/components/use-outreach-data.js.map +1 -1
- package/src/lib/constants/api-routes.d.ts +7 -0
- package/src/lib/constants/api-routes.js +6 -0
- package/src/lib/constants/api-routes.js.map +1 -1
- package/src/lib/engine/delivery-status.d.ts +8 -0
- package/src/lib/engine/delivery-status.js +10 -5
- package/src/lib/engine/delivery-status.js.map +1 -1
- package/src/lib/engine/enrollment-state.d.ts +2 -0
- package/src/lib/engine/enrollment-state.js +2 -2
- package/src/lib/engine/enrollment-state.js.map +1 -1
- package/src/lib/engine/gates.d.ts +28 -1
- package/src/lib/engine/gates.js +28 -1
- package/src/lib/engine/gates.js.map +1 -1
- package/src/lib/engine/mail-gateway.d.ts +97 -0
- package/src/lib/engine/mail-gateway.js +226 -0
- package/src/lib/engine/mail-gateway.js.map +1 -0
- package/src/lib/engine/mailbox-health.d.ts +3 -3
- package/src/lib/engine/mailbox-health.js +17 -11
- package/src/lib/engine/mailbox-health.js.map +1 -1
- package/src/lib/engine/thread-classification.d.ts +2 -0
- package/src/lib/engine/thread-classification.js +2 -1
- package/src/lib/engine/thread-classification.js.map +1 -1
- package/src/lib/enrollment/enroll-people.d.ts +6 -1
- package/src/lib/enrollment/enroll-people.js +18 -4
- package/src/lib/enrollment/enroll-people.js.map +1 -1
- package/src/lib/enrollment/gate-lookups.d.ts +7 -0
- package/src/lib/enrollment/gate-lookups.js +11 -2
- package/src/lib/enrollment/gate-lookups.js.map +1 -1
- package/src/lib/model/outreach-api.d.ts +35 -1
- package/src/lib/model/outreach-api.js.map +1 -1
- package/src/lib/model/outreach.types.d.ts +85 -3
- package/src/lib/model/outreach.types.js +21 -2
- package/src/lib/model/outreach.types.js.map +1 -1
- package/src/lib/model/step-preview.d.ts +7 -1
- package/src/lib/model/step-preview.js +3 -2
- package/src/lib/model/step-preview.js.map +1 -1
- package/src/lib/routes/enroll-routes.js +23 -4
- package/src/lib/routes/enroll-routes.js.map +1 -1
- package/src/lib/routes/enrollment-routes.js +15 -1
- package/src/lib/routes/enrollment-routes.js.map +1 -1
- package/src/lib/routes/platform-deps.d.ts +2 -2
- package/src/lib/routes/platform-deps.js +2 -2
- package/src/lib/routes/platform-deps.js.map +1 -1
- package/src/lib/routes/preview-routes.d.ts +24 -0
- package/src/lib/routes/preview-routes.js +99 -95
- package/src/lib/routes/preview-routes.js.map +1 -1
- package/src/lib/routes/register-routes.d.ts +7 -0
- package/src/lib/routes/register-routes.js +27 -0
- package/src/lib/routes/register-routes.js.map +1 -1
- package/src/lib/routes/route-deps.d.ts +6 -0
- package/src/lib/routes/route-deps.js.map +1 -1
- package/src/lib/routes/step-test-routes.d.ts +88 -0
- package/src/lib/routes/step-test-routes.js +275 -0
- package/src/lib/routes/step-test-routes.js.map +1 -0
- package/src/lib/runtime/click-events.js +10 -0
- package/src/lib/runtime/click-events.js.map +1 -1
- package/src/lib/runtime/click-link.d.ts +11 -0
- package/src/lib/runtime/click-link.js +9 -4
- package/src/lib/runtime/click-link.js.map +1 -1
- package/src/lib/runtime/click-route.js +3 -0
- package/src/lib/runtime/click-route.js.map +1 -1
- package/src/lib/runtime/gateway-ledger.d.ts +46 -0
- package/src/lib/runtime/gateway-ledger.js +113 -0
- package/src/lib/runtime/gateway-ledger.js.map +1 -0
- package/src/lib/runtime/platform-runtime-deps.js +3 -0
- package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
- package/src/lib/runtime/runtime-deps.d.ts +6 -0
- package/src/lib/runtime/runtime-deps.js.map +1 -1
- package/src/lib/runtime/send-job.js +48 -0
- package/src/lib/runtime/send-job.js.map +1 -1
- package/src/lib/runtime/sync-job.d.ts +2 -0
- package/src/lib/runtime/sync-job.js +40 -3
- package/src/lib/runtime/sync-job.js.map +1 -1
- package/src/lib/storage/domain-intel-store.d.ts +107 -0
- package/src/lib/storage/domain-intel-store.js +320 -0
- package/src/lib/storage/domain-intel-store.js.map +1 -0
|
@@ -0,0 +1,97 @@
|
|
|
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 OutreachMailGateway = 'barracuda' | 'proofpoint' | 'mimecast' | 'google' | 'microsoft' | 'other' | 'none';
|
|
18
|
+
export declare const OUTREACH_MAIL_GATEWAYS: readonly OutreachMailGateway[];
|
|
19
|
+
/** 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. */
|
|
28
|
+
export declare const OUTREACH_GATEWAY_HOLD_BLOCKS = 2;
|
|
29
|
+
/** The days the hold is judged over. */
|
|
30
|
+
export declare const OUTREACH_GATEWAY_WINDOW_DAYS = 30;
|
|
31
|
+
/** The days the Check-people chip counts. */
|
|
32
|
+
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
|
+
export declare const OUTREACH_DOMAIN_INTEL_TTL_MS: number;
|
|
35
|
+
/** A send with no bounce this long after it counts as delivered. */
|
|
36
|
+
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;
|
|
82
|
+
/** The sentence the hold is written with, on the enrollment and in the engine's reason. */
|
|
83
|
+
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;
|
|
@@ -0,0 +1,226 @@
|
|
|
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 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
|
+
}
|
|
187
|
+
/** 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
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
//# sourceMappingURL=mail-gateway.js.map
|
|
@@ -0,0 +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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"}
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
export declare const OUTREACH_PAUSE_BOUNCES_PER_DAY = 2;
|
|
19
19
|
/** How many of the most recent sends the bounce rate is taken over. */
|
|
20
20
|
export declare const OUTREACH_BOUNCE_RATE_WINDOW_SENDS = 50;
|
|
21
|
-
/**
|
|
22
|
-
export declare const
|
|
21
|
+
/** Hard bounces in that window that pause the mailbox. */
|
|
22
|
+
export declare const OUTREACH_PAUSE_BOUNCES_PER_WINDOW = 3;
|
|
23
23
|
/**
|
|
24
24
|
* The fewest sends the window must hold before its bounce rate is judged
|
|
25
25
|
* (AGL-3244). Below it the daily rule alone pauses the mailbox.
|
|
@@ -27,7 +27,7 @@ export declare const OUTREACH_PAUSE_BOUNCE_RATE = 0.03;
|
|
|
27
27
|
export declare const OUTREACH_BOUNCE_RATE_MIN_SENDS = 25;
|
|
28
28
|
/** How long a spam complaint keeps a mailbox paused. */
|
|
29
29
|
export declare const OUTREACH_COMPLAINT_PAUSE_MS: number;
|
|
30
|
-
export type OutreachHealthPauseReason = 'bounces_today' | '
|
|
30
|
+
export type OutreachHealthPauseReason = 'bounces_today' | 'bounces_in_window' | 'complaint';
|
|
31
31
|
export interface OutreachMailboxHealthInput {
|
|
32
32
|
/** Hard bounces on this mailbox's mail received during its current local day. */
|
|
33
33
|
bouncesToday: number;
|
|
@@ -24,14 +24,21 @@
|
|
|
24
24
|
*
|
|
25
25
|
* - it collects {@link OUTREACH_PAUSE_BOUNCES_PER_DAY} hard bounces in one
|
|
26
26
|
* of its own days; or
|
|
27
|
-
* -
|
|
27
|
+
* - {@link OUTREACH_PAUSE_BOUNCES_PER_WINDOW} of its last
|
|
28
28
|
* {@link OUTREACH_BOUNCE_RATE_WINDOW_SENDS} sends hard-bounced, once the
|
|
29
29
|
* window holds at least {@link OUTREACH_BOUNCE_RATE_MIN_SENDS} of them
|
|
30
|
-
* (AGL-3244). Under that floor one bounce IS the
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
30
|
+
* (AGL-3244). Under that floor one bounce IS the window — 1 of 21 — and a
|
|
31
|
+
* single gateway block on a cold day closed a whole sending window on the
|
|
32
|
+
* strength of one address. A mailbox with fewer sends is still judged on
|
|
33
|
+
* the sends it has by the first rule: a second bounce in one day pauses
|
|
34
|
+
* it whatever the window holds.
|
|
35
|
+
*
|
|
36
|
+
* A COUNT, not a rate (AGL-3326). The rule was "more than 3% of the last
|
|
37
|
+
* 50", and 3% of fifty is one and a half: a bounce from two days ago plus
|
|
38
|
+
* one today made 4%, and paused a mailbox on the strength of history the
|
|
39
|
+
* daily rule had already judged and let pass. Two bounces in one day are
|
|
40
|
+
* the first rule's; the window rule is for a slow drip the days hide, and
|
|
41
|
+
* three of fifty is where a drip stops being one address; or
|
|
35
42
|
* - a reply called its email spam in the last week — a complaint is worth a
|
|
36
43
|
* week of review, and the pause lasts until the week is out.
|
|
37
44
|
*
|
|
@@ -39,7 +46,7 @@
|
|
|
39
46
|
* and the sending server is still retrying it.
|
|
40
47
|
*==========================================*/ /** Hard bounces in one mailbox day that pause the mailbox. */ export const OUTREACH_PAUSE_BOUNCES_PER_DAY = 2;
|
|
41
48
|
/** How many of the most recent sends the bounce rate is taken over. */ export const OUTREACH_BOUNCE_RATE_WINDOW_SENDS = 50;
|
|
42
|
-
/**
|
|
49
|
+
/** Hard bounces in that window that pause the mailbox. */ export const OUTREACH_PAUSE_BOUNCES_PER_WINDOW = 3;
|
|
43
50
|
/**
|
|
44
51
|
* The fewest sends the window must hold before its bounce rate is judged
|
|
45
52
|
* (AGL-3244). Below it the daily rule alone pauses the mailbox.
|
|
@@ -61,12 +68,11 @@ const count = (value)=>{
|
|
|
61
68
|
}
|
|
62
69
|
const sends = Math.min(count(input.recentSends), OUTREACH_BOUNCE_RATE_WINDOW_SENDS);
|
|
63
70
|
const bounces = Math.min(count(input.recentHardBounces), sends);
|
|
64
|
-
if (sends >= OUTREACH_BOUNCE_RATE_MIN_SENDS && bounces
|
|
65
|
-
const percent = (bounces / sends * 100).toFixed(1);
|
|
71
|
+
if (sends >= OUTREACH_BOUNCE_RATE_MIN_SENDS && bounces >= OUTREACH_PAUSE_BOUNCES_PER_WINDOW) {
|
|
66
72
|
return {
|
|
67
73
|
pause: true,
|
|
68
|
-
reason: '
|
|
69
|
-
message: `Paused: ${bounces} of the last ${sends} emails hard-bounced
|
|
74
|
+
reason: 'bounces_in_window',
|
|
75
|
+
message: `Paused: ${bounces} of the last ${sends} emails hard-bounced. Re-check the addresses in your sequences before resuming.`,
|
|
70
76
|
pausedUntilMs: null
|
|
71
77
|
};
|
|
72
78
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/mailbox-health.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 * WHEN A MAILBOX STOPS ITSELF (AGL-2979).\n *\n * Bounces are the one signal a small sender gets early: at this volume the\n * mailbox providers' own dashboards show nothing, and a list with bad\n * addresses in it shows up as hard bounces days before it shows up as a\n * reputation problem. So a mailbox pauses itself, and a person re-checks\n * the list before it sends again, when:\n *\n * - it collects {@link OUTREACH_PAUSE_BOUNCES_PER_DAY} hard bounces in one\n * of its own days; or\n * -
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/mailbox-health.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 * WHEN A MAILBOX STOPS ITSELF (AGL-2979).\n *\n * Bounces are the one signal a small sender gets early: at this volume the\n * mailbox providers' own dashboards show nothing, and a list with bad\n * addresses in it shows up as hard bounces days before it shows up as a\n * reputation problem. So a mailbox pauses itself, and a person re-checks\n * the list before it sends again, when:\n *\n * - it collects {@link OUTREACH_PAUSE_BOUNCES_PER_DAY} hard bounces in one\n * of its own days; or\n * - {@link OUTREACH_PAUSE_BOUNCES_PER_WINDOW} of its last\n * {@link OUTREACH_BOUNCE_RATE_WINDOW_SENDS} sends hard-bounced, once the\n * window holds at least {@link OUTREACH_BOUNCE_RATE_MIN_SENDS} of them\n * (AGL-3244). Under that floor one bounce IS the window — 1 of 21 — and a\n * single gateway block on a cold day closed a whole sending window on the\n * strength of one address. A mailbox with fewer sends is still judged on\n * the sends it has by the first rule: a second bounce in one day pauses\n * it whatever the window holds.\n *\n * A COUNT, not a rate (AGL-3326). The rule was \"more than 3% of the last\n * 50\", and 3% of fifty is one and a half: a bounce from two days ago plus\n * one today made 4%, and paused a mailbox on the strength of history the\n * daily rule had already judged and let pass. Two bounces in one day are\n * the first rule's; the window rule is for a slow drip the days hide, and\n * three of fifty is where a drip stops being one address; or\n * - a reply called its email spam in the last week — a complaint is worth a\n * week of review, and the pause lasts until the week is out.\n *\n * Only hard bounces count. A soft bounce is a full mailbox or a busy server,\n * and the sending server is still retrying it.\n *==========================================*/\n\n/** Hard bounces in one mailbox day that pause the mailbox. */\nexport const OUTREACH_PAUSE_BOUNCES_PER_DAY = 2\n\n/** How many of the most recent sends the bounce rate is taken over. */\nexport const OUTREACH_BOUNCE_RATE_WINDOW_SENDS = 50\n\n/** Hard bounces in that window that pause the mailbox. */\nexport const OUTREACH_PAUSE_BOUNCES_PER_WINDOW = 3\n\n/**\n * The fewest sends the window must hold before its bounce rate is judged\n * (AGL-3244). Below it the daily rule alone pauses the mailbox.\n */\nexport const OUTREACH_BOUNCE_RATE_MIN_SENDS = 25\n\n/** How long a spam complaint keeps a mailbox paused. */\nexport const OUTREACH_COMPLAINT_PAUSE_MS = 7 * 24 * 60 * 60 * 1000\n\nexport type OutreachHealthPauseReason = 'bounces_today' | 'bounces_in_window' | 'complaint'\n\nexport interface OutreachMailboxHealthInput {\n /** Hard bounces on this mailbox's mail received during its current local day. */\n bouncesToday: number\n /**\n * The mailbox's most recent sends — at most\n * {@link OUTREACH_BOUNCE_RATE_WINDOW_SENDS} of them — and how many of\n * those hard-bounced.\n */\n recentSends: number\n recentHardBounces: number\n /** When a reply last called this mailbox's email spam, or `null`. */\n lastComplaintAtMs?: number | null\n nowMs: number\n}\n\nexport interface OutreachMailboxHealthDecision {\n pause: boolean\n reason: OutreachHealthPauseReason | null\n /** What the mailbox's page says, in a sentence. */\n message: string | null\n /** For a complaint, when the pause may end; `null` when a person has to resume it. */\n pausedUntilMs: number | null\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/** Whether a mailbox should pause itself now — see the module note. */\nexport function decideOutreachMailboxHealth(\n input: OutreachMailboxHealthInput,\n): OutreachMailboxHealthDecision {\n const bouncesToday = count(input.bouncesToday)\n if (bouncesToday >= OUTREACH_PAUSE_BOUNCES_PER_DAY) {\n return {\n pause: true,\n reason: 'bounces_today',\n message: `Paused after ${bouncesToday} hard bounces today. Re-check the addresses in your sequences before resuming.`,\n pausedUntilMs: null,\n }\n }\n const sends = Math.min(count(input.recentSends), OUTREACH_BOUNCE_RATE_WINDOW_SENDS)\n const bounces = Math.min(count(input.recentHardBounces), sends)\n if (sends >= OUTREACH_BOUNCE_RATE_MIN_SENDS && bounces >= OUTREACH_PAUSE_BOUNCES_PER_WINDOW) {\n return {\n pause: true,\n reason: 'bounces_in_window',\n message: `Paused: ${bounces} of the last ${sends} emails hard-bounced. Re-check the addresses in your sequences before resuming.`,\n pausedUntilMs: null,\n }\n }\n const complaintAtMs = input.lastComplaintAtMs\n if (\n typeof complaintAtMs === 'number' &&\n Number.isFinite(complaintAtMs) &&\n input.nowMs < complaintAtMs + OUTREACH_COMPLAINT_PAUSE_MS\n ) {\n return {\n pause: true,\n reason: 'complaint',\n message:\n 'Paused for a week after a reply called an email spam. Review who your sequences are reaching.',\n pausedUntilMs: complaintAtMs + OUTREACH_COMPLAINT_PAUSE_MS,\n }\n }\n return { pause: false, reason: null, message: null, pausedUntilMs: null }\n}\n"],"names":["OUTREACH_PAUSE_BOUNCES_PER_DAY","OUTREACH_BOUNCE_RATE_WINDOW_SENDS","OUTREACH_PAUSE_BOUNCES_PER_WINDOW","OUTREACH_BOUNCE_RATE_MIN_SENDS","OUTREACH_COMPLAINT_PAUSE_MS","count","value","number","Number","isFinite","Math","max","floor","decideOutreachMailboxHealth","input","bouncesToday","pause","reason","message","pausedUntilMs","sends","min","recentSends","bounces","recentHardBounces","complaintAtMs","lastComplaintAtMs","nowMs"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;4CA+B4C,GAE5C,4DAA4D,GAC5D,OAAO,MAAMA,iCAAiC,EAAC;AAE/C,qEAAqE,GACrE,OAAO,MAAMC,oCAAoC,GAAE;AAEnD,wDAAwD,GACxD,OAAO,MAAMC,oCAAoC,EAAC;AAElD;;;CAGC,GACD,OAAO,MAAMC,iCAAiC,GAAE;AAEhD,sDAAsD,GACtD,OAAO,MAAMC,8BAA8B,IAAI,KAAK,KAAK,KAAK,KAAI;AA4BlE,MAAMC,QAAQ,CAACC;IACb,MAAMC,SAASC,OAAOF;IACtB,OAAOE,OAAOC,QAAQ,CAACF,UAAUG,KAAKC,GAAG,CAAC,GAAGD,KAAKE,KAAK,CAACL,WAAW;AACrE;AAEA,qEAAqE,GACrE,OAAO,SAASM,4BACdC,KAAiC;IAEjC,MAAMC,eAAeV,MAAMS,MAAMC,YAAY;IAC7C,IAAIA,gBAAgBf,gCAAgC;QAClD,OAAO;YACLgB,OAAO;YACPC,QAAQ;YACRC,SAAS,CAAC,aAAa,EAAEH,aAAa,8EAA8E,CAAC;YACrHI,eAAe;QACjB;IACF;IACA,MAAMC,QAAQV,KAAKW,GAAG,CAAChB,MAAMS,MAAMQ,WAAW,GAAGrB;IACjD,MAAMsB,UAAUb,KAAKW,GAAG,CAAChB,MAAMS,MAAMU,iBAAiB,GAAGJ;IACzD,IAAIA,SAASjB,kCAAkCoB,WAAWrB,mCAAmC;QAC3F,OAAO;YACLc,OAAO;YACPC,QAAQ;YACRC,SAAS,CAAC,QAAQ,EAAEK,QAAQ,aAAa,EAAEH,MAAM,+EAA+E,CAAC;YACjID,eAAe;QACjB;IACF;IACA,MAAMM,gBAAgBX,MAAMY,iBAAiB;IAC7C,IACE,OAAOD,kBAAkB,YACzBjB,OAAOC,QAAQ,CAACgB,kBAChBX,MAAMa,KAAK,GAAGF,gBAAgBrB,6BAC9B;QACA,OAAO;YACLY,OAAO;YACPC,QAAQ;YACRC,SACE;YACFC,eAAeM,gBAAgBrB;QACjC;IACF;IACA,OAAO;QAAEY,OAAO;QAAOC,QAAQ;QAAMC,SAAS;QAAMC,eAAe;IAAK;AAC1E"}
|
|
@@ -21,6 +21,8 @@ export interface OutreachBounceDetail {
|
|
|
21
21
|
recipients: string[];
|
|
22
22
|
status: string | null;
|
|
23
23
|
diagnostic: string | null;
|
|
24
|
+
/** The receiving server the report names, when it does (AGL-3326). */
|
|
25
|
+
remoteMta: string | null;
|
|
24
26
|
}
|
|
25
27
|
export interface OutreachMessageClassification {
|
|
26
28
|
messageId: string;
|
|
@@ -122,7 +122,8 @@ function readableText(message) {
|
|
|
122
122
|
bounce: {
|
|
123
123
|
recipients: report.failedAddresses,
|
|
124
124
|
status: report.status,
|
|
125
|
-
diagnostic: report.diagnostic
|
|
125
|
+
diagnostic: report.diagnostic,
|
|
126
|
+
remoteMta: report.remoteMta
|
|
126
127
|
},
|
|
127
128
|
evidence: report.status ? `Status: ${report.status}` : 'delivery report'
|
|
128
129
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/thread-classification.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 * WHAT CAME BACK (AGL-2979).\n *\n * The runtime reads each enrollment's threads and asks what the new\n * messages mean. Each message is one of:\n *\n * - `self` — the mailbox's own: a step, or the rep writing in the thread.\n * - `hard_bounce` / `soft_bounce` — a delivery report (`./delivery-status.ts`).\n * - `delivery_report` — a mail system's message that reports no failure.\n * - `auto_reply` — an out-of-office or an autoresponder. It POSTPONES the\n * next step and does not stop the sequence: nobody answered.\n * - `opt_out` — a reply that asks to be left alone (`./opt-out-intent.ts`),\n * or a message sent to the unsubscribe address.\n * - `reply` — any other message from anybody else.\n *\n * ## Automatic replies\n *\n * Read from the headers the standards give an autoresponder —\n * `Auto-Submitted` with any value but `no` (RFC 3834), `X-Autoreply`,\n * `X-Autorespond`, `Precedence: auto_reply`, `bulk` or `junk` — and, for\n * the servers that set none, from an out-of-office subject in the usual\n * languages.\n *\n * ## The thread's outcome\n *\n * `opted_out` outranks `replied`, which outranks `bounced`, which outranks\n * `postpone`: an opt-out is a reply that also has to reach the do-not-contact\n * list, a person who wrote back matters more than a report about an earlier\n * send, and an out-of-office only matters when nothing else happened. The\n * bounces that report this recipient are counted either way, because mailbox\n * health counts every one.\n *==========================================*/\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { crmThreadSubject, emailAddressOf, htmlToPlainText } from '@aglyn/aglyn/app-utils/crm-inbound'\nimport { readOutreachDeliveryReport, isMailerDaemonMessage } from './delivery-status'\nimport { detectOutreachOptOutIntent, detectOutreachOptOutSubject } from './opt-out-intent'\nimport { outreachHeader, type OutreachThreadMessage } from './thread-message'\n\nexport type OutreachMessageKind =\n | 'self'\n | 'hard_bounce'\n | 'soft_bounce'\n | 'delivery_report'\n | 'auto_reply'\n | 'opt_out'\n | 'reply'\n\nexport interface OutreachBounceDetail {\n /** The addresses the report says failed; empty when it names none. */\n recipients: string[]\n status: string | null\n diagnostic: string | null\n}\n\nexport interface OutreachMessageClassification {\n messageId: string\n atMs: number\n kind: OutreachMessageKind\n /** The sender's address, normalized, when the `From` carried one. */\n from: string | null\n /** Set on a bounce. */\n bounce: OutreachBounceDetail | null\n /** An opt-out that calls the email spam. */\n complaint: boolean\n /** What decided it, for the timeline and the log. */\n evidence: string | null\n}\n\nexport interface OutreachMessageContext {\n /** The mailbox's own addresses: the account's and every alias it sends as. */\n selfAddresses: readonly string[]\n /** The address a `List-Unsubscribe` mailto points at, when the runtime set one. */\n unsubscribeAddress?: string | null\n /** The subject the thread was started with, so a new subject can be read on its own. */\n threadSubject?: string | null\n}\n\nconst AUTO_REPLY_SUBJECT = new RegExp(\n [\n '^\\\\s*\\\\[?\\\\s*(',\n 'auto(matic)?[\\\\s-]*(reply|response|responder|answer)',\n '|out[\\\\s-]+of[\\\\s-]+(the[\\\\s-]+)?office',\n '|ooo\\\\b',\n \"|i('m| am) (currently )?(out of the office|away|on (vacation|leave|holiday))\",\n '|(on )?(vacation|holiday|annual leave)[\\\\s-]*(reply|response|notice|message|autoreply)',\n '|away from (the |my )?(office|desk)',\n '|abwesenheitsnotiz|abwesenheit',\n '|r[ée]ponse automatique|absence du bureau',\n '|respuesta autom[áa]tica|fuera de la oficina',\n '|risposta automatica|fuori ufficio',\n '|automatisch antwoord|afwezigheidsbericht',\n '|resposta autom[áa]tica|fora do escrit[óo]rio',\n '|autosvar|automaattinen vastaus',\n ')',\n ].join(''),\n 'i',\n)\n\n/**\n * Why a message is an automatic reply, or `null` when it is not one — see\n * the module note for the headers and subjects read.\n */\nexport function outreachAutoReplyEvidence(\n message: Pick<OutreachThreadMessage, 'headers' | 'subject'>,\n): string | null {\n const autoSubmitted = outreachHeader(message, 'Auto-Submitted').split(';')[0].trim().toLowerCase()\n if (autoSubmitted && autoSubmitted !== 'no') return `Auto-Submitted: ${autoSubmitted}`\n for (const name of ['X-Autoreply', 'X-Autorespond']) {\n const value = outreachHeader(message, name).trim()\n if (value && value.toLowerCase() !== 'no') return `${name}: ${value}`\n }\n const precedence = outreachHeader(message, 'Precedence').trim().toLowerCase()\n if (['auto_reply', 'bulk', 'junk'].includes(precedence)) return `Precedence: ${precedence}`\n const subject = String(message?.subject ?? '').replace(/^\\s*(re|fwd?)\\s*:\\s*/i, '')\n const match = AUTO_REPLY_SUBJECT.exec(subject)\n return match ? `Subject: ${match[0].trim()}` : null\n}\n\nfunction addressesIn(header: string): string[] {\n return header\n .split(',')\n .map((part) => emailAddressOf(part))\n .filter((address): address is string => address !== null)\n}\n\nfunction readableText(message: OutreachThreadMessage): string {\n if (message.textBody && message.textBody.trim()) return message.textBody\n if (message.htmlBody && message.htmlBody.trim()) return htmlToPlainText(message.htmlBody)\n return message.snippet ?? ''\n}\n\n/** What one message in an enrollment's thread is — see the module note. */\nexport function classifyOutreachMessage(\n message: OutreachThreadMessage,\n context: OutreachMessageContext,\n): OutreachMessageClassification {\n const from = emailAddressOf(message.from)\n const base = {\n messageId: String(message.id ?? ''),\n atMs: Number.isFinite(message.internalDateMs) ? message.internalDateMs : 0,\n from,\n bounce: null,\n complaint: false,\n evidence: null,\n }\n\n const report = readOutreachDeliveryReport(message)\n if (report) {\n if (!report.kind) return { ...base, kind: 'delivery_report', evidence: 'delivery report' }\n return {\n ...base,\n kind: report.kind === 'hard' ? 'hard_bounce' : 'soft_bounce',\n bounce: {\n recipients: report.failedAddresses,\n status: report.status,\n diagnostic: report.diagnostic,\n },\n evidence: report.status ? `Status: ${report.status}` : 'delivery report',\n }\n }\n\n const self = new Set(\n (context.selfAddresses ?? [])\n .map((address) => normalizeContactEmail(address))\n .filter((address): address is string => address !== null),\n )\n if (from && self.has(from)) return { ...base, kind: 'self' }\n if (isMailerDaemonMessage(message)) {\n return { ...base, kind: 'delivery_report', evidence: 'mail system message' }\n }\n\n const unsubscribe = normalizeContactEmail(context.unsubscribeAddress)\n if (unsubscribe && addressesIn(message.to ?? '').includes(unsubscribe)) {\n return { ...base, kind: 'opt_out', evidence: `To: ${unsubscribe}` }\n }\n\n const autoReply = outreachAutoReplyEvidence(message)\n if (autoReply) return { ...base, kind: 'auto_reply', evidence: autoReply }\n\n const intent = detectOutreachOptOutIntent(readableText(message))\n if (intent.optOut) {\n return { ...base, kind: 'opt_out', complaint: intent.complaint, evidence: intent.matched }\n }\n // A subject is read only on a message that starts fresh — no `Re:`, and\n // not the thread's own — and only when it says nothing but stop.\n const subject = String(message.subject ?? '').replace(/\\s+/g, ' ').trim()\n const fresh = crmThreadSubject(subject) === subject\n if (subject && fresh && subject !== crmThreadSubject(context.threadSubject)) {\n const subjectIntent = detectOutreachOptOutSubject(subject)\n if (subjectIntent.optOut) {\n return { ...base, kind: 'opt_out', evidence: `Subject: ${subjectIntent.matched}` }\n }\n }\n return { ...base, kind: 'reply' }\n}\n\nexport type OutreachThreadOutcome = 'none' | 'postpone' | 'bounced' | 'replied' | 'opted_out'\n\nexport interface OutreachThreadDecisionInput extends OutreachMessageContext {\n /** The thread's messages, in any order. */\n messages: readonly OutreachThreadMessage[]\n /** The enrollment's recipient. */\n recipient: string\n /** Messages received at or before this instant were handled by an earlier run. */\n afterMs?: number | null\n /** Ids of messages an earlier run handled. */\n handledMessageIds?: readonly string[]\n}\n\nexport interface OutreachThreadDecision {\n outcome: OutreachThreadOutcome\n /** The message that decided the outcome; `null` for `none`. */\n decidedBy: OutreachMessageClassification | null\n /** Every new message, classified, oldest first. */\n classifications: OutreachMessageClassification[]\n /** A new reply called the email spam: the mailbox is paused as well. */\n complaint: boolean\n /** New hard bounces that report this recipient. */\n hardBounces: number\n /** New soft bounces that report this recipient. */\n softBounces: number\n}\n\nconst RANK: Record<OutreachThreadOutcome, number> = {\n none: 0,\n postpone: 1,\n bounced: 2,\n replied: 3,\n opted_out: 4,\n}\n\nconst OUTCOME_OF: Partial<Record<OutreachMessageKind, OutreachThreadOutcome>> = {\n auto_reply: 'postpone',\n hard_bounce: 'bounced',\n reply: 'replied',\n opt_out: 'opted_out',\n}\n\n/** What the new messages in an enrollment's thread mean for it — see the module note. */\nexport function decideOutreachThread(input: OutreachThreadDecisionInput): OutreachThreadDecision {\n const recipient = normalizeContactEmail(input.recipient)\n const handled = new Set(input.handledMessageIds ?? [])\n const afterMs = typeof input.afterMs === 'number' ? input.afterMs : null\n const fresh = [...(input.messages ?? [])]\n .filter((message) => message && !handled.has(message.id))\n .filter((message) => afterMs === null || message.internalDateMs > afterMs)\n .sort((a, b) => a.internalDateMs - b.internalDateMs)\n\n const decision: OutreachThreadDecision = {\n outcome: 'none',\n decidedBy: null,\n classifications: [],\n complaint: false,\n hardBounces: 0,\n softBounces: 0,\n }\n for (const message of fresh) {\n const classification = classifyOutreachMessage(message, input)\n decision.classifications.push(classification)\n const bounce = classification.bounce\n const aboutRecipient =\n !bounce || !bounce.recipients.length || (recipient !== null && bounce.recipients.includes(recipient))\n if (classification.kind === 'hard_bounce' && aboutRecipient) decision.hardBounces += 1\n if (classification.kind === 'soft_bounce' && aboutRecipient) decision.softBounces += 1\n if (classification.complaint) decision.complaint = true\n const outcome = OUTCOME_OF[classification.kind]\n if (!outcome || (classification.kind === 'hard_bounce' && !aboutRecipient)) continue\n if (RANK[outcome] > RANK[decision.outcome]) {\n decision.outcome = outcome\n decision.decidedBy = classification\n }\n }\n return decision\n}\n"],"names":["normalizeContactEmail","crmThreadSubject","emailAddressOf","htmlToPlainText","readOutreachDeliveryReport","isMailerDaemonMessage","detectOutreachOptOutIntent","detectOutreachOptOutSubject","outreachHeader","AUTO_REPLY_SUBJECT","RegExp","join","outreachAutoReplyEvidence","message","autoSubmitted","split","trim","toLowerCase","name","value","precedence","includes","subject","String","replace","match","exec","addressesIn","header","map","part","filter","address","readableText","textBody","htmlBody","snippet","classifyOutreachMessage","context","from","base","messageId","id","atMs","Number","isFinite","internalDateMs","bounce","complaint","evidence","report","kind","recipients","failedAddresses","status","diagnostic","self","Set","selfAddresses","has","unsubscribe","unsubscribeAddress","to","autoReply","intent","optOut","matched","fresh","threadSubject","subjectIntent","RANK","none","postpone","bounced","replied","opted_out","OUTCOME_OF","auto_reply","hard_bounce","reply","opt_out","decideOutreachThread","input","recipient","handled","handledMessageIds","afterMs","messages","sort","a","b","decision","outcome","decidedBy","classifications","hardBounces","softBounces","classification","push","aboutRecipient","length"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/thread-classification.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 * WHAT CAME BACK (AGL-2979).\n *\n * The runtime reads each enrollment's threads and asks what the new\n * messages mean. Each message is one of:\n *\n * - `self` — the mailbox's own: a step, or the rep writing in the thread.\n * - `hard_bounce` / `soft_bounce` — a delivery report (`./delivery-status.ts`).\n * - `delivery_report` — a mail system's message that reports no failure.\n * - `auto_reply` — an out-of-office or an autoresponder. It POSTPONES the\n * next step and does not stop the sequence: nobody answered.\n * - `opt_out` — a reply that asks to be left alone (`./opt-out-intent.ts`),\n * or a message sent to the unsubscribe address.\n * - `reply` — any other message from anybody else.\n *\n * ## Automatic replies\n *\n * Read from the headers the standards give an autoresponder —\n * `Auto-Submitted` with any value but `no` (RFC 3834), `X-Autoreply`,\n * `X-Autorespond`, `Precedence: auto_reply`, `bulk` or `junk` — and, for\n * the servers that set none, from an out-of-office subject in the usual\n * languages.\n *\n * ## The thread's outcome\n *\n * `opted_out` outranks `replied`, which outranks `bounced`, which outranks\n * `postpone`: an opt-out is a reply that also has to reach the do-not-contact\n * list, a person who wrote back matters more than a report about an earlier\n * send, and an out-of-office only matters when nothing else happened. The\n * bounces that report this recipient are counted either way, because mailbox\n * health counts every one.\n *==========================================*/\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { crmThreadSubject, emailAddressOf, htmlToPlainText } from '@aglyn/aglyn/app-utils/crm-inbound'\nimport { readOutreachDeliveryReport, isMailerDaemonMessage } from './delivery-status'\nimport { detectOutreachOptOutIntent, detectOutreachOptOutSubject } from './opt-out-intent'\nimport { outreachHeader, type OutreachThreadMessage } from './thread-message'\n\nexport type OutreachMessageKind =\n | 'self'\n | 'hard_bounce'\n | 'soft_bounce'\n | 'delivery_report'\n | 'auto_reply'\n | 'opt_out'\n | 'reply'\n\nexport interface OutreachBounceDetail {\n /** The addresses the report says failed; empty when it names none. */\n recipients: string[]\n status: string | null\n diagnostic: string | null\n /** The receiving server the report names, when it does (AGL-3326). */\n remoteMta: string | null\n}\n\nexport interface OutreachMessageClassification {\n messageId: string\n atMs: number\n kind: OutreachMessageKind\n /** The sender's address, normalized, when the `From` carried one. */\n from: string | null\n /** Set on a bounce. */\n bounce: OutreachBounceDetail | null\n /** An opt-out that calls the email spam. */\n complaint: boolean\n /** What decided it, for the timeline and the log. */\n evidence: string | null\n}\n\nexport interface OutreachMessageContext {\n /** The mailbox's own addresses: the account's and every alias it sends as. */\n selfAddresses: readonly string[]\n /** The address a `List-Unsubscribe` mailto points at, when the runtime set one. */\n unsubscribeAddress?: string | null\n /** The subject the thread was started with, so a new subject can be read on its own. */\n threadSubject?: string | null\n}\n\nconst AUTO_REPLY_SUBJECT = new RegExp(\n [\n '^\\\\s*\\\\[?\\\\s*(',\n 'auto(matic)?[\\\\s-]*(reply|response|responder|answer)',\n '|out[\\\\s-]+of[\\\\s-]+(the[\\\\s-]+)?office',\n '|ooo\\\\b',\n \"|i('m| am) (currently )?(out of the office|away|on (vacation|leave|holiday))\",\n '|(on )?(vacation|holiday|annual leave)[\\\\s-]*(reply|response|notice|message|autoreply)',\n '|away from (the |my )?(office|desk)',\n '|abwesenheitsnotiz|abwesenheit',\n '|r[ée]ponse automatique|absence du bureau',\n '|respuesta autom[áa]tica|fuera de la oficina',\n '|risposta automatica|fuori ufficio',\n '|automatisch antwoord|afwezigheidsbericht',\n '|resposta autom[áa]tica|fora do escrit[óo]rio',\n '|autosvar|automaattinen vastaus',\n ')',\n ].join(''),\n 'i',\n)\n\n/**\n * Why a message is an automatic reply, or `null` when it is not one — see\n * the module note for the headers and subjects read.\n */\nexport function outreachAutoReplyEvidence(\n message: Pick<OutreachThreadMessage, 'headers' | 'subject'>,\n): string | null {\n const autoSubmitted = outreachHeader(message, 'Auto-Submitted').split(';')[0].trim().toLowerCase()\n if (autoSubmitted && autoSubmitted !== 'no') return `Auto-Submitted: ${autoSubmitted}`\n for (const name of ['X-Autoreply', 'X-Autorespond']) {\n const value = outreachHeader(message, name).trim()\n if (value && value.toLowerCase() !== 'no') return `${name}: ${value}`\n }\n const precedence = outreachHeader(message, 'Precedence').trim().toLowerCase()\n if (['auto_reply', 'bulk', 'junk'].includes(precedence)) return `Precedence: ${precedence}`\n const subject = String(message?.subject ?? '').replace(/^\\s*(re|fwd?)\\s*:\\s*/i, '')\n const match = AUTO_REPLY_SUBJECT.exec(subject)\n return match ? `Subject: ${match[0].trim()}` : null\n}\n\nfunction addressesIn(header: string): string[] {\n return header\n .split(',')\n .map((part) => emailAddressOf(part))\n .filter((address): address is string => address !== null)\n}\n\nfunction readableText(message: OutreachThreadMessage): string {\n if (message.textBody && message.textBody.trim()) return message.textBody\n if (message.htmlBody && message.htmlBody.trim()) return htmlToPlainText(message.htmlBody)\n return message.snippet ?? ''\n}\n\n/** What one message in an enrollment's thread is — see the module note. */\nexport function classifyOutreachMessage(\n message: OutreachThreadMessage,\n context: OutreachMessageContext,\n): OutreachMessageClassification {\n const from = emailAddressOf(message.from)\n const base = {\n messageId: String(message.id ?? ''),\n atMs: Number.isFinite(message.internalDateMs) ? message.internalDateMs : 0,\n from,\n bounce: null,\n complaint: false,\n evidence: null,\n }\n\n const report = readOutreachDeliveryReport(message)\n if (report) {\n if (!report.kind) return { ...base, kind: 'delivery_report', evidence: 'delivery report' }\n return {\n ...base,\n kind: report.kind === 'hard' ? 'hard_bounce' : 'soft_bounce',\n bounce: {\n recipients: report.failedAddresses,\n status: report.status,\n diagnostic: report.diagnostic,\n remoteMta: report.remoteMta,\n },\n evidence: report.status ? `Status: ${report.status}` : 'delivery report',\n }\n }\n\n const self = new Set(\n (context.selfAddresses ?? [])\n .map((address) => normalizeContactEmail(address))\n .filter((address): address is string => address !== null),\n )\n if (from && self.has(from)) return { ...base, kind: 'self' }\n if (isMailerDaemonMessage(message)) {\n return { ...base, kind: 'delivery_report', evidence: 'mail system message' }\n }\n\n const unsubscribe = normalizeContactEmail(context.unsubscribeAddress)\n if (unsubscribe && addressesIn(message.to ?? '').includes(unsubscribe)) {\n return { ...base, kind: 'opt_out', evidence: `To: ${unsubscribe}` }\n }\n\n const autoReply = outreachAutoReplyEvidence(message)\n if (autoReply) return { ...base, kind: 'auto_reply', evidence: autoReply }\n\n const intent = detectOutreachOptOutIntent(readableText(message))\n if (intent.optOut) {\n return { ...base, kind: 'opt_out', complaint: intent.complaint, evidence: intent.matched }\n }\n // A subject is read only on a message that starts fresh — no `Re:`, and\n // not the thread's own — and only when it says nothing but stop.\n const subject = String(message.subject ?? '').replace(/\\s+/g, ' ').trim()\n const fresh = crmThreadSubject(subject) === subject\n if (subject && fresh && subject !== crmThreadSubject(context.threadSubject)) {\n const subjectIntent = detectOutreachOptOutSubject(subject)\n if (subjectIntent.optOut) {\n return { ...base, kind: 'opt_out', evidence: `Subject: ${subjectIntent.matched}` }\n }\n }\n return { ...base, kind: 'reply' }\n}\n\nexport type OutreachThreadOutcome = 'none' | 'postpone' | 'bounced' | 'replied' | 'opted_out'\n\nexport interface OutreachThreadDecisionInput extends OutreachMessageContext {\n /** The thread's messages, in any order. */\n messages: readonly OutreachThreadMessage[]\n /** The enrollment's recipient. */\n recipient: string\n /** Messages received at or before this instant were handled by an earlier run. */\n afterMs?: number | null\n /** Ids of messages an earlier run handled. */\n handledMessageIds?: readonly string[]\n}\n\nexport interface OutreachThreadDecision {\n outcome: OutreachThreadOutcome\n /** The message that decided the outcome; `null` for `none`. */\n decidedBy: OutreachMessageClassification | null\n /** Every new message, classified, oldest first. */\n classifications: OutreachMessageClassification[]\n /** A new reply called the email spam: the mailbox is paused as well. */\n complaint: boolean\n /** New hard bounces that report this recipient. */\n hardBounces: number\n /** New soft bounces that report this recipient. */\n softBounces: number\n}\n\nconst RANK: Record<OutreachThreadOutcome, number> = {\n none: 0,\n postpone: 1,\n bounced: 2,\n replied: 3,\n opted_out: 4,\n}\n\nconst OUTCOME_OF: Partial<Record<OutreachMessageKind, OutreachThreadOutcome>> = {\n auto_reply: 'postpone',\n hard_bounce: 'bounced',\n reply: 'replied',\n opt_out: 'opted_out',\n}\n\n/** What the new messages in an enrollment's thread mean for it — see the module note. */\nexport function decideOutreachThread(input: OutreachThreadDecisionInput): OutreachThreadDecision {\n const recipient = normalizeContactEmail(input.recipient)\n const handled = new Set(input.handledMessageIds ?? [])\n const afterMs = typeof input.afterMs === 'number' ? input.afterMs : null\n const fresh = [...(input.messages ?? [])]\n .filter((message) => message && !handled.has(message.id))\n .filter((message) => afterMs === null || message.internalDateMs > afterMs)\n .sort((a, b) => a.internalDateMs - b.internalDateMs)\n\n const decision: OutreachThreadDecision = {\n outcome: 'none',\n decidedBy: null,\n classifications: [],\n complaint: false,\n hardBounces: 0,\n softBounces: 0,\n }\n for (const message of fresh) {\n const classification = classifyOutreachMessage(message, input)\n decision.classifications.push(classification)\n const bounce = classification.bounce\n const aboutRecipient =\n !bounce || !bounce.recipients.length || (recipient !== null && bounce.recipients.includes(recipient))\n if (classification.kind === 'hard_bounce' && aboutRecipient) decision.hardBounces += 1\n if (classification.kind === 'soft_bounce' && aboutRecipient) decision.softBounces += 1\n if (classification.complaint) decision.complaint = true\n const outcome = OUTCOME_OF[classification.kind]\n if (!outcome || (classification.kind === 'hard_bounce' && !aboutRecipient)) continue\n if (RANK[outcome] > RANK[decision.outcome]) {\n decision.outcome = outcome\n decision.decidedBy = classification\n }\n }\n return decision\n}\n"],"names":["normalizeContactEmail","crmThreadSubject","emailAddressOf","htmlToPlainText","readOutreachDeliveryReport","isMailerDaemonMessage","detectOutreachOptOutIntent","detectOutreachOptOutSubject","outreachHeader","AUTO_REPLY_SUBJECT","RegExp","join","outreachAutoReplyEvidence","message","autoSubmitted","split","trim","toLowerCase","name","value","precedence","includes","subject","String","replace","match","exec","addressesIn","header","map","part","filter","address","readableText","textBody","htmlBody","snippet","classifyOutreachMessage","context","from","base","messageId","id","atMs","Number","isFinite","internalDateMs","bounce","complaint","evidence","report","kind","recipients","failedAddresses","status","diagnostic","remoteMta","self","Set","selfAddresses","has","unsubscribe","unsubscribeAddress","to","autoReply","intent","optOut","matched","fresh","threadSubject","subjectIntent","RANK","none","postpone","bounced","replied","opted_out","OUTCOME_OF","auto_reply","hard_bounce","reply","opt_out","decideOutreachThread","input","recipient","handled","handledMessageIds","afterMs","messages","sort","a","b","decision","outcome","decidedBy","classifications","hardBounces","softBounces","classification","push","aboutRecipient","length"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;4CA+B4C,GAE5C,SAASA,qBAAqB,QAAQ,kCAAiC;AACvE,SAASC,gBAAgB,EAAEC,cAAc,EAAEC,eAAe,QAAQ,qCAAoC;AACtG,SAASC,0BAA0B,EAAEC,qBAAqB,QAAQ,uBAAmB;AACrF,SAASC,0BAA0B,EAAEC,2BAA2B,QAAQ,sBAAkB;AAC1F,SAASC,cAAc,QAAoC,sBAAkB;AA2C7E,MAAMC,qBAAqB,IAAIC,OAC7B;IACE;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;CACD,CAACC,IAAI,CAAC,KACP;AAGF;;;CAGC,GACD,OAAO,SAASC,0BACdC,OAA2D;;IAE3D,MAAMC,gBAAgBN,eAAeK,SAAS,kBAAkBE,KAAK,CAAC,IAAI,CAAC,EAAE,CAACC,IAAI,GAAGC,WAAW;IAChG,IAAIH,iBAAiBA,kBAAkB,MAAM,OAAO,CAAC,gBAAgB,EAAEA,eAAe;IACtF,KAAK,MAAMI,QAAQ;QAAC;QAAe;KAAgB,CAAE;QACnD,MAAMC,QAAQX,eAAeK,SAASK,MAAMF,IAAI;QAChD,IAAIG,SAASA,MAAMF,WAAW,OAAO,MAAM,OAAO,GAAGC,KAAK,EAAE,EAAEC,OAAO;IACvE;IACA,MAAMC,aAAaZ,eAAeK,SAAS,cAAcG,IAAI,GAAGC,WAAW;IAC3E,IAAI;QAAC;QAAc;QAAQ;KAAO,CAACI,QAAQ,CAACD,aAAa,OAAO,CAAC,YAAY,EAAEA,YAAY;IAC3F,MAAME,UAAUC,eAAOV,2BAAAA,QAASS,OAAO,mBAAI,IAAIE,OAAO,CAAC,yBAAyB;IAChF,MAAMC,QAAQhB,mBAAmBiB,IAAI,CAACJ;IACtC,OAAOG,QAAQ,CAAC,SAAS,EAAEA,KAAK,CAAC,EAAE,CAACT,IAAI,IAAI,GAAG;AACjD;AAEA,SAASW,YAAYC,MAAc;IACjC,OAAOA,OACJb,KAAK,CAAC,KACNc,GAAG,CAAC,CAACC,OAAS5B,eAAe4B,OAC7BC,MAAM,CAAC,CAACC,UAA+BA,YAAY;AACxD;AAEA,SAASC,aAAapB,OAA8B;QAG3CA;IAFP,IAAIA,QAAQqB,QAAQ,IAAIrB,QAAQqB,QAAQ,CAAClB,IAAI,IAAI,OAAOH,QAAQqB,QAAQ;IACxE,IAAIrB,QAAQsB,QAAQ,IAAItB,QAAQsB,QAAQ,CAACnB,IAAI,IAAI,OAAOb,gBAAgBU,QAAQsB,QAAQ;IACxF,QAAOtB,mBAAAA,QAAQuB,OAAO,YAAfvB,mBAAmB;AAC5B;AAEA,yEAAyE,GACzE,OAAO,SAASwB,wBACdxB,OAA8B,EAC9ByB,OAA+B;QAIXzB,aAyBjByB,wBAU4BzB,aAaRA;IAlDvB,MAAM0B,OAAOrC,eAAeW,QAAQ0B,IAAI;IACxC,MAAMC,OAAO;QACXC,WAAWlB,QAAOV,cAAAA,QAAQ6B,EAAE,YAAV7B,cAAc;QAChC8B,MAAMC,OAAOC,QAAQ,CAAChC,QAAQiC,cAAc,IAAIjC,QAAQiC,cAAc,GAAG;QACzEP;QACAQ,QAAQ;QACRC,WAAW;QACXC,UAAU;IACZ;IAEA,MAAMC,SAAS9C,2BAA2BS;IAC1C,IAAIqC,QAAQ;QACV,IAAI,CAACA,OAAOC,IAAI,EAAE,OAAO,aAAKX;YAAMW,MAAM;YAAmBF,UAAU;;QACvE,OAAO,aACFT;YACHW,MAAMD,OAAOC,IAAI,KAAK,SAAS,gBAAgB;YAC/CJ,QAAQ;gBACNK,YAAYF,OAAOG,eAAe;gBAClCC,QAAQJ,OAAOI,MAAM;gBACrBC,YAAYL,OAAOK,UAAU;gBAC7BC,WAAWN,OAAOM,SAAS;YAC7B;YACAP,UAAUC,OAAOI,MAAM,GAAG,CAAC,QAAQ,EAAEJ,OAAOI,MAAM,EAAE,GAAG;;IAE3D;IAEA,MAAMG,OAAO,IAAIC,IACf,EAACpB,yBAAAA,QAAQqB,aAAa,YAArBrB,yBAAyB,EAAE,EACzBT,GAAG,CAAC,CAACG,UAAYhC,sBAAsBgC,UACvCD,MAAM,CAAC,CAACC,UAA+BA,YAAY;IAExD,IAAIO,QAAQkB,KAAKG,GAAG,CAACrB,OAAO,OAAO,aAAKC;QAAMW,MAAM;;IACpD,IAAI9C,sBAAsBQ,UAAU;QAClC,OAAO,aAAK2B;YAAMW,MAAM;YAAmBF,UAAU;;IACvD;IAEA,MAAMY,cAAc7D,sBAAsBsC,QAAQwB,kBAAkB;IACpE,IAAID,eAAelC,aAAYd,cAAAA,QAAQkD,EAAE,YAAVlD,cAAc,IAAIQ,QAAQ,CAACwC,cAAc;QACtE,OAAO,aAAKrB;YAAMW,MAAM;YAAWF,UAAU,CAAC,IAAI,EAAEY,aAAa;;IACnE;IAEA,MAAMG,YAAYpD,0BAA0BC;IAC5C,IAAImD,WAAW,OAAO,aAAKxB;QAAMW,MAAM;QAAcF,UAAUe;;IAE/D,MAAMC,SAAS3D,2BAA2B2B,aAAapB;IACvD,IAAIoD,OAAOC,MAAM,EAAE;QACjB,OAAO,aAAK1B;YAAMW,MAAM;YAAWH,WAAWiB,OAAOjB,SAAS;YAAEC,UAAUgB,OAAOE,OAAO;;IAC1F;IACA,wEAAwE;IACxE,iEAAiE;IACjE,MAAM7C,UAAUC,QAAOV,mBAAAA,QAAQS,OAAO,YAAfT,mBAAmB,IAAIW,OAAO,CAAC,QAAQ,KAAKR,IAAI;IACvE,MAAMoD,QAAQnE,iBAAiBqB,aAAaA;IAC5C,IAAIA,WAAW8C,SAAS9C,YAAYrB,iBAAiBqC,QAAQ+B,aAAa,GAAG;QAC3E,MAAMC,gBAAgB/D,4BAA4Be;QAClD,IAAIgD,cAAcJ,MAAM,EAAE;YACxB,OAAO,aAAK1B;gBAAMW,MAAM;gBAAWF,UAAU,CAAC,SAAS,EAAEqB,cAAcH,OAAO,EAAE;;QAClF;IACF;IACA,OAAO,aAAK3B;QAAMW,MAAM;;AAC1B;AA6BA,MAAMoB,OAA8C;IAClDC,MAAM;IACNC,UAAU;IACVC,SAAS;IACTC,SAAS;IACTC,WAAW;AACb;AAEA,MAAMC,aAA0E;IAC9EC,YAAY;IACZC,aAAa;IACbC,OAAO;IACPC,SAAS;AACX;AAEA,uFAAuF,GACvF,OAAO,SAASC,qBAAqBC,KAAkC;QAE7CA,0BAELA;IAHnB,MAAMC,YAAYpF,sBAAsBmF,MAAMC,SAAS;IACvD,MAAMC,UAAU,IAAI3B,KAAIyB,2BAAAA,MAAMG,iBAAiB,YAAvBH,2BAA2B,EAAE;IACrD,MAAMI,UAAU,OAAOJ,MAAMI,OAAO,KAAK,WAAWJ,MAAMI,OAAO,GAAG;IACpE,MAAMnB,QAAQ;YAAKe,kBAAAA,MAAMK,QAAQ,YAAdL,kBAAkB,EAAE;KAAE,CACtCpD,MAAM,CAAC,CAAClB,UAAYA,WAAW,CAACwE,QAAQzB,GAAG,CAAC/C,QAAQ6B,EAAE,GACtDX,MAAM,CAAC,CAAClB,UAAY0E,YAAY,QAAQ1E,QAAQiC,cAAc,GAAGyC,SACjEE,IAAI,CAAC,CAACC,GAAGC,IAAMD,EAAE5C,cAAc,GAAG6C,EAAE7C,cAAc;IAErD,MAAM8C,WAAmC;QACvCC,SAAS;QACTC,WAAW;QACXC,iBAAiB,EAAE;QACnB/C,WAAW;QACXgD,aAAa;QACbC,aAAa;IACf;IACA,KAAK,MAAMpF,WAAWuD,MAAO;QAC3B,MAAM8B,iBAAiB7D,wBAAwBxB,SAASsE;QACxDS,SAASG,eAAe,CAACI,IAAI,CAACD;QAC9B,MAAMnD,SAASmD,eAAenD,MAAM;QACpC,MAAMqD,iBACJ,CAACrD,UAAU,CAACA,OAAOK,UAAU,CAACiD,MAAM,IAAKjB,cAAc,QAAQrC,OAAOK,UAAU,CAAC/B,QAAQ,CAAC+D;QAC5F,IAAIc,eAAe/C,IAAI,KAAK,iBAAiBiD,gBAAgBR,SAASI,WAAW,IAAI;QACrF,IAAIE,eAAe/C,IAAI,KAAK,iBAAiBiD,gBAAgBR,SAASK,WAAW,IAAI;QACrF,IAAIC,eAAelD,SAAS,EAAE4C,SAAS5C,SAAS,GAAG;QACnD,MAAM6C,UAAUhB,UAAU,CAACqB,eAAe/C,IAAI,CAAC;QAC/C,IAAI,CAAC0C,WAAYK,eAAe/C,IAAI,KAAK,iBAAiB,CAACiD,gBAAiB;QAC5E,IAAI7B,IAAI,CAACsB,QAAQ,GAAGtB,IAAI,CAACqB,SAASC,OAAO,CAAC,EAAE;YAC1CD,SAASC,OAAO,GAAGA;YACnBD,SAASE,SAAS,GAAGI;QACvB;IACF;IACA,OAAON;AACT"}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
-
import { type OutreachGateLookups } from '../engine/gates';
|
|
17
|
+
import { type OutreachGateHold, type OutreachGateLookups } from '../engine/gates';
|
|
18
18
|
import { type OutreachAttestations, type OutreachSequenceSettings } from '../model/outreach.types';
|
|
19
19
|
import type { OutreachEnrollBlock, OutreachEnrollPreviewPerson, OutreachPersonRef } from '../model/outreach-api';
|
|
20
20
|
type Firestore = FirebaseFirestore.Firestore;
|
|
@@ -112,6 +112,11 @@ export interface OutreachEnrollDecision {
|
|
|
112
112
|
email: string | null;
|
|
113
113
|
/** What the rep confirmed, stamped with who and when. */
|
|
114
114
|
attestations: OutreachAttestations;
|
|
115
|
+
/**
|
|
116
|
+
* The gateway hold the gates would put on the send (AGL-3326), which the
|
|
117
|
+
* member enrolling past the red chip releases; `null` when none applies.
|
|
118
|
+
*/
|
|
119
|
+
hold: OutreachGateHold | null;
|
|
115
120
|
}
|
|
116
121
|
/** Confirm's answer for one person, with what the rep supplied — see the module note. */
|
|
117
122
|
export declare function decideOutreachEnrollment(question: OutreachGateQuestion, facts: {
|