@growth-labs/mailer 0.7.0 → 0.8.1
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/dist/_internal/tracking-update.d.ts +21 -0
- package/dist/_internal/tracking-update.d.ts.map +1 -0
- package/dist/_internal/tracking-update.js +44 -0
- package/dist/_internal/tracking-update.js.map +1 -0
- package/dist/queue/consumer.d.ts.map +1 -1
- package/dist/queue/consumer.js +8 -2
- package/dist/queue/consumer.js.map +1 -1
- package/dist/routes/track-click.d.ts.map +1 -1
- package/dist/routes/track-click.js +20 -12
- package/dist/routes/track-click.js.map +1 -1
- package/dist/routes/track-open.d.ts.map +1 -1
- package/dist/routes/track-open.js +20 -12
- package/dist/routes/track-open.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/broadcast.d.ts.map +1 -1
- package/dist/utils/broadcast.js +5 -3
- package/dist/utils/broadcast.js.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/providers.d.ts.map +1 -1
- package/dist/utils/providers.js +92 -2
- package/dist/utils/providers.js.map +1 -1
- package/dist/utils/send.d.ts.map +1 -1
- package/dist/utils/send.js +3 -0
- package/dist/utils/send.js.map +1 -1
- package/dist/utils/utm.d.ts +26 -0
- package/dist/utils/utm.d.ts.map +1 -0
- package/dist/utils/utm.js +63 -0
- package/dist/utils/utm.js.map +1 -0
- package/package.json +1 -1
- package/src/_internal/tracking-update.ts +67 -0
- package/src/queue/consumer.ts +12 -2
- package/src/routes/track-click.ts +20 -12
- package/src/routes/track-open.ts +20 -12
- package/src/types.ts +2 -0
- package/src/utils/broadcast.ts +9 -3
- package/src/utils/index.ts +1 -0
- package/src/utils/providers.ts +86 -2
- package/src/utils/send.ts +3 -0
- package/src/utils/utm.ts +73 -0
package/dist/utils/send.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"send.d.ts","sourceRoot":"","sources":["../../src/utils/send.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAA;AAExC,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAA;AAE1D,OAAO,KAAK,EACX,UAAU,EAGV,gBAAgB,EAEhB,YAAY,EACZ,MAAM,aAAa,CAAA;
|
|
1
|
+
{"version":3,"file":"send.d.ts","sourceRoot":"","sources":["../../src/utils/send.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAA;AAExC,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAA;AAE1D,OAAO,KAAK,EACX,UAAU,EAGV,gBAAgB,EAEhB,YAAY,EACZ,MAAM,aAAa,CAAA;AASpB,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,OAAO,CAAC,CAAA;AAI3C;;;;;GAKG;AACH,MAAM,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;AAkD/C,wBAAsB,iBAAiB,CACtC,GAAG,EAAE,SAAS,EACd,OAAO,EAAE,qBAAqB,EAC9B,MAAM,EAAE;IACP,EAAE,EAAE,MAAM,CAAA;IACV,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,YAAY,CAAA;IACvB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAC9B,GACC,OAAO,CAAC;IAAE,UAAU,EAAE,MAAM,CAAA;CAAE,CAAC,CA8DjC;AAuFD,wBAAsB,YAAY,CACjC,GAAG,EAAE,SAAS,EACd,OAAO,EAAE,qBAAqB,EAC9B,MAAM,EAAE;IACP,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,UAAU,CAAA;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC9B,MAAM,CAAC,EAAE,gBAAgB,CAAA;IACzB,UAAU,CAAC,EAAE,MAAM,CAAA;CACnB,GACC,OAAO,CAAC;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,cAAc,EAAE,MAAM,CAAA;CAAE,CAAC,CAsCzD;AAID,wBAAsB,UAAU,CAC/B,GAAG,EAAE,SAAS,EACd,OAAO,EAAE,qBAAqB,EAC9B,MAAM,EAAE;IACP,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,UAAU,EAAE,CAAA;IACnB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,MAAM,CAAC,EAAE,gBAAgB,CAAA;IACzB,UAAU,CAAC,EAAE,MAAM,CAAA;CACnB,GACC,OAAO,CAAC;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,cAAc,EAAE,MAAM,CAAA;CAAE,CAAC,CAiCzD;AAID,wBAAsB,gBAAgB,CACrC,EAAE,EAAE,SAAS,EACb,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,MAAM,EACd,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,GACpC,OAAO,CAAC,IAAI,CAAC,CAKf"}
|
package/dist/utils/send.js
CHANGED
|
@@ -8,6 +8,7 @@ import { renderDigestItems, renderEmail } from './templates.js';
|
|
|
8
8
|
import { generateToken } from './tokens.js';
|
|
9
9
|
import { injectTrackingPixel, rewriteLinksForTracking } from './tracking.js';
|
|
10
10
|
import { buildSubscriberManageUrls } from './urls.js';
|
|
11
|
+
import { emailLinkUtm, tagLinksWithUtm } from './utm.js';
|
|
11
12
|
async function requireSigningSecret(env, options) {
|
|
12
13
|
const result = await resolveSigningSecret(env, options, options.siteUrl);
|
|
13
14
|
if (!result.ok)
|
|
@@ -183,6 +184,7 @@ export async function sendCampaign(env, options, params) {
|
|
|
183
184
|
// Inject tracking
|
|
184
185
|
const trackOpenUrl = `${options.siteUrl}${options.trackOpenPath}`;
|
|
185
186
|
const trackClickUrl = `${options.siteUrl}${options.trackClickPath}`;
|
|
187
|
+
html = tagLinksWithUtm(html, emailLinkUtm(options, campaignId));
|
|
186
188
|
html = injectTrackingPixel(html, trackOpenUrl);
|
|
187
189
|
html = rewriteLinksForTracking(html, trackClickUrl);
|
|
188
190
|
const recipientCount = await fanOutToSubscribers(queue, db, sendsDb, options, signingSecret, {
|
|
@@ -213,6 +215,7 @@ export async function sendDigest(env, options, params) {
|
|
|
213
215
|
// Inject tracking
|
|
214
216
|
const trackOpenUrl = `${options.siteUrl}${options.trackOpenPath}`;
|
|
215
217
|
const trackClickUrl = `${options.siteUrl}${options.trackClickPath}`;
|
|
218
|
+
html = tagLinksWithUtm(html, emailLinkUtm(options, campaignId));
|
|
216
219
|
html = injectTrackingPixel(html, trackOpenUrl);
|
|
217
220
|
html = rewriteLinksForTracking(html, trackClickUrl);
|
|
218
221
|
const recipientCount = await fanOutToSubscribers(queue, db, sendsDb, options, signingSecret, {
|
package/dist/utils/send.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"send.js","sourceRoot":"","sources":["../../src/utils/send.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,EAAE,EAAE,MAAM,aAAa,CAAA;AAChC,OAAO,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAA;AACxC,OAAO,EAAE,IAAI,EAAE,MAAM,OAAO,CAAA;AAE5B,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAA;AAS/C,OAAO,EAAE,oBAAoB,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAA;AACrE,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAA;AACrD,OAAO,EAAE,iBAAiB,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAC/D,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAC3C,OAAO,EAAE,mBAAmB,EAAE,uBAAuB,EAAE,MAAM,eAAe,CAAA;AAC5E,OAAO,EAAE,yBAAyB,EAAE,MAAM,WAAW,CAAA;
|
|
1
|
+
{"version":3,"file":"send.js","sourceRoot":"","sources":["../../src/utils/send.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** UTM tags for links that stay on the sending site. */
|
|
2
|
+
export interface LinkUtm {
|
|
3
|
+
/** Sending site's host (e.g. `fronts.co`). Only links to it, or a subdomain, are tagged. */
|
|
4
|
+
siteHost: string;
|
|
5
|
+
source: string;
|
|
6
|
+
medium: string;
|
|
7
|
+
/** Campaign or template identifier; reduced to `[a-z0-9_-]`. */
|
|
8
|
+
campaign: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The tags every campaign, digest and broadcast link to the sending site
|
|
12
|
+
* carries: source `newsletter`, medium `email`, campaign = the campaign id.
|
|
13
|
+
* GA4 files source/medium `newsletter / email` under its Email channel.
|
|
14
|
+
*/
|
|
15
|
+
export declare function emailLinkUtm(options: {
|
|
16
|
+
siteUrl: string;
|
|
17
|
+
}, campaignId: string): LinkUtm;
|
|
18
|
+
/**
|
|
19
|
+
* Add utm_source / utm_medium / utm_campaign to a link that points at the
|
|
20
|
+
* sending site. Off-site links, non-http(s) links, links with a template
|
|
21
|
+
* placeholder, and links that already carry a utm_source are returned as-is.
|
|
22
|
+
*/
|
|
23
|
+
export declare function addUtmParams(href: string, utm: LinkUtm): string;
|
|
24
|
+
/** Tag every `<a href="...">` in the HTML that addUtmParams accepts. */
|
|
25
|
+
export declare function tagLinksWithUtm(html: string, utm: LinkUtm): string;
|
|
26
|
+
//# sourceMappingURL=utm.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"utm.d.ts","sourceRoot":"","sources":["../../src/utils/utm.ts"],"names":[],"mappings":"AAMA,wDAAwD;AACxD,MAAM,WAAW,OAAO;IACvB,4FAA4F;IAC5F,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;IACd,gEAAgE;IAChE,QAAQ,EAAE,MAAM,CAAA;CAChB;AAUD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE;IAAE,OAAO,EAAE,MAAM,CAAA;CAAE,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAOtF;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,GAAG,MAAM,CAkB/D;AAED,wEAAwE;AACxE,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,GAAG,MAAM,CAQlE"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// UTM tagging for links in campaign, digest and broadcast emails.
|
|
2
|
+
//
|
|
3
|
+
// Mailer links used to reach GA4 untagged, so email clicks landed as direct
|
|
4
|
+
// traffic. Tagging happens before the click-tracking rewrite wraps each href,
|
|
5
|
+
// so the tagged URL is what the redirect finally sends the reader to.
|
|
6
|
+
function utmValue(value) {
|
|
7
|
+
return value
|
|
8
|
+
.toLowerCase()
|
|
9
|
+
.replace(/[^a-z0-9_-]+/g, '-')
|
|
10
|
+
.replace(/^-+|-+$/g, '')
|
|
11
|
+
.slice(0, 100);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The tags every campaign, digest and broadcast link to the sending site
|
|
15
|
+
* carries: source `newsletter`, medium `email`, campaign = the campaign id.
|
|
16
|
+
* GA4 files source/medium `newsletter / email` under its Email channel.
|
|
17
|
+
*/
|
|
18
|
+
export function emailLinkUtm(options, campaignId) {
|
|
19
|
+
return {
|
|
20
|
+
siteHost: new URL(options.siteUrl).hostname,
|
|
21
|
+
source: 'newsletter',
|
|
22
|
+
medium: 'email',
|
|
23
|
+
campaign: campaignId,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Add utm_source / utm_medium / utm_campaign to a link that points at the
|
|
28
|
+
* sending site. Off-site links, non-http(s) links, links with a template
|
|
29
|
+
* placeholder, and links that already carry a utm_source are returned as-is.
|
|
30
|
+
*/
|
|
31
|
+
export function addUtmParams(href, utm) {
|
|
32
|
+
if (href.includes('{{'))
|
|
33
|
+
return href;
|
|
34
|
+
let url;
|
|
35
|
+
try {
|
|
36
|
+
url = new URL(href);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return href;
|
|
40
|
+
}
|
|
41
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:')
|
|
42
|
+
return href;
|
|
43
|
+
const host = url.hostname.toLowerCase();
|
|
44
|
+
const site = utm.siteHost.toLowerCase();
|
|
45
|
+
if (host !== site && !host.endsWith(`.${site}`))
|
|
46
|
+
return href;
|
|
47
|
+
if (url.searchParams.has('utm_source'))
|
|
48
|
+
return href;
|
|
49
|
+
url.searchParams.set('utm_source', utmValue(utm.source));
|
|
50
|
+
url.searchParams.set('utm_medium', utmValue(utm.medium));
|
|
51
|
+
const campaign = utmValue(utm.campaign);
|
|
52
|
+
if (campaign)
|
|
53
|
+
url.searchParams.set('utm_campaign', campaign);
|
|
54
|
+
return url.toString();
|
|
55
|
+
}
|
|
56
|
+
/** Tag every `<a href="...">` in the HTML that addUtmParams accepts. */
|
|
57
|
+
export function tagLinksWithUtm(html, utm) {
|
|
58
|
+
return html.replace(/<a\s([^>]*?)href\s*=\s*"([^"]*)"([^>]*?)>/gi, (match, before, href, after) => {
|
|
59
|
+
const tagged = addUtmParams(href, utm);
|
|
60
|
+
return tagged === href ? match : `<a ${before}href="${tagged}"${after}>`;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=utm.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"utm.js","sourceRoot":"","sources":["../../src/utils/utm.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,4EAA4E;AAC5E,8EAA8E;AAC9E,sEAAsE;AAYtE,SAAS,QAAQ,CAAC,KAAa;IAC9B,OAAO,KAAK;SACV,WAAW,EAAE;SACb,OAAO,CAAC,eAAe,EAAE,GAAG,CAAC;SAC7B,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC;SACvB,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,CAAA;AAChB,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,OAA4B,EAAE,UAAkB;IAC5E,OAAO;QACN,QAAQ,EAAE,IAAI,GAAG,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,QAAQ;QAC3C,MAAM,EAAE,YAAY;QACpB,MAAM,EAAE,OAAO;QACf,QAAQ,EAAE,UAAU;KACpB,CAAA;AACF,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,IAAY,EAAE,GAAY;IACtD,IAAI,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAA;IACpC,IAAI,GAAQ,CAAA;IACZ,IAAI,CAAC;QACJ,GAAG,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,CAAA;IACpB,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,IAAI,CAAA;IACZ,CAAC;IACD,IAAI,GAAG,CAAC,QAAQ,KAAK,QAAQ,IAAI,GAAG,CAAC,QAAQ,KAAK,OAAO;QAAE,OAAO,IAAI,CAAA;IACtE,MAAM,IAAI,GAAG,GAAG,CAAC,QAAQ,CAAC,WAAW,EAAE,CAAA;IACvC,MAAM,IAAI,GAAG,GAAG,CAAC,QAAQ,CAAC,WAAW,EAAE,CAAA;IACvC,IAAI,IAAI,KAAK,IAAI,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,IAAI,EAAE,CAAC;QAAE,OAAO,IAAI,CAAA;IAC5D,IAAI,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,YAAY,CAAC;QAAE,OAAO,IAAI,CAAA;IACnD,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,YAAY,EAAE,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAA;IACxD,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,YAAY,EAAE,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAA;IACxD,MAAM,QAAQ,GAAG,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;IACvC,IAAI,QAAQ;QAAE,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,cAAc,EAAE,QAAQ,CAAC,CAAA;IAC5D,OAAO,GAAG,CAAC,QAAQ,EAAE,CAAA;AACtB,CAAC;AAED,wEAAwE;AACxE,MAAM,UAAU,eAAe,CAAC,IAAY,EAAE,GAAY;IACzD,OAAO,IAAI,CAAC,OAAO,CAClB,6CAA6C,EAC7C,CAAC,KAAK,EAAE,MAAc,EAAE,IAAY,EAAE,KAAa,EAAE,EAAE;QACtD,MAAM,MAAM,GAAG,YAAY,CAAC,IAAI,EAAE,GAAG,CAAC,CAAA;QACtC,OAAO,MAAM,KAAK,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,MAAM,SAAS,MAAM,IAAI,KAAK,GAAG,CAAA;IACzE,CAAC,CACD,CAAA;AACF,CAAC"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Loud failure for open/click tracking updates.
|
|
2
|
+
//
|
|
3
|
+
// The open pixel and click redirect update one gl_email_sends row by
|
|
4
|
+
// tracking id. When the route is bound to the wrong D1 (a producer that
|
|
5
|
+
// writes sends elsewhere), the UPDATE matches zero rows and every send looks
|
|
6
|
+
// unopened forever. That silent drift is what left 745 Fronts sends at
|
|
7
|
+
// opened=0 while WAE counted hundreds of opens, so a zero-row update now
|
|
8
|
+
// distinguishes "row already past this status" (fine) from "no such row in
|
|
9
|
+
// this D1" (an error with a stable code, visible in Workers observability).
|
|
10
|
+
|
|
11
|
+
export const GL_MAILER_TRACKING_ROW_MISSING = 'GL_MAILER_TRACKING_ROW_MISSING'
|
|
12
|
+
export const GL_MAILER_TRACKING_UPDATE_FAILED = 'GL_MAILER_TRACKING_UPDATE_FAILED'
|
|
13
|
+
|
|
14
|
+
interface D1PreparedStatementLike {
|
|
15
|
+
bind(...values: unknown[]): { first<T = unknown>(): Promise<T | null> }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface D1Like {
|
|
19
|
+
prepare(query: string): D1PreparedStatementLike
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Rows changed by a D1 write, or undefined when the result carries no count. */
|
|
23
|
+
export function changedRows(result: unknown): number | undefined {
|
|
24
|
+
const changes = (result as { meta?: { changes?: unknown } } | null | undefined)?.meta?.changes
|
|
25
|
+
return typeof changes === 'number' ? changes : undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Call after a tracking UPDATE. Does nothing when the update changed a row.
|
|
30
|
+
* On zero rows, checks the row exists: absent logs GL_MAILER_TRACKING_ROW_MISSING.
|
|
31
|
+
* Never throws; the pixel and the redirect must still respond.
|
|
32
|
+
*/
|
|
33
|
+
export async function reportIfNoRowUpdated(
|
|
34
|
+
d1: D1Like,
|
|
35
|
+
bindingName: string,
|
|
36
|
+
trackingId: string,
|
|
37
|
+
route: 'open' | 'click',
|
|
38
|
+
updateResult: unknown,
|
|
39
|
+
): Promise<void> {
|
|
40
|
+
if (changedRows(updateResult) !== 0) return
|
|
41
|
+
try {
|
|
42
|
+
const row = await d1
|
|
43
|
+
.prepare('SELECT status FROM gl_email_sends WHERE tracking_id = ? LIMIT 1')
|
|
44
|
+
.bind(trackingId)
|
|
45
|
+
.first<{ status: string }>()
|
|
46
|
+
if (row) return
|
|
47
|
+
console.error(
|
|
48
|
+
`[mailer] ${GL_MAILER_TRACKING_ROW_MISSING}: track-${route} for trackingId=${trackingId} ` +
|
|
49
|
+
`matched no gl_email_sends row in env.${bindingName}. The send was recorded in a ` +
|
|
50
|
+
'different D1, or the id is unknown: set sendsD1Binding to the D1 that holds the sends.',
|
|
51
|
+
)
|
|
52
|
+
} catch (err) {
|
|
53
|
+
logTrackingUpdateFailed(route, trackingId, bindingName, err)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function logTrackingUpdateFailed(
|
|
58
|
+
route: 'open' | 'click',
|
|
59
|
+
trackingId: string,
|
|
60
|
+
bindingName: string,
|
|
61
|
+
err: unknown,
|
|
62
|
+
): void {
|
|
63
|
+
console.error(
|
|
64
|
+
`[mailer] ${GL_MAILER_TRACKING_UPDATE_FAILED}: track-${route} for trackingId=${trackingId} ` +
|
|
65
|
+
`could not update env.${bindingName}: ${err instanceof Error ? err.message : String(err)}`,
|
|
66
|
+
)
|
|
67
|
+
}
|
package/src/queue/consumer.ts
CHANGED
|
@@ -285,9 +285,15 @@ export async function handleEmailQueue(
|
|
|
285
285
|
},
|
|
286
286
|
)
|
|
287
287
|
} else {
|
|
288
|
+
// Record what the provider said: `hard` for a permanent refusal, `soft` for
|
|
289
|
+
// a transient failure that exhausted its retries, suffixed with the
|
|
290
|
+
// provider's own error code when it gave one (`hard:E_RECIPIENT_REJECTED`).
|
|
291
|
+
const bounceType = `${result.retryable ? 'soft' : 'hard'}${
|
|
292
|
+
result.errorCode ? `:${result.errorCode}` : ''
|
|
293
|
+
}`
|
|
288
294
|
await updateSendStatus(db, recipient.trackingId, 'bounced', {
|
|
289
295
|
bouncedAt: new Date().toISOString(),
|
|
290
|
-
bounceType
|
|
296
|
+
bounceType,
|
|
291
297
|
})
|
|
292
298
|
emitMailerAnalyticsEvent(
|
|
293
299
|
analyticsOptions(options, effective, message.body.siteId),
|
|
@@ -303,10 +309,14 @@ export async function handleEmailQueue(
|
|
|
303
309
|
type,
|
|
304
310
|
siteId: message.body.siteId,
|
|
305
311
|
error: result.error,
|
|
312
|
+
errorCode: result.errorCode,
|
|
313
|
+
bounceType,
|
|
306
314
|
},
|
|
307
315
|
},
|
|
308
316
|
)
|
|
309
|
-
console.error(
|
|
317
|
+
console.error(
|
|
318
|
+
`[mailer] Send failed for ${recipient.email} (${bounceType}): ${result.error}`,
|
|
319
|
+
)
|
|
310
320
|
}
|
|
311
321
|
}
|
|
312
322
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { env as cloudflareEnv } from 'cloudflare:workers'
|
|
2
2
|
import { config } from 'virtual:growth-labs/mailer/config'
|
|
3
3
|
import type { APIRoute } from 'astro'
|
|
4
|
-
import { and, eq, inArray } from 'drizzle-orm'
|
|
4
|
+
import { and, eq, inArray, sql } from 'drizzle-orm'
|
|
5
5
|
import { drizzle } from 'drizzle-orm/d1'
|
|
6
|
-
import {
|
|
6
|
+
import { probeMailerSendsSchema } from '../_internal/schema-probe.js'
|
|
7
|
+
import { logTrackingUpdateFailed, reportIfNoRowUpdated } from '../_internal/tracking-update.js'
|
|
7
8
|
import { emailSends } from '../schema/sends.js'
|
|
8
9
|
import { emitMailerAnalyticsEvent } from '../utils/analytics.js'
|
|
9
10
|
|
|
@@ -27,22 +28,28 @@ export const GET: APIRoute = async (context) => {
|
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
// Update status to 'clicked' only when it hasn't already reached 'clicked'.
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
// the
|
|
31
|
+
// A click proves the message was delivered and read, so delivered_at and
|
|
32
|
+
// opened_at are filled when nothing else set them (image-blocking clients
|
|
33
|
+
// never fire the pixel). Only gl_email_sends is touched, so only that table
|
|
34
|
+
// is probed. On schema miss the probe logs GL_MAILER_SCHEMA_MISSING and we
|
|
35
|
+
// skip the D1 update; a zero-row update or a D1 error is logged loudly. We
|
|
36
|
+
// always still 302 to the destination so the user's click intent is kept.
|
|
37
|
+
const bindingsEnv = cloudflareEnv as Record<string, unknown>
|
|
38
|
+
const sendsBindingName = config.sendsD1Binding ?? config.d1Binding
|
|
33
39
|
try {
|
|
34
|
-
const bindingsEnv = cloudflareEnv as Record<string, unknown>
|
|
35
40
|
if (bindingsEnv) {
|
|
36
|
-
const sendsBindingName = config.sendsD1Binding ?? config.d1Binding
|
|
37
41
|
const d1 = bindingsEnv[sendsBindingName] as D1Database
|
|
38
|
-
const schema = await
|
|
42
|
+
const schema = await probeMailerSendsSchema(d1, sendsBindingName)
|
|
39
43
|
if (schema.ok) {
|
|
40
44
|
const db = drizzle(d1)
|
|
41
|
-
|
|
45
|
+
const now = new Date().toISOString()
|
|
46
|
+
const result = await db
|
|
42
47
|
.update(emailSends)
|
|
43
48
|
.set({
|
|
44
49
|
status: 'clicked',
|
|
45
|
-
clickedAt:
|
|
50
|
+
clickedAt: now,
|
|
51
|
+
openedAt: sql`coalesce(${emailSends.openedAt}, ${now})`,
|
|
52
|
+
deliveredAt: sql`coalesce(${emailSends.deliveredAt}, ${now})`,
|
|
46
53
|
})
|
|
47
54
|
.where(
|
|
48
55
|
and(
|
|
@@ -50,10 +57,11 @@ export const GET: APIRoute = async (context) => {
|
|
|
50
57
|
inArray(emailSends.status, ['sent', 'delivered', 'opened']),
|
|
51
58
|
),
|
|
52
59
|
)
|
|
60
|
+
await reportIfNoRowUpdated(d1, sendsBindingName, trackingId, 'click', result)
|
|
53
61
|
}
|
|
54
62
|
}
|
|
55
|
-
} catch {
|
|
56
|
-
|
|
63
|
+
} catch (err) {
|
|
64
|
+
logTrackingUpdateFailed('click', trackingId, sendsBindingName, err)
|
|
57
65
|
}
|
|
58
66
|
|
|
59
67
|
emitMailerAnalyticsEvent(config, cloudflareEnv as Record<string, unknown>, 'newsletter_clicked', {
|
package/src/routes/track-open.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { env as cloudflareEnv } from 'cloudflare:workers'
|
|
2
2
|
import { config } from 'virtual:growth-labs/mailer/config'
|
|
3
3
|
import type { APIRoute } from 'astro'
|
|
4
|
-
import { and, eq, inArray } from 'drizzle-orm'
|
|
4
|
+
import { and, eq, inArray, sql } from 'drizzle-orm'
|
|
5
5
|
import { drizzle } from 'drizzle-orm/d1'
|
|
6
|
-
import {
|
|
6
|
+
import { probeMailerSendsSchema } from '../_internal/schema-probe.js'
|
|
7
|
+
import { logTrackingUpdateFailed, reportIfNoRowUpdated } from '../_internal/tracking-update.js'
|
|
7
8
|
import { emailSends } from '../schema/sends.js'
|
|
8
9
|
import { emitMailerAnalyticsEvent } from '../utils/analytics.js'
|
|
9
10
|
import { TRANSPARENT_GIF } from '../utils/tracking.js'
|
|
@@ -16,22 +17,28 @@ export const GET: APIRoute = async (context) => {
|
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
// Update status to 'opened' only when currently 'sent' or 'delivered'
|
|
19
|
-
// to avoid downgrading from 'clicked'.
|
|
20
|
-
//
|
|
21
|
-
//
|
|
20
|
+
// to avoid downgrading from 'clicked'. An open proves delivery, so
|
|
21
|
+
// delivered_at is filled when no delivery webhook set it. Only
|
|
22
|
+
// gl_email_sends is touched, so only that table is probed: the sends D1 of
|
|
23
|
+
// a realm holds no gl_subscribers. On schema miss, the probe logs
|
|
24
|
+
// GL_MAILER_SCHEMA_MISSING and we skip the D1 work; a zero-row update or a
|
|
25
|
+
// D1 error is logged loudly. The transparent GIF is returned regardless so
|
|
26
|
+
// email rendering never breaks.
|
|
27
|
+
const bindingsEnv = cloudflareEnv as Record<string, unknown>
|
|
28
|
+
const sendsBindingName = config.sendsD1Binding ?? config.d1Binding
|
|
22
29
|
try {
|
|
23
|
-
const bindingsEnv = cloudflareEnv as Record<string, unknown>
|
|
24
30
|
if (bindingsEnv) {
|
|
25
|
-
const sendsBindingName = config.sendsD1Binding ?? config.d1Binding
|
|
26
31
|
const d1 = bindingsEnv[sendsBindingName] as D1Database
|
|
27
|
-
const schema = await
|
|
32
|
+
const schema = await probeMailerSendsSchema(d1, sendsBindingName)
|
|
28
33
|
if (schema.ok) {
|
|
29
34
|
const db = drizzle(d1)
|
|
30
|
-
|
|
35
|
+
const now = new Date().toISOString()
|
|
36
|
+
const result = await db
|
|
31
37
|
.update(emailSends)
|
|
32
38
|
.set({
|
|
33
39
|
status: 'opened',
|
|
34
|
-
openedAt:
|
|
40
|
+
openedAt: now,
|
|
41
|
+
deliveredAt: sql`coalesce(${emailSends.deliveredAt}, ${now})`,
|
|
35
42
|
})
|
|
36
43
|
.where(
|
|
37
44
|
and(
|
|
@@ -39,10 +46,11 @@ export const GET: APIRoute = async (context) => {
|
|
|
39
46
|
inArray(emailSends.status, ['sent', 'delivered']),
|
|
40
47
|
),
|
|
41
48
|
)
|
|
49
|
+
await reportIfNoRowUpdated(d1, sendsBindingName, trackingId, 'open', result)
|
|
42
50
|
}
|
|
43
51
|
}
|
|
44
|
-
} catch {
|
|
45
|
-
|
|
52
|
+
} catch (err) {
|
|
53
|
+
logTrackingUpdateFailed('open', trackingId, sendsBindingName, err)
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
emitMailerAnalyticsEvent(config, cloudflareEnv as Record<string, unknown>, 'newsletter_opened', {
|
package/src/types.ts
CHANGED
package/src/utils/broadcast.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { resolveSigningSecret, siteHost } from './runtime-secrets.js'
|
|
|
10
10
|
import type { MailerEnv } from './send.js'
|
|
11
11
|
import { generateToken } from './tokens.js'
|
|
12
12
|
import { injectTrackingPixel, rewriteLinksForTracking } from './tracking.js'
|
|
13
|
+
import { emailLinkUtm, tagLinksWithUtm } from './utm.js'
|
|
13
14
|
|
|
14
15
|
type DrizzleDB = ReturnType<typeof drizzle>
|
|
15
16
|
|
|
@@ -244,10 +245,15 @@ async function filterAlreadySent(
|
|
|
244
245
|
* Build the broadcast HTML with the tracking pixel + click-rewrite applied
|
|
245
246
|
* once (the per-recipient {{TRACKING_ID}} is substituted in the consumer).
|
|
246
247
|
*/
|
|
247
|
-
function prepareBroadcastHtml(
|
|
248
|
+
function prepareBroadcastHtml(
|
|
249
|
+
html: string,
|
|
250
|
+
options: ResolvedMailerOptions,
|
|
251
|
+
campaignId: string,
|
|
252
|
+
): string {
|
|
248
253
|
const trackOpenUrl = `${options.siteUrl}${options.trackOpenPath}`
|
|
249
254
|
const trackClickUrl = `${options.siteUrl}${options.trackClickPath}`
|
|
250
|
-
|
|
255
|
+
const tagged = tagLinksWithUtm(html, emailLinkUtm(options, campaignId))
|
|
256
|
+
return rewriteLinksForTracking(injectTrackingPixel(tagged, trackOpenUrl), trackClickUrl)
|
|
251
257
|
}
|
|
252
258
|
|
|
253
259
|
function chunk<T>(items: T[], size: number): T[][] {
|
|
@@ -339,7 +345,7 @@ export async function sendBroadcast(
|
|
|
339
345
|
const signingSecret = signingSecretResult.secret
|
|
340
346
|
const host = siteHost(options.siteUrl)
|
|
341
347
|
|
|
342
|
-
const html = prepareBroadcastHtml(params.html, options)
|
|
348
|
+
const html = prepareBroadcastHtml(params.html, options, campaignId)
|
|
343
349
|
const now = new Date().toISOString()
|
|
344
350
|
|
|
345
351
|
// 4. Per-recipient row write + queue-recipient build.
|
package/src/utils/index.ts
CHANGED
package/src/utils/providers.ts
CHANGED
|
@@ -43,11 +43,13 @@ export class CloudflareEmailProvider implements EmailProvider {
|
|
|
43
43
|
return { success: true, retryable: false }
|
|
44
44
|
} catch (err) {
|
|
45
45
|
const message = err instanceof Error ? err.message : String(err)
|
|
46
|
-
const isRetryable =
|
|
46
|
+
const isRetryable = isRetryableFailure(err, message)
|
|
47
47
|
|
|
48
|
+
const errorCode = providerErrorCode(err)
|
|
48
49
|
return {
|
|
49
50
|
success: false,
|
|
50
51
|
error: message,
|
|
52
|
+
...(errorCode ? { errorCode } : {}),
|
|
51
53
|
retryable: isRetryable,
|
|
52
54
|
}
|
|
53
55
|
}
|
|
@@ -66,18 +68,100 @@ export function sleep(ms: number): Promise<void> {
|
|
|
66
68
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
67
69
|
}
|
|
68
70
|
|
|
71
|
+
/** The provider's error code, when the thrown error carries a string or numeric `code`. */
|
|
72
|
+
function providerErrorCode(err: unknown): string | undefined {
|
|
73
|
+
if (typeof err !== 'object' || err === null) return undefined
|
|
74
|
+
const code = (err as { code?: unknown }).code
|
|
75
|
+
if (typeof code === 'string' && code.length > 0) return code.slice(0, 80)
|
|
76
|
+
if (typeof code === 'number' && Number.isFinite(code)) return String(code)
|
|
77
|
+
return undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Whether a failed send is worth retrying (`soft`) rather than a permanent refusal (`hard`).
|
|
82
|
+
*
|
|
83
|
+
* A status code decides before any wording does:
|
|
84
|
+
* - an SMTP enhanced status (`5.1.1`) or reply code (`550`) in the error's code fields or at the
|
|
85
|
+
* start of its message: `5xx` is permanent, `4xx` is transient;
|
|
86
|
+
* - an HTTP status from the provider's API (`status` / `statusCode`): `5xx`, `408`, `425` and
|
|
87
|
+
* `429` are transient, any other `4xx` is permanent.
|
|
88
|
+
* Without a code, network faults, timeouts and server-side wording are transient and everything
|
|
89
|
+
* else is permanent. The message is never scanned for a bare digit.
|
|
90
|
+
*/
|
|
91
|
+
function isRetryableFailure(err: unknown, message: string): boolean {
|
|
92
|
+
const smtp = smtpClass(err, message)
|
|
93
|
+
if (smtp !== undefined) return smtp === 4
|
|
94
|
+
const http = httpStatus(err)
|
|
95
|
+
if (http !== undefined) return http >= 500 || http === 408 || http === 425 || http === 429
|
|
96
|
+
return isNetworkOrServerError(message)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const ENHANCED_STATUS = /(?<![\d.])([245])\.\d{1,3}\.\d{1,3}(?![\d.])/
|
|
100
|
+
const SMTP_REPLY_AT_START = /^\s*([45])\d\d(?:[\s-]|$)/
|
|
101
|
+
const SMTP_REPLY_LABELLED =
|
|
102
|
+
/\b(?:smtp|esmtp)\b[^\d]{0,24}([45])\d\d(?!\d)|\b(?:reply|response)\s+code[:\s]*([45])\d\d(?!\d)/i
|
|
103
|
+
|
|
104
|
+
/** The SMTP status class (4 or 5) an error reports, or undefined when it reports none. */
|
|
105
|
+
function smtpClass(err: unknown, message: string): 4 | 5 | undefined {
|
|
106
|
+
const fields: unknown[] = []
|
|
107
|
+
if (typeof err === 'object' && err !== null) {
|
|
108
|
+
const e = err as Record<string, unknown>
|
|
109
|
+
fields.push(e.enhancedStatus, e.enhancedCode, e.responseCode, e.smtpCode, e.smtpStatus)
|
|
110
|
+
// A numeric `code` (or a bare "550") is an SMTP reply code; other string codes are provider codes.
|
|
111
|
+
if (typeof e.code === 'number') fields.push(e.code)
|
|
112
|
+
else if (typeof e.code === 'string' && /^[45]\d\d$/.test(e.code)) fields.push(Number(e.code))
|
|
113
|
+
}
|
|
114
|
+
for (const f of fields) {
|
|
115
|
+
if (typeof f === 'number' && Number.isInteger(f) && f >= 400 && f < 600) {
|
|
116
|
+
return f >= 500 ? 5 : 4
|
|
117
|
+
}
|
|
118
|
+
if (typeof f === 'string') {
|
|
119
|
+
const enhanced = ENHANCED_STATUS.exec(f)
|
|
120
|
+
if (enhanced) return enhanced[1] === '5' ? 5 : enhanced[1] === '4' ? 4 : undefined
|
|
121
|
+
const reply = SMTP_REPLY_AT_START.exec(f)
|
|
122
|
+
if (reply) return reply[1] === '5' ? 5 : 4
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const enhanced = ENHANCED_STATUS.exec(message)
|
|
126
|
+
if (enhanced) return enhanced[1] === '5' ? 5 : enhanced[1] === '4' ? 4 : undefined
|
|
127
|
+
const reply = SMTP_REPLY_AT_START.exec(message) ?? SMTP_REPLY_LABELLED.exec(message)
|
|
128
|
+
if (reply) {
|
|
129
|
+
const digit = reply[1] ?? reply[2]
|
|
130
|
+
return digit === '5' ? 5 : 4
|
|
131
|
+
}
|
|
132
|
+
return undefined
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The HTTP status the provider's API answered with, when the error carries one. */
|
|
136
|
+
function httpStatus(err: unknown): number | undefined {
|
|
137
|
+
if (typeof err !== 'object' || err === null) return undefined
|
|
138
|
+
const e = err as { status?: unknown; statusCode?: unknown }
|
|
139
|
+
for (const v of [e.status, e.statusCode]) {
|
|
140
|
+
if (typeof v === 'number' && Number.isInteger(v) && v >= 400 && v < 600) return v
|
|
141
|
+
}
|
|
142
|
+
return undefined
|
|
143
|
+
}
|
|
144
|
+
|
|
69
145
|
function isNetworkOrServerError(message: string): boolean {
|
|
70
146
|
const patterns = [
|
|
71
147
|
'network',
|
|
72
148
|
'timeout',
|
|
149
|
+
'timed out',
|
|
150
|
+
'etimedout',
|
|
73
151
|
'econnrefused',
|
|
74
152
|
'econnreset',
|
|
75
153
|
'enotfound',
|
|
154
|
+
'eai_again',
|
|
155
|
+
'epipe',
|
|
156
|
+
'ehostunreach',
|
|
157
|
+
'fetch failed',
|
|
76
158
|
'socket',
|
|
77
|
-
'5',
|
|
78
159
|
'server error',
|
|
79
160
|
'service unavailable',
|
|
80
161
|
'internal server',
|
|
162
|
+
'temporarily',
|
|
163
|
+
'try again',
|
|
164
|
+
'rate limit',
|
|
81
165
|
]
|
|
82
166
|
const lower = message.toLowerCase()
|
|
83
167
|
return patterns.some((p) => lower.includes(p))
|
package/src/utils/send.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { renderDigestItems, renderEmail } from './templates.js'
|
|
|
17
17
|
import { generateToken } from './tokens.js'
|
|
18
18
|
import { injectTrackingPixel, rewriteLinksForTracking } from './tracking.js'
|
|
19
19
|
import { buildSubscriberManageUrls } from './urls.js'
|
|
20
|
+
import { emailLinkUtm, tagLinksWithUtm } from './utm.js'
|
|
20
21
|
|
|
21
22
|
type DrizzleDB = ReturnType<typeof drizzle>
|
|
22
23
|
|
|
@@ -274,6 +275,7 @@ export async function sendCampaign(
|
|
|
274
275
|
// Inject tracking
|
|
275
276
|
const trackOpenUrl = `${options.siteUrl}${options.trackOpenPath}`
|
|
276
277
|
const trackClickUrl = `${options.siteUrl}${options.trackClickPath}`
|
|
278
|
+
html = tagLinksWithUtm(html, emailLinkUtm(options, campaignId))
|
|
277
279
|
html = injectTrackingPixel(html, trackOpenUrl)
|
|
278
280
|
html = rewriteLinksForTracking(html, trackClickUrl)
|
|
279
281
|
|
|
@@ -320,6 +322,7 @@ export async function sendDigest(
|
|
|
320
322
|
// Inject tracking
|
|
321
323
|
const trackOpenUrl = `${options.siteUrl}${options.trackOpenPath}`
|
|
322
324
|
const trackClickUrl = `${options.siteUrl}${options.trackClickPath}`
|
|
325
|
+
html = tagLinksWithUtm(html, emailLinkUtm(options, campaignId))
|
|
323
326
|
html = injectTrackingPixel(html, trackOpenUrl)
|
|
324
327
|
html = rewriteLinksForTracking(html, trackClickUrl)
|
|
325
328
|
|