@growth-labs/mailer 0.6.5 → 0.8.0
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/options.d.ts +15 -221
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +6 -6
- package/dist/options.js.map +1 -1
- 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 +13 -0
- 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 +2 -2
- package/src/_internal/tracking-update.ts +67 -0
- package/src/options.ts +6 -6
- 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 +11 -0
- package/src/utils/send.ts +3 -0
- package/src/utils/utm.ts +73 -0
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
|
@@ -45,9 +45,11 @@ export class CloudflareEmailProvider implements EmailProvider {
|
|
|
45
45
|
const message = err instanceof Error ? err.message : String(err)
|
|
46
46
|
const isRetryable = isNetworkOrServerError(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,6 +68,15 @@ 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
|
+
|
|
69
80
|
function isNetworkOrServerError(message: string): boolean {
|
|
70
81
|
const patterns = [
|
|
71
82
|
'network',
|
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
|
|
package/src/utils/utm.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
|
|
7
|
+
/** UTM tags for links that stay on the sending site. */
|
|
8
|
+
export interface LinkUtm {
|
|
9
|
+
/** Sending site's host (e.g. `fronts.co`). Only links to it, or a subdomain, are tagged. */
|
|
10
|
+
siteHost: string
|
|
11
|
+
source: string
|
|
12
|
+
medium: string
|
|
13
|
+
/** Campaign or template identifier; reduced to `[a-z0-9_-]`. */
|
|
14
|
+
campaign: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function utmValue(value: string): string {
|
|
18
|
+
return value
|
|
19
|
+
.toLowerCase()
|
|
20
|
+
.replace(/[^a-z0-9_-]+/g, '-')
|
|
21
|
+
.replace(/^-+|-+$/g, '')
|
|
22
|
+
.slice(0, 100)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The tags every campaign, digest and broadcast link to the sending site
|
|
27
|
+
* carries: source `newsletter`, medium `email`, campaign = the campaign id.
|
|
28
|
+
* GA4 files source/medium `newsletter / email` under its Email channel.
|
|
29
|
+
*/
|
|
30
|
+
export function emailLinkUtm(options: { siteUrl: string }, campaignId: string): LinkUtm {
|
|
31
|
+
return {
|
|
32
|
+
siteHost: new URL(options.siteUrl).hostname,
|
|
33
|
+
source: 'newsletter',
|
|
34
|
+
medium: 'email',
|
|
35
|
+
campaign: campaignId,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Add utm_source / utm_medium / utm_campaign to a link that points at the
|
|
41
|
+
* sending site. Off-site links, non-http(s) links, links with a template
|
|
42
|
+
* placeholder, and links that already carry a utm_source are returned as-is.
|
|
43
|
+
*/
|
|
44
|
+
export function addUtmParams(href: string, utm: LinkUtm): string {
|
|
45
|
+
if (href.includes('{{')) return href
|
|
46
|
+
let url: URL
|
|
47
|
+
try {
|
|
48
|
+
url = new URL(href)
|
|
49
|
+
} catch {
|
|
50
|
+
return href
|
|
51
|
+
}
|
|
52
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') return href
|
|
53
|
+
const host = url.hostname.toLowerCase()
|
|
54
|
+
const site = utm.siteHost.toLowerCase()
|
|
55
|
+
if (host !== site && !host.endsWith(`.${site}`)) return href
|
|
56
|
+
if (url.searchParams.has('utm_source')) return href
|
|
57
|
+
url.searchParams.set('utm_source', utmValue(utm.source))
|
|
58
|
+
url.searchParams.set('utm_medium', utmValue(utm.medium))
|
|
59
|
+
const campaign = utmValue(utm.campaign)
|
|
60
|
+
if (campaign) url.searchParams.set('utm_campaign', campaign)
|
|
61
|
+
return url.toString()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Tag every `<a href="...">` in the HTML that addUtmParams accepts. */
|
|
65
|
+
export function tagLinksWithUtm(html: string, utm: LinkUtm): string {
|
|
66
|
+
return html.replace(
|
|
67
|
+
/<a\s([^>]*?)href\s*=\s*"([^"]*)"([^>]*?)>/gi,
|
|
68
|
+
(match, before: string, href: string, after: string) => {
|
|
69
|
+
const tagged = addUtmParams(href, utm)
|
|
70
|
+
return tagged === href ? match : `<a ${before}href="${tagged}"${after}>`
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
}
|