@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.
Files changed (48) hide show
  1. package/dist/_internal/tracking-update.d.ts +21 -0
  2. package/dist/_internal/tracking-update.d.ts.map +1 -0
  3. package/dist/_internal/tracking-update.js +44 -0
  4. package/dist/_internal/tracking-update.js.map +1 -0
  5. package/dist/options.d.ts +15 -221
  6. package/dist/options.d.ts.map +1 -1
  7. package/dist/options.js +6 -6
  8. package/dist/options.js.map +1 -1
  9. package/dist/queue/consumer.d.ts.map +1 -1
  10. package/dist/queue/consumer.js +8 -2
  11. package/dist/queue/consumer.js.map +1 -1
  12. package/dist/routes/track-click.d.ts.map +1 -1
  13. package/dist/routes/track-click.js +20 -12
  14. package/dist/routes/track-click.js.map +1 -1
  15. package/dist/routes/track-open.d.ts.map +1 -1
  16. package/dist/routes/track-open.js +20 -12
  17. package/dist/routes/track-open.js.map +1 -1
  18. package/dist/types.d.ts +2 -0
  19. package/dist/types.d.ts.map +1 -1
  20. package/dist/utils/broadcast.d.ts.map +1 -1
  21. package/dist/utils/broadcast.js +5 -3
  22. package/dist/utils/broadcast.js.map +1 -1
  23. package/dist/utils/index.d.ts +1 -0
  24. package/dist/utils/index.d.ts.map +1 -1
  25. package/dist/utils/index.js +1 -0
  26. package/dist/utils/index.js.map +1 -1
  27. package/dist/utils/providers.d.ts.map +1 -1
  28. package/dist/utils/providers.js +13 -0
  29. package/dist/utils/providers.js.map +1 -1
  30. package/dist/utils/send.d.ts.map +1 -1
  31. package/dist/utils/send.js +3 -0
  32. package/dist/utils/send.js.map +1 -1
  33. package/dist/utils/utm.d.ts +26 -0
  34. package/dist/utils/utm.d.ts.map +1 -0
  35. package/dist/utils/utm.js +63 -0
  36. package/dist/utils/utm.js.map +1 -0
  37. package/package.json +2 -2
  38. package/src/_internal/tracking-update.ts +67 -0
  39. package/src/options.ts +6 -6
  40. package/src/queue/consumer.ts +12 -2
  41. package/src/routes/track-click.ts +20 -12
  42. package/src/routes/track-open.ts +20 -12
  43. package/src/types.ts +2 -0
  44. package/src/utils/broadcast.ts +9 -3
  45. package/src/utils/index.ts +1 -0
  46. package/src/utils/providers.ts +11 -0
  47. package/src/utils/send.ts +3 -0
  48. package/src/utils/utm.ts +73 -0
@@ -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 { probeMailerSchema } from '../_internal/schema-probe.js'
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'. On schema miss, the probe logs
20
- // GL_MAILER_SCHEMA_MISSING and we skip the D1 work — but always still
21
- // return the transparent GIF so we don't break email rendering.
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 probeMailerSchema(d1, sendsBindingName)
32
+ const schema = await probeMailerSendsSchema(d1, sendsBindingName)
28
33
  if (schema.ok) {
29
34
  const db = drizzle(d1)
30
- await db
35
+ const now = new Date().toISOString()
36
+ const result = await db
31
37
  .update(emailSends)
32
38
  .set({
33
39
  status: 'opened',
34
- openedAt: new Date().toISOString(),
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
- // Never fail the pixel response on DB errors
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
@@ -163,6 +163,8 @@ export interface SendResult {
163
163
  success: boolean
164
164
  messageId?: string
165
165
  error?: string
166
+ /** The provider's own error code (e.g. Cloudflare `E_*`), when it supplies one. */
167
+ errorCode?: string
166
168
  retryable: boolean
167
169
  }
168
170
 
@@ -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(html: string, options: ResolvedMailerOptions): string {
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
- return rewriteLinksForTracking(injectTrackingPixel(html, trackOpenUrl), trackClickUrl)
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.
@@ -50,4 +50,5 @@ export {
50
50
  rewriteLinksForTracking,
51
51
  TRANSPARENT_GIF,
52
52
  } from './tracking.js'
53
+ export { addUtmParams, emailLinkUtm, type LinkUtm, tagLinksWithUtm } from './utm.js'
53
54
  export { signWebhookPayload, verifyWebhookSignature } from './webhook-signature.js'
@@ -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
 
@@ -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
+ }