@aglyn/plugins-outreach 1.0.0-beta.161 → 1.0.0-beta.163
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 -13
- package/src/lib/components/sequence-editor.js +24 -3
- package/src/lib/components/sequence-editor.js.map +1 -1
- package/src/lib/declarations.console-server.js +27 -3
- package/src/lib/declarations.console-server.js.map +1 -1
- package/src/lib/engine/enrollment-state.d.ts +1 -1
- package/src/lib/engine/enrollment-state.js +4 -0
- package/src/lib/engine/enrollment-state.js.map +1 -1
- package/src/lib/engine/gates.d.ts +1 -1
- package/src/lib/engine/gates.js +6 -1
- package/src/lib/engine/gates.js.map +1 -1
- package/src/lib/enrollment/enroll-people.js +6 -3
- package/src/lib/enrollment/enroll-people.js.map +1 -1
- package/src/lib/model/outreach.types.d.ts +15 -0
- package/src/lib/model/outreach.types.js.map +1 -1
- package/src/lib/model/sequence-draft.d.ts +8 -2
- package/src/lib/model/sequence-draft.js +6 -3
- package/src/lib/model/sequence-draft.js.map +1 -1
- package/src/lib/model/stored-records.js +6 -1
- package/src/lib/model/stored-records.js.map +1 -1
- package/src/lib/routes/enroll-routes.js +39 -1
- package/src/lib/routes/enroll-routes.js.map +1 -1
- package/src/lib/routes/enrollment-routes.js +13 -0
- package/src/lib/routes/enrollment-routes.js.map +1 -1
- package/src/lib/routes/platform-deps.d.ts +3 -1
- package/src/lib/routes/platform-deps.js +4 -2
- package/src/lib/routes/platform-deps.js.map +1 -1
- package/src/lib/routes/register-routes.js +12 -1
- package/src/lib/routes/register-routes.js.map +1 -1
- package/src/lib/routes/route-deps.d.ts +11 -0
- package/src/lib/routes/route-deps.js.map +1 -1
- package/src/lib/routes/sequence-routes.js +25 -2
- package/src/lib/routes/sequence-routes.js.map +1 -1
- package/src/lib/runtime/campaign-credit.d.ts +81 -0
- package/src/lib/runtime/campaign-credit.js +133 -0
- package/src/lib/runtime/campaign-credit.js.map +1 -0
- package/src/lib/runtime/campaign-detach.d.ts +21 -0
- package/src/lib/runtime/campaign-detach.js +85 -0
- package/src/lib/runtime/campaign-detach.js.map +1 -0
- package/src/lib/runtime/click-route.d.ts +1 -1
- package/src/lib/runtime/click-route.js +13 -1
- package/src/lib/runtime/click-route.js.map +1 -1
- package/src/lib/runtime/enrollment-events.d.ts +6 -2
- package/src/lib/runtime/enrollment-events.js +20 -3
- package/src/lib/runtime/enrollment-events.js.map +1 -1
- package/src/lib/runtime/platform-runtime-deps.d.ts +8 -1
- package/src/lib/runtime/platform-runtime-deps.js +52 -0
- package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
- package/src/lib/runtime/runtime-deps.d.ts +73 -0
- package/src/lib/runtime/runtime-deps.js.map +1 -1
- package/src/lib/runtime/send-job.js +17 -2
- package/src/lib/runtime/send-job.js.map +1 -1
- package/src/lib/runtime/sync-job.js +59 -10
- package/src/lib/runtime/sync-job.js.map +1 -1
- package/src/lib/runtime/timeline.d.ts +10 -1
- package/src/lib/runtime/timeline.js +23 -1
- package/src/lib/runtime/timeline.js.map +1 -1
- package/src/lib/runtime/unsubscribe-route.d.ts +1 -1
- package/src/lib/runtime/unsubscribe-route.js.map +1 -1
|
@@ -0,0 +1,85 @@
|
|
|
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 { CAMPAIGN_MEMBERSHIP_FIELD } from "@aglyn/aglyn/app-utils/campaign-membership";
|
|
17
|
+
import { FieldValue } from "firebase-admin/firestore";
|
|
18
|
+
import { outreachOrgCollection } from "../storage/outreach-records.js";
|
|
19
|
+
/**
|
|
20
|
+
* OUTREACH'S SHARE OF A CAMPAIGN DELETION (AGL-3254).
|
|
21
|
+
*
|
|
22
|
+
* A sequence and its enrollments name the campaigns they are in from their
|
|
23
|
+
* own documents, under the organization, in collections the core does not
|
|
24
|
+
* name — so the marketing plugin's deletion cannot reach them and asks
|
|
25
|
+
* every plugin's membership detacher instead. This one clears the campaign
|
|
26
|
+
* id out of every sequence and every enrollment of the org still carrying
|
|
27
|
+
* it, with the same walk the owner makes over its forms: `array-contains`
|
|
28
|
+
* on the automatic single-field index, `arrayRemove` of the one id, in
|
|
29
|
+
* pages under Firestore's batch limit, bounded per request and reporting
|
|
30
|
+
* `remaining` so the deletion holds the container for a second run.
|
|
31
|
+
*
|
|
32
|
+
* Only the campaign membership field is Outreach's to clear: a request
|
|
33
|
+
* about any other container's field answers nothing detached, because the
|
|
34
|
+
* sequences carry no such field. The records themselves stay, and so do
|
|
35
|
+
* the campaign's credits: what a sequence produced for a campaign that is
|
|
36
|
+
* gone is in a report document nobody reads any more, which is the
|
|
37
|
+
* conversions rollup's own fate.
|
|
38
|
+
*/ /** Members detached in one write, under the 500-operation batch limit. */ const DETACH_BATCH = 400;
|
|
39
|
+
/** How many pages one request walks per collection. */ const DETACH_PASSES = 25;
|
|
40
|
+
async function detachFrom(collection, campaignId) {
|
|
41
|
+
const firestore = collection.firestore;
|
|
42
|
+
let detached = 0;
|
|
43
|
+
for(let pass = 0; pass < DETACH_PASSES; pass += 1){
|
|
44
|
+
const page = await collection.where(CAMPAIGN_MEMBERSHIP_FIELD, 'array-contains', campaignId).limit(DETACH_BATCH).get();
|
|
45
|
+
if (page.empty) return {
|
|
46
|
+
detached,
|
|
47
|
+
remaining: false
|
|
48
|
+
};
|
|
49
|
+
const batch = firestore.batch();
|
|
50
|
+
for (const member of page.docs){
|
|
51
|
+
batch.update(member.ref, {
|
|
52
|
+
[CAMPAIGN_MEMBERSHIP_FIELD]: FieldValue.arrayRemove(campaignId)
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
await batch.commit();
|
|
56
|
+
detached += page.size;
|
|
57
|
+
if (page.size < DETACH_BATCH) return {
|
|
58
|
+
detached,
|
|
59
|
+
remaining: false
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
detached,
|
|
64
|
+
remaining: true
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** The detacher, on the Firestore it is handed. */ export function createOutreachCampaignDetacher(deps) {
|
|
68
|
+
return async ({ orgId, field, id })=>{
|
|
69
|
+
// A site with no organization holds no sequences, and a container held
|
|
70
|
+
// in any other field is not one a sequence joins: nothing to detach.
|
|
71
|
+
if (!orgId || field !== CAMPAIGN_MEMBERSHIP_FIELD) return {
|
|
72
|
+
detached: 0,
|
|
73
|
+
remaining: false
|
|
74
|
+
};
|
|
75
|
+
const firestore = deps.firestore();
|
|
76
|
+
const sequences = await detachFrom(outreachOrgCollection(firestore, orgId, 'sequences'), id);
|
|
77
|
+
const enrollments = await detachFrom(outreachOrgCollection(firestore, orgId, 'enrollments'), id);
|
|
78
|
+
return {
|
|
79
|
+
detached: sequences.detached + enrollments.detached,
|
|
80
|
+
remaining: sequences.remaining || enrollments.remaining
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
//# sourceMappingURL=campaign-detach.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/campaign-detach.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 { CAMPAIGN_MEMBERSHIP_FIELD } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport type {\n PluginMembershipDetacher,\n PluginMembershipDetachReport,\n} from '@aglyn/aglyn/plugin-manager/plugin-membership-detach'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { outreachOrgCollection } from '../storage/outreach-records'\n\n/**\n * OUTREACH'S SHARE OF A CAMPAIGN DELETION (AGL-3254).\n *\n * A sequence and its enrollments name the campaigns they are in from their\n * own documents, under the organization, in collections the core does not\n * name — so the marketing plugin's deletion cannot reach them and asks\n * every plugin's membership detacher instead. This one clears the campaign\n * id out of every sequence and every enrollment of the org still carrying\n * it, with the same walk the owner makes over its forms: `array-contains`\n * on the automatic single-field index, `arrayRemove` of the one id, in\n * pages under Firestore's batch limit, bounded per request and reporting\n * `remaining` so the deletion holds the container for a second run.\n *\n * Only the campaign membership field is Outreach's to clear: a request\n * about any other container's field answers nothing detached, because the\n * sequences carry no such field. The records themselves stay, and so do\n * the campaign's credits: what a sequence produced for a campaign that is\n * gone is in a report document nobody reads any more, which is the\n * conversions rollup's own fate.\n */\n\n/** Members detached in one write, under the 500-operation batch limit. */\nconst DETACH_BATCH = 400\n\n/** How many pages one request walks per collection. */\nconst DETACH_PASSES = 25\n\nasync function detachFrom(\n collection: FirebaseFirestore.CollectionReference,\n campaignId: string,\n): Promise<PluginMembershipDetachReport> {\n const firestore = collection.firestore\n let detached = 0\n for (let pass = 0; pass < DETACH_PASSES; pass += 1) {\n const page = await collection.where(CAMPAIGN_MEMBERSHIP_FIELD, 'array-contains', campaignId).limit(DETACH_BATCH).get()\n if (page.empty) return { detached, remaining: false }\n const batch = firestore.batch()\n for (const member of page.docs) {\n batch.update(member.ref, { [CAMPAIGN_MEMBERSHIP_FIELD]: FieldValue.arrayRemove(campaignId) })\n }\n await batch.commit()\n detached += page.size\n if (page.size < DETACH_BATCH) return { detached, remaining: false }\n }\n return { detached, remaining: true }\n}\n\n/** The detacher, on the Firestore it is handed. */\nexport function createOutreachCampaignDetacher(deps: {\n firestore(): FirebaseFirestore.Firestore\n}): PluginMembershipDetacher {\n return async ({ orgId, field, id }) => {\n // A site with no organization holds no sequences, and a container held\n // in any other field is not one a sequence joins: nothing to detach.\n if (!orgId || field !== CAMPAIGN_MEMBERSHIP_FIELD) return { detached: 0, remaining: false }\n const firestore = deps.firestore()\n const sequences = await detachFrom(outreachOrgCollection(firestore, orgId, 'sequences'), id)\n const enrollments = await detachFrom(outreachOrgCollection(firestore, orgId, 'enrollments'), id)\n return {\n detached: sequences.detached + enrollments.detached,\n remaining: sequences.remaining || enrollments.remaining,\n }\n }\n}\n"],"names":["CAMPAIGN_MEMBERSHIP_FIELD","FieldValue","outreachOrgCollection","DETACH_BATCH","DETACH_PASSES","detachFrom","collection","campaignId","firestore","detached","pass","page","where","limit","get","empty","remaining","batch","member","docs","update","ref","arrayRemove","commit","size","createOutreachCampaignDetacher","deps","orgId","field","id","sequences","enrollments"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,yBAAyB,QAAQ,6CAA4C;AAKtF,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,qBAAqB,QAAQ,iCAA6B;AAEnE;;;;;;;;;;;;;;;;;;;CAmBC,GAED,wEAAwE,GACxE,MAAMC,eAAe;AAErB,qDAAqD,GACrD,MAAMC,gBAAgB;AAEtB,eAAeC,WACbC,UAAiD,EACjDC,UAAkB;IAElB,MAAMC,YAAYF,WAAWE,SAAS;IACtC,IAAIC,WAAW;IACf,IAAK,IAAIC,OAAO,GAAGA,OAAON,eAAeM,QAAQ,EAAG;QAClD,MAAMC,OAAO,MAAML,WAAWM,KAAK,CAACZ,2BAA2B,kBAAkBO,YAAYM,KAAK,CAACV,cAAcW,GAAG;QACpH,IAAIH,KAAKI,KAAK,EAAE,OAAO;YAAEN;YAAUO,WAAW;QAAM;QACpD,MAAMC,QAAQT,UAAUS,KAAK;QAC7B,KAAK,MAAMC,UAAUP,KAAKQ,IAAI,CAAE;YAC9BF,MAAMG,MAAM,CAACF,OAAOG,GAAG,EAAE;gBAAE,CAACrB,0BAA0B,EAAEC,WAAWqB,WAAW,CAACf;YAAY;QAC7F;QACA,MAAMU,MAAMM,MAAM;QAClBd,YAAYE,KAAKa,IAAI;QACrB,IAAIb,KAAKa,IAAI,GAAGrB,cAAc,OAAO;YAAEM;YAAUO,WAAW;QAAM;IACpE;IACA,OAAO;QAAEP;QAAUO,WAAW;IAAK;AACrC;AAEA,iDAAiD,GACjD,OAAO,SAASS,+BAA+BC,IAE9C;IACC,OAAO,OAAO,EAAEC,KAAK,EAAEC,KAAK,EAAEC,EAAE,EAAE;QAChC,uEAAuE;QACvE,qEAAqE;QACrE,IAAI,CAACF,SAASC,UAAU5B,2BAA2B,OAAO;YAAES,UAAU;YAAGO,WAAW;QAAM;QAC1F,MAAMR,YAAYkB,KAAKlB,SAAS;QAChC,MAAMsB,YAAY,MAAMzB,WAAWH,sBAAsBM,WAAWmB,OAAO,cAAcE;QACzF,MAAME,cAAc,MAAM1B,WAAWH,sBAAsBM,WAAWmB,OAAO,gBAAgBE;QAC7F,OAAO;YACLpB,UAAUqB,UAAUrB,QAAQ,GAAGsB,YAAYtB,QAAQ;YACnDO,WAAWc,UAAUd,SAAS,IAAIe,YAAYf,SAAS;QACzD;IACF;AACF"}
|
|
@@ -16,4 +16,4 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import type { PluginWebApiHandler } from '@aglyn/aglyn/server';
|
|
18
18
|
import type { OutreachRuntimeDeps } from './runtime-deps';
|
|
19
|
-
export declare function createOutreachClickRoute(deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline'>): PluginWebApiHandler;
|
|
19
|
+
export declare function createOutreachClickRoute(deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline' | 'campaignCredit'>): PluginWebApiHandler;
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
17
|
+
import { recordOutreachSequenceTouch } from "./campaign-credit.js";
|
|
17
18
|
import { readOutreachClickToken } from "./click-link.js";
|
|
18
19
|
import { recordOutreachClick } from "./click-events.js";
|
|
19
20
|
// The plain page both recipient-facing routes answer with: no theme, no
|
|
@@ -91,11 +92,22 @@ export function createOutreachClickRoute(deps) {
|
|
|
91
92
|
})
|
|
92
93
|
});
|
|
93
94
|
try {
|
|
94
|
-
await recordOutreachClick(deps, {
|
|
95
|
+
const outcome = await recordOutreachClick(deps, {
|
|
95
96
|
target,
|
|
96
97
|
method: request.method,
|
|
97
98
|
userAgent: request.headers.get('user-agent')
|
|
98
99
|
});
|
|
100
|
+
/*
|
|
101
|
+
* A person's click — never a scanner's — on a sequence in a campaign
|
|
102
|
+
* is their last campaign touch on the site (AGL-3254), so the booking
|
|
103
|
+
* or the form they go on to make is credited to the sequence's
|
|
104
|
+
* campaign by the door that credits every other one.
|
|
105
|
+
*/ if (outcome.human && outcome.enrollment) {
|
|
106
|
+
await recordOutreachSequenceTouch(deps, {
|
|
107
|
+
enrollment: outcome.enrollment,
|
|
108
|
+
atMs: deps.now()
|
|
109
|
+
});
|
|
110
|
+
}
|
|
99
111
|
} catch (error) {
|
|
100
112
|
// The visit already has its answer. A click we failed to count is a
|
|
101
113
|
// missing number; a redirect we failed to send is a broken email.
|
|
@@ -1 +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,
|
|
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 { recordOutreachSequenceTouch } from './campaign-credit'\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' | 'campaignCredit'>,\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 const outcome = await recordOutreachClick(deps, {\n target,\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n })\n /*\n * A person's click — never a scanner's — on a sequence in a campaign\n * is their last campaign touch on the site (AGL-3254), so the booking\n * or the form they go on to make is credited to the sequence's\n * campaign by the door that credits every other one.\n */\n if (outcome.human && outcome.enrollment) {\n await recordOutreachSequenceTouch(deps, { enrollment: outcome.enrollment, atMs: deps.now() })\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":["recordOutreachSequenceTouch","readOutreachClickToken","recordOutreachClick","outreachUnsubscribePage","REDIRECT_HEADERS","createOutreachClickRoute","deps","request","method","Response","status","headers","Allow","target","URL","url","searchParams","get","title","body","redirect","Location","outcome","userAgent","human","enrollment","atMs","now","error","console"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAGD,SAASA,2BAA2B,QAAQ,uBAAmB;AAC/D,SAASC,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,IAAoF;IAEpF,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,MAAMO,UAAU,MAAMpB,oBAAoBI,MAAM;gBAC9CO;gBACAL,QAAQD,QAAQC,MAAM;gBACtBe,WAAWhB,QAAQI,OAAO,CAACM,GAAG,CAAC;YACjC;YACA;;;;;OAKC,GACD,IAAIK,QAAQE,KAAK,IAAIF,QAAQG,UAAU,EAAE;gBACvC,MAAMzB,4BAA4BM,MAAM;oBAAEmB,YAAYH,QAAQG,UAAU;oBAAEC,MAAMpB,KAAKqB,GAAG;gBAAG;YAC7F;QACF,EAAE,OAAOC,OAAO;YACd,oEAAoE;YACpE,kEAAkE;YAClEC,QAAQD,KAAK,CAAC,4CAA4CA;QAC5D;QACA,OAAOR;IACT;AACF"}
|
|
@@ -67,12 +67,14 @@ export type OutreachOptOutSource = Extract<OutreachDoNotContactReason, 'opt_out_
|
|
|
67
67
|
* 2. the `sales` topic of each site named, which the site's own sales
|
|
68
68
|
* email reads too;
|
|
69
69
|
* 3. every open enrollment of the address in the organization: the one it
|
|
70
|
-
* came from with the reason given, the others as `do_not_contact
|
|
70
|
+
* came from with the reason given, the others as `do_not_contact`;
|
|
71
|
+
* 4. the lead and the contact that carry the address (AGL-3245), so the
|
|
72
|
+
* record a person reads says what the lists hold.
|
|
71
73
|
*
|
|
72
74
|
* Each is idempotent, so a second request — the link used twice, a reply
|
|
73
75
|
* read by two runs — changes nothing.
|
|
74
76
|
*/
|
|
75
|
-
export declare function recordOutreachOptOut(deps: Pick<OutreachRuntimeDeps, 'firestore' | 'optOutOfSalesTopic'>, input: {
|
|
77
|
+
export declare function recordOutreachOptOut(deps: Pick<OutreachRuntimeDeps, 'firestore' | 'optOutOfSalesTopic' | 'stampRecordEmailState'>, input: {
|
|
76
78
|
orgId: string;
|
|
77
79
|
email: string;
|
|
78
80
|
source: OutreachOptOutSource;
|
|
@@ -80,6 +82,8 @@ export declare function recordOutreachOptOut(deps: Pick<OutreachRuntimeDeps, 'fi
|
|
|
80
82
|
hostIds: readonly string[];
|
|
81
83
|
detail: string | null;
|
|
82
84
|
nowMs: number;
|
|
85
|
+
/** The reply called the email spam (AGL-3245): the record says so. */
|
|
86
|
+
complaint?: boolean;
|
|
83
87
|
}): Promise<{
|
|
84
88
|
stopped: number;
|
|
85
89
|
}>;
|
|
@@ -83,13 +83,15 @@ import { outreachOrgCollection, readStoredOutreachEnrollment } from "../storage/
|
|
|
83
83
|
* 2. the `sales` topic of each site named, which the site's own sales
|
|
84
84
|
* email reads too;
|
|
85
85
|
* 3. every open enrollment of the address in the organization: the one it
|
|
86
|
-
* came from with the reason given, the others as `do_not_contact
|
|
86
|
+
* came from with the reason given, the others as `do_not_contact`;
|
|
87
|
+
* 4. the lead and the contact that carry the address (AGL-3245), so the
|
|
88
|
+
* record a person reads says what the lists hold.
|
|
87
89
|
*
|
|
88
90
|
* Each is idempotent, so a second request — the link used twice, a reply
|
|
89
91
|
* read by two runs — changes nothing.
|
|
90
92
|
*/ export async function recordOutreachOptOut(deps, input) {
|
|
91
93
|
var _ref, _ref1, _ref2;
|
|
92
|
-
var _input_enrollment, _input_enrollment1, _input_enrollment2;
|
|
94
|
+
var _input_enrollment, _input_enrollment1, _input_enrollment2, _input_enrollment3;
|
|
93
95
|
const email = normalizeContactEmail(input.email);
|
|
94
96
|
if (!email) return {
|
|
95
97
|
stopped: 0
|
|
@@ -111,6 +113,21 @@ import { outreachOrgCollection, readStoredOutreachEnrollment } from "../storage/
|
|
|
111
113
|
email
|
|
112
114
|
});
|
|
113
115
|
}
|
|
116
|
+
// 4. the record the person is (AGL-3245): the link and the header say
|
|
117
|
+
// "unsubscribed"; a reply that asked is the runtime's own list entry,
|
|
118
|
+
// and one that called the email spam is a complaint.
|
|
119
|
+
await deps.stampRecordEmailState({
|
|
120
|
+
orgId: input.orgId,
|
|
121
|
+
email,
|
|
122
|
+
state: _extends({
|
|
123
|
+
status: input.complaint ? 'complained' : input.source === 'unsubscribe' ? 'unsubscribed' : 'do_not_contact',
|
|
124
|
+
atMs: input.nowMs,
|
|
125
|
+
source: 'sequence',
|
|
126
|
+
detail: input.detail
|
|
127
|
+
}, ((_input_enrollment2 = input.enrollment) == null ? void 0 : _input_enrollment2.id) ? {
|
|
128
|
+
enrollmentId: input.enrollment.id
|
|
129
|
+
} : {})
|
|
130
|
+
});
|
|
114
131
|
let stopped = 0;
|
|
115
132
|
if (input.enrollment) {
|
|
116
133
|
const own = await applyOutreachEvent(firestore, {
|
|
@@ -129,7 +146,7 @@ import { outreachOrgCollection, readStoredOutreachEnrollment } from "../storage/
|
|
|
129
146
|
stopped += await optOutOtherOutreachEnrollments(firestore, {
|
|
130
147
|
orgId: input.orgId,
|
|
131
148
|
email,
|
|
132
|
-
exceptEnrollmentId: (_ref2 = (
|
|
149
|
+
exceptEnrollmentId: (_ref2 = (_input_enrollment3 = input.enrollment) == null ? void 0 : _input_enrollment3.id) != null ? _ref2 : null,
|
|
133
150
|
nowMs: input.nowMs,
|
|
134
151
|
detail: input.source === 'unsubscribe' ? 'Unsubscribed from another sequence.' : 'Asked not to be emailed in a reply to another sequence.'
|
|
135
152
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/enrollment-events.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 { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport {\n applyOutreachEnrollmentEvent,\n type OutreachEnrollmentEvent,\n} from '../engine/enrollment-state'\nimport { OUTREACH_OPEN_ENROLLMENT_STATUSES } from '../engine/gates'\nimport type { OutreachDoNotContactReason, OutreachEnrollment } from '../model/outreach.types'\nimport { addOutreachDoNotContact } from '../storage/do-not-contact-store'\nimport { outreachOrgCollection, readStoredOutreachEnrollment } from '../storage/outreach-records'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n\n/**\n * WHAT THE RUNTIME DOES TO ENROLLMENTS, AND TO THE LISTS BESIDE THEM\n * (AGL-2981).\n *\n * Every status change is the engine's (`applyOutreachEnrollmentEvent`),\n * written in a transaction over the one enrollment so a member's pause and a\n * run's step completion never interleave. The runtime's events name no\n * member: `stoppedByUid` stays `null`.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** The enrollment after an event, and whether the event changed it. */\nexport interface OutreachEventOutcome {\n enrollment: OutreachEnrollment | null\n changed: boolean\n}\n\n/**\n * Applies one event to one enrollment, with any fields the caller writes\n * beside it — its handled message ids, a postponed due time. A transition\n * the engine refuses writes only the extra fields.\n */\nexport async function applyOutreachEvent(\n firestore: Firestore,\n input: {\n orgId: string\n enrollmentId: string\n event: OutreachEnrollmentEvent | null\n extra?: (enrollment: OutreachEnrollment) => Record<string, unknown> | null\n nowMs: number\n },\n): Promise<OutreachEventOutcome> {\n const ref = outreachOrgCollection(firestore, input.orgId, 'enrollments').doc(input.enrollmentId)\n return firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const enrollment = readStoredOutreachEnrollment(input.enrollmentId, snapshot.exists ? snapshot.data() : undefined)\n if (!enrollment) return { enrollment: null, changed: false }\n const patch = input.event ? applyOutreachEnrollmentEvent(enrollment, input.event).patch : null\n const extra = input.extra?.(enrollment) ?? null\n if (!patch && !extra) return { enrollment, changed: false }\n const written = { ...(patch ?? {}), ...(extra ?? {}), updatedAtMs: input.nowMs }\n transaction.update(ref, written)\n return { enrollment: { ...enrollment, ...written } as OutreachEnrollment, changed: patch !== null }\n })\n}\n\n/**\n * Opts every OTHER open enrollment of an address in the organization out,\n * as `do_not_contact`: the person asked not to be emailed, so no sequence\n * is next. Answers how many moved.\n */\nexport async function optOutOtherOutreachEnrollments(\n firestore: Firestore,\n input: { orgId: string; email: string; exceptEnrollmentId: string | null; nowMs: number; detail: string },\n): Promise<number> {\n const found = await outreachOrgCollection(firestore, input.orgId, 'enrollments')\n .where('email', '==', input.email)\n .get()\n let moved = 0\n for (const doc of found.docs) {\n if (doc.id === input.exceptEnrollmentId) continue\n if (!OUTREACH_OPEN_ENROLLMENT_STATUSES.includes(doc.get('status'))) continue\n const outcome = await applyOutreachEvent(firestore, {\n orgId: input.orgId,\n enrollmentId: doc.id,\n event: { type: 'opt_out', atMs: input.nowMs, reason: 'do_not_contact', detail: input.detail },\n nowMs: input.nowMs,\n })\n if (outcome.changed) moved += 1\n }\n return moved\n}\n\n/** Where an opt-out came from. */\nexport type OutreachOptOutSource = Extract<OutreachDoNotContactReason, 'opt_out_reply' | 'unsubscribe'>\n\n/**\n * A person asked not to be emailed — by the unsubscribe link, the `mailto:`\n * or a reply — and every list that has to remember it does:\n *\n * 1. the organization's do-not-contact list, which every sequence checks\n * before every send (its first entry for the address stands);\n * 2. the `sales` topic of each site named, which the site's own sales\n * email reads too;\n * 3. every open enrollment of the address in the organization: the one it\n * came from with the reason given, the others as `do_not_contact`.\n *\n * Each is idempotent, so a second request — the link used twice, a reply\n * read by two runs — changes nothing.\n */\nexport async function recordOutreachOptOut(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'optOutOfSalesTopic'>,\n input: {\n orgId: string\n email: string\n source: OutreachOptOutSource\n enrollment: Pick<OutreachEnrollment, 'id' | 'sequenceId' | 'hostId'> | null\n hostIds: readonly string[]\n detail: string | null\n nowMs: number\n },\n): Promise<{ stopped: number }> {\n const email = normalizeContactEmail(input.email)\n if (!email) return { stopped: 0 }\n const firestore = deps.firestore()\n await addOutreachDoNotContact(firestore, {\n orgId: input.orgId,\n email,\n reason: input.source,\n source: 'runtime',\n nowMs: input.nowMs,\n enrollmentId: input.enrollment?.id ?? null,\n sequenceId: input.enrollment?.sequenceId ?? null,\n detail: input.detail,\n })\n for (const hostId of new Set(input.hostIds.filter(Boolean))) {\n await deps.optOutOfSalesTopic({ hostId, email })\n }\n let stopped = 0\n if (input.enrollment) {\n const own = await applyOutreachEvent(firestore, {\n orgId: input.orgId,\n enrollmentId: input.enrollment.id,\n event: { type: 'opt_out', atMs: input.nowMs, reason: input.source, detail: input.detail },\n nowMs: input.nowMs,\n })\n if (own.changed) stopped += 1\n }\n stopped += await optOutOtherOutreachEnrollments(firestore, {\n orgId: input.orgId,\n email,\n exceptEnrollmentId: input.enrollment?.id ?? null,\n nowMs: input.nowMs,\n detail:\n input.source === 'unsubscribe'\n ? 'Unsubscribed from another sequence.'\n : 'Asked not to be emailed in a reply to another sequence.',\n })\n return { stopped }\n}\n"],"names":["normalizeContactEmail","applyOutreachEnrollmentEvent","OUTREACH_OPEN_ENROLLMENT_STATUSES","addOutreachDoNotContact","outreachOrgCollection","readStoredOutreachEnrollment","applyOutreachEvent","firestore","input","ref","orgId","doc","enrollmentId","runTransaction","transaction","snapshot","get","enrollment","exists","data","undefined","changed","patch","event","extra","written","updatedAtMs","nowMs","update","optOutOtherOutreachEnrollments","found","where","email","moved","docs","id","exceptEnrollmentId","includes","outcome","type","atMs","reason","detail","recordOutreachOptOut","deps","stopped","source","sequenceId","hostId","Set","hostIds","filter","Boolean","optOutOfSalesTopic","own"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/enrollment-events.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 { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport {\n applyOutreachEnrollmentEvent,\n type OutreachEnrollmentEvent,\n} from '../engine/enrollment-state'\nimport { OUTREACH_OPEN_ENROLLMENT_STATUSES } from '../engine/gates'\nimport type { OutreachDoNotContactReason, OutreachEnrollment } from '../model/outreach.types'\nimport { addOutreachDoNotContact } from '../storage/do-not-contact-store'\nimport { outreachOrgCollection, readStoredOutreachEnrollment } from '../storage/outreach-records'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n\n/**\n * WHAT THE RUNTIME DOES TO ENROLLMENTS, AND TO THE LISTS BESIDE THEM\n * (AGL-2981).\n *\n * Every status change is the engine's (`applyOutreachEnrollmentEvent`),\n * written in a transaction over the one enrollment so a member's pause and a\n * run's step completion never interleave. The runtime's events name no\n * member: `stoppedByUid` stays `null`.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** The enrollment after an event, and whether the event changed it. */\nexport interface OutreachEventOutcome {\n enrollment: OutreachEnrollment | null\n changed: boolean\n}\n\n/**\n * Applies one event to one enrollment, with any fields the caller writes\n * beside it — its handled message ids, a postponed due time. A transition\n * the engine refuses writes only the extra fields.\n */\nexport async function applyOutreachEvent(\n firestore: Firestore,\n input: {\n orgId: string\n enrollmentId: string\n event: OutreachEnrollmentEvent | null\n extra?: (enrollment: OutreachEnrollment) => Record<string, unknown> | null\n nowMs: number\n },\n): Promise<OutreachEventOutcome> {\n const ref = outreachOrgCollection(firestore, input.orgId, 'enrollments').doc(input.enrollmentId)\n return firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const enrollment = readStoredOutreachEnrollment(input.enrollmentId, snapshot.exists ? snapshot.data() : undefined)\n if (!enrollment) return { enrollment: null, changed: false }\n const patch = input.event ? applyOutreachEnrollmentEvent(enrollment, input.event).patch : null\n const extra = input.extra?.(enrollment) ?? null\n if (!patch && !extra) return { enrollment, changed: false }\n const written = { ...(patch ?? {}), ...(extra ?? {}), updatedAtMs: input.nowMs }\n transaction.update(ref, written)\n return { enrollment: { ...enrollment, ...written } as OutreachEnrollment, changed: patch !== null }\n })\n}\n\n/**\n * Opts every OTHER open enrollment of an address in the organization out,\n * as `do_not_contact`: the person asked not to be emailed, so no sequence\n * is next. Answers how many moved.\n */\nexport async function optOutOtherOutreachEnrollments(\n firestore: Firestore,\n input: { orgId: string; email: string; exceptEnrollmentId: string | null; nowMs: number; detail: string },\n): Promise<number> {\n const found = await outreachOrgCollection(firestore, input.orgId, 'enrollments')\n .where('email', '==', input.email)\n .get()\n let moved = 0\n for (const doc of found.docs) {\n if (doc.id === input.exceptEnrollmentId) continue\n if (!OUTREACH_OPEN_ENROLLMENT_STATUSES.includes(doc.get('status'))) continue\n const outcome = await applyOutreachEvent(firestore, {\n orgId: input.orgId,\n enrollmentId: doc.id,\n event: { type: 'opt_out', atMs: input.nowMs, reason: 'do_not_contact', detail: input.detail },\n nowMs: input.nowMs,\n })\n if (outcome.changed) moved += 1\n }\n return moved\n}\n\n/** Where an opt-out came from. */\nexport type OutreachOptOutSource = Extract<OutreachDoNotContactReason, 'opt_out_reply' | 'unsubscribe'>\n\n/**\n * A person asked not to be emailed — by the unsubscribe link, the `mailto:`\n * or a reply — and every list that has to remember it does:\n *\n * 1. the organization's do-not-contact list, which every sequence checks\n * before every send (its first entry for the address stands);\n * 2. the `sales` topic of each site named, which the site's own sales\n * email reads too;\n * 3. every open enrollment of the address in the organization: the one it\n * came from with the reason given, the others as `do_not_contact`;\n * 4. the lead and the contact that carry the address (AGL-3245), so the\n * record a person reads says what the lists hold.\n *\n * Each is idempotent, so a second request — the link used twice, a reply\n * read by two runs — changes nothing.\n */\nexport async function recordOutreachOptOut(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'optOutOfSalesTopic' | 'stampRecordEmailState'>,\n input: {\n orgId: string\n email: string\n source: OutreachOptOutSource\n enrollment: Pick<OutreachEnrollment, 'id' | 'sequenceId' | 'hostId'> | null\n hostIds: readonly string[]\n detail: string | null\n nowMs: number\n /** The reply called the email spam (AGL-3245): the record says so. */\n complaint?: boolean\n },\n): Promise<{ stopped: number }> {\n const email = normalizeContactEmail(input.email)\n if (!email) return { stopped: 0 }\n const firestore = deps.firestore()\n await addOutreachDoNotContact(firestore, {\n orgId: input.orgId,\n email,\n reason: input.source,\n source: 'runtime',\n nowMs: input.nowMs,\n enrollmentId: input.enrollment?.id ?? null,\n sequenceId: input.enrollment?.sequenceId ?? null,\n detail: input.detail,\n })\n for (const hostId of new Set(input.hostIds.filter(Boolean))) {\n await deps.optOutOfSalesTopic({ hostId, email })\n }\n // 4. the record the person is (AGL-3245): the link and the header say\n // \"unsubscribed\"; a reply that asked is the runtime's own list entry,\n // and one that called the email spam is a complaint.\n await deps.stampRecordEmailState({\n orgId: input.orgId,\n email,\n state: {\n status: input.complaint ? 'complained' : input.source === 'unsubscribe' ? 'unsubscribed' : 'do_not_contact',\n atMs: input.nowMs,\n source: 'sequence',\n detail: input.detail,\n ...(input.enrollment?.id ? { enrollmentId: input.enrollment.id } : {}),\n },\n })\n let stopped = 0\n if (input.enrollment) {\n const own = await applyOutreachEvent(firestore, {\n orgId: input.orgId,\n enrollmentId: input.enrollment.id,\n event: { type: 'opt_out', atMs: input.nowMs, reason: input.source, detail: input.detail },\n nowMs: input.nowMs,\n })\n if (own.changed) stopped += 1\n }\n stopped += await optOutOtherOutreachEnrollments(firestore, {\n orgId: input.orgId,\n email,\n exceptEnrollmentId: input.enrollment?.id ?? null,\n nowMs: input.nowMs,\n detail:\n input.source === 'unsubscribe'\n ? 'Unsubscribed from another sequence.'\n : 'Asked not to be emailed in a reply to another sequence.',\n })\n return { stopped }\n}\n"],"names":["normalizeContactEmail","applyOutreachEnrollmentEvent","OUTREACH_OPEN_ENROLLMENT_STATUSES","addOutreachDoNotContact","outreachOrgCollection","readStoredOutreachEnrollment","applyOutreachEvent","firestore","input","ref","orgId","doc","enrollmentId","runTransaction","transaction","snapshot","get","enrollment","exists","data","undefined","changed","patch","event","extra","written","updatedAtMs","nowMs","update","optOutOtherOutreachEnrollments","found","where","email","moved","docs","id","exceptEnrollmentId","includes","outcome","type","atMs","reason","detail","recordOutreachOptOut","deps","stopped","source","sequenceId","hostId","Set","hostIds","filter","Boolean","optOutOfSalesTopic","stampRecordEmailState","state","status","complaint","own"],"mappings":"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"}
|
|
@@ -16,7 +16,14 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import type { PluginWebApiHandler } from '@aglyn/aglyn/server';
|
|
18
18
|
import type { PluginPersonEraser } from '@aglyn/aglyn/plugin-manager/plugin-person-erasure';
|
|
19
|
-
import type { OutreachRuntimeDeps } from './runtime-deps';
|
|
19
|
+
import type { OutreachCampaignCredit, OutreachRuntimeDeps } from './runtime-deps';
|
|
20
|
+
/**
|
|
21
|
+
* The platform's campaign attribution, for what a sequence produced
|
|
22
|
+
* (AGL-3254): the same join every form, booking and campaign email is
|
|
23
|
+
* credited through, so a sequence's numbers and a campaign send's are two
|
|
24
|
+
* readings of one rule. Each writer already never throws.
|
|
25
|
+
*/
|
|
26
|
+
export declare function platformOutreachCampaignCredit(firestore: () => FirebaseFirestore.Firestore): OutreachCampaignCredit;
|
|
20
27
|
/**
|
|
21
28
|
* The platform's own reach for the sending runtime (AGL-2981). Loaded only
|
|
22
29
|
* when a job runs or the unsubscribe route answers — never at boot — so the
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
import { checkEntitlement } from "@aglyn/aglyn/app-utils/plan-entitlements";
|
|
18
18
|
import { isPluginEnabled } from "@aglyn/aglyn/plugin-manager/enabled-plugins";
|
|
19
19
|
import { pluginRecordTimelineWriter } from "@aglyn/aglyn/plugin-manager/plugin-record-timeline";
|
|
20
|
+
import { attributeCampaignConversion, creditCampaignSequenceOutcome } from "@aglyn/tenant-data-admin/server/campaign-conversion-attribution";
|
|
21
|
+
import { recordEmailCampaignTouch } from "@aglyn/tenant-data-admin/server/email-delivery-log";
|
|
20
22
|
import { suppressEmail } from "@aglyn/tenant-data-admin/server/email-suppression";
|
|
21
23
|
import { recordTopicOptOut } from "@aglyn/tenant-data-admin/server/email-topic-confirmation";
|
|
22
24
|
import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
|
|
@@ -24,6 +26,7 @@ import { getLockdownVerdict } from "@aglyn/tenant-data-admin/server/lockdown";
|
|
|
24
26
|
import { sendOrgMemberNotice } from "@aglyn/tenant-data-admin/server/org-member-notice";
|
|
25
27
|
import { logOrgActivity } from "@aglyn/tenant-data-admin/server/organizations";
|
|
26
28
|
import { filterEnabledPluginsByReleaseFlags } from "@aglyn/tenant-data-admin/server/release-flags";
|
|
29
|
+
import { stampRecordEmailState } from "@aglyn/aglyn/plugin-manager/plugin-record-email-state";
|
|
27
30
|
import { OUTREACH_PLUGIN_ID } from "../constants/bundle-common.js";
|
|
28
31
|
import { composeOutreachMailboxNotice } from "../engine/mailbox-notice.js";
|
|
29
32
|
import { openOutreachMailboxClient } from "../mailboxes/mailbox-transport.js";
|
|
@@ -33,6 +36,48 @@ import { createOutreachClickRoute } from "./click-route.js";
|
|
|
33
36
|
import { createOutreachPersonEraser } from "./person-erasure.js";
|
|
34
37
|
import { outreachUnsubscribeUrl } from "./unsubscribe-link.js";
|
|
35
38
|
import { createOutreachUnsubscribeRoute } from "./unsubscribe-route.js";
|
|
39
|
+
/**
|
|
40
|
+
* The platform's campaign attribution, for what a sequence produced
|
|
41
|
+
* (AGL-3254): the same join every form, booking and campaign email is
|
|
42
|
+
* credited through, so a sequence's numbers and a campaign send's are two
|
|
43
|
+
* readings of one rule. Each writer already never throws.
|
|
44
|
+
*/ export function platformOutreachCampaignCredit(firestore) {
|
|
45
|
+
return {
|
|
46
|
+
async credit ({ hostId, campaignIds, outcome, atMs }) {
|
|
47
|
+
await creditCampaignSequenceOutcome({
|
|
48
|
+
hostId,
|
|
49
|
+
campaignIds,
|
|
50
|
+
outcome,
|
|
51
|
+
atMs
|
|
52
|
+
}, firestore());
|
|
53
|
+
},
|
|
54
|
+
async attributeRecord ({ hostId, kind, refId, campaignId, sequenceId, enrollmentId, atMs }) {
|
|
55
|
+
await attributeCampaignConversion({
|
|
56
|
+
hostId,
|
|
57
|
+
kind,
|
|
58
|
+
refId,
|
|
59
|
+
touch: {
|
|
60
|
+
channel: 'sequence',
|
|
61
|
+
campaignId,
|
|
62
|
+
sequenceId,
|
|
63
|
+
enrollmentId,
|
|
64
|
+
touchedAtMs: atMs
|
|
65
|
+
},
|
|
66
|
+
convertedAtMs: atMs
|
|
67
|
+
}, firestore());
|
|
68
|
+
},
|
|
69
|
+
async recordTouch ({ hostId, email, campaignId, sequenceId, enrollmentId, atMs }) {
|
|
70
|
+
await recordEmailCampaignTouch({
|
|
71
|
+
email,
|
|
72
|
+
hostId,
|
|
73
|
+
campaignId,
|
|
74
|
+
atMs,
|
|
75
|
+
sequenceId,
|
|
76
|
+
enrollmentId
|
|
77
|
+
}, firestore());
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
36
81
|
/**
|
|
37
82
|
* The platform's own reach for the sending runtime (AGL-2981). Loaded only
|
|
38
83
|
* when a job runs or the unsubscribe route answers — never at boot — so the
|
|
@@ -43,6 +88,7 @@ import { createOutreachUnsubscribeRoute } from "./unsubscribe-route.js";
|
|
|
43
88
|
firestore,
|
|
44
89
|
now: Date.now,
|
|
45
90
|
random: Math.random,
|
|
91
|
+
campaignCredit: platformOutreachCampaignCredit(firestore),
|
|
46
92
|
openMailbox: (mailboxId)=>openOutreachMailboxClient(firestore(), {
|
|
47
93
|
mailboxId
|
|
48
94
|
}),
|
|
@@ -76,14 +122,20 @@ import { createOutreachUnsubscribeRoute } from "./unsubscribe-route.js";
|
|
|
76
122
|
});
|
|
77
123
|
},
|
|
78
124
|
async suppressBouncedEmail ({ email, hostId }) {
|
|
125
|
+
// The runtime stamps the record itself, with the richer verdict — the
|
|
126
|
+
// domain block, the enrollment — so the list's own stamp stands down.
|
|
79
127
|
await suppressEmail({
|
|
80
128
|
email,
|
|
81
129
|
reason: 'bounce',
|
|
82
130
|
context: 'outreach',
|
|
83
131
|
hostId,
|
|
132
|
+
stampRecord: false,
|
|
84
133
|
firestore: firestore()
|
|
85
134
|
});
|
|
86
135
|
},
|
|
136
|
+
async stampRecordEmailState (stamp) {
|
|
137
|
+
await stampRecordEmailState(stamp);
|
|
138
|
+
},
|
|
87
139
|
async notifyMailboxOwner (notice) {
|
|
88
140
|
// The Mailboxes page, by the organization's slug — the page Resume and
|
|
89
141
|
// Reconnect live on (AGL-3244). Without a slug or a console origin the
|
|
@@ -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 { sendOrgMemberNotice } from '@aglyn/tenant-data-admin/server/org-member-notice'\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 { composeOutreachMailboxNotice } from '../engine/mailbox-notice'\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 async notifyMailboxOwner(notice) {\n // The Mailboxes page, by the organization's slug — the page Resume and\n // Reconnect live on (AGL-3244). Without a slug or a console origin the\n // notice still goes, and says where the page is instead.\n const org = await firestore().collection('orgs').doc(notice.orgId).get()\n const slug = typeof org.get('slug') === 'string' ? String(org.get('slug')) : ''\n const origin = canonicalConsoleOrigin()\n const mailboxesUrl = slug && origin ? `${origin}/${encodeURIComponent(slug)}/outreach/mailboxes` : null\n const email = composeOutreachMailboxNotice(notice, { mailboxesUrl })\n const result = await sendOrgMemberNotice({\n orgId: notice.orgId,\n uids: [notice.connectedByUid],\n includeAdmins: true,\n subject: email.subject,\n text: email.text,\n context: 'outreach-mailbox-notice',\n })\n if (!result.sent) console.warn(`[outreach] the mailbox owner was not emailed: ${result.reason}`)\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","sendOrgMemberNotice","logOrgActivity","filterEnabledPluginsByReleaseFlags","OUTREACH_PLUGIN_ID","composeOutreachMailboxNotice","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","notifyMailboxOwner","notice","collection","doc","get","slug","String","origin","mailboxesUrl","encodeURIComponent","result","uids","connectedByUid","includeAdmins","subject","text","sent","console","warn","unsubscribeUrl","clickUrl","platformOutreachUnsubscribeRoute","platformOutreachClickRoute","platformOutreachPersonEraser"],"mappings":"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"}
|
|
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 {\n attributeCampaignConversion,\n creditCampaignSequenceOutcome,\n} from '@aglyn/tenant-data-admin/server/campaign-conversion-attribution'\nimport { recordEmailCampaignTouch } from '@aglyn/tenant-data-admin/server/email-delivery-log'\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 { sendOrgMemberNotice } from '@aglyn/tenant-data-admin/server/org-member-notice'\nimport { logOrgActivity } from '@aglyn/tenant-data-admin/server/organizations'\nimport { filterEnabledPluginsByReleaseFlags } from '@aglyn/tenant-data-admin/server/release-flags'\nimport { stampRecordEmailState } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport { composeOutreachMailboxNotice } from '../engine/mailbox-notice'\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 { OutreachCampaignCredit, OutreachRuntimeDeps } from './runtime-deps'\nimport { outreachUnsubscribeUrl } from './unsubscribe-link'\nimport { createOutreachUnsubscribeRoute } from './unsubscribe-route'\n\n/**\n * The platform's campaign attribution, for what a sequence produced\n * (AGL-3254): the same join every form, booking and campaign email is\n * credited through, so a sequence's numbers and a campaign send's are two\n * readings of one rule. Each writer already never throws.\n */\nexport function platformOutreachCampaignCredit(\n firestore: () => FirebaseFirestore.Firestore,\n): OutreachCampaignCredit {\n return {\n async credit({ hostId, campaignIds, outcome, atMs }) {\n await creditCampaignSequenceOutcome({ hostId, campaignIds, outcome, atMs }, firestore())\n },\n async attributeRecord({ hostId, kind, refId, campaignId, sequenceId, enrollmentId, atMs }) {\n await attributeCampaignConversion(\n {\n hostId,\n kind,\n refId,\n touch: { channel: 'sequence', campaignId, sequenceId, enrollmentId, touchedAtMs: atMs },\n convertedAtMs: atMs,\n },\n firestore(),\n )\n },\n async recordTouch({ hostId, email, campaignId, sequenceId, enrollmentId, atMs }) {\n await recordEmailCampaignTouch({ email, hostId, campaignId, atMs, sequenceId, enrollmentId }, firestore())\n },\n }\n}\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 campaignCredit: platformOutreachCampaignCredit(firestore),\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 // The runtime stamps the record itself, with the richer verdict — the\n // domain block, the enrollment — so the list's own stamp stands down.\n await suppressEmail({ email, reason: 'bounce', context: 'outreach', hostId, stampRecord: false, firestore: firestore() })\n },\n async stampRecordEmailState(stamp) {\n await stampRecordEmailState(stamp)\n },\n async notifyMailboxOwner(notice) {\n // The Mailboxes page, by the organization's slug — the page Resume and\n // Reconnect live on (AGL-3244). Without a slug or a console origin the\n // notice still goes, and says where the page is instead.\n const org = await firestore().collection('orgs').doc(notice.orgId).get()\n const slug = typeof org.get('slug') === 'string' ? String(org.get('slug')) : ''\n const origin = canonicalConsoleOrigin()\n const mailboxesUrl = slug && origin ? `${origin}/${encodeURIComponent(slug)}/outreach/mailboxes` : null\n const email = composeOutreachMailboxNotice(notice, { mailboxesUrl })\n const result = await sendOrgMemberNotice({\n orgId: notice.orgId,\n uids: [notice.connectedByUid],\n includeAdmins: true,\n subject: email.subject,\n text: email.text,\n context: 'outreach-mailbox-notice',\n })\n if (!result.sent) console.warn(`[outreach] the mailbox owner was not emailed: ${result.reason}`)\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","attributeCampaignConversion","creditCampaignSequenceOutcome","recordEmailCampaignTouch","suppressEmail","recordTopicOptOut","firebaseAdmin","getLockdownVerdict","sendOrgMemberNotice","logOrgActivity","filterEnabledPluginsByReleaseFlags","stampRecordEmailState","OUTREACH_PLUGIN_ID","composeOutreachMailboxNotice","openOutreachMailboxClient","canonicalConsoleOrigin","outreachClickUrl","createOutreachClickRoute","createOutreachPersonEraser","outreachUnsubscribeUrl","createOutreachUnsubscribeRoute","platformOutreachCampaignCredit","firestore","credit","hostId","campaignIds","outcome","atMs","attributeRecord","kind","refId","campaignId","sequenceId","enrollmentId","touch","channel","touchedAtMs","convertedAtMs","recordTouch","email","platformOutreachRuntimeDeps","app","now","Date","random","Math","campaignCredit","openMailbox","mailboxId","orgRefusal","orgId","org","released","length","sendRefusal","uid","locked","intent","scope","timeline","writer","actor","action","target","optOutOfSalesTopic","suppressBouncedEmail","reason","context","stampRecord","stamp","notifyMailboxOwner","notice","collection","doc","get","slug","String","origin","mailboxesUrl","encodeURIComponent","result","uids","connectedByUid","includeAdmins","subject","text","sent","console","warn","unsubscribeUrl","clickUrl","platformOutreachUnsubscribeRoute","platformOutreachClickRoute","platformOutreachPersonEraser"],"mappings":"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"}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
+
import type { EmailState } from '@aglyn/aglyn/app-utils/email-state';
|
|
17
18
|
import type { PluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline';
|
|
18
19
|
import type { OutreachMailboxNotice } from '../engine/mailbox-notice';
|
|
19
20
|
import type { OpenedOutreachMailbox } from '../mailboxes/mailbox-transport';
|
|
@@ -48,6 +49,70 @@ export interface OutreachMailboxNoticeRequest extends OutreachMailboxNotice {
|
|
|
48
49
|
/** The member whose mailbox it is. */
|
|
49
50
|
connectedByUid: string;
|
|
50
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* The verdict on an address, for the record the person is (AGL-3245): what
|
|
54
|
+
* the runtime wrote on a list, said on the lead and the contact that carry
|
|
55
|
+
* the address. The platform finds the records; the runtime names none.
|
|
56
|
+
*/
|
|
57
|
+
export interface OutreachRecordEmailStamp {
|
|
58
|
+
orgId: string;
|
|
59
|
+
email: string;
|
|
60
|
+
state: EmailState;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* What a sequence produced, credited to the campaigns it is in (AGL-3254).
|
|
64
|
+
*
|
|
65
|
+
* Three doors into the platform's campaign attribution, all on the
|
|
66
|
+
* platform's own join and none on the marketing plugin, which Outreach may
|
|
67
|
+
* not import. Each is called once per enrollment per outcome, from the
|
|
68
|
+
* state change that happens once; none throws, because the send, the reply
|
|
69
|
+
* and the click have already happened and a lost credit understates a
|
|
70
|
+
* campaign while a thrown one loses a step.
|
|
71
|
+
*/
|
|
72
|
+
export interface OutreachCampaignCredit {
|
|
73
|
+
/**
|
|
74
|
+
* Adds one to an outcome under every campaign named, in the campaign's
|
|
75
|
+
* sequences report. `enrolled`, `sent`, `replied` and `converted` are the
|
|
76
|
+
* runtime's to credit; `meetings` is credited by the booking door from
|
|
77
|
+
* the touch {@link OutreachCampaignCredit.recordTouch} stamps.
|
|
78
|
+
*/
|
|
79
|
+
credit(input: {
|
|
80
|
+
hostId: string;
|
|
81
|
+
campaignIds: readonly string[];
|
|
82
|
+
outcome: 'enrolled' | 'sent' | 'replied' | 'converted';
|
|
83
|
+
atMs: number;
|
|
84
|
+
}): Promise<void>;
|
|
85
|
+
/**
|
|
86
|
+
* Writes the conversion record the person's own page reads as "where
|
|
87
|
+
* this came from": the sequence's campaign as the touch, on the lead or
|
|
88
|
+
* the contact the enrollment names. Once per record, and never over a
|
|
89
|
+
* record that already carries a web or email touch — the join's own
|
|
90
|
+
* `create()` decides that.
|
|
91
|
+
*/
|
|
92
|
+
attributeRecord(input: {
|
|
93
|
+
hostId: string;
|
|
94
|
+
kind: 'lead' | 'contact';
|
|
95
|
+
/** The lead's person key, or the contact's id. */
|
|
96
|
+
refId: string;
|
|
97
|
+
campaignId: string;
|
|
98
|
+
sequenceId: string;
|
|
99
|
+
enrollmentId: string;
|
|
100
|
+
atMs: number;
|
|
101
|
+
}): Promise<void>;
|
|
102
|
+
/**
|
|
103
|
+
* Stamps a click on a sequence email as the person's last campaign touch
|
|
104
|
+
* on the site, so a booking or a form they go on to submit is credited to
|
|
105
|
+
* the sequence's campaign by the door that credits every other one.
|
|
106
|
+
*/
|
|
107
|
+
recordTouch(input: {
|
|
108
|
+
hostId: string;
|
|
109
|
+
email: string;
|
|
110
|
+
campaignId: string;
|
|
111
|
+
sequenceId: string;
|
|
112
|
+
enrollmentId: string;
|
|
113
|
+
atMs: number;
|
|
114
|
+
}): Promise<void>;
|
|
115
|
+
}
|
|
51
116
|
export interface OutreachRuntimeDeps {
|
|
52
117
|
firestore(): FirebaseFirestore.Firestore;
|
|
53
118
|
now(): number;
|
|
@@ -72,6 +137,8 @@ export interface OutreachRuntimeDeps {
|
|
|
72
137
|
}): Promise<string | null>;
|
|
73
138
|
/** The workspace's record system on the timeline seam, when a plugin keeps one. */
|
|
74
139
|
timeline(): PluginRecordTimelineWriter | null;
|
|
140
|
+
/** The platform's campaign attribution, for what a sequence produced (AGL-3254). */
|
|
141
|
+
campaignCredit: OutreachCampaignCredit;
|
|
75
142
|
logOrgActivity(orgId: string, actor: {
|
|
76
143
|
uid: string | null;
|
|
77
144
|
email?: string | null;
|
|
@@ -86,6 +153,12 @@ export interface OutreachRuntimeDeps {
|
|
|
86
153
|
email: string;
|
|
87
154
|
hostId: string | null;
|
|
88
155
|
}): Promise<void>;
|
|
156
|
+
/**
|
|
157
|
+
* Stamps the verdict on the lead and the contact that carry the address
|
|
158
|
+
* (AGL-3245), beside every write the runtime makes to a list. Never
|
|
159
|
+
* throws: the list is the control, the stamp is what a person reads.
|
|
160
|
+
*/
|
|
161
|
+
stampRecordEmailState(stamp: OutreachRecordEmailStamp): Promise<void>;
|
|
89
162
|
/**
|
|
90
163
|
* Emails the mailbox's owner, and the organization's admins, that the
|
|
91
164
|
* mailbox paused itself or needs reconnecting (AGL-3244), through the
|
|
@@ -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 { OutreachMailboxNotice } from '../engine/mailbox-notice'\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\n/**\n * What the runtime tells a mailbox's owner (AGL-3244): the notice the\n * engine composes the email from, and who it is for — the member who\n * connected the mailbox, and the organization's owners and admins beside\n * them, since a paused mailbox is the organization's problem too.\n */\nexport interface OutreachMailboxNoticeRequest extends OutreachMailboxNotice {\n orgId: string\n mailboxId: string\n /** The member whose mailbox it is. */\n connectedByUid: 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 /**\n * Emails the mailbox's owner, and the organization's admins, that the\n * mailbox paused itself or needs reconnecting (AGL-3244), through the\n * platform's transactional sender. Called once per pause and once per\n * reconnect, by the code path that wrote it; a failure to send is the\n * platform's to log and never the runtime's to retry.\n */\n notifyMailboxOwner(notice: OutreachMailboxNoticeRequest): 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,
|
|
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 { EmailState } from '@aglyn/aglyn/app-utils/email-state'\nimport type { PluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport type { OutreachMailboxNotice } from '../engine/mailbox-notice'\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\n/**\n * What the runtime tells a mailbox's owner (AGL-3244): the notice the\n * engine composes the email from, and who it is for — the member who\n * connected the mailbox, and the organization's owners and admins beside\n * them, since a paused mailbox is the organization's problem too.\n */\nexport interface OutreachMailboxNoticeRequest extends OutreachMailboxNotice {\n orgId: string\n mailboxId: string\n /** The member whose mailbox it is. */\n connectedByUid: string\n}\n\n/**\n * The verdict on an address, for the record the person is (AGL-3245): what\n * the runtime wrote on a list, said on the lead and the contact that carry\n * the address. The platform finds the records; the runtime names none.\n */\nexport interface OutreachRecordEmailStamp {\n orgId: string\n email: string\n state: EmailState\n}\n\n/**\n * What a sequence produced, credited to the campaigns it is in (AGL-3254).\n *\n * Three doors into the platform's campaign attribution, all on the\n * platform's own join and none on the marketing plugin, which Outreach may\n * not import. Each is called once per enrollment per outcome, from the\n * state change that happens once; none throws, because the send, the reply\n * and the click have already happened and a lost credit understates a\n * campaign while a thrown one loses a step.\n */\nexport interface OutreachCampaignCredit {\n /**\n * Adds one to an outcome under every campaign named, in the campaign's\n * sequences report. `enrolled`, `sent`, `replied` and `converted` are the\n * runtime's to credit; `meetings` is credited by the booking door from\n * the touch {@link OutreachCampaignCredit.recordTouch} stamps.\n */\n credit(input: {\n hostId: string\n campaignIds: readonly string[]\n outcome: 'enrolled' | 'sent' | 'replied' | 'converted'\n atMs: number\n }): Promise<void>\n /**\n * Writes the conversion record the person's own page reads as \"where\n * this came from\": the sequence's campaign as the touch, on the lead or\n * the contact the enrollment names. Once per record, and never over a\n * record that already carries a web or email touch — the join's own\n * `create()` decides that.\n */\n attributeRecord(input: {\n hostId: string\n kind: 'lead' | 'contact'\n /** The lead's person key, or the contact's id. */\n refId: string\n campaignId: string\n sequenceId: string\n enrollmentId: string\n atMs: number\n }): Promise<void>\n /**\n * Stamps a click on a sequence email as the person's last campaign touch\n * on the site, so a booking or a form they go on to submit is credited to\n * the sequence's campaign by the door that credits every other one.\n */\n recordTouch(input: {\n hostId: string\n email: string\n campaignId: string\n sequenceId: string\n enrollmentId: string\n atMs: number\n }): Promise<void>\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 /** The platform's campaign attribution, for what a sequence produced (AGL-3254). */\n campaignCredit: OutreachCampaignCredit\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 /**\n * Stamps the verdict on the lead and the contact that carry the address\n * (AGL-3245), beside every write the runtime makes to a list. Never\n * throws: the list is the control, the stamp is what a person reads.\n */\n stampRecordEmailState(stamp: OutreachRecordEmailStamp): Promise<void>\n /**\n * Emails the mailbox's owner, and the organization's admins, that the\n * mailbox paused itself or needs reconnecting (AGL-3244), through the\n * platform's transactional sender. Called once per pause and once per\n * reconnect, by the code path that wrote it; a failure to send is the\n * platform's to log and never the runtime's to retry.\n */\n notifyMailboxOwner(notice: OutreachMailboxNoticeRequest): 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,GA2GD,WA0DC"}
|