@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,115 @@
|
|
|
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 STEP COMES DUE (AGL-2979).
|
|
18
|
+
*
|
|
19
|
+
* A step waits a number of BUSINESS days after the one before it, counted on
|
|
20
|
+
* the mailbox's own calendar — the rep's Monday, not the server's — and then
|
|
21
|
+
* waits again, if it has to, for the sending window to open. So a follow-up
|
|
22
|
+
* two business days after a Friday 16:40 send is due Tuesday at about 16:40,
|
|
23
|
+
* and one that lands on a Saturday or after hours goes out at the next
|
|
24
|
+
* opening instead.
|
|
25
|
+
*
|
|
26
|
+
* ## Jitter
|
|
27
|
+
*
|
|
28
|
+
* Every due time moves by up to half of {@link OUTREACH_DUE_JITTER_MINUTES}
|
|
29
|
+
* either way, drawn from an injected random source, and never out of the
|
|
30
|
+
* window it was placed in. A person does not answer every follow-up at the
|
|
31
|
+
* same minute, and a mailbox whose mail leaves on the dot reads like the
|
|
32
|
+
* software it is. A time that had to be moved to an opening only moves later,
|
|
33
|
+
* so a morning's worth of follow-ups is spread over the first hour rather
|
|
34
|
+
* than stacked on the opening minute.
|
|
35
|
+
*
|
|
36
|
+
* The random source is a parameter so a test can pin it and so nothing here
|
|
37
|
+
* reads a clock or a global.
|
|
38
|
+
*==========================================*/ import { addZonedBusinessDays, isValidTimeZone, isWithinWeeklySchedule, nextWeeklyOpening } from "@aglyn/shared-util-timestamp/zoned-time";
|
|
39
|
+
/** How far a due time may move, in total: half of it either way. */ export const OUTREACH_DUE_JITTER_MINUTES = 60;
|
|
40
|
+
/**
|
|
41
|
+
* How long an out-of-office reply holds the next step back, in business
|
|
42
|
+
* days — a working week, the usual length of an absence — measured from
|
|
43
|
+
* when the reply arrived. A step already due later than that keeps its time.
|
|
44
|
+
*/ export const OUTREACH_AUTO_REPLY_POSTPONE_BUSINESS_DAYS = 5;
|
|
45
|
+
const MINUTE_MS = 60000;
|
|
46
|
+
/** A send window as the weekly schedule the zone math reads. */ export function outreachWindowSchedule(window) {
|
|
47
|
+
const schedule = {};
|
|
48
|
+
if (!window || !Array.isArray(window.days)) return schedule;
|
|
49
|
+
for (const day of window.days){
|
|
50
|
+
if (Number.isInteger(day) && day >= 0 && day <= 6) {
|
|
51
|
+
schedule[day] = [
|
|
52
|
+
{
|
|
53
|
+
start: window.startMinute,
|
|
54
|
+
end: window.endMinute
|
|
55
|
+
}
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return schedule;
|
|
60
|
+
}
|
|
61
|
+
/** The window a sequence's steps go out in: its own, or else its mailbox's. */ export function effectiveOutreachWindow(sequenceWindow, mailboxWindow) {
|
|
62
|
+
var _ref;
|
|
63
|
+
return (_ref = sequenceWindow != null ? sequenceWindow : mailboxWindow) != null ? _ref : null;
|
|
64
|
+
}
|
|
65
|
+
/** Whether `atMs` is inside the window, read in `timeZone`. */ export function isOutreachWindowOpen(atMs, window, timeZone) {
|
|
66
|
+
if (!isValidTimeZone(timeZone)) return false;
|
|
67
|
+
return isWithinWeeklySchedule(atMs, outreachWindowSchedule(window), timeZone);
|
|
68
|
+
}
|
|
69
|
+
function draw(random) {
|
|
70
|
+
const value = typeof random === 'function' ? random() : 0.5;
|
|
71
|
+
if (!Number.isFinite(value)) return 0.5;
|
|
72
|
+
return Math.min(Math.max(value, 0), 1 - Number.EPSILON);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* When a step comes due — see the module note — or `null` when it never
|
|
76
|
+
* can: a zone `Intl` does not know, a delay that is not a whole number of
|
|
77
|
+
* days, or a window that never opens.
|
|
78
|
+
*/ export function scheduleOutreachDue(input) {
|
|
79
|
+
var _input_jitterMinutes;
|
|
80
|
+
const { fromMs, timeZone } = input;
|
|
81
|
+
if (!Number.isFinite(fromMs) || !isValidTimeZone(timeZone)) return null;
|
|
82
|
+
const delay = input.delayBusinessDays;
|
|
83
|
+
if (!Number.isInteger(delay) || delay < 0) return null;
|
|
84
|
+
const base = addZonedBusinessDays(fromMs, delay, timeZone);
|
|
85
|
+
const opening = nextWeeklyOpening(base, outreachWindowSchedule(input.window), timeZone);
|
|
86
|
+
if (!opening) return null;
|
|
87
|
+
const jitterMs = Math.max(0, (_input_jitterMinutes = input.jitterMinutes) != null ? _input_jitterMinutes : OUTREACH_DUE_JITTER_MINUTES) * MINUTE_MS;
|
|
88
|
+
const lastMs = opening.endMs - 1;
|
|
89
|
+
if (opening.startMs > base) {
|
|
90
|
+
const span = Math.min(jitterMs, lastMs - opening.startMs);
|
|
91
|
+
return opening.startMs + Math.floor(draw(input.random) * span);
|
|
92
|
+
}
|
|
93
|
+
const shifted = base + Math.round((draw(input.random) - 0.5) * jitterMs);
|
|
94
|
+
return Math.min(lastMs, Math.max(opening.startMs, shifted));
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The due time after an out-of-office reply: at least
|
|
98
|
+
* {@link OUTREACH_AUTO_REPLY_POSTPONE_BUSINESS_DAYS} business days after the
|
|
99
|
+
* reply, never earlier than the step was already due. An automatic reply
|
|
100
|
+
* says the person is away, not that they answered, so the enrollment waits
|
|
101
|
+
* and does not stop.
|
|
102
|
+
*/ export function postponeOutreachForAutoReply(input) {
|
|
103
|
+
var _input_businessDays;
|
|
104
|
+
if (input.nextDueAtMs === null || !Number.isFinite(input.nextDueAtMs)) return null;
|
|
105
|
+
const postponed = scheduleOutreachDue({
|
|
106
|
+
fromMs: input.receivedAtMs,
|
|
107
|
+
delayBusinessDays: (_input_businessDays = input.businessDays) != null ? _input_businessDays : OUTREACH_AUTO_REPLY_POSTPONE_BUSINESS_DAYS,
|
|
108
|
+
timeZone: input.timeZone,
|
|
109
|
+
window: input.window,
|
|
110
|
+
random: input.random
|
|
111
|
+
});
|
|
112
|
+
return postponed === null ? input.nextDueAtMs : Math.max(input.nextDueAtMs, postponed);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
//# sourceMappingURL=schedule.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/schedule.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 STEP COMES DUE (AGL-2979).\n *\n * A step waits a number of BUSINESS days after the one before it, counted on\n * the mailbox's own calendar — the rep's Monday, not the server's — and then\n * waits again, if it has to, for the sending window to open. So a follow-up\n * two business days after a Friday 16:40 send is due Tuesday at about 16:40,\n * and one that lands on a Saturday or after hours goes out at the next\n * opening instead.\n *\n * ## Jitter\n *\n * Every due time moves by up to half of {@link OUTREACH_DUE_JITTER_MINUTES}\n * either way, drawn from an injected random source, and never out of the\n * window it was placed in. A person does not answer every follow-up at the\n * same minute, and a mailbox whose mail leaves on the dot reads like the\n * software it is. A time that had to be moved to an opening only moves later,\n * so a morning's worth of follow-ups is spread over the first hour rather\n * than stacked on the opening minute.\n *\n * The random source is a parameter so a test can pin it and so nothing here\n * reads a clock or a global.\n *==========================================*/\n\nimport {\n addZonedBusinessDays,\n isValidTimeZone,\n isWithinWeeklySchedule,\n nextWeeklyOpening,\n type WeeklySchedule,\n} from '@aglyn/shared-util-timestamp/zoned-time'\nimport type { OutreachSendWindow } from '../model/outreach.types'\n\n/** How far a due time may move, in total: half of it either way. */\nexport const OUTREACH_DUE_JITTER_MINUTES = 60\n\n/**\n * How long an out-of-office reply holds the next step back, in business\n * days — a working week, the usual length of an absence — measured from\n * when the reply arrived. A step already due later than that keeps its time.\n */\nexport const OUTREACH_AUTO_REPLY_POSTPONE_BUSINESS_DAYS = 5\n\n/** A source of numbers in `[0, 1)`, such as `Math.random`. */\nexport type OutreachRandom = () => number\n\nconst MINUTE_MS = 60_000\n\n/** A send window as the weekly schedule the zone math reads. */\nexport function outreachWindowSchedule(\n window: OutreachSendWindow | null | undefined,\n): WeeklySchedule {\n const schedule: Record<number, { start: number; end: number }[]> = {}\n if (!window || !Array.isArray(window.days)) return schedule\n for (const day of window.days) {\n if (Number.isInteger(day) && day >= 0 && day <= 6) {\n schedule[day] = [{ start: window.startMinute, end: window.endMinute }]\n }\n }\n return schedule\n}\n\n/** The window a sequence's steps go out in: its own, or else its mailbox's. */\nexport function effectiveOutreachWindow(\n sequenceWindow: OutreachSendWindow | null | undefined,\n mailboxWindow: OutreachSendWindow | null | undefined,\n): OutreachSendWindow | null {\n return sequenceWindow ?? mailboxWindow ?? null\n}\n\n/** Whether `atMs` is inside the window, read in `timeZone`. */\nexport function isOutreachWindowOpen(\n atMs: number,\n window: OutreachSendWindow | null | undefined,\n timeZone: string,\n): boolean {\n if (!isValidTimeZone(timeZone)) return false\n return isWithinWeeklySchedule(atMs, outreachWindowSchedule(window), timeZone)\n}\n\nfunction draw(random: OutreachRandom | null | undefined): number {\n const value = typeof random === 'function' ? random() : 0.5\n if (!Number.isFinite(value)) return 0.5\n return Math.min(Math.max(value, 0), 1 - Number.EPSILON)\n}\n\nexport interface OutreachDueInput {\n /** The instant the wait is counted from: the last step's send, or the enrollment. */\n fromMs: number\n delayBusinessDays: number\n /** The mailbox's IANA zone. */\n timeZone: string\n /** The window the step goes out in — {@link effectiveOutreachWindow}. */\n window: OutreachSendWindow | null | undefined\n random: OutreachRandom\n /** Defaults to {@link OUTREACH_DUE_JITTER_MINUTES}; `0` places the time exactly. */\n jitterMinutes?: number\n}\n\n/**\n * When a step comes due — see the module note — or `null` when it never\n * can: a zone `Intl` does not know, a delay that is not a whole number of\n * days, or a window that never opens.\n */\nexport function scheduleOutreachDue(input: OutreachDueInput): number | null {\n const { fromMs, timeZone } = input\n if (!Number.isFinite(fromMs) || !isValidTimeZone(timeZone)) return null\n const delay = input.delayBusinessDays\n if (!Number.isInteger(delay) || delay < 0) return null\n const base = addZonedBusinessDays(fromMs, delay, timeZone)\n const opening = nextWeeklyOpening(base, outreachWindowSchedule(input.window), timeZone)\n if (!opening) return null\n const jitterMs = Math.max(0, input.jitterMinutes ?? OUTREACH_DUE_JITTER_MINUTES) * MINUTE_MS\n const lastMs = opening.endMs - 1\n if (opening.startMs > base) {\n const span = Math.min(jitterMs, lastMs - opening.startMs)\n return opening.startMs + Math.floor(draw(input.random) * span)\n }\n const shifted = base + Math.round((draw(input.random) - 0.5) * jitterMs)\n return Math.min(lastMs, Math.max(opening.startMs, shifted))\n}\n\nexport interface OutreachAutoReplyPostponeInput {\n /** The enrollment's `nextDueAtMs`; `null` has nothing waiting to hold back. */\n nextDueAtMs: number | null\n /** When the automatic reply arrived. */\n receivedAtMs: number\n timeZone: string\n window: OutreachSendWindow | null | undefined\n random: OutreachRandom\n businessDays?: number\n}\n\n/**\n * The due time after an out-of-office reply: at least\n * {@link OUTREACH_AUTO_REPLY_POSTPONE_BUSINESS_DAYS} business days after the\n * reply, never earlier than the step was already due. An automatic reply\n * says the person is away, not that they answered, so the enrollment waits\n * and does not stop.\n */\nexport function postponeOutreachForAutoReply(\n input: OutreachAutoReplyPostponeInput,\n): number | null {\n if (input.nextDueAtMs === null || !Number.isFinite(input.nextDueAtMs)) return null\n const postponed = scheduleOutreachDue({\n fromMs: input.receivedAtMs,\n delayBusinessDays: input.businessDays ?? OUTREACH_AUTO_REPLY_POSTPONE_BUSINESS_DAYS,\n timeZone: input.timeZone,\n window: input.window,\n random: input.random,\n })\n return postponed === null ? input.nextDueAtMs : Math.max(input.nextDueAtMs, postponed)\n}\n"],"names":["addZonedBusinessDays","isValidTimeZone","isWithinWeeklySchedule","nextWeeklyOpening","OUTREACH_DUE_JITTER_MINUTES","OUTREACH_AUTO_REPLY_POSTPONE_BUSINESS_DAYS","MINUTE_MS","outreachWindowSchedule","window","schedule","Array","isArray","days","day","Number","isInteger","start","startMinute","end","endMinute","effectiveOutreachWindow","sequenceWindow","mailboxWindow","isOutreachWindowOpen","atMs","timeZone","draw","random","value","isFinite","Math","min","max","EPSILON","scheduleOutreachDue","input","fromMs","delay","delayBusinessDays","base","opening","jitterMs","jitterMinutes","lastMs","endMs","startMs","span","floor","shifted","round","postponeOutreachForAutoReply","nextDueAtMs","postponed","receivedAtMs","businessDays"],"mappings":"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"}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import type { OutreachEnrollment, OutreachMailbox, OutreachSendWindow, OutreachSequence } from '../model/outreach.types';
|
|
18
|
+
/** The ramp's daily caps: week one, week two, and every week after. */
|
|
19
|
+
export declare const OUTREACH_RAMP_DAILY_CAPS: readonly number[];
|
|
20
|
+
/** The most any mailbox sends in one day, whatever is configured. */
|
|
21
|
+
export declare const OUTREACH_DAILY_CAP_MAX = 50;
|
|
22
|
+
/** How often the sending runtime runs, in minutes. */
|
|
23
|
+
export declare const OUTREACH_TICK_MINUTES = 15;
|
|
24
|
+
/** The most one mailbox sends in one run. */
|
|
25
|
+
export declare const OUTREACH_SENDS_PER_TICK_MAX = 5;
|
|
26
|
+
/**
|
|
27
|
+
* The ramp's cap for the day `nowMs` falls in, or `null` when no ramp is
|
|
28
|
+
* running. Weeks are counted in calendar days of the mailbox's zone from the
|
|
29
|
+
* day the ramp began, which is day one of week one; a start in the future
|
|
30
|
+
* reads as week one.
|
|
31
|
+
*/
|
|
32
|
+
export declare function outreachRampCap(rampStartedAtMs: number | null | undefined, nowMs: number, timeZone: string): number | null;
|
|
33
|
+
/** The day's cap: the least of the configured cap, the ramp and the ceiling. */
|
|
34
|
+
export declare function outreachDailyCap(input: {
|
|
35
|
+
configuredCap: number;
|
|
36
|
+
rampStartedAtMs: number | null | undefined;
|
|
37
|
+
nowMs: number;
|
|
38
|
+
timeZone: string;
|
|
39
|
+
}): number;
|
|
40
|
+
/**
|
|
41
|
+
* How many emails the mailbox has sent today: its counter when the counter
|
|
42
|
+
* belongs to today in the mailbox's zone, and `0` when it belongs to an
|
|
43
|
+
* earlier day.
|
|
44
|
+
*/
|
|
45
|
+
export declare function outreachSentToday(health: Pick<OutreachMailbox['health'], 'sentToday' | 'sentOnDay'> | null | undefined, nowMs: number, timeZone: string): number;
|
|
46
|
+
/** Why a mailbox may send nothing this run. */
|
|
47
|
+
export type OutreachTickHold = 'mailbox_not_connected' | 'invalid_timezone' | 'daily_cap_reached';
|
|
48
|
+
export interface OutreachTickAllowanceInput {
|
|
49
|
+
mailbox: Pick<OutreachMailbox, 'status' | 'dailyCap' | 'rampStartedAtMs' | 'timezone' | 'window' | 'health'>;
|
|
50
|
+
nowMs: number;
|
|
51
|
+
/**
|
|
52
|
+
* The window the day's sends are spread across. The mailbox's own by
|
|
53
|
+
* default; when it is closed but a sequence's own window is open, the
|
|
54
|
+
* remainder is not paced and only the per-run maximum holds.
|
|
55
|
+
*/
|
|
56
|
+
paceWindow?: OutreachSendWindow | null;
|
|
57
|
+
tickMinutes?: number;
|
|
58
|
+
perTickMax?: number;
|
|
59
|
+
}
|
|
60
|
+
export interface OutreachTickAllowance {
|
|
61
|
+
/** How many emails this run may send from the mailbox. */
|
|
62
|
+
allowance: number;
|
|
63
|
+
dailyCap: number;
|
|
64
|
+
sentToday: number;
|
|
65
|
+
remainingToday: number;
|
|
66
|
+
/** Why the allowance is zero, when a rule rather than an empty queue made it so. */
|
|
67
|
+
hold: OutreachTickHold | null;
|
|
68
|
+
}
|
|
69
|
+
/** How many emails a mailbox may send in this run — see the module note. */
|
|
70
|
+
export declare function outreachTickAllowance(input: OutreachTickAllowanceInput): OutreachTickAllowance;
|
|
71
|
+
/** One due enrollment beside the sequence it runs through. */
|
|
72
|
+
export interface OutreachDueCandidate {
|
|
73
|
+
enrollment: Pick<OutreachEnrollment, 'id' | 'status' | 'nextDueAtMs' | 'stepIndex' | 'email' | 'mailboxId' | 'sequenceId'>;
|
|
74
|
+
sequence: Pick<OutreachSequence, 'id' | 'status' | 'steps' | 'settings'>;
|
|
75
|
+
}
|
|
76
|
+
export interface OutreachDueSelection {
|
|
77
|
+
/** Email steps to send this run, in the order to send them. */
|
|
78
|
+
emails: OutreachDueCandidate[];
|
|
79
|
+
/** Task steps due now. Each becomes a CRM task; none spends the allowance. */
|
|
80
|
+
tasks: OutreachDueCandidate[];
|
|
81
|
+
/** Email steps that were due inside their window and did not fit this run. */
|
|
82
|
+
deferred: OutreachDueCandidate[];
|
|
83
|
+
}
|
|
84
|
+
export interface OutreachDueSelectionInput {
|
|
85
|
+
candidates: readonly OutreachDueCandidate[];
|
|
86
|
+
mailbox: Pick<OutreachMailbox, 'id' | 'timezone' | 'window'>;
|
|
87
|
+
nowMs: number;
|
|
88
|
+
/** {@link outreachTickAllowance}'s `allowance`. */
|
|
89
|
+
allowance: number;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The due enrollments one mailbox runs now, in order — see the module note.
|
|
93
|
+
* An enrollment is due when it and its sequence are active, it belongs to
|
|
94
|
+
* this mailbox, its time has come, and — for an email — its window is open.
|
|
95
|
+
*/
|
|
96
|
+
export declare function selectDueOutreachEnrollments(input: OutreachDueSelectionInput): OutreachDueSelection;
|
|
@@ -0,0 +1,172 @@
|
|
|
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
|
+
* HOW MUCH A MAILBOX MAY SEND (AGL-2979).
|
|
18
|
+
*
|
|
19
|
+
* ## The daily cap
|
|
20
|
+
*
|
|
21
|
+
* A mailbox sends at most the least of three numbers in one of its own
|
|
22
|
+
* days: the cap its owner configured; the warm-up ramp — 10 a day in its
|
|
23
|
+
* first week, 20 in its second, 30 from its third — while a ramp is running;
|
|
24
|
+
* and {@link OUTREACH_DAILY_CAP_MAX}, which nothing lifts. Mailbox providers
|
|
25
|
+
* judge a new sender by how its volume grows, and a mailbox that goes from
|
|
26
|
+
* nothing to fifty on its first morning is judged accordingly.
|
|
27
|
+
*
|
|
28
|
+
* ## The tick
|
|
29
|
+
*
|
|
30
|
+
* The runtime runs every {@link OUTREACH_TICK_MINUTES} minutes. What is left
|
|
31
|
+
* of the day's cap is spread over the ticks left in the window, so thirty
|
|
32
|
+
* emails go out across the working day rather than in its first half hour,
|
|
33
|
+
* and no tick sends more than {@link OUTREACH_SENDS_PER_TICK_MAX}.
|
|
34
|
+
*
|
|
35
|
+
* ## Which due enrollments go first
|
|
36
|
+
*
|
|
37
|
+
* Follow-ups before first emails: a thread already started is a
|
|
38
|
+
* conversation a delay would break, and a first email can start tomorrow
|
|
39
|
+
* just as well. Then the longest-waiting, then the id, so two runs over the
|
|
40
|
+
* same enrollments agree. One email per address per tick, whatever the data
|
|
41
|
+
* says.
|
|
42
|
+
*==========================================*/ import { isValidTimeZone, nextWeeklyOpening, zonedCalendarDaysBetween, zonedDayKey } from "@aglyn/shared-util-timestamp/zoned-time";
|
|
43
|
+
import { effectiveOutreachWindow, isOutreachWindowOpen, outreachWindowSchedule } from "./schedule.js";
|
|
44
|
+
import { firstEmailStepIndex } from "./sequence-validation.js";
|
|
45
|
+
/** The ramp's daily caps: week one, week two, and every week after. */ export const OUTREACH_RAMP_DAILY_CAPS = [
|
|
46
|
+
10,
|
|
47
|
+
20,
|
|
48
|
+
30
|
|
49
|
+
];
|
|
50
|
+
/** The most any mailbox sends in one day, whatever is configured. */ export const OUTREACH_DAILY_CAP_MAX = 50;
|
|
51
|
+
/** How often the sending runtime runs, in minutes. */ export const OUTREACH_TICK_MINUTES = 15;
|
|
52
|
+
/** The most one mailbox sends in one run. */ export const OUTREACH_SENDS_PER_TICK_MAX = 5;
|
|
53
|
+
const MINUTE_MS = 60000;
|
|
54
|
+
/**
|
|
55
|
+
* The ramp's cap for the day `nowMs` falls in, or `null` when no ramp is
|
|
56
|
+
* running. Weeks are counted in calendar days of the mailbox's zone from the
|
|
57
|
+
* day the ramp began, which is day one of week one; a start in the future
|
|
58
|
+
* reads as week one.
|
|
59
|
+
*/ export function outreachRampCap(rampStartedAtMs, nowMs, timeZone) {
|
|
60
|
+
if (typeof rampStartedAtMs !== 'number' || !Number.isFinite(rampStartedAtMs)) return null;
|
|
61
|
+
const days = zonedCalendarDaysBetween(rampStartedAtMs, nowMs, timeZone);
|
|
62
|
+
const week = Math.max(0, Math.floor(days / 7));
|
|
63
|
+
return OUTREACH_RAMP_DAILY_CAPS[Math.min(week, OUTREACH_RAMP_DAILY_CAPS.length - 1)];
|
|
64
|
+
}
|
|
65
|
+
/** The day's cap: the least of the configured cap, the ramp and the ceiling. */ export function outreachDailyCap(input) {
|
|
66
|
+
const configured = Number.isFinite(input.configuredCap) ? Math.max(0, Math.floor(input.configuredCap)) : 0;
|
|
67
|
+
const ramp = outreachRampCap(input.rampStartedAtMs, input.nowMs, input.timeZone);
|
|
68
|
+
return Math.min(configured, ramp != null ? ramp : OUTREACH_DAILY_CAP_MAX, OUTREACH_DAILY_CAP_MAX);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* How many emails the mailbox has sent today: its counter when the counter
|
|
72
|
+
* belongs to today in the mailbox's zone, and `0` when it belongs to an
|
|
73
|
+
* earlier day.
|
|
74
|
+
*/ export function outreachSentToday(health, nowMs, timeZone) {
|
|
75
|
+
if (!health || health.sentOnDay !== zonedDayKey(nowMs, timeZone)) return 0;
|
|
76
|
+
const sent = Number(health.sentToday);
|
|
77
|
+
return Number.isFinite(sent) ? Math.max(0, Math.floor(sent)) : 0;
|
|
78
|
+
}
|
|
79
|
+
/** How many emails a mailbox may send in this run — see the module note. */ export function outreachTickAllowance(input) {
|
|
80
|
+
var _input_tickMinutes, _input_perTickMax;
|
|
81
|
+
const { mailbox, nowMs } = input;
|
|
82
|
+
const none = (hold, dailyCap = 0, sentToday = 0)=>({
|
|
83
|
+
allowance: 0,
|
|
84
|
+
dailyCap,
|
|
85
|
+
sentToday,
|
|
86
|
+
remainingToday: Math.max(0, dailyCap - sentToday),
|
|
87
|
+
hold
|
|
88
|
+
});
|
|
89
|
+
if ((mailbox == null ? void 0 : mailbox.status) !== 'connected') return none('mailbox_not_connected');
|
|
90
|
+
if (!isValidTimeZone(mailbox.timezone)) return none('invalid_timezone');
|
|
91
|
+
const dailyCap = outreachDailyCap({
|
|
92
|
+
configuredCap: mailbox.dailyCap,
|
|
93
|
+
rampStartedAtMs: mailbox.rampStartedAtMs,
|
|
94
|
+
nowMs,
|
|
95
|
+
timeZone: mailbox.timezone
|
|
96
|
+
});
|
|
97
|
+
const sentToday = outreachSentToday(mailbox.health, nowMs, mailbox.timezone);
|
|
98
|
+
const remainingToday = Math.max(0, dailyCap - sentToday);
|
|
99
|
+
if (remainingToday === 0) return none('daily_cap_reached', dailyCap, sentToday);
|
|
100
|
+
const tickMinutes = Math.max(1, (_input_tickMinutes = input.tickMinutes) != null ? _input_tickMinutes : OUTREACH_TICK_MINUTES);
|
|
101
|
+
const perTickMax = Math.max(0, Math.floor((_input_perTickMax = input.perTickMax) != null ? _input_perTickMax : OUTREACH_SENDS_PER_TICK_MAX));
|
|
102
|
+
const paceWindow = input.paceWindow === undefined ? mailbox.window : input.paceWindow;
|
|
103
|
+
const opening = nextWeeklyOpening(nowMs, outreachWindowSchedule(paceWindow), mailbox.timezone);
|
|
104
|
+
let paced = remainingToday;
|
|
105
|
+
if (opening && opening.startMs <= nowMs) {
|
|
106
|
+
const ticksLeft = Math.max(1, Math.ceil((opening.endMs - nowMs) / (tickMinutes * MINUTE_MS)));
|
|
107
|
+
paced = Math.ceil(remainingToday / ticksLeft);
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
allowance: Math.min(remainingToday, paced, perTickMax),
|
|
111
|
+
dailyCap,
|
|
112
|
+
sentToday,
|
|
113
|
+
remainingToday,
|
|
114
|
+
hold: null
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const byDueThenId = (a, b)=>{
|
|
118
|
+
var _a_enrollment_nextDueAtMs, _b_enrollment_nextDueAtMs;
|
|
119
|
+
return ((_a_enrollment_nextDueAtMs = a.enrollment.nextDueAtMs) != null ? _a_enrollment_nextDueAtMs : 0) - ((_b_enrollment_nextDueAtMs = b.enrollment.nextDueAtMs) != null ? _b_enrollment_nextDueAtMs : 0) || (a.enrollment.id < b.enrollment.id ? -1 : a.enrollment.id > b.enrollment.id ? 1 : 0);
|
|
120
|
+
};
|
|
121
|
+
/**
|
|
122
|
+
* The due enrollments one mailbox runs now, in order — see the module note.
|
|
123
|
+
* An enrollment is due when it and its sequence are active, it belongs to
|
|
124
|
+
* this mailbox, its time has come, and — for an email — its window is open.
|
|
125
|
+
*/ export function selectDueOutreachEnrollments(input) {
|
|
126
|
+
var _input_candidates;
|
|
127
|
+
const { mailbox, nowMs } = input;
|
|
128
|
+
const emails = [];
|
|
129
|
+
const tasks = [];
|
|
130
|
+
for (const candidate of (_input_candidates = input.candidates) != null ? _input_candidates : []){
|
|
131
|
+
var _sequence_steps;
|
|
132
|
+
const { enrollment, sequence } = candidate != null ? candidate : {};
|
|
133
|
+
if ((enrollment == null ? void 0 : enrollment.status) !== 'active' || (sequence == null ? void 0 : sequence.status) !== 'active') continue;
|
|
134
|
+
if (enrollment.sequenceId !== sequence.id || enrollment.mailboxId !== mailbox.id) continue;
|
|
135
|
+
const due = enrollment.nextDueAtMs;
|
|
136
|
+
if (typeof due !== 'number' || !Number.isFinite(due) || due > nowMs) continue;
|
|
137
|
+
const step = (_sequence_steps = sequence.steps) == null ? void 0 : _sequence_steps[enrollment.stepIndex];
|
|
138
|
+
if ((step == null ? void 0 : step.kind) === 'task') {
|
|
139
|
+
tasks.push(candidate);
|
|
140
|
+
} else if ((step == null ? void 0 : step.kind) === 'email') {
|
|
141
|
+
var _sequence_settings;
|
|
142
|
+
const window = effectiveOutreachWindow((_sequence_settings = sequence.settings) == null ? void 0 : _sequence_settings.window, mailbox.window);
|
|
143
|
+
if (!isOutreachWindowOpen(nowMs, window, mailbox.timezone)) continue;
|
|
144
|
+
emails.push({
|
|
145
|
+
candidate,
|
|
146
|
+
followUp: enrollment.stepIndex > firstEmailStepIndex(sequence.steps)
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
emails.sort((a, b)=>Number(b.followUp) - Number(a.followUp) || byDueThenId(a.candidate, b.candidate));
|
|
151
|
+
const allowance = Math.max(0, Math.floor(Number(input.allowance) || 0));
|
|
152
|
+
const selected = [];
|
|
153
|
+
const deferred = [];
|
|
154
|
+
const addresses = new Set();
|
|
155
|
+
for (const { candidate } of emails){
|
|
156
|
+
var _candidate_enrollment_email;
|
|
157
|
+
const address = String((_candidate_enrollment_email = candidate.enrollment.email) != null ? _candidate_enrollment_email : '').trim().toLowerCase();
|
|
158
|
+
if (selected.length < allowance && !addresses.has(address)) {
|
|
159
|
+
selected.push(candidate);
|
|
160
|
+
addresses.add(address);
|
|
161
|
+
} else {
|
|
162
|
+
deferred.push(candidate);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
emails: selected,
|
|
167
|
+
tasks: tasks.sort(byDueThenId),
|
|
168
|
+
deferred
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
//# sourceMappingURL=sending-capacity.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/sending-capacity.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 * HOW MUCH A MAILBOX MAY SEND (AGL-2979).\n *\n * ## The daily cap\n *\n * A mailbox sends at most the least of three numbers in one of its own\n * days: the cap its owner configured; the warm-up ramp — 10 a day in its\n * first week, 20 in its second, 30 from its third — while a ramp is running;\n * and {@link OUTREACH_DAILY_CAP_MAX}, which nothing lifts. Mailbox providers\n * judge a new sender by how its volume grows, and a mailbox that goes from\n * nothing to fifty on its first morning is judged accordingly.\n *\n * ## The tick\n *\n * The runtime runs every {@link OUTREACH_TICK_MINUTES} minutes. What is left\n * of the day's cap is spread over the ticks left in the window, so thirty\n * emails go out across the working day rather than in its first half hour,\n * and no tick sends more than {@link OUTREACH_SENDS_PER_TICK_MAX}.\n *\n * ## Which due enrollments go first\n *\n * Follow-ups before first emails: a thread already started is a\n * conversation a delay would break, and a first email can start tomorrow\n * just as well. Then the longest-waiting, then the id, so two runs over the\n * same enrollments agree. One email per address per tick, whatever the data\n * says.\n *==========================================*/\n\nimport {\n isValidTimeZone,\n nextWeeklyOpening,\n zonedCalendarDaysBetween,\n zonedDayKey,\n} from '@aglyn/shared-util-timestamp/zoned-time'\nimport type {\n OutreachEnrollment,\n OutreachMailbox,\n OutreachSendWindow,\n OutreachSequence,\n} from '../model/outreach.types'\nimport { effectiveOutreachWindow, isOutreachWindowOpen, outreachWindowSchedule } from './schedule'\nimport { firstEmailStepIndex } from './sequence-validation'\n\n/** The ramp's daily caps: week one, week two, and every week after. */\nexport const OUTREACH_RAMP_DAILY_CAPS: readonly number[] = [10, 20, 30]\n\n/** The most any mailbox sends in one day, whatever is configured. */\nexport const OUTREACH_DAILY_CAP_MAX = 50\n\n/** How often the sending runtime runs, in minutes. */\nexport const OUTREACH_TICK_MINUTES = 15\n\n/** The most one mailbox sends in one run. */\nexport const OUTREACH_SENDS_PER_TICK_MAX = 5\n\nconst MINUTE_MS = 60_000\n\n/**\n * The ramp's cap for the day `nowMs` falls in, or `null` when no ramp is\n * running. Weeks are counted in calendar days of the mailbox's zone from the\n * day the ramp began, which is day one of week one; a start in the future\n * reads as week one.\n */\nexport function outreachRampCap(\n rampStartedAtMs: number | null | undefined,\n nowMs: number,\n timeZone: string,\n): number | null {\n if (typeof rampStartedAtMs !== 'number' || !Number.isFinite(rampStartedAtMs)) return null\n const days = zonedCalendarDaysBetween(rampStartedAtMs, nowMs, timeZone)\n const week = Math.max(0, Math.floor(days / 7))\n return OUTREACH_RAMP_DAILY_CAPS[Math.min(week, OUTREACH_RAMP_DAILY_CAPS.length - 1)]\n}\n\n/** The day's cap: the least of the configured cap, the ramp and the ceiling. */\nexport function outreachDailyCap(input: {\n configuredCap: number\n rampStartedAtMs: number | null | undefined\n nowMs: number\n timeZone: string\n}): number {\n const configured = Number.isFinite(input.configuredCap)\n ? Math.max(0, Math.floor(input.configuredCap))\n : 0\n const ramp = outreachRampCap(input.rampStartedAtMs, input.nowMs, input.timeZone)\n return Math.min(configured, ramp ?? OUTREACH_DAILY_CAP_MAX, OUTREACH_DAILY_CAP_MAX)\n}\n\n/**\n * How many emails the mailbox has sent today: its counter when the counter\n * belongs to today in the mailbox's zone, and `0` when it belongs to an\n * earlier day.\n */\nexport function outreachSentToday(\n health: Pick<OutreachMailbox['health'], 'sentToday' | 'sentOnDay'> | null | undefined,\n nowMs: number,\n timeZone: string,\n): number {\n if (!health || health.sentOnDay !== zonedDayKey(nowMs, timeZone)) return 0\n const sent = Number(health.sentToday)\n return Number.isFinite(sent) ? Math.max(0, Math.floor(sent)) : 0\n}\n\n/** Why a mailbox may send nothing this run. */\nexport type OutreachTickHold = 'mailbox_not_connected' | 'invalid_timezone' | 'daily_cap_reached'\n\nexport interface OutreachTickAllowanceInput {\n mailbox: Pick<\n OutreachMailbox,\n 'status' | 'dailyCap' | 'rampStartedAtMs' | 'timezone' | 'window' | 'health'\n >\n nowMs: number\n /**\n * The window the day's sends are spread across. The mailbox's own by\n * default; when it is closed but a sequence's own window is open, the\n * remainder is not paced and only the per-run maximum holds.\n */\n paceWindow?: OutreachSendWindow | null\n tickMinutes?: number\n perTickMax?: number\n}\n\nexport interface OutreachTickAllowance {\n /** How many emails this run may send from the mailbox. */\n allowance: number\n dailyCap: number\n sentToday: number\n remainingToday: number\n /** Why the allowance is zero, when a rule rather than an empty queue made it so. */\n hold: OutreachTickHold | null\n}\n\n/** How many emails a mailbox may send in this run — see the module note. */\nexport function outreachTickAllowance(input: OutreachTickAllowanceInput): OutreachTickAllowance {\n const { mailbox, nowMs } = input\n const none = (hold: OutreachTickHold, dailyCap = 0, sentToday = 0): OutreachTickAllowance => ({\n allowance: 0,\n dailyCap,\n sentToday,\n remainingToday: Math.max(0, dailyCap - sentToday),\n hold,\n })\n if (mailbox?.status !== 'connected') return none('mailbox_not_connected')\n if (!isValidTimeZone(mailbox.timezone)) return none('invalid_timezone')\n const dailyCap = outreachDailyCap({\n configuredCap: mailbox.dailyCap,\n rampStartedAtMs: mailbox.rampStartedAtMs,\n nowMs,\n timeZone: mailbox.timezone,\n })\n const sentToday = outreachSentToday(mailbox.health, nowMs, mailbox.timezone)\n const remainingToday = Math.max(0, dailyCap - sentToday)\n if (remainingToday === 0) return none('daily_cap_reached', dailyCap, sentToday)\n const tickMinutes = Math.max(1, input.tickMinutes ?? OUTREACH_TICK_MINUTES)\n const perTickMax = Math.max(0, Math.floor(input.perTickMax ?? OUTREACH_SENDS_PER_TICK_MAX))\n const paceWindow = input.paceWindow === undefined ? mailbox.window : input.paceWindow\n const opening = nextWeeklyOpening(nowMs, outreachWindowSchedule(paceWindow), mailbox.timezone)\n let paced = remainingToday\n if (opening && opening.startMs <= nowMs) {\n const ticksLeft = Math.max(1, Math.ceil((opening.endMs - nowMs) / (tickMinutes * MINUTE_MS)))\n paced = Math.ceil(remainingToday / ticksLeft)\n }\n return {\n allowance: Math.min(remainingToday, paced, perTickMax),\n dailyCap,\n sentToday,\n remainingToday,\n hold: null,\n }\n}\n\n/** One due enrollment beside the sequence it runs through. */\nexport interface OutreachDueCandidate {\n enrollment: Pick<\n OutreachEnrollment,\n 'id' | 'status' | 'nextDueAtMs' | 'stepIndex' | 'email' | 'mailboxId' | 'sequenceId'\n >\n sequence: Pick<OutreachSequence, 'id' | 'status' | 'steps' | 'settings'>\n}\n\nexport interface OutreachDueSelection {\n /** Email steps to send this run, in the order to send them. */\n emails: OutreachDueCandidate[]\n /** Task steps due now. Each becomes a CRM task; none spends the allowance. */\n tasks: OutreachDueCandidate[]\n /** Email steps that were due inside their window and did not fit this run. */\n deferred: OutreachDueCandidate[]\n}\n\nexport interface OutreachDueSelectionInput {\n candidates: readonly OutreachDueCandidate[]\n mailbox: Pick<OutreachMailbox, 'id' | 'timezone' | 'window'>\n nowMs: number\n /** {@link outreachTickAllowance}'s `allowance`. */\n allowance: number\n}\n\nconst byDueThenId = (a: OutreachDueCandidate, b: OutreachDueCandidate) =>\n (a.enrollment.nextDueAtMs ?? 0) - (b.enrollment.nextDueAtMs ?? 0) ||\n (a.enrollment.id < b.enrollment.id ? -1 : a.enrollment.id > b.enrollment.id ? 1 : 0)\n\n/**\n * The due enrollments one mailbox runs now, in order — see the module note.\n * An enrollment is due when it and its sequence are active, it belongs to\n * this mailbox, its time has come, and — for an email — its window is open.\n */\nexport function selectDueOutreachEnrollments(input: OutreachDueSelectionInput): OutreachDueSelection {\n const { mailbox, nowMs } = input\n const emails: Array<{ candidate: OutreachDueCandidate; followUp: boolean }> = []\n const tasks: OutreachDueCandidate[] = []\n for (const candidate of input.candidates ?? []) {\n const { enrollment, sequence } = candidate ?? ({} as OutreachDueCandidate)\n if (enrollment?.status !== 'active' || sequence?.status !== 'active') continue\n if (enrollment.sequenceId !== sequence.id || enrollment.mailboxId !== mailbox.id) continue\n const due = enrollment.nextDueAtMs\n if (typeof due !== 'number' || !Number.isFinite(due) || due > nowMs) continue\n const step = sequence.steps?.[enrollment.stepIndex]\n if (step?.kind === 'task') {\n tasks.push(candidate)\n } else if (step?.kind === 'email') {\n const window = effectiveOutreachWindow(sequence.settings?.window, mailbox.window)\n if (!isOutreachWindowOpen(nowMs, window, mailbox.timezone)) continue\n emails.push({\n candidate,\n followUp: enrollment.stepIndex > firstEmailStepIndex(sequence.steps),\n })\n }\n }\n emails.sort(\n (a, b) => Number(b.followUp) - Number(a.followUp) || byDueThenId(a.candidate, b.candidate),\n )\n const allowance = Math.max(0, Math.floor(Number(input.allowance) || 0))\n const selected: OutreachDueCandidate[] = []\n const deferred: OutreachDueCandidate[] = []\n const addresses = new Set<string>()\n for (const { candidate } of emails) {\n const address = String(candidate.enrollment.email ?? '').trim().toLowerCase()\n if (selected.length < allowance && !addresses.has(address)) {\n selected.push(candidate)\n addresses.add(address)\n } else {\n deferred.push(candidate)\n }\n }\n return { emails: selected, tasks: tasks.sort(byDueThenId), deferred }\n}\n"],"names":["isValidTimeZone","nextWeeklyOpening","zonedCalendarDaysBetween","zonedDayKey","effectiveOutreachWindow","isOutreachWindowOpen","outreachWindowSchedule","firstEmailStepIndex","OUTREACH_RAMP_DAILY_CAPS","OUTREACH_DAILY_CAP_MAX","OUTREACH_TICK_MINUTES","OUTREACH_SENDS_PER_TICK_MAX","MINUTE_MS","outreachRampCap","rampStartedAtMs","nowMs","timeZone","Number","isFinite","days","week","Math","max","floor","min","length","outreachDailyCap","input","configured","configuredCap","ramp","outreachSentToday","health","sentOnDay","sent","sentToday","outreachTickAllowance","mailbox","none","hold","dailyCap","allowance","remainingToday","status","timezone","tickMinutes","perTickMax","paceWindow","undefined","window","opening","paced","startMs","ticksLeft","ceil","endMs","byDueThenId","a","b","enrollment","nextDueAtMs","id","selectDueOutreachEnrollments","emails","tasks","candidate","candidates","sequence","sequenceId","mailboxId","due","step","steps","stepIndex","kind","push","settings","followUp","sort","selected","deferred","addresses","Set","address","String","email","trim","toLowerCase","has","add"],"mappings":"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"}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { type CrmMergeFieldGroup } from '@aglyn/aglyn/app-utils/crm-email-templates';
|
|
18
|
+
import { type OutreachOrgSettings, type OutreachSendWindow, type OutreachSequence, type OutreachSequenceSettings, type OutreachSequenceStep } from '../model/outreach.types';
|
|
19
|
+
/** The merge field the rep's signal sentence is written into a step with. */
|
|
20
|
+
export declare const OUTREACH_PERSONAL_LINE_FIELD = "enrollment.personalLine";
|
|
21
|
+
/** A merge field as the Outreach editor's picker offers it. */
|
|
22
|
+
export interface OutreachMergeFieldDefinition {
|
|
23
|
+
key: string;
|
|
24
|
+
label: string;
|
|
25
|
+
group: CrmMergeFieldGroup | 'Enrollment';
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Every field an Outreach step may use: the CRM's, which the composer fills
|
|
29
|
+
* through the CRM's own resolver, and the enrollment's.
|
|
30
|
+
*/
|
|
31
|
+
export declare const OUTREACH_MERGE_FIELDS: readonly OutreachMergeFieldDefinition[];
|
|
32
|
+
/** The longest name a sequence carries. */
|
|
33
|
+
export declare const OUTREACH_SEQUENCE_NAME_MAX = 120;
|
|
34
|
+
/** The longest title a task step carries. */
|
|
35
|
+
export declare const OUTREACH_TASK_TITLE_MAX = 200;
|
|
36
|
+
export type OutreachValidationSeverity = 'error' | 'warning';
|
|
37
|
+
export type OutreachValidationCode = 'name_required' | 'name_too_long' | 'mailbox_required' | 'steps_required' | 'too_many_steps' | 'too_many_email_steps' | 'step_id_required' | 'duplicate_step_id' | 'unknown_step_kind' | 'delay_invalid' | 'subject_required' | 'subject_too_long' | 'subject_reply_prefix' | 'body_required' | 'body_and_template' | 'body_too_long' | 'unknown_merge_field' | 'personal_line_unused' | 'task_kind_invalid' | 'task_title_required' | 'task_title_too_long' | 'window_days_invalid' | 'window_hours_invalid' | 'countries_required' | 'country_invalid' | 'legal_name_required' | 'postal_address_required';
|
|
38
|
+
export interface OutreachValidationIssue {
|
|
39
|
+
/** The field, dotted: `name`, `steps.2.subject`, `settings.window`, `orgSettings.postalAddress`. */
|
|
40
|
+
path: string;
|
|
41
|
+
code: OutreachValidationCode;
|
|
42
|
+
message: string;
|
|
43
|
+
severity: OutreachValidationSeverity;
|
|
44
|
+
}
|
|
45
|
+
/** Whether any issue in the list stops a save or an activation. */
|
|
46
|
+
export declare function hasOutreachValidationErrors(issues: readonly OutreachValidationIssue[]): boolean;
|
|
47
|
+
/** The index of the first email step, or `-1` for a sequence with none. */
|
|
48
|
+
export declare function firstEmailStepIndex(steps: readonly OutreachSequenceStep[] | null | undefined): number;
|
|
49
|
+
/**
|
|
50
|
+
* Whether the email at `index` is sent as a reply in the thread an earlier
|
|
51
|
+
* email started: every email after the first unless it says otherwise. The
|
|
52
|
+
* first email starts the thread whatever its `replyInThread` says.
|
|
53
|
+
*/
|
|
54
|
+
export declare function isInThreadEmailStep(steps: readonly OutreachSequenceStep[] | null | undefined, index: number): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Whether a subject opens with a reply or forward prefix — `Re:`, `Fwd:`,
|
|
57
|
+
* `AW:` — which on an email that answers nothing tells the recipient a
|
|
58
|
+
* conversation exists that does not. The prefixes are the CRM thread
|
|
59
|
+
* reader's, so the two agree on what one looks like.
|
|
60
|
+
*/
|
|
61
|
+
export declare function outreachSubjectHasReplyPrefix(subject: unknown): boolean;
|
|
62
|
+
/** A sending window's shape: at least one weekday, and an opening before a close. */
|
|
63
|
+
export declare function validateOutreachSendWindow(window: OutreachSendWindow | null | undefined, path?: string): OutreachValidationIssue[];
|
|
64
|
+
/**
|
|
65
|
+
* The allowed countries, as stored or typed, reduced to unique uppercase
|
|
66
|
+
* alpha-2 codes in the order given. Anything that is not two letters is
|
|
67
|
+
* dropped, so a caller that needs to report it checks the raw value.
|
|
68
|
+
*/
|
|
69
|
+
export declare function normalizeOutreachAllowedCountries(value: unknown): string[];
|
|
70
|
+
/**
|
|
71
|
+
* A stored settings map with the defaults applied: the United States alone,
|
|
72
|
+
* no customers, the mailbox's own window. A field a writer never set reads
|
|
73
|
+
* as its default rather than as a permission nobody gave.
|
|
74
|
+
*/
|
|
75
|
+
export declare function readOutreachSequenceSettings(raw: Partial<OutreachSequenceSettings> | Record<string, unknown> | null | undefined): OutreachSequenceSettings;
|
|
76
|
+
export declare function validateOutreachSequenceSettings(settings: OutreachSequenceSettings | null | undefined, path?: string): OutreachValidationIssue[];
|
|
77
|
+
/** The steps of a sequence: their count, their ids, and each one's own fields. */
|
|
78
|
+
export declare function validateOutreachSteps(steps: readonly OutreachSequenceStep[] | null | undefined, path?: string): OutreachValidationIssue[];
|
|
79
|
+
/** Everything a stored sequence has to be before it may be saved. */
|
|
80
|
+
export declare function validateOutreachSequence(sequence: Pick<OutreachSequence, 'name' | 'mailboxId' | 'steps' | 'settings'> | null | undefined): OutreachValidationIssue[];
|
|
81
|
+
/**
|
|
82
|
+
* The organization's footer fields: a legal name and a postal address, the
|
|
83
|
+
* two things the law requires every commercial email to carry.
|
|
84
|
+
*/
|
|
85
|
+
export declare function validateOutreachOrgSettings(settings: Pick<OutreachOrgSettings, 'legalName' | 'postalAddress'> | null | undefined, path?: string): OutreachValidationIssue[];
|
|
86
|
+
/**
|
|
87
|
+
* What activating a sequence refuses on: every issue saving refuses on, and
|
|
88
|
+
* an organization whose footer cannot be written yet.
|
|
89
|
+
*/
|
|
90
|
+
export declare function validateOutreachSequenceActivation(sequence: Pick<OutreachSequence, 'name' | 'mailboxId' | 'steps' | 'settings'> | null | undefined, orgSettings: Pick<OutreachOrgSettings, 'legalName' | 'postalAddress'> | null | undefined): OutreachValidationIssue[];
|