@aglyn/plugins-marketing 1.0.0-beta.143
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +7 -0
- package/package.json +56 -0
- package/src/index.d.ts +19 -0
- package/src/index.js +20 -0
- package/src/index.js.map +1 -0
- package/src/lib/components/announcement-bar-card.component.d.ts +20 -0
- package/src/lib/components/announcement-bar-card.component.js +292 -0
- package/src/lib/components/announcement-bar-card.component.js.map +1 -0
- package/src/lib/components/campaign-composer.d.ts +84 -0
- package/src/lib/components/campaign-composer.js +1643 -0
- package/src/lib/components/campaign-composer.js.map +1 -0
- package/src/lib/components/campaign-conversions-card.d.ts +22 -0
- package/src/lib/components/campaign-conversions-card.js +642 -0
- package/src/lib/components/campaign-conversions-card.js.map +1 -0
- package/src/lib/components/campaign-detail-card.d.ts +55 -0
- package/src/lib/components/campaign-detail-card.js +771 -0
- package/src/lib/components/campaign-detail-card.js.map +1 -0
- package/src/lib/components/campaign-edit-drawer.d.ts +60 -0
- package/src/lib/components/campaign-edit-drawer.js +252 -0
- package/src/lib/components/campaign-edit-drawer.js.map +1 -0
- package/src/lib/components/campaign-email-zones.d.ts +111 -0
- package/src/lib/components/campaign-email-zones.js +54 -0
- package/src/lib/components/campaign-email-zones.js.map +1 -0
- package/src/lib/components/campaign-glance-card.component.d.ts +18 -0
- package/src/lib/components/campaign-glance-card.component.js +151 -0
- package/src/lib/components/campaign-glance-card.component.js.map +1 -0
- package/src/lib/components/campaign-members-section.d.ts +87 -0
- package/src/lib/components/campaign-members-section.js +499 -0
- package/src/lib/components/campaign-members-section.js.map +1 -0
- package/src/lib/components/campaign-reach-sections.d.ts +137 -0
- package/src/lib/components/campaign-reach-sections.js +694 -0
- package/src/lib/components/campaign-reach-sections.js.map +1 -0
- package/src/lib/components/campaign-report-card.d.ts +91 -0
- package/src/lib/components/campaign-report-card.js +667 -0
- package/src/lib/components/campaign-report-card.js.map +1 -0
- package/src/lib/components/campaign-test-send-drawer.d.ts +45 -0
- package/src/lib/components/campaign-test-send-drawer.js +238 -0
- package/src/lib/components/campaign-test-send-drawer.js.map +1 -0
- package/src/lib/components/campaigns-card.d.ts +31 -0
- package/src/lib/components/campaigns-card.js +667 -0
- package/src/lib/components/campaigns-card.js.map +1 -0
- package/src/lib/components/conversion-attribution.component.d.ts +62 -0
- package/src/lib/components/conversion-attribution.component.js +127 -0
- package/src/lib/components/conversion-attribution.component.js.map +1 -0
- package/src/lib/components/email-compose-card.d.ts +12 -0
- package/src/lib/components/email-compose-card.js +157 -0
- package/src/lib/components/email-compose-card.js.map +1 -0
- package/src/lib/components/email-detail.d.ts +40 -0
- package/src/lib/components/email-detail.js +1180 -0
- package/src/lib/components/email-detail.js.map +1 -0
- package/src/lib/components/email-edit-drawer.d.ts +54 -0
- package/src/lib/components/email-edit-drawer.js +172 -0
- package/src/lib/components/email-edit-drawer.js.map +1 -0
- package/src/lib/components/email-messages-widget.d.ts +22 -0
- package/src/lib/components/email-messages-widget.js +49 -0
- package/src/lib/components/email-messages-widget.js.map +1 -0
- package/src/lib/components/email-recipients-card.d.ts +50 -0
- package/src/lib/components/email-recipients-card.js +338 -0
- package/src/lib/components/email-recipients-card.js.map +1 -0
- package/src/lib/components/emails-list-card.d.ts +36 -0
- package/src/lib/components/emails-list-card.js +544 -0
- package/src/lib/components/emails-list-card.js.map +1 -0
- package/src/lib/components/experiment-zones.d.ts +78 -0
- package/src/lib/components/experiment-zones.js +65 -0
- package/src/lib/components/experiment-zones.js.map +1 -0
- package/src/lib/components/host-experiments-card.component.d.ts +17 -0
- package/src/lib/components/host-experiments-card.component.js +887 -0
- package/src/lib/components/host-experiments-card.component.js.map +1 -0
- package/src/lib/components/host-marketing-summary-card.component.d.ts +15 -0
- package/src/lib/components/host-marketing-summary-card.component.js +161 -0
- package/src/lib/components/host-marketing-summary-card.component.js.map +1 -0
- package/src/lib/components/host-overlays-card.component.d.ts +19 -0
- package/src/lib/components/host-overlays-card.component.js +783 -0
- package/src/lib/components/host-overlays-card.component.js.map +1 -0
- package/src/lib/components/marketing-console-page.d.ts +28 -0
- package/src/lib/components/marketing-console-page.js +166 -0
- package/src/lib/components/marketing-console-page.js.map +1 -0
- package/src/lib/components/marketing-console-sections.d.ts +34 -0
- package/src/lib/components/marketing-console-sections.js +77 -0
- package/src/lib/components/marketing-console-sections.js.map +1 -0
- package/src/lib/components/overlay-stats-row.component.d.ts +15 -0
- package/src/lib/components/overlay-stats-row.component.js +78 -0
- package/src/lib/components/overlay-stats-row.component.js.map +1 -0
- package/src/lib/components/popup-card.component.d.ts +18 -0
- package/src/lib/components/popup-card.component.js +462 -0
- package/src/lib/components/popup-card.component.js.map +1 -0
- package/src/lib/components/record-attribution-widget.d.ts +35 -0
- package/src/lib/components/record-attribution-widget.js +33 -0
- package/src/lib/components/record-attribution-widget.js.map +1 -0
- package/src/lib/components/site-runtime.d.ts +9 -0
- package/src/lib/components/site-runtime.js +1081 -0
- package/src/lib/components/site-runtime.js.map +1 -0
- package/src/lib/components/use-campaign-send-api.d.ts +35 -0
- package/src/lib/components/use-campaign-send-api.js +99 -0
- package/src/lib/components/use-campaign-send-api.js.map +1 -0
- package/src/lib/components/use-campaign-topic-options.d.ts +20 -0
- package/src/lib/components/use-campaign-topic-options.js +44 -0
- package/src/lib/components/use-campaign-topic-options.js.map +1 -0
- package/src/lib/components/use-emails-hub-path.d.ts +39 -0
- package/src/lib/components/use-emails-hub-path.js +70 -0
- package/src/lib/components/use-emails-hub-path.js.map +1 -0
- package/src/lib/components/use-marketing-hub-path.d.ts +21 -0
- package/src/lib/components/use-marketing-hub-path.js +50 -0
- package/src/lib/components/use-marketing-hub-path.js.map +1 -0
- package/src/lib/components/use-overlay-copy-editor.d.ts +79 -0
- package/src/lib/components/use-overlay-copy-editor.js +110 -0
- package/src/lib/components/use-overlay-copy-editor.js.map +1 -0
- package/src/lib/constants/bundle-common.d.ts +8 -0
- package/src/lib/constants/bundle-common.js +9 -0
- package/src/lib/constants/bundle-common.js.map +1 -0
- package/src/lib/model/campaign-membership-figures.d.ts +109 -0
- package/src/lib/model/campaign-membership-figures.js +113 -0
- package/src/lib/model/campaign-membership-figures.js.map +1 -0
- package/src/lib/model/compile-client-automations.d.ts +65 -0
- package/src/lib/model/compile-client-automations.js +95 -0
- package/src/lib/model/compile-client-automations.js.map +1 -0
- package/src/lib/model/experiments.d.ts +130 -0
- package/src/lib/model/experiments.js +186 -0
- package/src/lib/model/experiments.js.map +1 -0
- package/src/lib/model/index.d.ts +26 -0
- package/src/lib/model/index.js +26 -0
- package/src/lib/model/index.js.map +1 -0
- package/src/lib/model/marketing-record-routes.d.ts +6 -0
- package/src/lib/model/marketing-record-routes.js +46 -0
- package/src/lib/model/marketing-record-routes.js.map +1 -0
- package/src/lib/model/overlay-copy.d.ts +62 -0
- package/src/lib/model/overlay-copy.js +78 -0
- package/src/lib/model/overlay-copy.js.map +1 -0
- package/src/lib/model/overlays.d.ts +113 -0
- package/src/lib/model/overlays.js +93 -0
- package/src/lib/model/overlays.js.map +1 -0
- package/src/lib/model/site-contract.d.ts +100 -0
- package/src/lib/model/site-contract.js +23 -0
- package/src/lib/model/site-contract.js.map +1 -0
- package/src/lib/plugin.d.ts +27 -0
- package/src/lib/plugin.js +168 -0
- package/src/lib/plugin.js.map +1 -0
- package/src/lib/preview-props.d.ts +31 -0
- package/src/lib/preview-props.js +54 -0
- package/src/lib/preview-props.js.map +1 -0
- package/src/lib/server/campaign-manage.d.ts +62 -0
- package/src/lib/server/campaign-manage.js +829 -0
- package/src/lib/server/campaign-manage.js.map +1 -0
- package/src/lib/server/campaign-process-scheduled.d.ts +26 -0
- package/src/lib/server/campaign-process-scheduled.js +198 -0
- package/src/lib/server/campaign-process-scheduled.js.map +1 -0
- package/src/lib/server/campaign-recipients.d.ts +55 -0
- package/src/lib/server/campaign-recipients.js +219 -0
- package/src/lib/server/campaign-recipients.js.map +1 -0
- package/src/lib/server/campaign-send.d.ts +487 -0
- package/src/lib/server/campaign-send.js +3157 -0
- package/src/lib/server/campaign-send.js.map +1 -0
- package/src/lib/server/email-campaign-reach.d.ts +174 -0
- package/src/lib/server/email-campaign-reach.js +254 -0
- package/src/lib/server/email-campaign-reach.js.map +1 -0
- package/src/lib/server/email-events.d.ts +27 -0
- package/src/lib/server/email-events.js +714 -0
- package/src/lib/server/email-events.js.map +1 -0
- package/src/lib/server/get-client-automations.d.ts +60 -0
- package/src/lib/server/get-client-automations.js +87 -0
- package/src/lib/server/get-client-automations.js.map +1 -0
- package/src/lib/server/get-overlays.d.ts +28 -0
- package/src/lib/server/get-overlays.js +65 -0
- package/src/lib/server/get-overlays.js.map +1 -0
- package/src/lib/server/get-screen-experiments.d.ts +42 -0
- package/src/lib/server/get-screen-experiments.js +82 -0
- package/src/lib/server/get-screen-experiments.js.map +1 -0
- package/src/lib/server/lists-materialize.d.ts +18 -0
- package/src/lib/server/lists-materialize.js +130 -0
- package/src/lib/server/lists-materialize.js.map +1 -0
- package/src/lib/server/marketing-figures.d.ts +37 -0
- package/src/lib/server/marketing-figures.js +248 -0
- package/src/lib/server/marketing-figures.js.map +1 -0
- package/src/lib/server/site-page-enricher.d.ts +26 -0
- package/src/lib/server/site-page-enricher.js +210 -0
- package/src/lib/server/site-page-enricher.js.map +1 -0
- package/src/lib/server.d.ts +20 -0
- package/src/lib/server.js +152 -0
- package/src/lib/server.js.map +1 -0
- package/src/lib/site.d.ts +22 -0
- package/src/lib/site.js +48 -0
- package/src/lib/site.js.map +1 -0
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { claimAttempt, CRM_EMAIL_ACTIVITY_TAG, CRM_EMAIL_ORG_TAG } from "@aglyn/aglyn/server";
|
|
18
|
+
import { normalizeResendDeliveryEvents } from "@aglyn/shared-util-email";
|
|
19
|
+
// By its own path, not the barrel: the check holds a `crypto` HMAC, and the
|
|
20
|
+
// barrel is reached from the browser through the campaign model (AGL-2657).
|
|
21
|
+
import { verifySvixSignature } from "@aglyn/shared-util-email/svix-signature";
|
|
22
|
+
// AGL-1771 lifted `isDocumentId` here from the local copy AGL-1768 wrote. The
|
|
23
|
+
// copy's stated reason was wrong: `@nx/enforce-module-boundaries` does NOT
|
|
24
|
+
// refuse an edge between two feature plugins by that tag — every plugin
|
|
25
|
+
// carries `aglyn:addons`, whose rule permits `aglyn:addons` as a target (it is
|
|
26
|
+
// `scope:plugin` that refuses another plugin, and `check:lib-boundaries` that
|
|
27
|
+
// holds it). It now lives beside `updateExisting` in the library where Firestore paths are
|
|
28
|
+
// built, which was always the better home and is now the reachable one.
|
|
29
|
+
import { firebaseAdmin, updateExisting } from "@aglyn/tenant-data-admin";
|
|
30
|
+
// From the LEAF, not the barrel, for the same reason `isDocumentId` is
|
|
31
|
+
// (AGL-1771): a spec that mocks `@aglyn/tenant-data-admin` — which it must,
|
|
32
|
+
// because that graph reaches the admin SDK — would otherwise replace the real
|
|
33
|
+
// suppression writer with whatever the factory happened to list. A stub there
|
|
34
|
+
// is a false green on the one behaviour AGL-2407 is about.
|
|
35
|
+
import { suppressEmail } from "@aglyn/tenant-data-admin/server/email-suppression";
|
|
36
|
+
// Same leaf-import reasoning again: the per-recipient delivery log is the only
|
|
37
|
+
// record staff have of what we sent someone, and a mocked-away writer is a
|
|
38
|
+
// green test over an empty log.
|
|
39
|
+
import { recordEmailCampaignTouch, recordEmailDeliveryEvents, recordPersonEngagement } from "@aglyn/tenant-data-admin/server/email-delivery-log";
|
|
40
|
+
// The leaf again: the contact's own stamp is what the record page, the list
|
|
41
|
+
// and the re-engagement audience read, and a wholesale mock would green a
|
|
42
|
+
// webhook that stamped nobody.
|
|
43
|
+
import { recordContactEmailEngagement } from "@aglyn/tenant-data-admin/server/contact-email-engagement";
|
|
44
|
+
import { isDocumentId } from "@aglyn/tenant-data-admin/server/document-id";
|
|
45
|
+
import { crmEmailDeliveryStateForEvent, recordCrmEmailDelivery } from "@aglyn/tenant-data-admin/server/crm-email-activity";
|
|
46
|
+
import { getOrgForHost } from "@aglyn/tenant-data-admin/server/organizations";
|
|
47
|
+
import { recordEmailReputationFailure } from "@aglyn/tenant-data-admin/server/email-sender-reputation";
|
|
48
|
+
// The link rollup's key derivation and its cap live beside the READER that
|
|
49
|
+
// renders them (`@aglyn/shared-ui-email-campaigns/model`) rather than here, so
|
|
50
|
+
// the shape the webhook writes and the shape the report reads cannot drift
|
|
51
|
+
// into two definitions of what a "link" is.
|
|
52
|
+
import { CAMPAIGN_LINK_ROLLUP_MAX, campaignLinkKey } from "@aglyn/shared-ui-email-campaigns/model";
|
|
53
|
+
import { createHash } from "crypto";
|
|
54
|
+
import { FieldValue } from "firebase-admin/firestore";
|
|
55
|
+
import { assignExperimentVariant } from "../model/experiments.js";
|
|
56
|
+
// The one list `campaign-send` reads, keyed the one way it keys it.
|
|
57
|
+
import { suppressionId } from "./campaign-send.js";
|
|
58
|
+
/**
|
|
59
|
+
* Svix signature check (Resend webhooks): the shared implementation in
|
|
60
|
+
* `@aglyn/shared-util-email`, which the CRM's capture webhook (AGL-2657)
|
|
61
|
+
* verifies with too, so the constant-time comparison lives once.
|
|
62
|
+
*/ const verifySvix = verifySvixSignature;
|
|
63
|
+
/** Tags arrive as an array of {name, value} or a plain map — accept both. */ function tagMap(raw) {
|
|
64
|
+
if (Array.isArray(raw)) {
|
|
65
|
+
const map = {};
|
|
66
|
+
for (const tag of raw){
|
|
67
|
+
var _tag_value;
|
|
68
|
+
if (tag == null ? void 0 : tag.name) map[String(tag.name)] = String((_tag_value = tag.value) != null ? _tag_value : '');
|
|
69
|
+
}
|
|
70
|
+
return map;
|
|
71
|
+
}
|
|
72
|
+
if (raw && typeof raw === 'object') {
|
|
73
|
+
return Object.fromEntries(Object.entries(raw).map(([key, value])=>[
|
|
74
|
+
key,
|
|
75
|
+
String(value != null ? value : '')
|
|
76
|
+
]));
|
|
77
|
+
}
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* A bounce or a complaint suppresses the address (AGL-1918).
|
|
82
|
+
*
|
|
83
|
+
* Until now this webhook answered `email.bounced` and `email.complained` with
|
|
84
|
+
* `200 {ignored:true}` — acknowledged, and dropped. The consequences were not
|
|
85
|
+
* cosmetic, because `campaign-send.ts` filters its audience against exactly
|
|
86
|
+
* one list and nothing but an unsubscribe click ever wrote to it: a hard-bounced
|
|
87
|
+
* address was re-sent on every subsequent campaign forever, and a spam
|
|
88
|
+
* complaint had no effect on whether that complainant was mailed again. Both
|
|
89
|
+
* are the behaviours mailbox providers measure a sending domain on, and
|
|
90
|
+
* `aglyn.com` carries the password resets and receipts on the same key and the
|
|
91
|
+
* same From address as the campaigns.
|
|
92
|
+
*
|
|
93
|
+
* WHICH failures suppress:
|
|
94
|
+
*
|
|
95
|
+
* - **Every complaint.** Someone pressed "report spam". There is no reading of
|
|
96
|
+
* that which permits mailing them again.
|
|
97
|
+
* - **Permanent bounces only.** Resend reports `data.bounce.type` as
|
|
98
|
+
* `Permanent` or `Transient`. A transient bounce is a full mailbox or a
|
|
99
|
+
* greylisting server — suppressing on one would unsubscribe a real
|
|
100
|
+
* subscriber over a temporary condition at their provider, which is a
|
|
101
|
+
* customer's list being quietly destroyed by our error handling.
|
|
102
|
+
* - An **unrecognised or absent** bounce type does NOT suppress. Guessing in
|
|
103
|
+
* the suppressing direction is the destructive guess, and the spec asserts
|
|
104
|
+
* the shape so a payload change fails as itself rather than by silently
|
|
105
|
+
* ceasing to suppress anything.
|
|
106
|
+
*
|
|
107
|
+
* The entry is keyed and shaped exactly as the unsubscribe handler's
|
|
108
|
+
* (`suppressionId(email)` → `{ email, createdAt }`), because the reader does
|
|
109
|
+
* not care how an address got there — it is one list, and a second shape would
|
|
110
|
+
* be a second list that `campaign-send` reads half of. `createdAt` is written
|
|
111
|
+
* only when the document is new, so a bounce arriving after an unsubscribe
|
|
112
|
+
* does not restamp the date the person actually unsubscribed.
|
|
113
|
+
*
|
|
114
|
+
* WHERE it lands, since AGL-2407: BOTH lists, and the per-host one is now the
|
|
115
|
+
* optional half.
|
|
116
|
+
*
|
|
117
|
+
* - The PLATFORM list (`emailSuppressions`) is always written. That is the
|
|
118
|
+
* half that did not exist: a bounce on an invite, a password reset, a
|
|
119
|
+
* receipt or a usage summary carries no `hostId` — only `campaign-send`
|
|
120
|
+
* ever stamped one — so this webhook had nowhere to file it and answered
|
|
121
|
+
* `200 {ignored:true}`, exactly as if the address were fine. A dead mailbox
|
|
122
|
+
* was re-mailed on every subsequent send, forever.
|
|
123
|
+
* - The PER-HOST list is written when the send named a site, because that is
|
|
124
|
+
* the list `campaign-send` filters its audience against and a merchant's
|
|
125
|
+
* own list is theirs to see and undo (AGL-2410).
|
|
126
|
+
*
|
|
127
|
+
* A hard bounce is address-level truth and belongs on the platform list even
|
|
128
|
+
* when a site was named — the mailbox does not exist for anyone. A COMPLAINT
|
|
129
|
+
* is a judgement about one sender's mail, and it goes on the platform list too
|
|
130
|
+
* for a narrower reason: the platform list is consulted only by bulk mail, and
|
|
131
|
+
* someone who pressed "report spam" on anything from `noreply@aglyn.com` must
|
|
132
|
+
* not receive more bulk mail from `noreply@aglyn.com`. Neither list is
|
|
133
|
+
* consulted by transactional mail; see `email-suppression.ts` for why.
|
|
134
|
+
*/ async function recordDeliveryFailure(args) {
|
|
135
|
+
var _ref;
|
|
136
|
+
const { firestore, hostRef, type, recipient, bounceType, context, res } = args;
|
|
137
|
+
if (!recipient) return res.status(200).json({
|
|
138
|
+
ignored: true
|
|
139
|
+
});
|
|
140
|
+
const complaint = type === 'email.complained';
|
|
141
|
+
const permanent = bounceType.trim().toLowerCase() === 'permanent';
|
|
142
|
+
if (!complaint && !permanent) {
|
|
143
|
+
// A transient bounce is real information we deliberately do not act on.
|
|
144
|
+
return res.status(200).json({
|
|
145
|
+
ok: true,
|
|
146
|
+
suppressed: false
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
const reason = complaint ? 'complaint' : 'bounce';
|
|
150
|
+
/*==========================================
|
|
151
|
+
* THE SAME EVENT, COUNTED AGAINST THE TENANT.
|
|
152
|
+
*
|
|
153
|
+
* The campaign counter the caller writes answers "how did this mailing do".
|
|
154
|
+
* This one answers the question the shared sending domain actually depends
|
|
155
|
+
* on: how is THIS WORKSPACE doing, across every campaign it has sent.
|
|
156
|
+
* Nothing computed a rate at any scope before it, so one merchant's bad
|
|
157
|
+
* list could push the domain every other merchant's receipts leave on
|
|
158
|
+
* toward a block, and the first anybody would hear of it is a rejection.
|
|
159
|
+
*
|
|
160
|
+
* ## Why HERE, below the permanence filter
|
|
161
|
+
*
|
|
162
|
+
* The two decisions in front of it are the same two the suppression needs,
|
|
163
|
+
* and a rate that included them would be measuring something else. A
|
|
164
|
+
* TRANSIENT bounce is a full mailbox or a greylisting server — it says
|
|
165
|
+
* nothing about list quality, it does not suppress, and counting it would
|
|
166
|
+
* trip a merchant's breaker on their subscribers' holiday auto-replies.
|
|
167
|
+
*
|
|
168
|
+
* ## Why only a send that named a site
|
|
169
|
+
*
|
|
170
|
+
* `hostId` is the only tenant identity a delivery event carries, and only
|
|
171
|
+
* `campaign-send` stamps one. A bounce on a password reset or an invite
|
|
172
|
+
* therefore reaches the suppression lists — address-level truth belongs on
|
|
173
|
+
* them — and deliberately not this counter: the breaker it feeds may only
|
|
174
|
+
* ever refuse a CAMPAIGN, so mail it could never act on must neither
|
|
175
|
+
* inflate the rate nor dilute it.
|
|
176
|
+
*
|
|
177
|
+
* Swallowed, and taken before the suppression writes rather than after, so
|
|
178
|
+
* a counter that fails cannot cost an address its place on either list.
|
|
179
|
+
*=========================================*/ if (hostRef) {
|
|
180
|
+
await getOrgForHost(hostRef.id).then((org)=>(org == null ? void 0 : org.orgId) ? recordEmailReputationFailure(org.orgId, reason) : undefined).catch(()=>undefined);
|
|
181
|
+
}
|
|
182
|
+
// The platform list FIRST, and unconditionally. Ordered ahead of the
|
|
183
|
+
// per-host write because it is the one that must happen for every failure:
|
|
184
|
+
// if the per-host write throws, the outer handler answers 200 and the
|
|
185
|
+
// address is still off the bulk senders' lists.
|
|
186
|
+
await suppressEmail({
|
|
187
|
+
email: recipient,
|
|
188
|
+
reason,
|
|
189
|
+
context: context || null,
|
|
190
|
+
hostId: (_ref = hostRef == null ? void 0 : hostRef.id) != null ? _ref : null,
|
|
191
|
+
firestore
|
|
192
|
+
});
|
|
193
|
+
if (hostRef) {
|
|
194
|
+
const ref = hostRef.collection('suppressions').doc(suppressionId(recipient));
|
|
195
|
+
await firestore.runTransaction(async (transaction)=>{
|
|
196
|
+
const existing = await transaction.get(ref);
|
|
197
|
+
transaction.set(ref, _extends({
|
|
198
|
+
email: recipient,
|
|
199
|
+
reason,
|
|
200
|
+
suppressedAt: FieldValue.serverTimestamp()
|
|
201
|
+
}, existing.exists ? {} : {
|
|
202
|
+
createdAt: FieldValue.serverTimestamp()
|
|
203
|
+
}), {
|
|
204
|
+
merge: true
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
return res.status(200).json({
|
|
209
|
+
ok: true,
|
|
210
|
+
suppressed: true,
|
|
211
|
+
scope: hostRef ? 'host' : 'platform'
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Counts one click against its destination in the campaign's link rollup.
|
|
216
|
+
*
|
|
217
|
+
* A transaction because the CAP has to be enforced against the map as it
|
|
218
|
+
* stands: a merge-set cannot ask "is this key already here, and how many keys
|
|
219
|
+
* are there" and would grow the document without limit. Firestore's 1 MiB
|
|
220
|
+
* document ceiling is the hard reason, and a rollup that silently stopped
|
|
221
|
+
* being writable at that ceiling would take the campaign's other counters
|
|
222
|
+
* down with it if they shared the document — which is why this is its own.
|
|
223
|
+
*
|
|
224
|
+
* Nothing is DROPPED at the cap. A click on a destination past it lands in
|
|
225
|
+
* `overflowClicks`, and a click that arrived with no destination at all lands
|
|
226
|
+
* in `unattributedClicks`, so the table's own total plus the two excluded
|
|
227
|
+
* figures reconcile with `stats.clicks` and the screen can say where the
|
|
228
|
+
* difference went. Dropping either would leave a link table whose sum quietly
|
|
229
|
+
* disagreed with the click count printed above it.
|
|
230
|
+
*
|
|
231
|
+
* The rollup does NOT create the campaign. Same reasoning as `updateExisting`
|
|
232
|
+
* on the counters: a click arriving days after a merchant deleted a campaign
|
|
233
|
+
* must not resurrect it, here as a document holding one map of URLs.
|
|
234
|
+
*/ async function recordCampaignLinkClick(args) {
|
|
235
|
+
const { firestore, campaignRef, link } = args;
|
|
236
|
+
const ref = campaignRef.collection('reports').doc('links');
|
|
237
|
+
await firestore.runTransaction(async (transaction)=>{
|
|
238
|
+
var _ref, _stored_links;
|
|
239
|
+
const campaign = await transaction.get(campaignRef);
|
|
240
|
+
if (!campaign.exists) return;
|
|
241
|
+
const snapshot = await transaction.get(ref);
|
|
242
|
+
const stored = (_ref = snapshot.exists ? snapshot.data() : null) != null ? _ref : {};
|
|
243
|
+
const links = (_stored_links = stored.links) != null ? _stored_links : {};
|
|
244
|
+
if (!link) {
|
|
245
|
+
transaction.set(ref, {
|
|
246
|
+
unattributedClicks: FieldValue.increment(1)
|
|
247
|
+
}, {
|
|
248
|
+
merge: true
|
|
249
|
+
});
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
// The map key is a hash, not the URL: a Firestore field name may not
|
|
253
|
+
// contain `.`, `/` or `~`, and every URL contains at least two of them.
|
|
254
|
+
// The URL itself rides in the value, so nothing has to be un-hashed.
|
|
255
|
+
const key = createHash('sha256').update(link).digest('hex').slice(0, 32);
|
|
256
|
+
if (links[key] === undefined && Object.keys(links).length >= CAMPAIGN_LINK_ROLLUP_MAX) {
|
|
257
|
+
transaction.set(ref, {
|
|
258
|
+
overflowClicks: FieldValue.increment(1)
|
|
259
|
+
}, {
|
|
260
|
+
merge: true
|
|
261
|
+
});
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
transaction.set(ref, {
|
|
265
|
+
// A nested map under a merge-set, which merges at depth — the dotted
|
|
266
|
+
// form would write a field whose NAME contains dots and leave `links`
|
|
267
|
+
// empty, the exact fault `email-delivery-log.ts` records against
|
|
268
|
+
// `timestamps`.
|
|
269
|
+
links: {
|
|
270
|
+
[key]: {
|
|
271
|
+
url: link,
|
|
272
|
+
clicks: FieldValue.increment(1)
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
276
|
+
}, {
|
|
277
|
+
merge: true
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Resend event ingestion (AGL-268), relocated from the console app route
|
|
283
|
+
* into its owning plugin (AGL-418) — the URL `/api/email/events` is
|
|
284
|
+
* preserved through the plugin API dispatcher. Opened/clicked events
|
|
285
|
+
* increment the tagged campaign's stats; clicks on experiment sends also
|
|
286
|
+
* count as the recipient's variant conversion — the variant re-derives
|
|
287
|
+
* deterministically from the address, so nothing per-send is stored.
|
|
288
|
+
* Svix signs the RAW body: `req.rawBody` carries the exact request text.
|
|
289
|
+
*/ export const emailEventsHandler = async (req, res)=>{
|
|
290
|
+
var _req_rawBody, _headers_svixid, _headers_svixtimestamp, _headers_svixsignature;
|
|
291
|
+
if (req.method !== 'POST') {
|
|
292
|
+
return res.status(405).json({
|
|
293
|
+
error: 'Method not allowed'
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
const secret = process.env.RESEND_WEBHOOK_SECRET;
|
|
297
|
+
if (!secret) {
|
|
298
|
+
return res.status(501).json({
|
|
299
|
+
error: 'Webhook is not configured'
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
const payload = Buffer.from((_req_rawBody = req.rawBody) != null ? _req_rawBody : '', 'utf8');
|
|
303
|
+
const headers = req.headers;
|
|
304
|
+
const svixId = String((_headers_svixid = headers['svix-id']) != null ? _headers_svixid : '');
|
|
305
|
+
const svixTimestamp = String((_headers_svixtimestamp = headers['svix-timestamp']) != null ? _headers_svixtimestamp : '');
|
|
306
|
+
const svixSignature = String((_headers_svixsignature = headers['svix-signature']) != null ? _headers_svixsignature : '');
|
|
307
|
+
if (!svixId || !svixTimestamp || !verifySvix(secret, svixId, svixTimestamp, payload, svixSignature)) {
|
|
308
|
+
return res.status(401).json({
|
|
309
|
+
error: 'Bad signature'
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
var _ref, _ref1, _data_to_, _ref2;
|
|
314
|
+
var _deliveryEvents_;
|
|
315
|
+
const event = JSON.parse(payload.toString('utf8'));
|
|
316
|
+
const type = String((_ref = event == null ? void 0 : event.type) != null ? _ref : '');
|
|
317
|
+
/*==========================================
|
|
318
|
+
* THE PER-RECIPIENT DELIVERY LOG.
|
|
319
|
+
*
|
|
320
|
+
* FIRST, and for every event type rather than the four below, because the
|
|
321
|
+
* log is the staff answer to "did this person get their invite" and that
|
|
322
|
+
* question is mostly asked about `sent`, `delivered` and `bounced` —
|
|
323
|
+
* none of which the campaign statistics below have any use for.
|
|
324
|
+
*
|
|
325
|
+
* `normalizeResendDeliveryEvents` is the one place in the tree that reads
|
|
326
|
+
* Resend's wire format; everything downstream stores and renders our own
|
|
327
|
+
* vocabulary, so changing sender is a new adapter and nothing else.
|
|
328
|
+
*
|
|
329
|
+
* Best-effort and awaited but never fatal: a log write that fails must
|
|
330
|
+
* not turn into a non-2xx, which the provider would answer by retrying
|
|
331
|
+
* the same event forever.
|
|
332
|
+
*=========================================*/ const deliveryEvents = normalizeResendDeliveryEvents(event, Date.now());
|
|
333
|
+
const outcomes = await recordEmailDeliveryEvents(deliveryEvents).catch(()=>[]);
|
|
334
|
+
/*
|
|
335
|
+
* DISTINCT RECIPIENTS this event is the first of its kind for.
|
|
336
|
+
*
|
|
337
|
+
* The log's transaction already held each message's prior state, so this
|
|
338
|
+
* is free — and it is the only honest way to count distinct openers
|
|
339
|
+
* without a second document per recipient. It is also idempotent for the
|
|
340
|
+
* same reason the replay guard below exists: a redelivered or replayed
|
|
341
|
+
* event finds the state already recorded and contributes zero.
|
|
342
|
+
*
|
|
343
|
+
* Zero when the log write failed, which loses the count rather than
|
|
344
|
+
* inventing one. That is the correct direction: a lost increment
|
|
345
|
+
* understates engagement, and a guessed one is a number nobody can
|
|
346
|
+
* defend.
|
|
347
|
+
*/ const firstSeen = outcomes.filter((one)=>one.firstOfType).length;
|
|
348
|
+
/*==========================================
|
|
349
|
+
* THE PER-PERSON ENGAGEMENT ROLLUP.
|
|
350
|
+
*
|
|
351
|
+
* Opens and clicks were recorded per message and per campaign and rolled
|
|
352
|
+
* onto NOBODY, so "has this person engaged lately" could only be answered
|
|
353
|
+
* by walking every message subcollection. Two shipped things needed that
|
|
354
|
+
* answer and could not have it: an audience rule that says "opened in the
|
|
355
|
+
* last 30 days", and a sunset that stops mailing an address which has
|
|
356
|
+
* gone quiet.
|
|
357
|
+
*
|
|
358
|
+
* HERE, above the type gate and above the campaign gates, on purpose.
|
|
359
|
+
* Engagement is a fact about the PERSON, and the message they engaged
|
|
360
|
+
* with does not have to be a campaign for it to be one — somebody who
|
|
361
|
+
* clicks a receipt is reading our mail. Placing it below the
|
|
362
|
+
* `hostId`/`campaignId` gate would record engagement for campaign mail
|
|
363
|
+
* only and then let a sunset refuse people on the strength of it, which
|
|
364
|
+
* is a control drawing conclusions from a fraction of the evidence.
|
|
365
|
+
*
|
|
366
|
+
* Driven by the same `firstOfType` outcomes `firstSeen` is counted from,
|
|
367
|
+
* so a replay contributes nothing here for the same reason it contributes
|
|
368
|
+
* nothing to `stats.uniqueOpens` — and the rollup needs no claim of its
|
|
369
|
+
* own. Best-effort: a person's stamp is worth less than the campaign
|
|
370
|
+
* counters below it and much less than a suppression.
|
|
371
|
+
*=========================================*/ await recordPersonEngagement(outcomes).catch(()=>0);
|
|
372
|
+
if (type !== 'email.opened' && type !== 'email.clicked' && type !== 'email.bounced' && type !== 'email.complained' && type !== 'email.delivered') {
|
|
373
|
+
return res.status(200).json({
|
|
374
|
+
ignored: true
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
const data = (_ref1 = event == null ? void 0 : event.data) != null ? _ref1 : {};
|
|
378
|
+
const tags = tagMap(data == null ? void 0 : data.tags);
|
|
379
|
+
const hostId = tags['hostId'];
|
|
380
|
+
const campaignId = tags['campaignId'];
|
|
381
|
+
const recipient = String(Array.isArray(data == null ? void 0 : data.to) ? (_data_to_ = data.to[0]) != null ? _data_to_ : '' : (_ref2 = data == null ? void 0 : data.to) != null ? _ref2 : '').trim().toLowerCase();
|
|
382
|
+
// A path component, so "non-empty" was never the whole question.
|
|
383
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
384
|
+
const hostRef = isDocumentId(hostId) ? firestore.collection('hosts').doc(hostId) : null;
|
|
385
|
+
/** The campaign this event belongs to, or null when it names none. */ const campaignRef = hostRef && isDocumentId(campaignId) ? hostRef.collection('campaigns').doc(campaignId) : null;
|
|
386
|
+
/*==========================================
|
|
387
|
+
* THE CONTACT'S OWN STAMP (AGL-2616).
|
|
388
|
+
*
|
|
389
|
+
* The person rollup above is about the ADDRESS and moves on any sender's
|
|
390
|
+
* mail; this one is about THIS SITE's relationship with the person and
|
|
391
|
+
* moves only on its own campaigns — which is why it sits below the
|
|
392
|
+
* `hostId` gate the rollup deliberately sits above. Same `firstOfType`
|
|
393
|
+
* outcomes, so a replay contributes nothing here either, and the leaf
|
|
394
|
+
* decides which types count. Best-effort: a stamp is worth less than
|
|
395
|
+
* the campaign counters below it.
|
|
396
|
+
*=========================================*/ if (hostRef && (type === 'email.opened' || type === 'email.clicked')) {
|
|
397
|
+
await recordContactEmailEngagement({
|
|
398
|
+
hostId: hostRef.id,
|
|
399
|
+
outcomes
|
|
400
|
+
}).catch(()=>0);
|
|
401
|
+
}
|
|
402
|
+
/*==========================================
|
|
403
|
+
* THE ONE-TO-ONE EMAIL'S TIMELINE ENTRY (AGL-2615).
|
|
404
|
+
*
|
|
405
|
+
* A message sent from a CRM record carries the org and the activity row
|
|
406
|
+
* it was logged as, and the five events below are the row's delivery
|
|
407
|
+
* state — the chip a rep reads beside "Email" on the timeline. Placed
|
|
408
|
+
* above the campaign gates because a one-to-one email is not a campaign
|
|
409
|
+
* and names none; placed below the log and the engagement rollup
|
|
410
|
+
* because those are facts about the message and the person that this
|
|
411
|
+
* row merely restates.
|
|
412
|
+
*
|
|
413
|
+
* The state comes from the NORMALIZED event, not the wire string: the
|
|
414
|
+
* adapter above is the one reader of the provider's vocabulary. Both ids
|
|
415
|
+
* are path components, so both are checked the way `hostId` is. Never
|
|
416
|
+
* fatal — the writer reports rather than throws — and never behind the
|
|
417
|
+
* replay claim: the write is monotonic, so a replay finds the row
|
|
418
|
+
* already there and changes nothing.
|
|
419
|
+
*=========================================*/ const crmState = crmEmailDeliveryStateForEvent((_deliveryEvents_ = deliveryEvents[0]) == null ? void 0 : _deliveryEvents_.type);
|
|
420
|
+
const crmActivityId = tags[CRM_EMAIL_ACTIVITY_TAG];
|
|
421
|
+
const crmOrgId = tags[CRM_EMAIL_ORG_TAG];
|
|
422
|
+
if (crmState && isDocumentId(crmActivityId) && isDocumentId(crmOrgId)) {
|
|
423
|
+
var _ref3;
|
|
424
|
+
var _deliveryEvents_1;
|
|
425
|
+
await recordCrmEmailDelivery(firestore, {
|
|
426
|
+
orgId: crmOrgId,
|
|
427
|
+
activityId: crmActivityId,
|
|
428
|
+
state: crmState,
|
|
429
|
+
atMs: (_ref3 = (_deliveryEvents_1 = deliveryEvents[0]) == null ? void 0 : _deliveryEvents_1.at) != null ? _ref3 : Date.now()
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
/*==========================================
|
|
433
|
+
* THE DELIVERY DENOMINATOR.
|
|
434
|
+
*
|
|
435
|
+
* `email.delivered` used to be answered `200 {ignored:true}`, which is
|
|
436
|
+
* why every campaign rate had to be taken over `sent`. Sent is what the
|
|
437
|
+
* PROVIDER accepted; delivered is what the receiving server accepted, and
|
|
438
|
+
* the gap between them is the bounces. An open rate over `sent` therefore
|
|
439
|
+
* reads lower than the same campaign measured anywhere else, and a rate
|
|
440
|
+
* that only we compute differently is a rate a merchant cannot check.
|
|
441
|
+
*
|
|
442
|
+
* NO REPLAY CLAIM ON THIS ONE, and that is deliberate rather than an
|
|
443
|
+
* omission: `firstSeen` is derived from whether the delivery log had
|
|
444
|
+
* already recorded a `delivered` for this MESSAGE, so a retry, a replay
|
|
445
|
+
* and a duplicate webhook all contribute zero without a claim document
|
|
446
|
+
* being minted per delivered event. The counters below that DO carry a
|
|
447
|
+
* claim are the ones counting events rather than messages.
|
|
448
|
+
*=========================================*/ if (type === 'email.delivered') {
|
|
449
|
+
if (!campaignRef || !firstSeen) {
|
|
450
|
+
return res.status(200).json({
|
|
451
|
+
ignored: true
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
// `updateExisting` for the AGL-1768 reason the open counter carries:
|
|
455
|
+
// a merge-set against a deleted campaign RE-CREATES it as a document
|
|
456
|
+
// holding nothing but a `stats` map.
|
|
457
|
+
await updateExisting(campaignRef, {
|
|
458
|
+
'stats.delivered': FieldValue.increment(firstSeen)
|
|
459
|
+
});
|
|
460
|
+
return res.status(200).json({
|
|
461
|
+
ok: true,
|
|
462
|
+
counted: true
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
if (type === 'email.bounced' || type === 'email.complained') {
|
|
466
|
+
var _ref4, _tags_context;
|
|
467
|
+
var _data_bounce;
|
|
468
|
+
/*
|
|
469
|
+
* The campaign counter FIRST, and its failure swallowed.
|
|
470
|
+
*
|
|
471
|
+
* Ordered ahead of the suppression because the suppression is the write
|
|
472
|
+
* that must happen — it is what stops us mailing a dead or hostile
|
|
473
|
+
* address again — and `recordDeliveryFailure` owns the response. A
|
|
474
|
+
* statistic must never be able to cost a suppression, so this is
|
|
475
|
+
* wrapped rather than awaited into the same failure path.
|
|
476
|
+
*
|
|
477
|
+
* Same `firstSeen` idempotency as `delivered` above: one bounce per
|
|
478
|
+
* message, however many times the provider tells us about it.
|
|
479
|
+
*/ if (campaignRef && firstSeen) {
|
|
480
|
+
await updateExisting(campaignRef, {
|
|
481
|
+
[type === 'email.bounced' ? 'stats.bounced' : 'stats.complained']: FieldValue.increment(firstSeen)
|
|
482
|
+
}).catch(()=>undefined);
|
|
483
|
+
}
|
|
484
|
+
// NO `hostId` gate here since AGL-2407. It used to sit above this
|
|
485
|
+
// branch, which is what made every transactional bounce a no-op: only
|
|
486
|
+
// `campaign-send` stamps a `hostId` tag, so a bounce on an invite or a
|
|
487
|
+
// password reset failed the gate and was dropped with `ignored: true`.
|
|
488
|
+
// The failure path needs no site at all — `recordDeliveryFailure` files
|
|
489
|
+
// the platform record either way and adds the per-host one when there
|
|
490
|
+
// is a host to add it to.
|
|
491
|
+
return await recordDeliveryFailure({
|
|
492
|
+
firestore,
|
|
493
|
+
hostRef,
|
|
494
|
+
type,
|
|
495
|
+
recipient,
|
|
496
|
+
bounceType: String((_ref4 = data == null ? void 0 : (_data_bounce = data.bounce) == null ? void 0 : _data_bounce.type) != null ? _ref4 : ''),
|
|
497
|
+
context: (_tags_context = tags['context']) != null ? _tags_context : '',
|
|
498
|
+
res
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
// Opens and clicks ARE per-campaign, so this pair still needs both ids —
|
|
502
|
+
// and therefore still needs the host, which the failure path above no
|
|
503
|
+
// longer does.
|
|
504
|
+
if (!hostRef || !isDocumentId(campaignId)) {
|
|
505
|
+
return res.status(200).json({
|
|
506
|
+
ignored: true
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
/*==========================================
|
|
510
|
+
* THE REPLAY GUARD, around the counters and nothing else.
|
|
511
|
+
*
|
|
512
|
+
* Delivery is AT LEAST ONCE and the two writes below are
|
|
513
|
+
* `FieldValue.increment(1)`, which is the combination that inflates a
|
|
514
|
+
* statistic. Three things deliver the same event twice: a provider retry
|
|
515
|
+
* after our function wrote and then timed out before answering, a
|
|
516
|
+
* retry after any non-2xx, and a human pressing **Replay** in the
|
|
517
|
+
* provider's dashboard — which is not a hypothetical, since replay is how
|
|
518
|
+
* events that failed while the signing secret was unset get recovered.
|
|
519
|
+
* Every one of those turns one open into two.
|
|
520
|
+
*
|
|
521
|
+
* The claim is keyed on the Svix message id, which is stable across all
|
|
522
|
+
* three: a retry and a replay of one event carry the id the first
|
|
523
|
+
* delivery carried. `kind` and `scopeId` go into the digest with it, so
|
|
524
|
+
* one site's event cannot collide with another's.
|
|
525
|
+
*
|
|
526
|
+
* ## Why it wraps the counters rather than the whole handler
|
|
527
|
+
*
|
|
528
|
+
* Everything above this point is already idempotent and worth re-running.
|
|
529
|
+
* The per-recipient delivery log keys by the provider's message id and
|
|
530
|
+
* merges, so a replay refreshes a row rather than adding one — and a row
|
|
531
|
+
* that failed to write the first time SHOULD get another chance.
|
|
532
|
+
* Suppression is a set: suppressing an address twice is suppressing it
|
|
533
|
+
* once. Only the increments cannot survive being repeated, so only they
|
|
534
|
+
* are behind the claim.
|
|
535
|
+
*
|
|
536
|
+
* ⚠️ `claimAttempt` treats an EMPTY key as "no claim" and returns a
|
|
537
|
+
* no-op, which would silently reopen this hole. It cannot happen here —
|
|
538
|
+
* the signature check above refuses a request with no `svix-id` before
|
|
539
|
+
* reaching this line — and there is a test that fails if that stops
|
|
540
|
+
* being true.
|
|
541
|
+
*=========================================*/ const counted = await claimAttempt(firestore, {
|
|
542
|
+
kind: 'resend-email-event',
|
|
543
|
+
scopeId: hostId,
|
|
544
|
+
orgId: '',
|
|
545
|
+
key: svixId,
|
|
546
|
+
busyMessage: 'This delivery event is already being counted.'
|
|
547
|
+
});
|
|
548
|
+
if ('replay' in counted) {
|
|
549
|
+
return res.status(200).json({
|
|
550
|
+
ok: true,
|
|
551
|
+
counted: false
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
// Plain refusal (AGL-1768). A merge-set against a missing path CREATES it,
|
|
555
|
+
// so an open re-created a campaign the merchant had deleted — a document
|
|
556
|
+
// holding a `stats` map and nothing else: no subject, no body, no
|
|
557
|
+
// audience, no status. Opens trail sends by days, so deleting it again did
|
|
558
|
+
// not help. `updateExisting` rejects only that case (gRPC NOT_FOUND) and
|
|
559
|
+
// rethrows everything else, so a Firestore outage stays distinguishable
|
|
560
|
+
// from an open against a deleted campaign instead of being swallowed by a
|
|
561
|
+
// `.catch(() => undefined)`. AGL-1760's test — does refusing discard money
|
|
562
|
+
// or work that already happened? — is passed: the open count for a
|
|
563
|
+
// campaign that no longer exists has no reader.
|
|
564
|
+
//
|
|
565
|
+
// DOTTED FIELD PATH, not a nested map. `update({ stats: { opens: … } })`
|
|
566
|
+
// REPLACES the whole `stats` map, so every open would clobber `clicks`;
|
|
567
|
+
// only `set({ merge: true })` merges maps at depth.
|
|
568
|
+
try {
|
|
569
|
+
/*
|
|
570
|
+
* TWO COUNTERS PER EVENT, because they answer two questions and the
|
|
571
|
+
* report has to be able to name which it is showing.
|
|
572
|
+
*
|
|
573
|
+
* `stats.opens` counts EVENTS: one reader opening four times is four.
|
|
574
|
+
* That is the number this handler has always kept, it is the honest
|
|
575
|
+
* count of what happened, and it is useless as a rate numerator — an
|
|
576
|
+
* open rate built on it exceeds 100% the moment anyone reads an email
|
|
577
|
+
* twice, and a percentage above 100 teaches a reader that the label is
|
|
578
|
+
* lying.
|
|
579
|
+
*
|
|
580
|
+
* `stats.uniqueOpens` counts MESSAGES that had never been opened
|
|
581
|
+
* before, which is distinct readers, which is what every open rate in
|
|
582
|
+
* the industry divides. `firstSeen` comes from the delivery log's own
|
|
583
|
+
* transaction, so it costs no read here.
|
|
584
|
+
*/ const totals = {
|
|
585
|
+
[type === 'email.opened' ? 'stats.opens' : 'stats.clicks']: FieldValue.increment(1)
|
|
586
|
+
};
|
|
587
|
+
if (firstSeen) {
|
|
588
|
+
totals[type === 'email.opened' ? 'stats.uniqueOpens' : 'stats.uniqueClicks'] = FieldValue.increment(firstSeen);
|
|
589
|
+
}
|
|
590
|
+
await updateExisting(hostRef.collection('campaigns').doc(campaignId), totals);
|
|
591
|
+
/*==========================================
|
|
592
|
+
* LINK-LEVEL CLICKS — the aggregate `data.click.link` never had.
|
|
593
|
+
*
|
|
594
|
+
* The field IS present on Resend's `email.clicked` payload and has been
|
|
595
|
+
* read for a while: `normalizeResendDeliveryEvents` puts it on the
|
|
596
|
+
* event and the per-recipient delivery log stores it. What did not
|
|
597
|
+
* exist was a per-campaign rollup, and it could not be produced at read
|
|
598
|
+
* time — that would mean querying every recipient's delivery row for
|
|
599
|
+
* the campaign, which is the scan a campaign report must not do.
|
|
600
|
+
*
|
|
601
|
+
* ONE DOCUMENT, not a document per URL. The report then reads the whole
|
|
602
|
+
* table with a single `getDoc`, and the map cannot grow without bound
|
|
603
|
+
* because the transaction refuses a new key past the cap and counts the
|
|
604
|
+
* click as overflow instead. `campaignLinkKey` drops the query string,
|
|
605
|
+
* so a link personalised per recipient cannot mint a row per recipient
|
|
606
|
+
* — see that function for why that is a correctness requirement and not
|
|
607
|
+
* only a size one.
|
|
608
|
+
*
|
|
609
|
+
* Inside the claim, so a replayed click cannot inflate a link row any
|
|
610
|
+
* more than it can inflate `stats.clicks`. Best-effort: a rollup write
|
|
611
|
+
* that fails must not cost the click count above it, which is the
|
|
612
|
+
* number the whole report leans on.
|
|
613
|
+
*=========================================*/ if (type === 'email.clicked') {
|
|
614
|
+
var _ref5;
|
|
615
|
+
var _data_click, _outcomes_find;
|
|
616
|
+
await recordCampaignLinkClick({
|
|
617
|
+
firestore,
|
|
618
|
+
campaignRef: hostRef.collection('campaigns').doc(campaignId),
|
|
619
|
+
link: campaignLinkKey(data == null ? void 0 : (_data_click = data.click) == null ? void 0 : _data_click.link)
|
|
620
|
+
}).catch(()=>undefined);
|
|
621
|
+
/*==========================================
|
|
622
|
+
* THE TOUCH REVENUE ATTRIBUTION IS TAKEN OVER.
|
|
623
|
+
*
|
|
624
|
+
* "Which campaign brought this buyer here" cannot be answered from
|
|
625
|
+
* anything above: the delivery log holds a row per message, and
|
|
626
|
+
* finding a person's most recent click would mean reading every one
|
|
627
|
+
* of them. So the click writes the answer down — one field on the
|
|
628
|
+
* person's own document, per site — and an order reads it with a
|
|
629
|
+
* single keyed lookup.
|
|
630
|
+
*
|
|
631
|
+
* ONLY a click. An open would be the weaker evidence and, since Mail
|
|
632
|
+
* Privacy Protection, frequently not a human at all; crediting money
|
|
633
|
+
* to one would hand a campaign the orders of people who never read
|
|
634
|
+
* it. `email-revenue-attribution.ts` records the full reasoning.
|
|
635
|
+
*
|
|
636
|
+
* The instant is the PROVIDER'S, taken from the outcome the delivery
|
|
637
|
+
* log already wrote, so a delayed webhook credits the click at the
|
|
638
|
+
* time it happened rather than at the time we heard about it — which
|
|
639
|
+
* is the difference between inside and outside the window for a click
|
|
640
|
+
* near its edge. `Date.now()` is the fallback for a click whose log
|
|
641
|
+
* write failed, and it is the later of the two, so it can only narrow
|
|
642
|
+
* the window rather than widen it.
|
|
643
|
+
*
|
|
644
|
+
* Best-effort, like the link rollup above it and for the same reason:
|
|
645
|
+
* a touch that failed to write costs one order's attribution, and it
|
|
646
|
+
* must not cost the click count the whole report leans on.
|
|
647
|
+
*=========================================*/ await recordEmailCampaignTouch({
|
|
648
|
+
email: recipient,
|
|
649
|
+
hostId,
|
|
650
|
+
campaignId,
|
|
651
|
+
atMs: (_ref5 = (_outcomes_find = outcomes.find((one)=>one.type === 'clicked')) == null ? void 0 : _outcomes_find.at) != null ? _ref5 : Date.now()
|
|
652
|
+
}).catch(()=>false);
|
|
653
|
+
}
|
|
654
|
+
// Experiment conversion (AGL-268): clicks are the signal.
|
|
655
|
+
const experimentId = tags['experimentId'];
|
|
656
|
+
if (type === 'email.clicked' && isDocumentId(experimentId) && recipient) {
|
|
657
|
+
const experimentSnapshot = await hostRef.collection('experiments').doc(experimentId).get();
|
|
658
|
+
const experiment = experimentSnapshot.data();
|
|
659
|
+
if (experimentSnapshot.exists && experiment) {
|
|
660
|
+
const variant = assignExperimentVariant(experiment, experimentId, recipient);
|
|
661
|
+
// `variant.id` is merchant-authored — `validateExperiment` checks the
|
|
662
|
+
// ids are unique and nothing about their SHAPE — and it is a path
|
|
663
|
+
// component here too.
|
|
664
|
+
if (variant && isDocumentId(variant.id)) {
|
|
665
|
+
// Still a merge-set, and deliberately so: this one CREATES. The
|
|
666
|
+
// first conversion for a variant has no stats document yet, the
|
|
667
|
+
// experiment it hangs off was just proven to exist, and the click
|
|
668
|
+
// is work that really happened — refusing would discard it. The
|
|
669
|
+
// `.catch(() => undefined)` is gone for the same reason as above: a
|
|
670
|
+
// swallowed failure here is a conversion lost silently.
|
|
671
|
+
await experimentSnapshot.ref.collection('stats').doc(variant.id).set({
|
|
672
|
+
conversions: FieldValue.increment(1),
|
|
673
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
674
|
+
}, {
|
|
675
|
+
merge: true
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
} catch (error) {
|
|
681
|
+
/*
|
|
682
|
+
* Give the key back, so the count this attempt did not make is still
|
|
683
|
+
* makeable. Without it a failure here is permanent in a way the failure
|
|
684
|
+
* itself is not: the outer handler answers 200 whatever happens (see
|
|
685
|
+
* below), so the provider never retries, and a settled claim would then
|
|
686
|
+
* refuse the manual replay that is the only remaining way to recover
|
|
687
|
+
* the event.
|
|
688
|
+
*
|
|
689
|
+
* Releasing cannot itself be fatal — the original error is what is
|
|
690
|
+
* worth reporting, and losing it to a secondary failure while cleaning
|
|
691
|
+
* up would hide the real cause.
|
|
692
|
+
*/ await counted.claim.release().catch(()=>undefined);
|
|
693
|
+
throw error;
|
|
694
|
+
}
|
|
695
|
+
/*
|
|
696
|
+
* Settled only after both writes landed, which is what makes the claim
|
|
697
|
+
* mean "this event has been counted" rather than "this event was seen".
|
|
698
|
+
*/ await counted.claim.record(200, {
|
|
699
|
+
ok: true,
|
|
700
|
+
counted: true
|
|
701
|
+
});
|
|
702
|
+
return res.status(200).json({
|
|
703
|
+
ok: true
|
|
704
|
+
});
|
|
705
|
+
} catch (error) {
|
|
706
|
+
console.error(error);
|
|
707
|
+
// Never make Resend retry-storm.
|
|
708
|
+
return res.status(200).json({
|
|
709
|
+
ok: true
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
//# sourceMappingURL=email-events.js.map
|