@growth-labs/mailer 0.2.1 → 0.3.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 (57) hide show
  1. package/README.md +5 -1
  2. package/dist/_internal/schema-probe.d.ts +30 -0
  3. package/dist/_internal/schema-probe.d.ts.map +1 -0
  4. package/dist/_internal/schema-probe.js +68 -0
  5. package/dist/_internal/schema-probe.js.map +1 -0
  6. package/dist/options.d.ts +58 -0
  7. package/dist/options.d.ts.map +1 -1
  8. package/dist/options.js +22 -0
  9. package/dist/options.js.map +1 -1
  10. package/dist/queue/consumer.d.ts.map +1 -1
  11. package/dist/queue/consumer.js +12 -0
  12. package/dist/queue/consumer.js.map +1 -1
  13. package/dist/routes/confirm.d.ts.map +1 -1
  14. package/dist/routes/confirm.js +5 -0
  15. package/dist/routes/confirm.js.map +1 -1
  16. package/dist/routes/subscribe.d.ts.map +1 -1
  17. package/dist/routes/subscribe.js +6 -0
  18. package/dist/routes/subscribe.js.map +1 -1
  19. package/dist/routes/track-click.d.ts.map +1 -1
  20. package/dist/routes/track-click.js +16 -9
  21. package/dist/routes/track-click.js.map +1 -1
  22. package/dist/routes/track-open.d.ts.map +1 -1
  23. package/dist/routes/track-open.js +15 -9
  24. package/dist/routes/track-open.js.map +1 -1
  25. package/dist/routes/unsubscribe.d.ts.map +1 -1
  26. package/dist/routes/unsubscribe.js +10 -0
  27. package/dist/routes/unsubscribe.js.map +1 -1
  28. package/dist/routes/webhook.d.ts.map +1 -1
  29. package/dist/routes/webhook.js +24 -1
  30. package/dist/routes/webhook.js.map +1 -1
  31. package/dist/utils/analytics.d.ts +1 -1
  32. package/dist/utils/analytics.d.ts.map +1 -1
  33. package/dist/utils/analytics.js +1 -0
  34. package/dist/utils/analytics.js.map +1 -1
  35. package/dist/utils/index.d.ts +1 -0
  36. package/dist/utils/index.d.ts.map +1 -1
  37. package/dist/utils/index.js +1 -0
  38. package/dist/utils/index.js.map +1 -1
  39. package/dist/utils/webhook-signature.d.ts +6 -0
  40. package/dist/utils/webhook-signature.d.ts.map +1 -0
  41. package/dist/utils/webhook-signature.js +59 -0
  42. package/dist/utils/webhook-signature.js.map +1 -0
  43. package/migrations/0001_create_gl_mailer_tables.sql +48 -0
  44. package/package.json +2 -1
  45. package/src/_internal/schema-probe.ts +89 -0
  46. package/src/options.ts +22 -0
  47. package/src/queue/consumer.ts +13 -0
  48. package/src/routes/confirm.ts +6 -0
  49. package/src/routes/subscribe.ts +7 -0
  50. package/src/routes/track-click.ts +21 -14
  51. package/src/routes/track-open.ts +20 -14
  52. package/src/routes/unsubscribe.ts +9 -0
  53. package/src/routes/webhook.ts +25 -1
  54. package/src/utils/analytics.ts +2 -0
  55. package/src/utils/index.ts +1 -0
  56. package/src/utils/webhook-signature.ts +91 -0
  57. package/src/virtual.d.ts +14 -0
@@ -0,0 +1,91 @@
1
+ import type { ResolvedMailerOptions } from '../options.js'
2
+
3
+ type WebhookSignatureOptions = ResolvedMailerOptions['webhookSignature']
4
+
5
+ export async function verifyWebhookSignature(
6
+ options: WebhookSignatureOptions,
7
+ request: Request,
8
+ body: string,
9
+ nowMs: number = Date.now(),
10
+ ): Promise<boolean> {
11
+ if (!options.enabled) return true
12
+
13
+ const timestamp = request.headers.get(options.timestampHeader)
14
+ const signatureHeader = request.headers.get(options.header)
15
+ if (!timestamp || !signatureHeader) return false
16
+
17
+ if (!timestampWithinTolerance(timestamp, options.toleranceSeconds, nowMs)) return false
18
+
19
+ const signature = signatureFromHeader(signatureHeader)
20
+ if (!signature) return false
21
+
22
+ const key = await importWebhookKey(options.secret)
23
+ const signedBody = `${timestamp}.${body}`
24
+ return crypto.subtle.verify(
25
+ 'HMAC',
26
+ key,
27
+ fromBase64Url(signature),
28
+ new TextEncoder().encode(signedBody),
29
+ )
30
+ }
31
+
32
+ export async function signWebhookPayload(
33
+ secret: string,
34
+ timestamp: string,
35
+ body: string,
36
+ ): Promise<string> {
37
+ const key = await importWebhookKey(secret)
38
+ const signature = await crypto.subtle.sign(
39
+ 'HMAC',
40
+ key,
41
+ new TextEncoder().encode(`${timestamp}.${body}`),
42
+ )
43
+ return toBase64Url(signature)
44
+ }
45
+
46
+ function timestampWithinTolerance(
47
+ timestamp: string,
48
+ toleranceSeconds: number,
49
+ nowMs: number,
50
+ ): boolean {
51
+ if (toleranceSeconds === 0) return true
52
+ const parsed = Number(timestamp)
53
+ if (!Number.isFinite(parsed)) return false
54
+ const timestampMs = parsed > 1_000_000_000_000 ? parsed : parsed * 1000
55
+ return Math.abs(nowMs - timestampMs) <= toleranceSeconds * 1000
56
+ }
57
+
58
+ function signatureFromHeader(header: string): string | null {
59
+ const parts = header.split(',').map((part) => part.trim())
60
+ for (const part of parts) {
61
+ if (part.startsWith('v1=')) return part.slice(3)
62
+ }
63
+ return parts[0] || null
64
+ }
65
+
66
+ async function importWebhookKey(secret: string): Promise<CryptoKey> {
67
+ return crypto.subtle.importKey(
68
+ 'raw',
69
+ new TextEncoder().encode(secret),
70
+ { name: 'HMAC', hash: 'SHA-256' },
71
+ false,
72
+ ['sign', 'verify'],
73
+ )
74
+ }
75
+
76
+ function fromBase64Url(value: string): Uint8Array<ArrayBuffer> {
77
+ const padded = value
78
+ .replace(/-/g, '+')
79
+ .replace(/_/g, '/')
80
+ .padEnd(Math.ceil(value.length / 4) * 4, '=')
81
+ const binary = atob(padded)
82
+ const bytes = new Uint8Array(new ArrayBuffer(binary.length))
83
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
84
+ return bytes
85
+ }
86
+
87
+ function toBase64Url(bytes: ArrayBuffer): string {
88
+ let binary = ''
89
+ for (const byte of new Uint8Array(bytes)) binary += String.fromCharCode(byte)
90
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
91
+ }
package/src/virtual.d.ts CHANGED
@@ -15,6 +15,20 @@ declare module 'virtual:growth-labs/mailer/config' {
15
15
  unsubscribePath: string
16
16
  preferencesPath: string
17
17
  webhookPath: string
18
+ webhookSignature:
19
+ | {
20
+ enabled: false
21
+ header: string
22
+ timestampHeader: string
23
+ toleranceSeconds: number
24
+ }
25
+ | {
26
+ enabled: true
27
+ secret: string
28
+ header: string
29
+ timestampHeader: string
30
+ toleranceSeconds: number
31
+ }
18
32
  trackOpenPath: string
19
33
  trackClickPath: string
20
34
  siteUrl: string