@aglyn/plugins-outreach 1.0.0-beta.155 → 1.0.0-beta.157
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +13 -12
- package/src/lib/components/enrollments-table.d.ts +7 -0
- package/src/lib/components/enrollments-table.js +46 -2
- package/src/lib/components/enrollments-table.js.map +1 -1
- package/src/lib/components/sequence-detail.js +14 -4
- package/src/lib/components/sequence-detail.js.map +1 -1
- package/src/lib/components/sequence-editor.js +22 -0
- package/src/lib/components/sequence-editor.js.map +1 -1
- package/src/lib/components/sequence-report-card.d.ts +23 -0
- package/src/lib/components/sequence-report-card.js +191 -0
- package/src/lib/components/sequence-report-card.js.map +1 -0
- package/src/lib/components/use-outreach-data.d.ts +11 -0
- package/src/lib/components/use-outreach-data.js +24 -1
- package/src/lib/components/use-outreach-data.js.map +1 -1
- package/src/lib/constants/api-routes.d.ts +9 -0
- package/src/lib/constants/api-routes.js +9 -1
- package/src/lib/constants/api-routes.js.map +1 -1
- package/src/lib/engine/click-tracking.d.ts +98 -0
- package/src/lib/engine/click-tracking.js +252 -0
- package/src/lib/engine/click-tracking.js.map +1 -0
- package/src/lib/engine/compose.d.ts +24 -0
- package/src/lib/engine/compose.js +15 -2
- package/src/lib/engine/compose.js.map +1 -1
- package/src/lib/engine/sequence-validation.js +4 -1
- package/src/lib/engine/sequence-validation.js.map +1 -1
- package/src/lib/model/enrollment-engagement.d.ts +31 -0
- package/src/lib/model/enrollment-engagement.js +44 -0
- package/src/lib/model/enrollment-engagement.js.map +1 -0
- package/src/lib/model/outreach.types.d.ts +89 -0
- package/src/lib/model/outreach.types.js +25 -0
- package/src/lib/model/outreach.types.js.map +1 -1
- package/src/lib/model/sequence-draft.js +5 -1
- package/src/lib/model/sequence-draft.js.map +1 -1
- package/src/lib/model/sequence-report.d.ts +68 -0
- package/src/lib/model/sequence-report.js +118 -0
- package/src/lib/model/sequence-report.js.map +1 -0
- package/src/lib/model/stored-records.d.ts +11 -1
- package/src/lib/model/stored-records.js +34 -1
- package/src/lib/model/stored-records.js.map +1 -1
- package/src/lib/routes/sequence-routes.js +4 -1
- package/src/lib/routes/sequence-routes.js.map +1 -1
- package/src/lib/runtime/click-events.d.ts +67 -0
- package/src/lib/runtime/click-events.js +232 -0
- package/src/lib/runtime/click-events.js.map +1 -0
- package/src/lib/runtime/click-link.d.ts +94 -0
- package/src/lib/runtime/click-link.js +158 -0
- package/src/lib/runtime/click-link.js.map +1 -0
- package/src/lib/runtime/click-route.d.ts +19 -0
- package/src/lib/runtime/click-route.js +108 -0
- package/src/lib/runtime/click-route.js.map +1 -0
- package/src/lib/runtime/platform-runtime-deps.d.ts +2 -0
- package/src/lib/runtime/platform-runtime-deps.js +9 -0
- package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
- package/src/lib/runtime/runtime-deps.d.ts +11 -0
- package/src/lib/runtime/runtime-deps.js.map +1 -1
- package/src/lib/runtime/send-job.js +65 -5
- package/src/lib/runtime/send-job.js.map +1 -1
- package/src/lib/server.js +8 -0
- package/src/lib/server.js.map +1 -1
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
17
|
+
import { readOutreachClickToken } from "./click-link.js";
|
|
18
|
+
import { recordOutreachClick } from "./click-events.js";
|
|
19
|
+
// The plain page both recipient-facing routes answer with: no theme, no
|
|
20
|
+
// session, the reader's own colors. It lives beside the first route that
|
|
21
|
+
// needed one rather than being copied for the second.
|
|
22
|
+
import { outreachUnsubscribePage } from "./unsubscribe-route.js";
|
|
23
|
+
/**
|
|
24
|
+
* A LINK IN A TRACKED SEQUENCE EMAIL (AGL-3239): `GET /api/outreach/click?t=…`.
|
|
25
|
+
*
|
|
26
|
+
* ## The redirect happens first, and it happens for everyone
|
|
27
|
+
*
|
|
28
|
+
* The destination is in the token, so answering needs no read: the handler
|
|
29
|
+
* verifies the signature, and the 302 goes out. Recording is awaited after
|
|
30
|
+
* the answer is built and never gates it — a Firestore that is slow or down
|
|
31
|
+
* costs a number, not a visit.
|
|
32
|
+
*
|
|
33
|
+
* A link scanner is redirected exactly as a person is. Refusing one, or
|
|
34
|
+
* answering it differently, is how a security gateway learns to report the
|
|
35
|
+
* link as broken and the message as suspicious; it is counted apart instead
|
|
36
|
+
* (`../engine/click-tracking.ts`).
|
|
37
|
+
*
|
|
38
|
+
* ## No session, and no release gate
|
|
39
|
+
*
|
|
40
|
+
* Registered as a recipient link, like the one-click unsubscribe. The
|
|
41
|
+
* signature in the token is the whole of the authority, and a link that is
|
|
42
|
+
* already in somebody's inbox has to keep working whether or not Outreach is
|
|
43
|
+
* released to that organization today.
|
|
44
|
+
*
|
|
45
|
+
* ## A token that does not verify is not followed
|
|
46
|
+
*
|
|
47
|
+
* There is nowhere to send them: the destination WAS the thing that failed
|
|
48
|
+
* the check. So it answers the same plain page the unsubscribe route
|
|
49
|
+
* answers, rather than guessing — an endpoint that redirects to an unsigned
|
|
50
|
+
* URL is an open redirect, whatever it is called.
|
|
51
|
+
*
|
|
52
|
+
* ## What the recipient's browser is told
|
|
53
|
+
*
|
|
54
|
+
* `no-store`, so a gateway's copy is never served to the person after it;
|
|
55
|
+
* `no-referrer`, so the destination never learns the token; and `noindex`,
|
|
56
|
+
* because a tracking link that reached a crawler should not be in an index.
|
|
57
|
+
*/ const REDIRECT_HEADERS = {
|
|
58
|
+
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
|
59
|
+
'Referrer-Policy': 'no-referrer',
|
|
60
|
+
'X-Robots-Tag': 'noindex, nofollow'
|
|
61
|
+
};
|
|
62
|
+
export function createOutreachClickRoute(deps) {
|
|
63
|
+
return async (request)=>{
|
|
64
|
+
// `HEAD` is answered because a gateway often sends one before the `GET`
|
|
65
|
+
// a person makes; it is redirected and counted as the machine it is.
|
|
66
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
67
|
+
return new Response('Method not allowed', {
|
|
68
|
+
status: 405,
|
|
69
|
+
headers: {
|
|
70
|
+
Allow: 'GET, HEAD'
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const target = readOutreachClickToken(new URL(request.url).searchParams.get('t'));
|
|
75
|
+
if (!target) {
|
|
76
|
+
return outreachUnsubscribePage({
|
|
77
|
+
title: 'This link doesn’t work',
|
|
78
|
+
body: 'This link isn’t valid, or it has been changed since it was sent. Try opening it from the original email.'
|
|
79
|
+
}, 400);
|
|
80
|
+
}
|
|
81
|
+
/*
|
|
82
|
+
* Built by hand rather than with `Response.redirect`, which seals its
|
|
83
|
+
* headers: the three above are the point of the answer as much as the
|
|
84
|
+
* `Location` is. `target.url` is `URL.href` of an http(s) URL — the
|
|
85
|
+
* token reader will return nothing else — so it is a header value that
|
|
86
|
+
* cannot carry a newline into the response.
|
|
87
|
+
*/ const redirect = new Response(null, {
|
|
88
|
+
status: 302,
|
|
89
|
+
headers: _extends({}, REDIRECT_HEADERS, {
|
|
90
|
+
Location: target.url
|
|
91
|
+
})
|
|
92
|
+
});
|
|
93
|
+
try {
|
|
94
|
+
await recordOutreachClick(deps, {
|
|
95
|
+
target,
|
|
96
|
+
method: request.method,
|
|
97
|
+
userAgent: request.headers.get('user-agent')
|
|
98
|
+
});
|
|
99
|
+
} catch (error) {
|
|
100
|
+
// The visit already has its answer. A click we failed to count is a
|
|
101
|
+
// missing number; a redirect we failed to send is a broken email.
|
|
102
|
+
console.error('[outreach] a click could not be recorded', error);
|
|
103
|
+
}
|
|
104
|
+
return redirect;
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
//# sourceMappingURL=click-route.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/click-route.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport { readOutreachClickToken } from './click-link'\nimport { recordOutreachClick } from './click-events'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n// The plain page both recipient-facing routes answer with: no theme, no\n// session, the reader's own colors. It lives beside the first route that\n// needed one rather than being copied for the second.\nimport { outreachUnsubscribePage } from './unsubscribe-route'\n\n/**\n * A LINK IN A TRACKED SEQUENCE EMAIL (AGL-3239): `GET /api/outreach/click?t=…`.\n *\n * ## The redirect happens first, and it happens for everyone\n *\n * The destination is in the token, so answering needs no read: the handler\n * verifies the signature, and the 302 goes out. Recording is awaited after\n * the answer is built and never gates it — a Firestore that is slow or down\n * costs a number, not a visit.\n *\n * A link scanner is redirected exactly as a person is. Refusing one, or\n * answering it differently, is how a security gateway learns to report the\n * link as broken and the message as suspicious; it is counted apart instead\n * (`../engine/click-tracking.ts`).\n *\n * ## No session, and no release gate\n *\n * Registered as a recipient link, like the one-click unsubscribe. The\n * signature in the token is the whole of the authority, and a link that is\n * already in somebody's inbox has to keep working whether or not Outreach is\n * released to that organization today.\n *\n * ## A token that does not verify is not followed\n *\n * There is nowhere to send them: the destination WAS the thing that failed\n * the check. So it answers the same plain page the unsubscribe route\n * answers, rather than guessing — an endpoint that redirects to an unsigned\n * URL is an open redirect, whatever it is called.\n *\n * ## What the recipient's browser is told\n *\n * `no-store`, so a gateway's copy is never served to the person after it;\n * `no-referrer`, so the destination never learns the token; and `noindex`,\n * because a tracking link that reached a crawler should not be in an index.\n */\n\nconst REDIRECT_HEADERS = {\n 'Cache-Control': 'no-store, no-cache, must-revalidate',\n 'Referrer-Policy': 'no-referrer',\n 'X-Robots-Tag': 'noindex, nofollow',\n}\n\nexport function createOutreachClickRoute(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline'>,\n): PluginWebApiHandler {\n return async (request) => {\n // `HEAD` is answered because a gateway often sends one before the `GET`\n // a person makes; it is redirected and counted as the machine it is.\n if (request.method !== 'GET' && request.method !== 'HEAD') {\n return new Response('Method not allowed', { status: 405, headers: { Allow: 'GET, HEAD' } })\n }\n const target = readOutreachClickToken(new URL(request.url).searchParams.get('t'))\n if (!target) {\n return outreachUnsubscribePage(\n {\n title: 'This link doesn’t work',\n body: 'This link isn’t valid, or it has been changed since it was sent. Try opening it from the original email.',\n },\n 400,\n )\n }\n /*\n * Built by hand rather than with `Response.redirect`, which seals its\n * headers: the three above are the point of the answer as much as the\n * `Location` is. `target.url` is `URL.href` of an http(s) URL — the\n * token reader will return nothing else — so it is a header value that\n * cannot carry a newline into the response.\n */\n const redirect = new Response(null, {\n status: 302,\n headers: { ...REDIRECT_HEADERS, Location: target.url },\n })\n try {\n await recordOutreachClick(deps, {\n target,\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n })\n } catch (error) {\n // The visit already has its answer. A click we failed to count is a\n // missing number; a redirect we failed to send is a broken email.\n console.error('[outreach] a click could not be recorded', error)\n }\n return redirect\n }\n}\n"],"names":["readOutreachClickToken","recordOutreachClick","outreachUnsubscribePage","REDIRECT_HEADERS","createOutreachClickRoute","deps","request","method","Response","status","headers","Allow","target","URL","url","searchParams","get","title","body","redirect","Location","userAgent","error","console"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAGD,SAASA,sBAAsB,QAAQ,kBAAc;AACrD,SAASC,mBAAmB,QAAQ,oBAAgB;AAEpD,wEAAwE;AACxE,yEAAyE;AACzE,sDAAsD;AACtD,SAASC,uBAAuB,QAAQ,yBAAqB;AAE7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkCC,GAED,MAAMC,mBAAmB;IACvB,iBAAiB;IACjB,mBAAmB;IACnB,gBAAgB;AAClB;AAEA,OAAO,SAASC,yBACdC,IAAiE;IAEjE,OAAO,OAAOC;QACZ,wEAAwE;QACxE,qEAAqE;QACrE,IAAIA,QAAQC,MAAM,KAAK,SAASD,QAAQC,MAAM,KAAK,QAAQ;YACzD,OAAO,IAAIC,SAAS,sBAAsB;gBAAEC,QAAQ;gBAAKC,SAAS;oBAAEC,OAAO;gBAAY;YAAE;QAC3F;QACA,MAAMC,SAASZ,uBAAuB,IAAIa,IAAIP,QAAQQ,GAAG,EAAEC,YAAY,CAACC,GAAG,CAAC;QAC5E,IAAI,CAACJ,QAAQ;YACX,OAAOV,wBACL;gBACEe,OAAO;gBACPC,MAAM;YACR,GACA;QAEJ;QACA;;;;;;KAMC,GACD,MAAMC,WAAW,IAAIX,SAAS,MAAM;YAClCC,QAAQ;YACRC,SAAS,aAAKP;gBAAkBiB,UAAUR,OAAOE,GAAG;;QACtD;QACA,IAAI;YACF,MAAMb,oBAAoBI,MAAM;gBAC9BO;gBACAL,QAAQD,QAAQC,MAAM;gBACtBc,WAAWf,QAAQI,OAAO,CAACM,GAAG,CAAC;YACjC;QACF,EAAE,OAAOM,OAAO;YACd,oEAAoE;YACpE,kEAAkE;YAClEC,QAAQD,KAAK,CAAC,4CAA4CA;QAC5D;QACA,OAAOH;IACT;AACF"}
|
|
@@ -25,5 +25,7 @@ import type { OutreachRuntimeDeps } from './runtime-deps';
|
|
|
25
25
|
export declare function platformOutreachRuntimeDeps(): OutreachRuntimeDeps;
|
|
26
26
|
/** The unsubscribe route on the platform's own reach. */
|
|
27
27
|
export declare function platformOutreachUnsubscribeRoute(): PluginWebApiHandler;
|
|
28
|
+
/** The click-tracking redirect on the platform's own reach (AGL-3239). */
|
|
29
|
+
export declare function platformOutreachClickRoute(): PluginWebApiHandler;
|
|
28
30
|
/** The person eraser on the platform's own Firestore. */
|
|
29
31
|
export declare function platformOutreachPersonEraser(): PluginPersonEraser;
|
|
@@ -26,6 +26,8 @@ import { filterEnabledPluginsByReleaseFlags } from "@aglyn/tenant-data-admin/ser
|
|
|
26
26
|
import { OUTREACH_PLUGIN_ID } from "../constants/bundle-common.js";
|
|
27
27
|
import { openOutreachMailboxClient } from "../mailboxes/mailbox-transport.js";
|
|
28
28
|
import { canonicalConsoleOrigin } from "../mailboxes/oauth-redirect.js";
|
|
29
|
+
import { outreachClickUrl } from "./click-link.js";
|
|
30
|
+
import { createOutreachClickRoute } from "./click-route.js";
|
|
29
31
|
import { createOutreachPersonEraser } from "./person-erasure.js";
|
|
30
32
|
import { outreachUnsubscribeUrl } from "./unsubscribe-link.js";
|
|
31
33
|
import { createOutreachUnsubscribeRoute } from "./unsubscribe-route.js";
|
|
@@ -83,12 +85,19 @@ import { createOutreachUnsubscribeRoute } from "./unsubscribe-route.js";
|
|
|
83
85
|
unsubscribeUrl: (target)=>outreachUnsubscribeUrl({
|
|
84
86
|
origin: canonicalConsoleOrigin(),
|
|
85
87
|
target
|
|
88
|
+
}),
|
|
89
|
+
clickUrl: (target)=>outreachClickUrl({
|
|
90
|
+
origin: canonicalConsoleOrigin(),
|
|
91
|
+
target
|
|
86
92
|
})
|
|
87
93
|
};
|
|
88
94
|
}
|
|
89
95
|
/** The unsubscribe route on the platform's own reach. */ export function platformOutreachUnsubscribeRoute() {
|
|
90
96
|
return createOutreachUnsubscribeRoute(platformOutreachRuntimeDeps());
|
|
91
97
|
}
|
|
98
|
+
/** The click-tracking redirect on the platform's own reach (AGL-3239). */ export function platformOutreachClickRoute() {
|
|
99
|
+
return createOutreachClickRoute(platformOutreachRuntimeDeps());
|
|
100
|
+
}
|
|
92
101
|
/** The person eraser on the platform's own Firestore. */ export function platformOutreachPersonEraser() {
|
|
93
102
|
return createOutreachPersonEraser({
|
|
94
103
|
firestore: ()=>firebaseAdmin.app().firestore()
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/platform-runtime-deps.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { EMAIL_TOPIC_SALES } from '@aglyn/aglyn/app-utils/email-topics'\nimport { checkEntitlement } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { isPluginEnabled } from '@aglyn/aglyn/plugin-manager/enabled-plugins'\nimport { pluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport { suppressEmail } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport { recordTopicOptOut } from '@aglyn/tenant-data-admin/server/email-topic-confirmation'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { getLockdownVerdict } from '@aglyn/tenant-data-admin/server/lockdown'\nimport { logOrgActivity } from '@aglyn/tenant-data-admin/server/organizations'\nimport { filterEnabledPluginsByReleaseFlags } from '@aglyn/tenant-data-admin/server/release-flags'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport { openOutreachMailboxClient } from '../mailboxes/mailbox-transport'\nimport { canonicalConsoleOrigin } from '../mailboxes/oauth-redirect'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport type { PluginPersonEraser } from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport { createOutreachPersonEraser } from './person-erasure'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\nimport { outreachUnsubscribeUrl } from './unsubscribe-link'\nimport { createOutreachUnsubscribeRoute } from './unsubscribe-route'\n\n/**\n * The platform's own reach for the sending runtime (AGL-2981). Loaded only\n * when a job runs or the unsubscribe route answers — never at boot — so the\n * console pays for these modules when Outreach does work.\n */\nexport function platformOutreachRuntimeDeps(): OutreachRuntimeDeps {\n const firestore = () => firebaseAdmin.app().firestore()\n return {\n firestore,\n now: Date.now,\n random: Math.random,\n openMailbox: (mailboxId) => openOutreachMailboxClient(firestore(), { mailboxId }),\n async orgRefusal(orgId, org) {\n if (!isPluginEnabled(org as { enabledPlugins?: string[] }, OUTREACH_PLUGIN_ID)) return 'plugin-disabled'\n if (!checkEntitlement(org, 'outreach')) return 'not-entitled'\n const released = await filterEnabledPluginsByReleaseFlags([OUTREACH_PLUGIN_ID], { orgId })\n return released.length ? null : 'not-released'\n },\n async sendRefusal({ org, uid }) {\n const locked = await getLockdownVerdict({ org, uid, intent: 'write' })\n return locked ? `locked:${locked.scope}` : null\n },\n timeline: () => pluginRecordTimelineWriter()?.writer ?? null,\n logOrgActivity: (orgId, actor, action, target) => logOrgActivity(orgId, actor, action, target),\n async optOutOfSalesTopic({ hostId, email }) {\n await recordTopicOptOut(hostId, email, EMAIL_TOPIC_SALES, { firestore: firestore() })\n },\n async suppressBouncedEmail({ email, hostId }) {\n await suppressEmail({ email, reason: 'bounce', context: 'outreach', hostId, firestore: firestore() })\n },\n unsubscribeUrl: (target) => outreachUnsubscribeUrl({ origin: canonicalConsoleOrigin(), target }),\n }\n}\n\n/** The unsubscribe route on the platform's own reach. */\nexport function platformOutreachUnsubscribeRoute(): PluginWebApiHandler {\n return createOutreachUnsubscribeRoute(platformOutreachRuntimeDeps())\n}\n\n/** The person eraser on the platform's own Firestore. */\nexport function platformOutreachPersonEraser(): PluginPersonEraser {\n return createOutreachPersonEraser({ firestore: () => firebaseAdmin.app().firestore() })\n}\n"],"names":["EMAIL_TOPIC_SALES","checkEntitlement","isPluginEnabled","pluginRecordTimelineWriter","suppressEmail","recordTopicOptOut","firebaseAdmin","getLockdownVerdict","logOrgActivity","filterEnabledPluginsByReleaseFlags","OUTREACH_PLUGIN_ID","openOutreachMailboxClient","canonicalConsoleOrigin","createOutreachPersonEraser","outreachUnsubscribeUrl","createOutreachUnsubscribeRoute","platformOutreachRuntimeDeps","firestore","app","now","Date","random","Math","openMailbox","mailboxId","orgRefusal","orgId","org","released","length","sendRefusal","uid","locked","intent","scope","timeline","writer","actor","action","target","optOutOfSalesTopic","hostId","email","suppressBouncedEmail","reason","context","unsubscribeUrl","origin","platformOutreachUnsubscribeRoute","platformOutreachPersonEraser"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,iBAAiB,QAAQ,sCAAqC;AACvE,SAASC,gBAAgB,QAAQ,2CAA0C;AAC3E,SAASC,eAAe,QAAQ,8CAA6C;AAC7E,SAASC,0BAA0B,QAAQ,qDAAoD;AAC/F,SAASC,aAAa,QAAQ,oDAAmD;AACjF,SAASC,iBAAiB,QAAQ,2DAA0D;AAC5F,SAASC,aAAa,QAAQ,iDAAgD;AAC9E,SAASC,kBAAkB,QAAQ,2CAA0C;AAC7E,SAASC,cAAc,QAAQ,gDAA+C;AAC9E,SAASC,kCAAkC,QAAQ,gDAA+C;AAClG,SAASC,kBAAkB,QAAQ,gCAA4B;AAC/D,SAASC,yBAAyB,QAAQ,oCAAgC;AAC1E,SAASC,sBAAsB,QAAQ,iCAA6B;AAGpE,SAASC,0BAA0B,QAAQ,sBAAkB;AAE7D,SAASC,sBAAsB,QAAQ,wBAAoB;AAC3D,SAASC,8BAA8B,QAAQ,yBAAqB;AAEpE;;;;CAIC,GACD,OAAO,SAASC;IACd,MAAMC,YAAY,
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/platform-runtime-deps.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { EMAIL_TOPIC_SALES } from '@aglyn/aglyn/app-utils/email-topics'\nimport { checkEntitlement } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { isPluginEnabled } from '@aglyn/aglyn/plugin-manager/enabled-plugins'\nimport { pluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport { suppressEmail } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport { recordTopicOptOut } from '@aglyn/tenant-data-admin/server/email-topic-confirmation'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { getLockdownVerdict } from '@aglyn/tenant-data-admin/server/lockdown'\nimport { logOrgActivity } from '@aglyn/tenant-data-admin/server/organizations'\nimport { filterEnabledPluginsByReleaseFlags } from '@aglyn/tenant-data-admin/server/release-flags'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport { openOutreachMailboxClient } from '../mailboxes/mailbox-transport'\nimport { canonicalConsoleOrigin } from '../mailboxes/oauth-redirect'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport type { PluginPersonEraser } from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport { outreachClickUrl } from './click-link'\nimport { createOutreachClickRoute } from './click-route'\nimport { createOutreachPersonEraser } from './person-erasure'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\nimport { outreachUnsubscribeUrl } from './unsubscribe-link'\nimport { createOutreachUnsubscribeRoute } from './unsubscribe-route'\n\n/**\n * The platform's own reach for the sending runtime (AGL-2981). Loaded only\n * when a job runs or the unsubscribe route answers — never at boot — so the\n * console pays for these modules when Outreach does work.\n */\nexport function platformOutreachRuntimeDeps(): OutreachRuntimeDeps {\n const firestore = () => firebaseAdmin.app().firestore()\n return {\n firestore,\n now: Date.now,\n random: Math.random,\n openMailbox: (mailboxId) => openOutreachMailboxClient(firestore(), { mailboxId }),\n async orgRefusal(orgId, org) {\n if (!isPluginEnabled(org as { enabledPlugins?: string[] }, OUTREACH_PLUGIN_ID)) return 'plugin-disabled'\n if (!checkEntitlement(org, 'outreach')) return 'not-entitled'\n const released = await filterEnabledPluginsByReleaseFlags([OUTREACH_PLUGIN_ID], { orgId })\n return released.length ? null : 'not-released'\n },\n async sendRefusal({ org, uid }) {\n const locked = await getLockdownVerdict({ org, uid, intent: 'write' })\n return locked ? `locked:${locked.scope}` : null\n },\n timeline: () => pluginRecordTimelineWriter()?.writer ?? null,\n logOrgActivity: (orgId, actor, action, target) => logOrgActivity(orgId, actor, action, target),\n async optOutOfSalesTopic({ hostId, email }) {\n await recordTopicOptOut(hostId, email, EMAIL_TOPIC_SALES, { firestore: firestore() })\n },\n async suppressBouncedEmail({ email, hostId }) {\n await suppressEmail({ email, reason: 'bounce', context: 'outreach', hostId, firestore: firestore() })\n },\n unsubscribeUrl: (target) => outreachUnsubscribeUrl({ origin: canonicalConsoleOrigin(), target }),\n clickUrl: (target) => outreachClickUrl({ origin: canonicalConsoleOrigin(), target }),\n }\n}\n\n/** The unsubscribe route on the platform's own reach. */\nexport function platformOutreachUnsubscribeRoute(): PluginWebApiHandler {\n return createOutreachUnsubscribeRoute(platformOutreachRuntimeDeps())\n}\n\n/** The click-tracking redirect on the platform's own reach (AGL-3239). */\nexport function platformOutreachClickRoute(): PluginWebApiHandler {\n return createOutreachClickRoute(platformOutreachRuntimeDeps())\n}\n\n/** The person eraser on the platform's own Firestore. */\nexport function platformOutreachPersonEraser(): PluginPersonEraser {\n return createOutreachPersonEraser({ firestore: () => firebaseAdmin.app().firestore() })\n}\n"],"names":["EMAIL_TOPIC_SALES","checkEntitlement","isPluginEnabled","pluginRecordTimelineWriter","suppressEmail","recordTopicOptOut","firebaseAdmin","getLockdownVerdict","logOrgActivity","filterEnabledPluginsByReleaseFlags","OUTREACH_PLUGIN_ID","openOutreachMailboxClient","canonicalConsoleOrigin","outreachClickUrl","createOutreachClickRoute","createOutreachPersonEraser","outreachUnsubscribeUrl","createOutreachUnsubscribeRoute","platformOutreachRuntimeDeps","firestore","app","now","Date","random","Math","openMailbox","mailboxId","orgRefusal","orgId","org","released","length","sendRefusal","uid","locked","intent","scope","timeline","writer","actor","action","target","optOutOfSalesTopic","hostId","email","suppressBouncedEmail","reason","context","unsubscribeUrl","origin","clickUrl","platformOutreachUnsubscribeRoute","platformOutreachClickRoute","platformOutreachPersonEraser"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,iBAAiB,QAAQ,sCAAqC;AACvE,SAASC,gBAAgB,QAAQ,2CAA0C;AAC3E,SAASC,eAAe,QAAQ,8CAA6C;AAC7E,SAASC,0BAA0B,QAAQ,qDAAoD;AAC/F,SAASC,aAAa,QAAQ,oDAAmD;AACjF,SAASC,iBAAiB,QAAQ,2DAA0D;AAC5F,SAASC,aAAa,QAAQ,iDAAgD;AAC9E,SAASC,kBAAkB,QAAQ,2CAA0C;AAC7E,SAASC,cAAc,QAAQ,gDAA+C;AAC9E,SAASC,kCAAkC,QAAQ,gDAA+C;AAClG,SAASC,kBAAkB,QAAQ,gCAA4B;AAC/D,SAASC,yBAAyB,QAAQ,oCAAgC;AAC1E,SAASC,sBAAsB,QAAQ,iCAA6B;AAGpE,SAASC,gBAAgB,QAAQ,kBAAc;AAC/C,SAASC,wBAAwB,QAAQ,mBAAe;AACxD,SAASC,0BAA0B,QAAQ,sBAAkB;AAE7D,SAASC,sBAAsB,QAAQ,wBAAoB;AAC3D,SAASC,8BAA8B,QAAQ,yBAAqB;AAEpE;;;;CAIC,GACD,OAAO,SAASC;IACd,MAAMC,YAAY,IAAMb,cAAcc,GAAG,GAAGD,SAAS;IACrD,OAAO;QACLA;QACAE,KAAKC,KAAKD,GAAG;QACbE,QAAQC,KAAKD,MAAM;QACnBE,aAAa,CAACC,YAAcf,0BAA0BQ,aAAa;gBAAEO;YAAU;QAC/E,MAAMC,YAAWC,KAAK,EAAEC,GAAG;YACzB,IAAI,CAAC3B,gBAAgB2B,KAAsCnB,qBAAqB,OAAO;YACvF,IAAI,CAACT,iBAAiB4B,KAAK,aAAa,OAAO;YAC/C,MAAMC,WAAW,MAAMrB,mCAAmC;gBAACC;aAAmB,EAAE;gBAAEkB;YAAM;YACxF,OAAOE,SAASC,MAAM,GAAG,OAAO;QAClC;QACA,MAAMC,aAAY,EAAEH,GAAG,EAAEI,GAAG,EAAE;YAC5B,MAAMC,SAAS,MAAM3B,mBAAmB;gBAAEsB;gBAAKI;gBAAKE,QAAQ;YAAQ;YACpE,OAAOD,SAAS,CAAC,OAAO,EAAEA,OAAOE,KAAK,EAAE,GAAG;QAC7C;QACAC,UAAU;;gBAAMlC;4BAAAA,8BAAAA,iDAAAA,4BAA8BmC,MAAM,mBAAI;;QACxD9B,gBAAgB,CAACoB,OAAOW,OAAOC,QAAQC,SAAWjC,eAAeoB,OAAOW,OAAOC,QAAQC;QACvF,MAAMC,oBAAmB,EAAEC,MAAM,EAAEC,KAAK,EAAE;YACxC,MAAMvC,kBAAkBsC,QAAQC,OAAO5C,mBAAmB;gBAAEmB,WAAWA;YAAY;QACrF;QACA,MAAM0B,sBAAqB,EAAED,KAAK,EAAED,MAAM,EAAE;YAC1C,MAAMvC,cAAc;gBAAEwC;gBAAOE,QAAQ;gBAAUC,SAAS;gBAAYJ;gBAAQxB,WAAWA;YAAY;QACrG;QACA6B,gBAAgB,CAACP,SAAWzB,uBAAuB;gBAAEiC,QAAQrC;gBAA0B6B;YAAO;QAC9FS,UAAU,CAACT,SAAW5B,iBAAiB;gBAAEoC,QAAQrC;gBAA0B6B;YAAO;IACpF;AACF;AAEA,uDAAuD,GACvD,OAAO,SAASU;IACd,OAAOlC,+BAA+BC;AACxC;AAEA,wEAAwE,GACxE,OAAO,SAASkC;IACd,OAAOtC,yBAAyBI;AAClC;AAEA,uDAAuD,GACvD,OAAO,SAASmC;IACd,OAAOtC,2BAA2B;QAAEI,WAAW,IAAMb,cAAcc,GAAG,GAAGD,SAAS;IAAG;AACvF"}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import type { PluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline';
|
|
18
18
|
import type { OpenedOutreachMailbox } from '../mailboxes/mailbox-transport';
|
|
19
|
+
import type { OutreachClickTarget } from './click-link';
|
|
19
20
|
import type { OutreachUnsubscribeTarget } from './unsubscribe-link';
|
|
20
21
|
/**
|
|
21
22
|
* EVERYTHING THE SENDING RUNTIME REACHES OUTSIDE ITS OWN DOCUMENTS (AGL-2981).
|
|
@@ -74,4 +75,14 @@ export interface OutreachRuntimeDeps {
|
|
|
74
75
|
}): Promise<void>;
|
|
75
76
|
/** The signed one-click link for an enrollment, or `null` when none can be minted. */
|
|
76
77
|
unsubscribeUrl(target: OutreachUnsubscribeTarget): string | null;
|
|
78
|
+
/**
|
|
79
|
+
* The signed tracking link for one link in one email, or `null` when none
|
|
80
|
+
* can be minted (AGL-3239) — no console origin, no signing secret, or a
|
|
81
|
+
* destination we will not sign.
|
|
82
|
+
*
|
|
83
|
+
* A `null` is not an error: the link goes out as the step wrote it, and
|
|
84
|
+
* that click is not counted. An email whose links do not work is worse
|
|
85
|
+
* than an email we cannot measure.
|
|
86
|
+
*/
|
|
87
|
+
clickUrl(target: OutreachClickTarget): string | null;
|
|
77
88
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/runtime-deps.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport type { OpenedOutreachMailbox } from '../mailboxes/mailbox-transport'\nimport type { OutreachUnsubscribeTarget } from './unsubscribe-link'\n\n/**\n * EVERYTHING THE SENDING RUNTIME REACHES OUTSIDE ITS OWN DOCUMENTS (AGL-2981).\n *\n * The send and sync jobs and the unsubscribe route read and write Outreach's\n * collections through the Firestore they are handed, and reach everything\n * else through these: the mailbox's Gmail, the record system, the platform's\n * suppression and topic lists, the activity feed and the gates that decide\n * whether Outreach may act for a workspace at all. The platform's own are\n * `platform-runtime-deps.ts`; the emulator specs hand in a fake Gmail and\n * recorders, so no test reaches Google or sends mail.\n */\n\n/** A target an org activity line written by the runtime points at. */\nexport interface OutreachRuntimeActivityTarget {\n type: `${string}:${string}`\n id: string\n name: string\n}\n\nexport interface OutreachRuntimeDeps {\n firestore(): FirebaseFirestore.Firestore\n now(): number\n /** A source of numbers in `[0, 1)` — the scheduler's jitter. */\n random(): number\n /** Opens a mailbox's Gmail client from its sealed grant. */\n openMailbox(mailboxId: string): Promise<OpenedOutreachMailbox>\n /**\n * Why Outreach may not act for this workspace now, or `null` when it may:\n * the plugin switched on and entitled, and its release flag on for the\n * organization.\n */\n orgRefusal(orgId: string, org: Record<string, unknown>): Promise<string | null>\n /**\n * Why nothing may be sent as this member now — a lockdown of the platform,\n * the workspace or the member — or `null`.\n */\n sendRefusal(input: { orgId: string; org: Record<string, unknown>; uid: string }): Promise<string | null>\n /** The workspace's record system on the timeline seam, when a plugin keeps one. */\n timeline(): PluginRecordTimelineWriter | null\n logOrgActivity(\n orgId: string,\n actor: { uid: string | null; email?: string | null },\n action: string,\n target: OutreachRuntimeActivityTarget,\n ): Promise<void>\n /** Takes an address off one site's `sales` email topic. */\n optOutOfSalesTopic(input: { hostId: string; email: string }): Promise<void>\n /** Files a hard bounce on the platform's suppression list. */\n suppressBouncedEmail(input: { email: string; hostId: string | null }): Promise<void>\n /** The signed one-click link for an enrollment, or `null` when none can be minted. */\n unsubscribeUrl(target: OutreachUnsubscribeTarget): string | null\n}\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/runtime-deps.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport type { OpenedOutreachMailbox } from '../mailboxes/mailbox-transport'\nimport type { OutreachClickTarget } from './click-link'\nimport type { OutreachUnsubscribeTarget } from './unsubscribe-link'\n\n/**\n * EVERYTHING THE SENDING RUNTIME REACHES OUTSIDE ITS OWN DOCUMENTS (AGL-2981).\n *\n * The send and sync jobs and the unsubscribe route read and write Outreach's\n * collections through the Firestore they are handed, and reach everything\n * else through these: the mailbox's Gmail, the record system, the platform's\n * suppression and topic lists, the activity feed and the gates that decide\n * whether Outreach may act for a workspace at all. The platform's own are\n * `platform-runtime-deps.ts`; the emulator specs hand in a fake Gmail and\n * recorders, so no test reaches Google or sends mail.\n */\n\n/** A target an org activity line written by the runtime points at. */\nexport interface OutreachRuntimeActivityTarget {\n type: `${string}:${string}`\n id: string\n name: string\n}\n\nexport interface OutreachRuntimeDeps {\n firestore(): FirebaseFirestore.Firestore\n now(): number\n /** A source of numbers in `[0, 1)` — the scheduler's jitter. */\n random(): number\n /** Opens a mailbox's Gmail client from its sealed grant. */\n openMailbox(mailboxId: string): Promise<OpenedOutreachMailbox>\n /**\n * Why Outreach may not act for this workspace now, or `null` when it may:\n * the plugin switched on and entitled, and its release flag on for the\n * organization.\n */\n orgRefusal(orgId: string, org: Record<string, unknown>): Promise<string | null>\n /**\n * Why nothing may be sent as this member now — a lockdown of the platform,\n * the workspace or the member — or `null`.\n */\n sendRefusal(input: { orgId: string; org: Record<string, unknown>; uid: string }): Promise<string | null>\n /** The workspace's record system on the timeline seam, when a plugin keeps one. */\n timeline(): PluginRecordTimelineWriter | null\n logOrgActivity(\n orgId: string,\n actor: { uid: string | null; email?: string | null },\n action: string,\n target: OutreachRuntimeActivityTarget,\n ): Promise<void>\n /** Takes an address off one site's `sales` email topic. */\n optOutOfSalesTopic(input: { hostId: string; email: string }): Promise<void>\n /** Files a hard bounce on the platform's suppression list. */\n suppressBouncedEmail(input: { email: string; hostId: string | null }): Promise<void>\n /** The signed one-click link for an enrollment, or `null` when none can be minted. */\n unsubscribeUrl(target: OutreachUnsubscribeTarget): string | null\n /**\n * The signed tracking link for one link in one email, or `null` when none\n * can be minted (AGL-3239) — no console origin, no signing secret, or a\n * destination we will not sign.\n *\n * A `null` is not an error: the link goes out as the step wrote it, and\n * that click is not counted. An email whose links do not work is worse\n * than an email we cannot measure.\n */\n clickUrl(target: OutreachClickTarget): string | null\n}\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GA0BD,WA0CC"}
|
|
@@ -17,6 +17,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
17
17
|
*/ import { consentGroupForHost } from "@aglyn/aglyn/app-utils/consent-groups";
|
|
18
18
|
import { CRM_COLLECTIONS } from "@aglyn/aglyn/app-utils/crm";
|
|
19
19
|
import { normalizeCrmEmailTemplate } from "@aglyn/aglyn/app-utils/crm-email-templates";
|
|
20
|
+
import { FieldValue } from "firebase-admin/firestore";
|
|
20
21
|
import { randomUUID } from "node:crypto";
|
|
21
22
|
import { composeOutreachEmail } from "../engine/compose.js";
|
|
22
23
|
import { planOutreachStepCompletion } from "../engine/enrollment-state.js";
|
|
@@ -401,8 +402,15 @@ async function openClient(deps, firestore, run) {
|
|
|
401
402
|
const enrollmentRef = outreachOrgCollection(firestore, run.orgId, 'enrollments').doc(input.enrollment.id);
|
|
402
403
|
const boxRef = mailboxRef(firestore, run.orgId, run.mailbox.id);
|
|
403
404
|
const nowMs = deps.now();
|
|
404
|
-
|
|
405
|
-
|
|
405
|
+
/*
|
|
406
|
+
* Whether this is the FIRST email this person has had from the sequence
|
|
407
|
+
* (AGL-3239), decided from the same read the step is recorded against and
|
|
408
|
+
* so re-decided on every transaction retry. It is what `stats.people`
|
|
409
|
+
* counts: the denominator of every engagement rate, and the thing
|
|
410
|
+
* `stats.sent` is not, since one person takes several steps.
|
|
411
|
+
*/ let firstEmail = false;
|
|
412
|
+
const completed = await firestore.runTransaction(async (transaction)=>{
|
|
413
|
+
var _stored_stepRecords, _stored_stepRecords1, _plan_patch;
|
|
406
414
|
const [current, box] = await Promise.all([
|
|
407
415
|
transaction.get(enrollmentRef),
|
|
408
416
|
input.sent ? transaction.get(boxRef) : Promise.resolve(null)
|
|
@@ -413,6 +421,7 @@ async function openClient(deps, firestore, run) {
|
|
|
413
421
|
// The step RAN, whatever happened to the enrollment while it did — a
|
|
414
422
|
// member paused it, a reply stopped it — so it is recorded as run, and a
|
|
415
423
|
// resume never sends it twice.
|
|
424
|
+
firstEmail = input.sent !== null && !((_stored_stepRecords = stored.stepRecords) != null ? _stored_stepRecords : []).some((record)=>record.kind === 'email');
|
|
416
425
|
const plan = planOutreachStepCompletion({
|
|
417
426
|
enrollment: _extends({}, stored, {
|
|
418
427
|
status: 'active'
|
|
@@ -426,7 +435,7 @@ async function openClient(deps, firestore, run) {
|
|
|
426
435
|
const patch = _extends({
|
|
427
436
|
sendClaim: null,
|
|
428
437
|
stepRecords: [
|
|
429
|
-
...(
|
|
438
|
+
...(_stored_stepRecords1 = stored.stepRecords) != null ? _stored_stepRecords1 : [],
|
|
430
439
|
input.record
|
|
431
440
|
].slice(-20),
|
|
432
441
|
updatedAtMs: nowMs
|
|
@@ -465,6 +474,42 @@ async function openClient(deps, firestore, run) {
|
|
|
465
474
|
}
|
|
466
475
|
return true;
|
|
467
476
|
});
|
|
477
|
+
if (completed && input.sent) {
|
|
478
|
+
var _ref;
|
|
479
|
+
var _input_record_links;
|
|
480
|
+
await bumpOutreachSequenceStats(firestore, {
|
|
481
|
+
orgId: run.orgId,
|
|
482
|
+
sequenceId: input.sequence.id,
|
|
483
|
+
firstEmail,
|
|
484
|
+
trackedLinks: (_ref = (_input_record_links = input.record.links) == null ? void 0 : _input_record_links.length) != null ? _ref : 0
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
return completed;
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* The sequence's send counters (AGL-3239), written outside the enrollment's
|
|
491
|
+
* transaction.
|
|
492
|
+
*
|
|
493
|
+
* One sequence holds every enrollment, so a transaction over it would
|
|
494
|
+
* serialise every recipient against every other; `FieldValue.increment` is a
|
|
495
|
+
* blind write with nothing to contend on. It is bookkeeping beside the act,
|
|
496
|
+
* so a failure is logged and the send stands — the email has already left.
|
|
497
|
+
*
|
|
498
|
+
* `clickTracked` is stamped by the first send that actually carried a
|
|
499
|
+
* rewritten link, never by the SETTING: a sequence with the setting on whose
|
|
500
|
+
* emails have no links in them measured nothing, and the report must say
|
|
501
|
+
* "not measured" for it rather than publish a 0% click rate.
|
|
502
|
+
*/ async function bumpOutreachSequenceStats(firestore, input) {
|
|
503
|
+
if (!input.sequenceId) return;
|
|
504
|
+
await outreachOrgCollection(firestore, input.orgId, 'sequences').doc(input.sequenceId).update(_extends({
|
|
505
|
+
'stats.sent': FieldValue.increment(1)
|
|
506
|
+
}, input.firstEmail ? {
|
|
507
|
+
'stats.people': FieldValue.increment(1)
|
|
508
|
+
} : {}, input.trackedLinks > 0 ? {
|
|
509
|
+
'stats.clickTracked': true
|
|
510
|
+
} : {})).catch((error)=>{
|
|
511
|
+
console.warn('[outreach] a send could not be counted on its sequence', error);
|
|
512
|
+
});
|
|
468
513
|
}
|
|
469
514
|
/** One task step: the rep's task on the contact, then the step recorded. */ async function runTaskStep(deps, firestore, run, candidate) {
|
|
470
515
|
const enrollment = candidate.enrollment;
|
|
@@ -591,10 +636,23 @@ async function runEmailStep(deps, firestore, run, input) {
|
|
|
591
636
|
}
|
|
592
637
|
const step = sequence.steps[enrollment.stepIndex];
|
|
593
638
|
const sender = senderOf(mailbox);
|
|
639
|
+
/*
|
|
640
|
+
* Click tracking is the sequence's own setting (AGL-3239) and is read
|
|
641
|
+
* here, per send, rather than once per run: a member turning it off
|
|
642
|
+
* applies to the next email, not to the next hour. A link the minter
|
|
643
|
+
* cannot sign is left exactly as the step wrote it.
|
|
644
|
+
*/ const rewriteLink = sequence.settings.trackClicks ? (link)=>deps.clickUrl({
|
|
645
|
+
orgId: run.orgId,
|
|
646
|
+
enrollmentId: enrollment.id,
|
|
647
|
+
stepIndex: enrollment.stepIndex,
|
|
648
|
+
linkIndex: link.index,
|
|
649
|
+
url: link.url
|
|
650
|
+
}) : null;
|
|
594
651
|
const composed = composeOutreachEmail({
|
|
595
652
|
sequence,
|
|
596
653
|
enrollment,
|
|
597
654
|
orgSettings: run.settings,
|
|
655
|
+
rewriteLink,
|
|
598
656
|
merge: {
|
|
599
657
|
// For a lead, the contact-shaped view of it (`leadAsContact`), so a
|
|
600
658
|
// step written with `{{contact.*}}` reads the lead; `{{lead.*}}` reads
|
|
@@ -700,7 +758,7 @@ async function runEmailStep(deps, firestore, run, input) {
|
|
|
700
758
|
console.error(`[outreach] a send from a mailbox failed (${code}); its sends wait for the next run`);
|
|
701
759
|
return;
|
|
702
760
|
}
|
|
703
|
-
const record = {
|
|
761
|
+
const record = _extends({
|
|
704
762
|
stepIndex: enrollment.stepIndex,
|
|
705
763
|
stepId: (_ref = step == null ? void 0 : step.id) != null ? _ref : '',
|
|
706
764
|
kind: 'email',
|
|
@@ -709,7 +767,9 @@ async function runEmailStep(deps, firestore, run, input) {
|
|
|
709
767
|
gmailThreadId: sent.threadId,
|
|
710
768
|
messageId: sent.messageId,
|
|
711
769
|
subject: sent.subject
|
|
712
|
-
}
|
|
770
|
+
}, composed.email.trackedLinks.length ? {
|
|
771
|
+
links: composed.email.trackedLinks
|
|
772
|
+
} : {});
|
|
713
773
|
await completeStep(deps, firestore, run, {
|
|
714
774
|
enrollment,
|
|
715
775
|
sequence,
|