@aglyn/plugins-outreach 1.0.0-beta.143
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +93 -0
- package/package.json +52 -0
- package/src/index.d.ts +22 -0
- package/src/index.js +29 -0
- package/src/index.js.map +1 -0
- package/src/lib/components/compliance-section.d.ts +20 -0
- package/src/lib/components/compliance-section.js +291 -0
- package/src/lib/components/compliance-section.js.map +1 -0
- package/src/lib/components/enroll-dialog.d.ts +36 -0
- package/src/lib/components/enroll-dialog.js +656 -0
- package/src/lib/components/enroll-dialog.js.map +1 -0
- package/src/lib/components/enrollments-table.d.ts +28 -0
- package/src/lib/components/enrollments-table.js +375 -0
- package/src/lib/components/enrollments-table.js.map +1 -0
- package/src/lib/components/mailbox-card.d.ts +46 -0
- package/src/lib/components/mailbox-card.js +517 -0
- package/src/lib/components/mailbox-card.js.map +1 -0
- package/src/lib/components/mailboxes-section.d.ts +24 -0
- package/src/lib/components/mailboxes-section.js +258 -0
- package/src/lib/components/mailboxes-section.js.map +1 -0
- package/src/lib/components/outreach-console-page.d.ts +17 -0
- package/src/lib/components/outreach-console-page.js +81 -0
- package/src/lib/components/outreach-console-page.js.map +1 -0
- package/src/lib/components/outreach-console-sections.d.ts +43 -0
- package/src/lib/components/outreach-console-sections.js +55 -0
- package/src/lib/components/outreach-console-sections.js.map +1 -0
- package/src/lib/components/outreach-ui.d.ts +51 -0
- package/src/lib/components/outreach-ui.js +171 -0
- package/src/lib/components/outreach-ui.js.map +1 -0
- package/src/lib/components/send-window-fields.d.ts +25 -0
- package/src/lib/components/send-window-fields.js +178 -0
- package/src/lib/components/send-window-fields.js.map +1 -0
- package/src/lib/components/sequence-detail.d.ts +54 -0
- package/src/lib/components/sequence-detail.js +435 -0
- package/src/lib/components/sequence-detail.js.map +1 -0
- package/src/lib/components/sequence-editor.d.ts +37 -0
- package/src/lib/components/sequence-editor.js +485 -0
- package/src/lib/components/sequence-editor.js.map +1 -0
- package/src/lib/components/sequence-mailbox-picker.d.ts +44 -0
- package/src/lib/components/sequence-mailbox-picker.js +137 -0
- package/src/lib/components/sequence-mailbox-picker.js.map +1 -0
- package/src/lib/components/sequence-preview.d.ts +27 -0
- package/src/lib/components/sequence-preview.js +146 -0
- package/src/lib/components/sequence-preview.js.map +1 -0
- package/src/lib/components/sequence-step-card.d.ts +31 -0
- package/src/lib/components/sequence-step-card.js +326 -0
- package/src/lib/components/sequence-step-card.js.map +1 -0
- package/src/lib/components/sequences-section.d.ts +40 -0
- package/src/lib/components/sequences-section.js +310 -0
- package/src/lib/components/sequences-section.js.map +1 -0
- package/src/lib/components/use-outreach-api.d.ts +38 -0
- package/src/lib/components/use-outreach-api.js +139 -0
- package/src/lib/components/use-outreach-api.js.map +1 -0
- package/src/lib/components/use-outreach-crm.d.ts +50 -0
- package/src/lib/components/use-outreach-crm.js +200 -0
- package/src/lib/components/use-outreach-crm.js.map +1 -0
- package/src/lib/components/use-outreach-data.d.ts +55 -0
- package/src/lib/components/use-outreach-data.js +199 -0
- package/src/lib/components/use-outreach-data.js.map +1 -0
- package/src/lib/components/use-outreach-mailbox-api.d.ts +30 -0
- package/src/lib/components/use-outreach-mailbox-api.js +101 -0
- package/src/lib/components/use-outreach-mailbox-api.js.map +1 -0
- package/src/lib/components/use-outreach-mailboxes.d.ts +22 -0
- package/src/lib/components/use-outreach-mailboxes.js +68 -0
- package/src/lib/components/use-outreach-mailboxes.js.map +1 -0
- package/src/lib/components/use-outreach-settings.d.ts +17 -0
- package/src/lib/components/use-outreach-settings.js +64 -0
- package/src/lib/components/use-outreach-settings.js.map +1 -0
- package/src/lib/constants/api-routes.d.ts +68 -0
- package/src/lib/constants/api-routes.js +57 -0
- package/src/lib/constants/api-routes.js.map +1 -0
- package/src/lib/constants/bundle-common.d.ts +46 -0
- package/src/lib/constants/bundle-common.js +55 -0
- package/src/lib/constants/bundle-common.js.map +1 -0
- package/src/lib/constants/runtime-jobs.d.ts +24 -0
- package/src/lib/constants/runtime-jobs.js +24 -0
- package/src/lib/constants/runtime-jobs.js.map +1 -0
- package/src/lib/declarations.console-server.d.ts +38 -0
- package/src/lib/declarations.console-server.js +99 -0
- package/src/lib/declarations.console-server.js.map +1 -0
- package/src/lib/engine/compose.d.ts +86 -0
- package/src/lib/engine/compose.js +212 -0
- package/src/lib/engine/compose.js.map +1 -0
- package/src/lib/engine/delivery-status.d.ts +53 -0
- package/src/lib/engine/delivery-status.js +184 -0
- package/src/lib/engine/delivery-status.js.map +1 -0
- package/src/lib/engine/do-not-contact.d.ts +18 -0
- package/src/lib/engine/do-not-contact.js +34 -0
- package/src/lib/engine/do-not-contact.js.map +1 -0
- package/src/lib/engine/enrollment-state.d.ts +156 -0
- package/src/lib/engine/enrollment-state.js +303 -0
- package/src/lib/engine/enrollment-state.js.map +1 -0
- package/src/lib/engine/gates.d.ts +125 -0
- package/src/lib/engine/gates.js +232 -0
- package/src/lib/engine/gates.js.map +1 -0
- package/src/lib/engine/index.d.ts +28 -0
- package/src/lib/engine/index.js +33 -0
- package/src/lib/engine/index.js.map +1 -0
- package/src/lib/engine/mailbox-health.d.ts +49 -0
- package/src/lib/engine/mailbox-health.js +83 -0
- package/src/lib/engine/mailbox-health.js.map +1 -0
- package/src/lib/engine/opt-out-intent.d.ts +41 -0
- package/src/lib/engine/opt-out-intent.js +220 -0
- package/src/lib/engine/opt-out-intent.js.map +1 -0
- package/src/lib/engine/recipient-country.d.ts +41 -0
- package/src/lib/engine/recipient-country.js +171 -0
- package/src/lib/engine/recipient-country.js.map +1 -0
- package/src/lib/engine/schedule.d.ts +70 -0
- package/src/lib/engine/schedule.js +115 -0
- package/src/lib/engine/schedule.js.map +1 -0
- package/src/lib/engine/sending-capacity.d.ts +96 -0
- package/src/lib/engine/sending-capacity.js +172 -0
- package/src/lib/engine/sending-capacity.js.map +1 -0
- package/src/lib/engine/sequence-validation.d.ts +90 -0
- package/src/lib/engine/sequence-validation.js +291 -0
- package/src/lib/engine/sequence-validation.js.map +1 -0
- package/src/lib/engine/thread-classification.d.ts +78 -0
- package/src/lib/engine/thread-classification.js +224 -0
- package/src/lib/engine/thread-classification.js.map +1 -0
- package/src/lib/engine/thread-message.d.ts +80 -0
- package/src/lib/engine/thread-message.js +143 -0
- package/src/lib/engine/thread-message.js.map +1 -0
- package/src/lib/enrollment/enroll-people.d.ts +81 -0
- package/src/lib/enrollment/enroll-people.js +250 -0
- package/src/lib/enrollment/enroll-people.js.map +1 -0
- package/src/lib/enrollment/gate-lookups.d.ts +32 -0
- package/src/lib/enrollment/gate-lookups.js +154 -0
- package/src/lib/enrollment/gate-lookups.js.map +1 -0
- package/src/lib/mailboxes/mailbox-api.d.ts +126 -0
- package/src/lib/mailboxes/mailbox-api.js +66 -0
- package/src/lib/mailboxes/mailbox-api.js.map +1 -0
- package/src/lib/mailboxes/mailbox-credentials.d.ts +95 -0
- package/src/lib/mailboxes/mailbox-credentials.js +102 -0
- package/src/lib/mailboxes/mailbox-credentials.js.map +1 -0
- package/src/lib/mailboxes/mailbox-erasure.d.ts +69 -0
- package/src/lib/mailboxes/mailbox-erasure.js +148 -0
- package/src/lib/mailboxes/mailbox-erasure.js.map +1 -0
- package/src/lib/mailboxes/mailbox-gate.d.ts +75 -0
- package/src/lib/mailboxes/mailbox-gate.js +98 -0
- package/src/lib/mailboxes/mailbox-gate.js.map +1 -0
- package/src/lib/mailboxes/mailbox-revoke.d.ts +59 -0
- package/src/lib/mailboxes/mailbox-revoke.js +41 -0
- package/src/lib/mailboxes/mailbox-revoke.js.map +1 -0
- package/src/lib/mailboxes/mailbox-routes.d.ts +119 -0
- package/src/lib/mailboxes/mailbox-routes.js +605 -0
- package/src/lib/mailboxes/mailbox-routes.js.map +1 -0
- package/src/lib/mailboxes/mailbox-settings.d.ts +110 -0
- package/src/lib/mailboxes/mailbox-settings.js +212 -0
- package/src/lib/mailboxes/mailbox-settings.js.map +1 -0
- package/src/lib/mailboxes/mailbox-transport.d.ts +64 -0
- package/src/lib/mailboxes/mailbox-transport.js +94 -0
- package/src/lib/mailboxes/mailbox-transport.js.map +1 -0
- package/src/lib/mailboxes/oauth-redirect.d.ts +41 -0
- package/src/lib/mailboxes/oauth-redirect.js +63 -0
- package/src/lib/mailboxes/oauth-redirect.js.map +1 -0
- package/src/lib/mailboxes/oauth-state.d.ts +152 -0
- package/src/lib/mailboxes/oauth-state.js +212 -0
- package/src/lib/mailboxes/oauth-state.js.map +1 -0
- package/src/lib/mailboxes/outreach-config.d.ts +64 -0
- package/src/lib/mailboxes/outreach-config.js +77 -0
- package/src/lib/mailboxes/outreach-config.js.map +1 -0
- package/src/lib/mailboxes/register-mailbox-routes.d.ts +47 -0
- package/src/lib/mailboxes/register-mailbox-routes.js +135 -0
- package/src/lib/mailboxes/register-mailbox-routes.js.map +1 -0
- package/src/lib/model/compliance-settings.d.ts +78 -0
- package/src/lib/model/compliance-settings.js +155 -0
- package/src/lib/model/compliance-settings.js.map +1 -0
- package/src/lib/model/outreach-api.d.ts +210 -0
- package/src/lib/model/outreach-api.js +28 -0
- package/src/lib/model/outreach-api.js.map +1 -0
- package/src/lib/model/outreach.types.d.ts +563 -0
- package/src/lib/model/outreach.types.js +225 -0
- package/src/lib/model/outreach.types.js.map +1 -0
- package/src/lib/model/sequence-draft.d.ts +84 -0
- package/src/lib/model/sequence-draft.js +239 -0
- package/src/lib/model/sequence-draft.js.map +1 -0
- package/src/lib/model/step-preview.d.ts +45 -0
- package/src/lib/model/step-preview.js +98 -0
- package/src/lib/model/step-preview.js.map +1 -0
- package/src/lib/model/stored-records.d.ts +23 -0
- package/src/lib/model/stored-records.js +84 -0
- package/src/lib/model/stored-records.js.map +1 -0
- package/src/lib/plugin.d.ts +46 -0
- package/src/lib/plugin.js +86 -0
- package/src/lib/plugin.js.map +1 -0
- package/src/lib/routes/enroll-routes.d.ts +64 -0
- package/src/lib/routes/enroll-routes.js +350 -0
- package/src/lib/routes/enroll-routes.js.map +1 -0
- package/src/lib/routes/enrollment-routes.d.ts +19 -0
- package/src/lib/routes/enrollment-routes.js +171 -0
- package/src/lib/routes/enrollment-routes.js.map +1 -0
- package/src/lib/routes/platform-deps.d.ts +51 -0
- package/src/lib/routes/platform-deps.js +66 -0
- package/src/lib/routes/platform-deps.js.map +1 -0
- package/src/lib/routes/preview-routes.d.ts +33 -0
- package/src/lib/routes/preview-routes.js +117 -0
- package/src/lib/routes/preview-routes.js.map +1 -0
- package/src/lib/routes/register-routes.d.ts +22 -0
- package/src/lib/routes/register-routes.js +92 -0
- package/src/lib/routes/register-routes.js.map +1 -0
- package/src/lib/routes/route-deps.d.ts +49 -0
- package/src/lib/routes/route-deps.js +27 -0
- package/src/lib/routes/route-deps.js.map +1 -0
- package/src/lib/routes/route-gate.d.ts +94 -0
- package/src/lib/routes/route-gate.js +87 -0
- package/src/lib/routes/route-gate.js.map +1 -0
- package/src/lib/routes/route-http.d.ts +32 -0
- package/src/lib/routes/route-http.js +62 -0
- package/src/lib/routes/route-http.js.map +1 -0
- package/src/lib/routes/sequence-routes.d.ts +32 -0
- package/src/lib/routes/sequence-routes.js +359 -0
- package/src/lib/routes/sequence-routes.js.map +1 -0
- package/src/lib/routes/settings-routes.d.ts +30 -0
- package/src/lib/routes/settings-routes.js +73 -0
- package/src/lib/routes/settings-routes.js.map +1 -0
- package/src/lib/runtime/enrollment-events.d.ts +86 -0
- package/src/lib/runtime/enrollment-events.js +141 -0
- package/src/lib/runtime/enrollment-events.js.map +1 -0
- package/src/lib/runtime/fixtures/fake-gmail.d.ts +121 -0
- package/src/lib/runtime/fixtures/fake-gmail.js +312 -0
- package/src/lib/runtime/fixtures/fake-gmail.js.map +1 -0
- package/src/lib/runtime/mailbox-health-store.d.ts +47 -0
- package/src/lib/runtime/mailbox-health-store.js +168 -0
- package/src/lib/runtime/mailbox-health-store.js.map +1 -0
- package/src/lib/runtime/person-erasure.d.ts +39 -0
- package/src/lib/runtime/person-erasure.js +57 -0
- package/src/lib/runtime/person-erasure.js.map +1 -0
- package/src/lib/runtime/platform-runtime-deps.d.ts +29 -0
- package/src/lib/runtime/platform-runtime-deps.js +98 -0
- package/src/lib/runtime/platform-runtime-deps.js.map +1 -0
- package/src/lib/runtime/runtime-deps.d.ts +77 -0
- package/src/lib/runtime/runtime-deps.js +18 -0
- package/src/lib/runtime/runtime-deps.js.map +1 -0
- package/src/lib/runtime/send-job.d.ts +42 -0
- package/src/lib/runtime/send-job.js +780 -0
- package/src/lib/runtime/send-job.js.map +1 -0
- package/src/lib/runtime/sync-job.d.ts +41 -0
- package/src/lib/runtime/sync-job.js +480 -0
- package/src/lib/runtime/sync-job.js.map +1 -0
- package/src/lib/runtime/timeline.d.ts +67 -0
- package/src/lib/runtime/timeline.js +93 -0
- package/src/lib/runtime/timeline.js.map +1 -0
- package/src/lib/runtime/unsubscribe-link.d.ts +75 -0
- package/src/lib/runtime/unsubscribe-link.js +121 -0
- package/src/lib/runtime/unsubscribe-link.js.map +1 -0
- package/src/lib/runtime/unsubscribe-route.d.ts +24 -0
- package/src/lib/runtime/unsubscribe-route.js +103 -0
- package/src/lib/runtime/unsubscribe-route.js.map +1 -0
- package/src/lib/server.d.ts +53 -0
- package/src/lib/server.js +94 -0
- package/src/lib/server.js.map +1 -0
- package/src/lib/storage/compliance-settings-store.d.ts +44 -0
- package/src/lib/storage/compliance-settings-store.js +78 -0
- package/src/lib/storage/compliance-settings-store.js.map +1 -0
- package/src/lib/storage/do-not-contact-store.d.ts +60 -0
- package/src/lib/storage/do-not-contact-store.js +168 -0
- package/src/lib/storage/do-not-contact-store.js.map +1 -0
- package/src/lib/storage/outreach-records.d.ts +33 -0
- package/src/lib/storage/outreach-records.js +35 -0
- package/src/lib/storage/outreach-records.js.map +1 -0
- package/src/lib/subprocessors.d.ts +48 -0
- package/src/lib/subprocessors.js +70 -0
- package/src/lib/subprocessors.js.map +1 -0
- package/src/lib/transport/gmail-client.d.ts +145 -0
- package/src/lib/transport/gmail-client.js +229 -0
- package/src/lib/transport/gmail-client.js.map +1 -0
- package/src/lib/transport/gmail-errors.d.ts +84 -0
- package/src/lib/transport/gmail-errors.js +138 -0
- package/src/lib/transport/gmail-errors.js.map +1 -0
- package/src/lib/transport/google-oauth.d.ts +138 -0
- package/src/lib/transport/google-oauth.js +247 -0
- package/src/lib/transport/google-oauth.js.map +1 -0
- package/src/lib/transport/http.d.ts +53 -0
- package/src/lib/transport/http.js +79 -0
- package/src/lib/transport/http.js.map +1 -0
- package/src/lib/transport/rfc5322.d.ts +116 -0
- package/src/lib/transport/rfc5322.js +330 -0
- package/src/lib/transport/rfc5322.js.map +1 -0
- package/src/lib/transport/send-message.d.ts +69 -0
- package/src/lib/transport/send-message.js +77 -0
- package/src/lib/transport/send-message.js.map +1 -0
|
@@ -0,0 +1,232 @@
|
|
|
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
|
+
* WHO MAY BE EMAILED (AGL-2979).
|
|
18
|
+
*
|
|
19
|
+
* One pure decision, run twice: when a rep enrolls a person, so the dialog
|
|
20
|
+
* can say everything that stands in the way at once, and again before every
|
|
21
|
+
* send, because a person enrolled on Monday can unsubscribe, become a
|
|
22
|
+
* customer or join the team by Thursday. It reads a contact as stored and
|
|
23
|
+
* the RESULTS of the lookups the runtime makes; it makes none itself.
|
|
24
|
+
*
|
|
25
|
+
* ## Every block, not the first
|
|
26
|
+
*
|
|
27
|
+
* The result lists each gate that refuses, in the order below, with a
|
|
28
|
+
* sentence a rep can act on. A dialog that revealed one problem per attempt
|
|
29
|
+
* would be a dialog people learn to fight.
|
|
30
|
+
*
|
|
31
|
+
* ## Cold
|
|
32
|
+
*
|
|
33
|
+
* A contact is COLD when no inbound door ever brought them to the sending
|
|
34
|
+
* site's business — no form, sign-up, order or booking recorded on that
|
|
35
|
+
* site's facet of the contact, and no email they wrote to the workspace.
|
|
36
|
+
* Cold contacts carry the heavier rules: a business address rather than a
|
|
37
|
+
* personal mailbox, a United States address, a personal line from the rep,
|
|
38
|
+
* and the three attestations only the rep can make. The facet is read, never
|
|
39
|
+
* the whole document, because a form filled in on another client's site is
|
|
40
|
+
* that client's relationship and not this one's.
|
|
41
|
+
*
|
|
42
|
+
* ## Lookups fail closed
|
|
43
|
+
*
|
|
44
|
+
* A lookup the runtime could not complete is passed as `null`, and `null`
|
|
45
|
+
* refuses — with a sentence that says the check could not be made rather
|
|
46
|
+
* than one that accuses the address of something. A person who asked not to
|
|
47
|
+
* be emailed must not be emailed because a read timed out.
|
|
48
|
+
*==========================================*/ import { contactEmails, normalizeContactEmail, readContactFacet } from "@aglyn/aglyn/app-utils/contacts";
|
|
49
|
+
import { isPublicMailboxDomain } from "@aglyn/aglyn/app-utils/crm";
|
|
50
|
+
import { findMemberByEmailAddress } from "@aglyn/aglyn/app-utils/member-email-aliases";
|
|
51
|
+
import { OUTREACH_ATTESTATION_KINDS, OUTREACH_PERSONAL_LINE_MAX } from "../model/outreach.types.js";
|
|
52
|
+
import { outreachCountryName, resolveOutreachRecipientCountry } from "./recipient-country.js";
|
|
53
|
+
import { readOutreachSequenceSettings } from "./sequence-validation.js";
|
|
54
|
+
/**
|
|
55
|
+
* The capture doors that mean a person came to the business: a form, a
|
|
56
|
+
* sign-up to the site or its newsletter, an order, a booking. `api`,
|
|
57
|
+
* `import` and `manual` are how a business adds people it found, which is
|
|
58
|
+
* exactly what cold means.
|
|
59
|
+
*/ export const OUTREACH_INBOUND_CONTACT_SOURCES = [
|
|
60
|
+
'form',
|
|
61
|
+
'member',
|
|
62
|
+
'newsletter',
|
|
63
|
+
'order',
|
|
64
|
+
'booking'
|
|
65
|
+
];
|
|
66
|
+
/** The enrollment statuses that hold a person: one sequence at a time. */ export const OUTREACH_OPEN_ENROLLMENT_STATUSES = [
|
|
67
|
+
'active',
|
|
68
|
+
'paused'
|
|
69
|
+
];
|
|
70
|
+
/** Local parts that answer no person: nobody reads a reply sent there. */ const MACHINE_LOCAL_PART = /^(no-?reply|do-?not-?reply|mailer-daemon|postmaster|bounces?)(\+.*)?$/;
|
|
71
|
+
/**
|
|
72
|
+
* Whether a contact came to the sending site's business through no inbound
|
|
73
|
+
* door — see the module note.
|
|
74
|
+
*/ export function isColdOutreachContact(contact, contactGroupId, hasInboundEmail) {
|
|
75
|
+
if (hasInboundEmail) return false;
|
|
76
|
+
const { sources } = readContactFacet(contact, contactGroupId);
|
|
77
|
+
return !OUTREACH_INBOUND_CONTACT_SOURCES.some((source)=>(sources == null ? void 0 : sources[source]) === true);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Whether the sending site's business counts the contact as a customer: a
|
|
81
|
+
* `customer` stage, the `evangelist` stage past it, or an order on record.
|
|
82
|
+
*/ export function isOutreachCustomer(contact, contactGroupId) {
|
|
83
|
+
var _facet_sources;
|
|
84
|
+
const facet = readContactFacet(contact, contactGroupId);
|
|
85
|
+
return facet.lifecycleStage === 'customer' || facet.lifecycleStage === 'evangelist' || ((_facet_sources = facet.sources) == null ? void 0 : _facet_sources.order) === true || Number(facet.ordersCount) > 0;
|
|
86
|
+
}
|
|
87
|
+
/** A confirmation counts only when it says who confirmed it and when. */ export function outreachAttestationHolds(attestations, kind) {
|
|
88
|
+
const entry = attestations == null ? void 0 : attestations[kind];
|
|
89
|
+
return typeof (entry == null ? void 0 : entry.uid) === 'string' && entry.uid.trim() !== '' && typeof entry.atMs === 'number' && Number.isFinite(entry.atMs) && entry.atMs > 0;
|
|
90
|
+
}
|
|
91
|
+
/** The personal line as stored: one line, whitespace settled. */ export function normalizeOutreachPersonalLine(value) {
|
|
92
|
+
return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
|
|
93
|
+
}
|
|
94
|
+
/** Each attestation as the sentence that asks for it reads it. */ const ATTESTATION_PHRASES = {
|
|
95
|
+
us_business_address: "it's a US business address",
|
|
96
|
+
published_or_given: 'they or their company published it, or they gave it to you',
|
|
97
|
+
verified_deliverable: 'it was verified as deliverable'
|
|
98
|
+
};
|
|
99
|
+
function listInWords(items) {
|
|
100
|
+
if (items.length <= 1) return items.join('');
|
|
101
|
+
return `${items.slice(0, -1).join('; ')}; and ${items[items.length - 1]}`;
|
|
102
|
+
}
|
|
103
|
+
const COULD_NOT_CHECK = 'Try again in a moment.';
|
|
104
|
+
/** Every gate an address and a contact must pass — see the module note. */ export function evaluateOutreachGates(input) {
|
|
105
|
+
var _input_contact;
|
|
106
|
+
const lookups = input.lookups;
|
|
107
|
+
const settings = readOutreachSequenceSettings(input.settings);
|
|
108
|
+
const email = normalizeContactEmail(input.email);
|
|
109
|
+
const contact = (_input_contact = input.contact) != null ? _input_contact : null;
|
|
110
|
+
const cold = isColdOutreachContact(contact, input.contactGroupId, (lookups == null ? void 0 : lookups.hasInboundEmail) === true);
|
|
111
|
+
const blocks = [];
|
|
112
|
+
const block = (code, reason)=>blocks.push({
|
|
113
|
+
code,
|
|
114
|
+
reason
|
|
115
|
+
});
|
|
116
|
+
// The gates below need both a record and an address to read.
|
|
117
|
+
const refusedOutright = ()=>({
|
|
118
|
+
allowed: false,
|
|
119
|
+
email,
|
|
120
|
+
cold,
|
|
121
|
+
country: {
|
|
122
|
+
country: null,
|
|
123
|
+
source: null
|
|
124
|
+
},
|
|
125
|
+
blocks,
|
|
126
|
+
missingAttestations: []
|
|
127
|
+
});
|
|
128
|
+
if (!contact) {
|
|
129
|
+
block('contact_missing', 'This contact no longer exists in the CRM.');
|
|
130
|
+
return refusedOutright();
|
|
131
|
+
}
|
|
132
|
+
// 1. A valid address, one that belongs to this contact and to a person.
|
|
133
|
+
if (!email) {
|
|
134
|
+
var _input_email;
|
|
135
|
+
block('invalid_email', `"${String((_input_email = input.email) != null ? _input_email : '').trim()}" isn't a valid email address.`);
|
|
136
|
+
return refusedOutright();
|
|
137
|
+
}
|
|
138
|
+
if (MACHINE_LOCAL_PART.test(email.slice(0, email.lastIndexOf('@')))) {
|
|
139
|
+
block('invalid_email', `${email} is an automated address that no person reads.`);
|
|
140
|
+
}
|
|
141
|
+
if (!contactEmails(contact).includes(email)) {
|
|
142
|
+
block('email_not_on_contact', `${email} isn't an address on this contact's record.`);
|
|
143
|
+
}
|
|
144
|
+
// 2. A business address for a cold contact.
|
|
145
|
+
if (cold && isPublicMailboxDomain(email)) {
|
|
146
|
+
block('free_mail', `${email} is a personal mailbox. Cold outreach goes only to business addresses.`);
|
|
147
|
+
}
|
|
148
|
+
// 3. The country.
|
|
149
|
+
const facet = readContactFacet(contact, input.contactGroupId);
|
|
150
|
+
const companyAddress = input.company ? input.company['address'] : null;
|
|
151
|
+
const country = resolveOutreachRecipientCountry({
|
|
152
|
+
email,
|
|
153
|
+
contactAddress: facet.address,
|
|
154
|
+
companyAddress
|
|
155
|
+
});
|
|
156
|
+
const attestedUs = outreachAttestationHolds(input.attestations, 'us_business_address');
|
|
157
|
+
if (country.country) {
|
|
158
|
+
const name = outreachCountryName(country.country);
|
|
159
|
+
const evidence = country.source === 'domain' ? `its domain places it in ${name}` : `it's in ${name}`;
|
|
160
|
+
if (!settings.allowedCountries.includes(country.country)) {
|
|
161
|
+
block('country_not_allowed', `${email} can't be emailed from this sequence: ${evidence}, which isn't one of its countries.`);
|
|
162
|
+
} else if (cold && country.country !== 'US') {
|
|
163
|
+
block('country_not_allowed', `Cold outreach goes only to US business addresses, and ${evidence}.`);
|
|
164
|
+
}
|
|
165
|
+
} else if (!settings.allowedCountries.includes('US')) {
|
|
166
|
+
block('country_unknown', `We don't know which country ${email} is in. Add the country to the contact's address.`);
|
|
167
|
+
} else if (!attestedUs) {
|
|
168
|
+
block('country_unknown', `We don't know which country ${email} is in. Confirm it's a US business address, or add the country to the contact.`);
|
|
169
|
+
}
|
|
170
|
+
// 4. Every list that says not to.
|
|
171
|
+
if ((lookups == null ? void 0 : lookups.platformSuppressed) === true) {
|
|
172
|
+
block('platform_suppressed', `${email} is on the platform's suppression list after a hard bounce or a spam complaint.`);
|
|
173
|
+
} else if ((lookups == null ? void 0 : lookups.platformSuppressed) !== false) {
|
|
174
|
+
block('platform_suppressed', `We couldn't check the platform's suppression list for ${email}. ${COULD_NOT_CHECK}`);
|
|
175
|
+
}
|
|
176
|
+
if ((lookups == null ? void 0 : lookups.hostSuppressed) === true) {
|
|
177
|
+
block('host_suppressed', `${email} unsubscribed from this site's email, or mail to it bounced.`);
|
|
178
|
+
} else if ((lookups == null ? void 0 : lookups.hostSuppressed) !== false) {
|
|
179
|
+
block('host_suppressed', `We couldn't check this site's suppression list for ${email}. ${COULD_NOT_CHECK}`);
|
|
180
|
+
}
|
|
181
|
+
if ((lookups == null ? void 0 : lookups.salesTopicState) === 'opted-out') {
|
|
182
|
+
block('sales_opted_out', `${email} opted out of sales outreach from this site.`);
|
|
183
|
+
} else if ((lookups == null ? void 0 : lookups.salesTopicState) !== 'subscribed' && (lookups == null ? void 0 : lookups.salesTopicState) !== 'pending') {
|
|
184
|
+
block('sales_opted_out', `We couldn't check whether ${email} opted out of sales outreach. ${COULD_NOT_CHECK}`);
|
|
185
|
+
}
|
|
186
|
+
if ((lookups == null ? void 0 : lookups.doNotContact) === true) {
|
|
187
|
+
block('do_not_contact', `${email} is on your organization's do-not-contact list.`);
|
|
188
|
+
} else if ((lookups == null ? void 0 : lookups.doNotContact) !== false) {
|
|
189
|
+
block('do_not_contact', `We couldn't check your do-not-contact list for ${email}. ${COULD_NOT_CHECK}`);
|
|
190
|
+
}
|
|
191
|
+
// 5. Not one of the workspace's own people.
|
|
192
|
+
const members = lookups == null ? void 0 : lookups.workspaceMembers;
|
|
193
|
+
if (!Array.isArray(members)) {
|
|
194
|
+
block('workspace_member', `We couldn't check whether ${email} belongs to a member of this workspace. ${COULD_NOT_CHECK}`);
|
|
195
|
+
} else if (findMemberByEmailAddress(members, email)) {
|
|
196
|
+
block('workspace_member', `${email} belongs to a member of this workspace.`);
|
|
197
|
+
}
|
|
198
|
+
// 6. Not a customer, unless the sequence is written for them.
|
|
199
|
+
if (!settings.allowCustomers && isOutreachCustomer(contact, input.contactGroupId)) {
|
|
200
|
+
block('customer', "This contact is a customer, and this sequence doesn't include customers.");
|
|
201
|
+
}
|
|
202
|
+
// 7. One sequence at a time.
|
|
203
|
+
const enrollments = lookups == null ? void 0 : lookups.openEnrollments;
|
|
204
|
+
if (!Array.isArray(enrollments)) {
|
|
205
|
+
block('already_enrolled', `We couldn't check this contact's other sequences. ${COULD_NOT_CHECK}`);
|
|
206
|
+
} else if (enrollments.some((enrollment)=>(enrollment == null ? void 0 : enrollment.id) !== input.enrollmentId && OUTREACH_OPEN_ENROLLMENT_STATUSES.includes(enrollment == null ? void 0 : enrollment.status))) {
|
|
207
|
+
block('already_enrolled', 'This contact is already in another sequence. Finish or stop that one first.');
|
|
208
|
+
}
|
|
209
|
+
// 8. What only the rep can vouch for, on a cold contact.
|
|
210
|
+
const personalLine = normalizeOutreachPersonalLine(input.personalLine);
|
|
211
|
+
const missingAttestations = cold ? OUTREACH_ATTESTATION_KINDS.filter((kind)=>!outreachAttestationHolds(input.attestations, kind)) : [];
|
|
212
|
+
if (missingAttestations.length) {
|
|
213
|
+
const phrases = missingAttestations.map((kind)=>`that ${ATTESTATION_PHRASES[kind]}`);
|
|
214
|
+
block('attestations_missing', `Before emailing a cold contact, confirm ${listInWords(phrases)}.`);
|
|
215
|
+
}
|
|
216
|
+
if (cold && !personalLine) {
|
|
217
|
+
block('personal_line_missing', 'Write a personal line: one sentence on why you are writing to this person now.');
|
|
218
|
+
}
|
|
219
|
+
if (personalLine.length > OUTREACH_PERSONAL_LINE_MAX) {
|
|
220
|
+
block('personal_line_too_long', `Keep the personal line to one sentence, under ${OUTREACH_PERSONAL_LINE_MAX} characters.`);
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
allowed: blocks.length === 0,
|
|
224
|
+
email,
|
|
225
|
+
cold,
|
|
226
|
+
country,
|
|
227
|
+
blocks,
|
|
228
|
+
missingAttestations
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
//# sourceMappingURL=gates.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/gates.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 * WHO MAY BE EMAILED (AGL-2979).\n *\n * One pure decision, run twice: when a rep enrolls a person, so the dialog\n * can say everything that stands in the way at once, and again before every\n * send, because a person enrolled on Monday can unsubscribe, become a\n * customer or join the team by Thursday. It reads a contact as stored and\n * the RESULTS of the lookups the runtime makes; it makes none itself.\n *\n * ## Every block, not the first\n *\n * The result lists each gate that refuses, in the order below, with a\n * sentence a rep can act on. A dialog that revealed one problem per attempt\n * would be a dialog people learn to fight.\n *\n * ## Cold\n *\n * A contact is COLD when no inbound door ever brought them to the sending\n * site's business — no form, sign-up, order or booking recorded on that\n * site's facet of the contact, and no email they wrote to the workspace.\n * Cold contacts carry the heavier rules: a business address rather than a\n * personal mailbox, a United States address, a personal line from the rep,\n * and the three attestations only the rep can make. The facet is read, never\n * the whole document, because a form filled in on another client's site is\n * that client's relationship and not this one's.\n *\n * ## Lookups fail closed\n *\n * A lookup the runtime could not complete is passed as `null`, and `null`\n * refuses — with a sentence that says the check could not be made rather\n * than one that accuses the address of something. A person who asked not to\n * be emailed must not be emailed because a read timed out.\n *==========================================*/\n\nimport {\n type ContactSource,\n contactEmails,\n normalizeContactEmail,\n readContactFacet,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport { isPublicMailboxDomain } from '@aglyn/aglyn/app-utils/crm'\nimport type { TopicSubscriptionState } from '@aglyn/aglyn/app-utils/email-topics'\nimport {\n findMemberByEmailAddress,\n type MemberAddresses,\n} from '@aglyn/aglyn/app-utils/member-email-aliases'\nimport {\n OUTREACH_ATTESTATION_KINDS,\n OUTREACH_PERSONAL_LINE_MAX,\n type OutreachAttestationKind,\n type OutreachAttestations,\n type OutreachEnrollmentStatus,\n type OutreachSequenceSettings,\n} from '../model/outreach.types'\nimport {\n type OutreachRecipientCountry,\n outreachCountryName,\n resolveOutreachRecipientCountry,\n} from './recipient-country'\nimport { readOutreachSequenceSettings } from './sequence-validation'\n\n/**\n * The capture doors that mean a person came to the business: a form, a\n * sign-up to the site or its newsletter, an order, a booking. `api`,\n * `import` and `manual` are how a business adds people it found, which is\n * exactly what cold means.\n */\nexport const OUTREACH_INBOUND_CONTACT_SOURCES: readonly ContactSource[] = [\n 'form',\n 'member',\n 'newsletter',\n 'order',\n 'booking',\n]\n\n/** The enrollment statuses that hold a person: one sequence at a time. */\nexport const OUTREACH_OPEN_ENROLLMENT_STATUSES: readonly OutreachEnrollmentStatus[] = [\n 'active',\n 'paused',\n]\n\n/**\n * What the runtime looked up about the address before asking. Every field is\n * `null` when its read failed, and a `null` refuses.\n */\nexport interface OutreachGateLookups {\n /**\n * `emailSuppressions/{key}` holds a record in force — a hard bounce or a\n * spam complaint anywhere on the platform (`isEmailSuppressed`).\n */\n platformSuppressed: boolean | null\n /** `hosts/{hostId}/suppressions/{key}` exists for the sequence's site. */\n hostSuppressed: boolean | null\n /**\n * The address's standing on the site's `sales` topic —\n * `readTopicSubscriptionState` of the `sales` entry in\n * `hosts/{hostId}/topicOptOuts/{key}`. Only `opted-out` refuses: a pending\n * confirmation is about a subscription, and a sales email does not rest on\n * one.\n */\n salesTopicState: TopicSubscriptionState | null\n /** The address is on the organization's Outreach do-not-contact list. */\n doNotContact: boolean | null\n /**\n * The organization's roster: each member's sign-in address and confirmed\n * aliases, as `crmInboundRoster` builds it.\n */\n workspaceMembers: readonly MemberAddresses[] | null\n /**\n * The person's enrollments that are `active` or `paused`, found by contact\n * id or by address. Other statuses are ignored, so passing every\n * enrollment is safe too.\n */\n openEnrollments: ReadonlyArray<{\n id: string\n sequenceId: string\n status: OutreachEnrollmentStatus\n }> | null\n /**\n * An email the person wrote (`direction: 'inbound'`) is filed on the\n * contact. `null` reads as `false`, which only makes the contact cold and\n * the rules stricter.\n */\n hasInboundEmail: boolean | null\n}\n\nexport interface OutreachGateInput {\n /** The address the steps go to. */\n email: string\n /** The contact document as stored (`orgs/{orgId}/contacts/{id}`), or `null` when it is gone. */\n contact: Record<string, unknown> | null | undefined\n /** The consent group of the sequence's site: whose facet holds stage, sources and address. */\n contactGroupId: string\n /** The company document the contact is filed under, when there is one. */\n company?: Record<string, unknown> | null\n /** The sequence's settings as stored; a missing field reads as its default. */\n settings: Partial<OutreachSequenceSettings> | null | undefined\n /** The rep's signal sentence. */\n personalLine: string | null | undefined\n attestations: OutreachAttestations | null | undefined\n /** The enrollment being re-checked before a send, so it does not count as another. */\n enrollmentId?: string | null\n lookups: OutreachGateLookups\n}\n\nexport type OutreachGateCode =\n | 'contact_missing'\n | 'invalid_email'\n | 'email_not_on_contact'\n | 'free_mail'\n | 'country_not_allowed'\n | 'country_unknown'\n | 'platform_suppressed'\n | 'host_suppressed'\n | 'sales_opted_out'\n | 'do_not_contact'\n | 'workspace_member'\n | 'customer'\n | 'already_enrolled'\n | 'attestations_missing'\n | 'personal_line_missing'\n | 'personal_line_too_long'\n\nexport interface OutreachGateBlock {\n code: OutreachGateCode\n /** A sentence a rep can act on. */\n reason: string\n}\n\nexport interface OutreachGateResult {\n allowed: boolean\n /** The address, normalized, or `null` when it is not one. */\n email: string | null\n cold: boolean\n country: OutreachRecipientCountry\n /** Every gate that refuses, in gate order. */\n blocks: OutreachGateBlock[]\n /** The attestations a cold contact still needs, in the order they are asked. */\n missingAttestations: OutreachAttestationKind[]\n}\n\n/** Local parts that answer no person: nobody reads a reply sent there. */\nconst MACHINE_LOCAL_PART = /^(no-?reply|do-?not-?reply|mailer-daemon|postmaster|bounces?)(\\+.*)?$/\n\n/**\n * Whether a contact came to the sending site's business through no inbound\n * door — see the module note.\n */\nexport function isColdOutreachContact(\n contact: Record<string, unknown> | null | undefined,\n contactGroupId: string,\n hasInboundEmail: boolean,\n): boolean {\n if (hasInboundEmail) return false\n const { sources } = readContactFacet(contact, contactGroupId)\n return !OUTREACH_INBOUND_CONTACT_SOURCES.some((source) => sources?.[source] === true)\n}\n\n/**\n * Whether the sending site's business counts the contact as a customer: a\n * `customer` stage, the `evangelist` stage past it, or an order on record.\n */\nexport function isOutreachCustomer(\n contact: Record<string, unknown> | null | undefined,\n contactGroupId: string,\n): boolean {\n const facet = readContactFacet(contact, contactGroupId)\n return (\n facet.lifecycleStage === 'customer' ||\n facet.lifecycleStage === 'evangelist' ||\n facet.sources?.order === true ||\n Number(facet.ordersCount) > 0\n )\n}\n\n/** A confirmation counts only when it says who confirmed it and when. */\nexport function outreachAttestationHolds(\n attestations: OutreachAttestations | null | undefined,\n kind: OutreachAttestationKind,\n): boolean {\n const entry = attestations?.[kind]\n return (\n typeof entry?.uid === 'string' &&\n entry.uid.trim() !== '' &&\n typeof entry.atMs === 'number' &&\n Number.isFinite(entry.atMs) &&\n entry.atMs > 0\n )\n}\n\n/** The personal line as stored: one line, whitespace settled. */\nexport function normalizeOutreachPersonalLine(value: unknown): string {\n return typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n}\n\n/** Each attestation as the sentence that asks for it reads it. */\nconst ATTESTATION_PHRASES: Record<OutreachAttestationKind, string> = {\n us_business_address: \"it's a US business address\",\n published_or_given: 'they or their company published it, or they gave it to you',\n verified_deliverable: 'it was verified as deliverable',\n}\n\nfunction listInWords(items: readonly string[]): string {\n if (items.length <= 1) return items.join('')\n return `${items.slice(0, -1).join('; ')}; and ${items[items.length - 1]}`\n}\n\nconst COULD_NOT_CHECK = 'Try again in a moment.'\n\n/** Every gate an address and a contact must pass — see the module note. */\nexport function evaluateOutreachGates(input: OutreachGateInput): OutreachGateResult {\n const lookups = input.lookups\n const settings = readOutreachSequenceSettings(input.settings)\n const email = normalizeContactEmail(input.email)\n const contact = input.contact ?? null\n const cold = isColdOutreachContact(\n contact,\n input.contactGroupId,\n lookups?.hasInboundEmail === true,\n )\n const blocks: OutreachGateBlock[] = []\n const block = (code: OutreachGateCode, reason: string) => blocks.push({ code, reason })\n // The gates below need both a record and an address to read.\n const refusedOutright = (): OutreachGateResult => ({\n allowed: false,\n email,\n cold,\n country: { country: null, source: null },\n blocks,\n missingAttestations: [],\n })\n\n if (!contact) {\n block('contact_missing', 'This contact no longer exists in the CRM.')\n return refusedOutright()\n }\n\n // 1. A valid address, one that belongs to this contact and to a person.\n if (!email) {\n block('invalid_email', `\"${String(input.email ?? '').trim()}\" isn't a valid email address.`)\n return refusedOutright()\n }\n if (MACHINE_LOCAL_PART.test(email.slice(0, email.lastIndexOf('@')))) {\n block('invalid_email', `${email} is an automated address that no person reads.`)\n }\n if (!contactEmails(contact).includes(email)) {\n block('email_not_on_contact', `${email} isn't an address on this contact's record.`)\n }\n\n // 2. A business address for a cold contact.\n if (cold && isPublicMailboxDomain(email)) {\n block(\n 'free_mail',\n `${email} is a personal mailbox. Cold outreach goes only to business addresses.`,\n )\n }\n\n // 3. The country.\n const facet = readContactFacet(contact, input.contactGroupId)\n const companyAddress = input.company ? input.company['address'] : null\n const country = resolveOutreachRecipientCountry({\n email,\n contactAddress: facet.address,\n companyAddress,\n })\n const attestedUs = outreachAttestationHolds(input.attestations, 'us_business_address')\n if (country.country) {\n const name = outreachCountryName(country.country)\n const evidence =\n country.source === 'domain' ? `its domain places it in ${name}` : `it's in ${name}`\n if (!settings.allowedCountries.includes(country.country)) {\n block(\n 'country_not_allowed',\n `${email} can't be emailed from this sequence: ${evidence}, which isn't one of its countries.`,\n )\n } else if (cold && country.country !== 'US') {\n block(\n 'country_not_allowed',\n `Cold outreach goes only to US business addresses, and ${evidence}.`,\n )\n }\n } else if (!settings.allowedCountries.includes('US')) {\n block(\n 'country_unknown',\n `We don't know which country ${email} is in. Add the country to the contact's address.`,\n )\n } else if (!attestedUs) {\n block(\n 'country_unknown',\n `We don't know which country ${email} is in. Confirm it's a US business address, or add the country to the contact.`,\n )\n }\n\n // 4. Every list that says not to.\n if (lookups?.platformSuppressed === true) {\n block(\n 'platform_suppressed',\n `${email} is on the platform's suppression list after a hard bounce or a spam complaint.`,\n )\n } else if (lookups?.platformSuppressed !== false) {\n block(\n 'platform_suppressed',\n `We couldn't check the platform's suppression list for ${email}. ${COULD_NOT_CHECK}`,\n )\n }\n if (lookups?.hostSuppressed === true) {\n block('host_suppressed', `${email} unsubscribed from this site's email, or mail to it bounced.`)\n } else if (lookups?.hostSuppressed !== false) {\n block(\n 'host_suppressed',\n `We couldn't check this site's suppression list for ${email}. ${COULD_NOT_CHECK}`,\n )\n }\n if (lookups?.salesTopicState === 'opted-out') {\n block('sales_opted_out', `${email} opted out of sales outreach from this site.`)\n } else if (lookups?.salesTopicState !== 'subscribed' && lookups?.salesTopicState !== 'pending') {\n block(\n 'sales_opted_out',\n `We couldn't check whether ${email} opted out of sales outreach. ${COULD_NOT_CHECK}`,\n )\n }\n if (lookups?.doNotContact === true) {\n block('do_not_contact', `${email} is on your organization's do-not-contact list.`)\n } else if (lookups?.doNotContact !== false) {\n block(\n 'do_not_contact',\n `We couldn't check your do-not-contact list for ${email}. ${COULD_NOT_CHECK}`,\n )\n }\n\n // 5. Not one of the workspace's own people.\n const members = lookups?.workspaceMembers\n if (!Array.isArray(members)) {\n block(\n 'workspace_member',\n `We couldn't check whether ${email} belongs to a member of this workspace. ${COULD_NOT_CHECK}`,\n )\n } else if (findMemberByEmailAddress(members, email)) {\n block('workspace_member', `${email} belongs to a member of this workspace.`)\n }\n\n // 6. Not a customer, unless the sequence is written for them.\n if (!settings.allowCustomers && isOutreachCustomer(contact, input.contactGroupId)) {\n block('customer', \"This contact is a customer, and this sequence doesn't include customers.\")\n }\n\n // 7. One sequence at a time.\n const enrollments = lookups?.openEnrollments\n if (!Array.isArray(enrollments)) {\n block(\n 'already_enrolled',\n `We couldn't check this contact's other sequences. ${COULD_NOT_CHECK}`,\n )\n } else if (\n enrollments.some(\n (enrollment) =>\n enrollment?.id !== input.enrollmentId &&\n OUTREACH_OPEN_ENROLLMENT_STATUSES.includes(enrollment?.status),\n )\n ) {\n block(\n 'already_enrolled',\n 'This contact is already in another sequence. Finish or stop that one first.',\n )\n }\n\n // 8. What only the rep can vouch for, on a cold contact.\n const personalLine = normalizeOutreachPersonalLine(input.personalLine)\n const missingAttestations = cold\n ? OUTREACH_ATTESTATION_KINDS.filter(\n (kind) => !outreachAttestationHolds(input.attestations, kind),\n )\n : []\n if (missingAttestations.length) {\n const phrases = missingAttestations.map((kind) => `that ${ATTESTATION_PHRASES[kind]}`)\n block(\n 'attestations_missing',\n `Before emailing a cold contact, confirm ${listInWords(phrases)}.`,\n )\n }\n if (cold && !personalLine) {\n block(\n 'personal_line_missing',\n 'Write a personal line: one sentence on why you are writing to this person now.',\n )\n }\n if (personalLine.length > OUTREACH_PERSONAL_LINE_MAX) {\n block(\n 'personal_line_too_long',\n `Keep the personal line to one sentence, under ${OUTREACH_PERSONAL_LINE_MAX} characters.`,\n )\n }\n\n return {\n allowed: blocks.length === 0,\n email,\n cold,\n country,\n blocks,\n missingAttestations,\n }\n}\n"],"names":["contactEmails","normalizeContactEmail","readContactFacet","isPublicMailboxDomain","findMemberByEmailAddress","OUTREACH_ATTESTATION_KINDS","OUTREACH_PERSONAL_LINE_MAX","outreachCountryName","resolveOutreachRecipientCountry","readOutreachSequenceSettings","OUTREACH_INBOUND_CONTACT_SOURCES","OUTREACH_OPEN_ENROLLMENT_STATUSES","MACHINE_LOCAL_PART","isColdOutreachContact","contact","contactGroupId","hasInboundEmail","sources","some","source","isOutreachCustomer","facet","lifecycleStage","order","Number","ordersCount","outreachAttestationHolds","attestations","kind","entry","uid","trim","atMs","isFinite","normalizeOutreachPersonalLine","value","replace","ATTESTATION_PHRASES","us_business_address","published_or_given","verified_deliverable","listInWords","items","length","join","slice","COULD_NOT_CHECK","evaluateOutreachGates","input","lookups","settings","email","cold","blocks","block","code","reason","push","refusedOutright","allowed","country","missingAttestations","String","test","lastIndexOf","includes","companyAddress","company","contactAddress","address","attestedUs","name","evidence","allowedCountries","platformSuppressed","hostSuppressed","salesTopicState","doNotContact","members","workspaceMembers","Array","isArray","allowCustomers","enrollments","openEnrollments","enrollment","id","enrollmentId","status","personalLine","filter","phrases","map"],"mappings":"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"}
|
|
@@ -0,0 +1,28 @@
|
|
|
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 * from './sequence-validation';
|
|
18
|
+
export * from './recipient-country';
|
|
19
|
+
export * from './gates';
|
|
20
|
+
export * from './schedule';
|
|
21
|
+
export * from './sending-capacity';
|
|
22
|
+
export * from './enrollment-state';
|
|
23
|
+
export * from './compose';
|
|
24
|
+
export * from './thread-message';
|
|
25
|
+
export * from './delivery-status';
|
|
26
|
+
export * from './opt-out-intent';
|
|
27
|
+
export * from './thread-classification';
|
|
28
|
+
export * from './mailbox-health';
|
|
@@ -0,0 +1,33 @@
|
|
|
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 Outreach engine (AGL-2979): pure decisions, no I/O, safe for the
|
|
18
|
+
* console and the server alike. `./do-not-contact` is deliberately absent —
|
|
19
|
+
* it hashes with `node:crypto` — and is imported by its own path.
|
|
20
|
+
*/ export * from "./sequence-validation.js";
|
|
21
|
+
export * from "./recipient-country.js";
|
|
22
|
+
export * from "./gates.js";
|
|
23
|
+
export * from "./schedule.js";
|
|
24
|
+
export * from "./sending-capacity.js";
|
|
25
|
+
export * from "./enrollment-state.js";
|
|
26
|
+
export * from "./compose.js";
|
|
27
|
+
export * from "./thread-message.js";
|
|
28
|
+
export * from "./delivery-status.js";
|
|
29
|
+
export * from "./opt-out-intent.js";
|
|
30
|
+
export * from "./thread-classification.js";
|
|
31
|
+
export * from "./mailbox-health.js";
|
|
32
|
+
|
|
33
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/index.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 Outreach engine (AGL-2979): pure decisions, no I/O, safe for the\n * console and the server alike. `./do-not-contact` is deliberately absent —\n * it hashes with `node:crypto` — and is imported by its own path.\n */\nexport * from './sequence-validation'\nexport * from './recipient-country'\nexport * from './gates'\nexport * from './schedule'\nexport * from './sending-capacity'\nexport * from './enrollment-state'\nexport * from './compose'\nexport * from './thread-message'\nexport * from './delivery-status'\nexport * from './opt-out-intent'\nexport * from './thread-classification'\nexport * from './mailbox-health'\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;CAIC,GACD,cAAc,2BAAuB;AACrC,cAAc,yBAAqB;AACnC,cAAc,aAAS;AACvB,cAAc,gBAAY;AAC1B,cAAc,wBAAoB;AAClC,cAAc,wBAAoB;AAClC,cAAc,eAAW;AACzB,cAAc,sBAAkB;AAChC,cAAc,uBAAmB;AACjC,cAAc,sBAAkB;AAChC,cAAc,6BAAyB;AACvC,cAAc,sBAAkB"}
|
|
@@ -0,0 +1,49 @@
|
|
|
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
|
+
/** Hard bounces in one mailbox day that pause the mailbox. */
|
|
18
|
+
export declare const OUTREACH_PAUSE_BOUNCES_PER_DAY = 2;
|
|
19
|
+
/** How many of the most recent sends the bounce rate is taken over. */
|
|
20
|
+
export declare const OUTREACH_BOUNCE_RATE_WINDOW_SENDS = 50;
|
|
21
|
+
/** The bounce rate a mailbox may not exceed over that window. */
|
|
22
|
+
export declare const OUTREACH_PAUSE_BOUNCE_RATE = 0.03;
|
|
23
|
+
/** How long a spam complaint keeps a mailbox paused. */
|
|
24
|
+
export declare const OUTREACH_COMPLAINT_PAUSE_MS: number;
|
|
25
|
+
export type OutreachHealthPauseReason = 'bounces_today' | 'bounce_rate' | 'complaint';
|
|
26
|
+
export interface OutreachMailboxHealthInput {
|
|
27
|
+
/** Hard bounces on this mailbox's mail received during its current local day. */
|
|
28
|
+
bouncesToday: number;
|
|
29
|
+
/**
|
|
30
|
+
* The mailbox's most recent sends — at most
|
|
31
|
+
* {@link OUTREACH_BOUNCE_RATE_WINDOW_SENDS} of them — and how many of
|
|
32
|
+
* those hard-bounced.
|
|
33
|
+
*/
|
|
34
|
+
recentSends: number;
|
|
35
|
+
recentHardBounces: number;
|
|
36
|
+
/** When a reply last called this mailbox's email spam, or `null`. */
|
|
37
|
+
lastComplaintAtMs?: number | null;
|
|
38
|
+
nowMs: number;
|
|
39
|
+
}
|
|
40
|
+
export interface OutreachMailboxHealthDecision {
|
|
41
|
+
pause: boolean;
|
|
42
|
+
reason: OutreachHealthPauseReason | null;
|
|
43
|
+
/** What the mailbox's page says, in a sentence. */
|
|
44
|
+
message: string | null;
|
|
45
|
+
/** For a complaint, when the pause may end; `null` when a person has to resume it. */
|
|
46
|
+
pausedUntilMs: number | null;
|
|
47
|
+
}
|
|
48
|
+
/** Whether a mailbox should pause itself now — see the module note. */
|
|
49
|
+
export declare function decideOutreachMailboxHealth(input: OutreachMailboxHealthInput): OutreachMailboxHealthDecision;
|
|
@@ -0,0 +1,83 @@
|
|
|
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
|
+
* WHEN A MAILBOX STOPS ITSELF (AGL-2979).
|
|
18
|
+
*
|
|
19
|
+
* Bounces are the one signal a small sender gets early: at this volume the
|
|
20
|
+
* mailbox providers' own dashboards show nothing, and a list with bad
|
|
21
|
+
* addresses in it shows up as hard bounces days before it shows up as a
|
|
22
|
+
* reputation problem. So a mailbox pauses itself, and a person re-checks
|
|
23
|
+
* the list before it sends again, when:
|
|
24
|
+
*
|
|
25
|
+
* - it collects {@link OUTREACH_PAUSE_BOUNCES_PER_DAY} hard bounces in one
|
|
26
|
+
* of its own days; or
|
|
27
|
+
* - more than {@link OUTREACH_PAUSE_BOUNCE_RATE} of its last
|
|
28
|
+
* {@link OUTREACH_BOUNCE_RATE_WINDOW_SENDS} sends hard-bounced. A mailbox
|
|
29
|
+
* with fewer sends than that is judged on the sends it has, which is
|
|
30
|
+
* deliberate: a young mailbox's reputation is the most fragile, and one
|
|
31
|
+
* bounce in its first ten sends is a list problem, not bad luck; or
|
|
32
|
+
* - a reply called its email spam in the last week — a complaint is worth a
|
|
33
|
+
* week of review, and the pause lasts until the week is out.
|
|
34
|
+
*
|
|
35
|
+
* Only hard bounces count. A soft bounce is a full mailbox or a busy server,
|
|
36
|
+
* and the sending server is still retrying it.
|
|
37
|
+
*==========================================*/ /** Hard bounces in one mailbox day that pause the mailbox. */ export const OUTREACH_PAUSE_BOUNCES_PER_DAY = 2;
|
|
38
|
+
/** How many of the most recent sends the bounce rate is taken over. */ export const OUTREACH_BOUNCE_RATE_WINDOW_SENDS = 50;
|
|
39
|
+
/** The bounce rate a mailbox may not exceed over that window. */ export const OUTREACH_PAUSE_BOUNCE_RATE = 0.03;
|
|
40
|
+
/** How long a spam complaint keeps a mailbox paused. */ export const OUTREACH_COMPLAINT_PAUSE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
41
|
+
const count = (value)=>{
|
|
42
|
+
const number = Number(value);
|
|
43
|
+
return Number.isFinite(number) ? Math.max(0, Math.floor(number)) : 0;
|
|
44
|
+
};
|
|
45
|
+
/** Whether a mailbox should pause itself now — see the module note. */ export function decideOutreachMailboxHealth(input) {
|
|
46
|
+
const bouncesToday = count(input.bouncesToday);
|
|
47
|
+
if (bouncesToday >= OUTREACH_PAUSE_BOUNCES_PER_DAY) {
|
|
48
|
+
return {
|
|
49
|
+
pause: true,
|
|
50
|
+
reason: 'bounces_today',
|
|
51
|
+
message: `Paused after ${bouncesToday} hard bounces today. Re-check the addresses in your sequences before resuming.`,
|
|
52
|
+
pausedUntilMs: null
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const sends = Math.min(count(input.recentSends), OUTREACH_BOUNCE_RATE_WINDOW_SENDS);
|
|
56
|
+
const bounces = Math.min(count(input.recentHardBounces), sends);
|
|
57
|
+
if (sends > 0 && bounces / sends > OUTREACH_PAUSE_BOUNCE_RATE) {
|
|
58
|
+
const percent = (bounces / sends * 100).toFixed(1);
|
|
59
|
+
return {
|
|
60
|
+
pause: true,
|
|
61
|
+
reason: 'bounce_rate',
|
|
62
|
+
message: `Paused: ${bounces} of the last ${sends} emails hard-bounced (${percent}%). Re-check the addresses in your sequences before resuming.`,
|
|
63
|
+
pausedUntilMs: null
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const complaintAtMs = input.lastComplaintAtMs;
|
|
67
|
+
if (typeof complaintAtMs === 'number' && Number.isFinite(complaintAtMs) && input.nowMs < complaintAtMs + OUTREACH_COMPLAINT_PAUSE_MS) {
|
|
68
|
+
return {
|
|
69
|
+
pause: true,
|
|
70
|
+
reason: 'complaint',
|
|
71
|
+
message: 'Paused for a week after a reply called an email spam. Review who your sequences are reaching.',
|
|
72
|
+
pausedUntilMs: complaintAtMs + OUTREACH_COMPLAINT_PAUSE_MS
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
pause: false,
|
|
77
|
+
reason: null,
|
|
78
|
+
message: null,
|
|
79
|
+
pausedUntilMs: null
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
//# sourceMappingURL=mailbox-health.js.map
|
|
@@ -0,0 +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 * - more than {@link OUTREACH_PAUSE_BOUNCE_RATE} of its last\n * {@link OUTREACH_BOUNCE_RATE_WINDOW_SENDS} sends hard-bounced. A mailbox\n * with fewer sends than that is judged on the sends it has, which is\n * deliberate: a young mailbox's reputation is the most fragile, and one\n * bounce in its first ten sends is a list problem, not bad luck; 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/** The bounce rate a mailbox may not exceed over that window. */\nexport const OUTREACH_PAUSE_BOUNCE_RATE = 0.03\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' | 'bounce_rate' | '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 > 0 && bounces / sends > OUTREACH_PAUSE_BOUNCE_RATE) {\n const percent = ((bounces / sends) * 100).toFixed(1)\n return {\n pause: true,\n reason: 'bounce_rate',\n message: `Paused: ${bounces} of the last ${sends} emails hard-bounced (${percent}%). 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_BOUNCE_RATE","OUTREACH_COMPLAINT_PAUSE_MS","count","value","number","Number","isFinite","Math","max","floor","decideOutreachMailboxHealth","input","bouncesToday","pause","reason","message","pausedUntilMs","sends","min","recentSends","bounces","recentHardBounces","percent","toFixed","complaintAtMs","lastComplaintAtMs","nowMs"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;4CAqB4C,GAE5C,4DAA4D,GAC5D,OAAO,MAAMA,iCAAiC,EAAC;AAE/C,qEAAqE,GACrE,OAAO,MAAMC,oCAAoC,GAAE;AAEnD,+DAA+D,GAC/D,OAAO,MAAMC,6BAA6B,KAAI;AAE9C,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,gBAAgBd,gCAAgC;QAClD,OAAO;YACLe,OAAO;YACPC,QAAQ;YACRC,SAAS,CAAC,aAAa,EAAEH,aAAa,8EAA8E,CAAC;YACrHI,eAAe;QACjB;IACF;IACA,MAAMC,QAAQV,KAAKW,GAAG,CAAChB,MAAMS,MAAMQ,WAAW,GAAGpB;IACjD,MAAMqB,UAAUb,KAAKW,GAAG,CAAChB,MAAMS,MAAMU,iBAAiB,GAAGJ;IACzD,IAAIA,QAAQ,KAAKG,UAAUH,QAAQjB,4BAA4B;QAC7D,MAAMsB,UAAU,AAAC,CAAA,AAACF,UAAUH,QAAS,GAAE,EAAGM,OAAO,CAAC;QAClD,OAAO;YACLV,OAAO;YACPC,QAAQ;YACRC,SAAS,CAAC,QAAQ,EAAEK,QAAQ,aAAa,EAAEH,MAAM,sBAAsB,EAAEK,QAAQ,6DAA6D,CAAC;YAC/IN,eAAe;QACjB;IACF;IACA,MAAMQ,gBAAgBb,MAAMc,iBAAiB;IAC7C,IACE,OAAOD,kBAAkB,YACzBnB,OAAOC,QAAQ,CAACkB,kBAChBb,MAAMe,KAAK,GAAGF,gBAAgBvB,6BAC9B;QACA,OAAO;YACLY,OAAO;YACPC,QAAQ;YACRC,SACE;YACFC,eAAeQ,gBAAgBvB;QACjC;IACF;IACA,OAAO;QAAEY,OAAO;QAAOC,QAAQ;QAAMC,SAAS;QAAMC,eAAe;IAAK;AAC1E"}
|
|
@@ -0,0 +1,41 @@
|
|
|
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 interface OutreachOptOutIntent {
|
|
18
|
+
/** The reply asks not to be emailed again. */
|
|
19
|
+
optOut: boolean;
|
|
20
|
+
/** The reply calls the email spam — an opt-out that also counts against the mailbox. */
|
|
21
|
+
complaint: boolean;
|
|
22
|
+
/** The words that decided it, for the timeline and the log; `null` when nothing matched. */
|
|
23
|
+
matched: string | null;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The words a reply adds to its thread: the text above the quoted history,
|
|
27
|
+
* with a signature and any quoted footer line removed.
|
|
28
|
+
*/
|
|
29
|
+
export declare function outreachFreshReplyText(text: unknown): string;
|
|
30
|
+
/**
|
|
31
|
+
* Whether a SUBJECT says nothing but stop — "Unsubscribe", "Remove me",
|
|
32
|
+
* "STOP" — as a message sent from an unsubscribe link, or typed fresh, does.
|
|
33
|
+
* Only a bare answer counts: a subject is a sender's headline, and "Stop
|
|
34
|
+
* losing leads" is one.
|
|
35
|
+
*/
|
|
36
|
+
export declare function detectOutreachOptOutSubject(subject: unknown): OutreachOptOutIntent;
|
|
37
|
+
/**
|
|
38
|
+
* Whether a reply's text asks to be left alone — see the module note. Pass
|
|
39
|
+
* the reply's text as received; the quoted history is removed here.
|
|
40
|
+
*/
|
|
41
|
+
export declare function detectOutreachOptOutIntent(text: unknown): OutreachOptOutIntent;
|