@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.
- package/README.md +5 -1
- package/dist/_internal/schema-probe.d.ts +30 -0
- package/dist/_internal/schema-probe.d.ts.map +1 -0
- package/dist/_internal/schema-probe.js +68 -0
- package/dist/_internal/schema-probe.js.map +1 -0
- package/dist/options.d.ts +58 -0
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +22 -0
- package/dist/options.js.map +1 -1
- package/dist/queue/consumer.d.ts.map +1 -1
- package/dist/queue/consumer.js +12 -0
- package/dist/queue/consumer.js.map +1 -1
- package/dist/routes/confirm.d.ts.map +1 -1
- package/dist/routes/confirm.js +5 -0
- package/dist/routes/confirm.js.map +1 -1
- package/dist/routes/subscribe.d.ts.map +1 -1
- package/dist/routes/subscribe.js +6 -0
- package/dist/routes/subscribe.js.map +1 -1
- package/dist/routes/track-click.d.ts.map +1 -1
- package/dist/routes/track-click.js +16 -9
- 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 +15 -9
- package/dist/routes/track-open.js.map +1 -1
- package/dist/routes/unsubscribe.d.ts.map +1 -1
- package/dist/routes/unsubscribe.js +10 -0
- package/dist/routes/unsubscribe.js.map +1 -1
- package/dist/routes/webhook.d.ts.map +1 -1
- package/dist/routes/webhook.js +24 -1
- package/dist/routes/webhook.js.map +1 -1
- package/dist/utils/analytics.d.ts +1 -1
- package/dist/utils/analytics.d.ts.map +1 -1
- package/dist/utils/analytics.js +1 -0
- package/dist/utils/analytics.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/webhook-signature.d.ts +6 -0
- package/dist/utils/webhook-signature.d.ts.map +1 -0
- package/dist/utils/webhook-signature.js +59 -0
- package/dist/utils/webhook-signature.js.map +1 -0
- package/migrations/0001_create_gl_mailer_tables.sql +48 -0
- package/package.json +2 -1
- package/src/_internal/schema-probe.ts +89 -0
- package/src/options.ts +22 -0
- package/src/queue/consumer.ts +13 -0
- package/src/routes/confirm.ts +6 -0
- package/src/routes/subscribe.ts +7 -0
- package/src/routes/track-click.ts +21 -14
- package/src/routes/track-open.ts +20 -14
- package/src/routes/unsubscribe.ts +9 -0
- package/src/routes/webhook.ts +25 -1
- package/src/utils/analytics.ts +2 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/webhook-signature.ts +91 -0
- 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
|