@c9up/rover 0.1.3

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 (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +91 -0
  3. package/dist/BaseMail.d.ts +28 -0
  4. package/dist/BaseMail.d.ts.map +1 -0
  5. package/dist/BaseMail.js +42 -0
  6. package/dist/BaseMail.js.map +1 -0
  7. package/dist/Mail.d.ts +166 -0
  8. package/dist/Mail.d.ts.map +1 -0
  9. package/dist/Mail.js +451 -0
  10. package/dist/Mail.js.map +1 -0
  11. package/dist/MessageBuilder.d.ts +38 -0
  12. package/dist/MessageBuilder.d.ts.map +1 -0
  13. package/dist/MessageBuilder.js +70 -0
  14. package/dist/MessageBuilder.js.map +1 -0
  15. package/dist/RoverError.d.ts +30 -0
  16. package/dist/RoverError.d.ts.map +1 -0
  17. package/dist/RoverError.js +33 -0
  18. package/dist/RoverError.js.map +1 -0
  19. package/dist/RoverProvider.d.ts +24 -0
  20. package/dist/RoverProvider.d.ts.map +1 -0
  21. package/dist/RoverProvider.js +51 -0
  22. package/dist/RoverProvider.js.map +1 -0
  23. package/dist/config.d.ts +4 -0
  24. package/dist/config.d.ts.map +1 -0
  25. package/dist/config.js +4 -0
  26. package/dist/config.js.map +1 -0
  27. package/dist/index.d.ts +9 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +7 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/queue/MailJob.d.ts +41 -0
  32. package/dist/queue/MailJob.d.ts.map +1 -0
  33. package/dist/queue/MailJob.js +75 -0
  34. package/dist/queue/MailJob.js.map +1 -0
  35. package/dist/retry.d.ts +20 -0
  36. package/dist/retry.d.ts.map +1 -0
  37. package/dist/retry.js +101 -0
  38. package/dist/retry.js.map +1 -0
  39. package/dist/services/main.d.ts +20 -0
  40. package/dist/services/main.d.ts.map +1 -0
  41. package/dist/services/main.js +33 -0
  42. package/dist/services/main.js.map +1 -0
  43. package/dist/templating/SimpleTemplate.d.ts +7 -0
  44. package/dist/templating/SimpleTemplate.d.ts.map +1 -0
  45. package/dist/templating/SimpleTemplate.js +192 -0
  46. package/dist/templating/SimpleTemplate.js.map +1 -0
  47. package/dist/templating/loadNapi.d.ts +21 -0
  48. package/dist/templating/loadNapi.d.ts.map +1 -0
  49. package/dist/templating/loadNapi.js +115 -0
  50. package/dist/templating/loadNapi.js.map +1 -0
  51. package/dist/testing/FakeMail.d.ts +31 -0
  52. package/dist/testing/FakeMail.d.ts.map +1 -0
  53. package/dist/testing/FakeMail.js +90 -0
  54. package/dist/testing/FakeMail.js.map +1 -0
  55. package/dist/transports/MailgunTransport.d.ts +7 -0
  56. package/dist/transports/MailgunTransport.d.ts.map +1 -0
  57. package/dist/transports/MailgunTransport.js +149 -0
  58. package/dist/transports/MailgunTransport.js.map +1 -0
  59. package/dist/transports/ResendTransport.d.ts +7 -0
  60. package/dist/transports/ResendTransport.d.ts.map +1 -0
  61. package/dist/transports/ResendTransport.js +111 -0
  62. package/dist/transports/ResendTransport.js.map +1 -0
  63. package/dist/transports/SendGridTransport.d.ts +7 -0
  64. package/dist/transports/SendGridTransport.d.ts.map +1 -0
  65. package/dist/transports/SendGridTransport.js +165 -0
  66. package/dist/transports/SendGridTransport.js.map +1 -0
  67. package/dist/transports/SesTransport.d.ts +7 -0
  68. package/dist/transports/SesTransport.d.ts.map +1 -0
  69. package/dist/transports/SesTransport.js +334 -0
  70. package/dist/transports/SesTransport.js.map +1 -0
  71. package/dist/transports/fetchError.d.ts +13 -0
  72. package/dist/transports/fetchError.d.ts.map +1 -0
  73. package/dist/transports/fetchError.js +35 -0
  74. package/dist/transports/fetchError.js.map +1 -0
  75. package/dist/webhooks/context.d.ts +35 -0
  76. package/dist/webhooks/context.d.ts.map +1 -0
  77. package/dist/webhooks/context.js +7 -0
  78. package/dist/webhooks/context.js.map +1 -0
  79. package/dist/webhooks/mailgun.d.ts +13 -0
  80. package/dist/webhooks/mailgun.d.ts.map +1 -0
  81. package/dist/webhooks/mailgun.js +102 -0
  82. package/dist/webhooks/mailgun.js.map +1 -0
  83. package/dist/webhooks/resend.d.ts +16 -0
  84. package/dist/webhooks/resend.d.ts.map +1 -0
  85. package/dist/webhooks/resend.js +121 -0
  86. package/dist/webhooks/resend.js.map +1 -0
  87. package/dist/webhooks/sendgrid.d.ts +13 -0
  88. package/dist/webhooks/sendgrid.d.ts.map +1 -0
  89. package/dist/webhooks/sendgrid.js +104 -0
  90. package/dist/webhooks/sendgrid.js.map +1 -0
  91. package/index.darwin-arm64.node +0 -0
  92. package/index.darwin-x64.node +0 -0
  93. package/index.linux-arm64-gnu.node +0 -0
  94. package/index.linux-x64-gnu.node +0 -0
  95. package/index.win32-x64-msvc.node +0 -0
  96. package/package.json +97 -0
  97. package/scripts/copy-napi.mjs +62 -0
  98. package/src/BaseMail.ts +52 -0
  99. package/src/Mail.ts +663 -0
  100. package/src/MessageBuilder.ts +101 -0
  101. package/src/RoverError.ts +44 -0
  102. package/src/RoverProvider.ts +72 -0
  103. package/src/config.ts +7 -0
  104. package/src/index.ts +30 -0
  105. package/src/queue/MailJob.ts +131 -0
  106. package/src/retry.ts +135 -0
  107. package/src/services/main.ts +41 -0
  108. package/src/templating/SimpleTemplate.ts +240 -0
  109. package/src/templating/loadNapi.ts +169 -0
  110. package/src/testing/FakeMail.ts +112 -0
  111. package/src/transports/MailgunTransport.ts +204 -0
  112. package/src/transports/ResendTransport.ts +147 -0
  113. package/src/transports/SendGridTransport.ts +246 -0
  114. package/src/transports/SesTransport.ts +394 -0
  115. package/src/transports/fetchError.ts +42 -0
  116. package/src/webhooks/context.ts +41 -0
  117. package/src/webhooks/mailgun.ts +134 -0
  118. package/src/webhooks/resend.ts +164 -0
  119. package/src/webhooks/sendgrid.ts +133 -0
@@ -0,0 +1,164 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { createHmac, timingSafeEqual } from "node:crypto";
3
+ import type {
4
+ WebhookEmitter,
5
+ WebhookHttpContext,
6
+ WebhookMiddleware,
7
+ } from "./context.js";
8
+
9
+ export interface ResendWebhookOptions {
10
+ /**
11
+ * Webhook signing secret from the Resend dashboard. Supports the full
12
+ * `whsec_...` prefix or a raw base64-encoded key.
13
+ */
14
+ secret: string;
15
+ emitter: WebhookEmitter;
16
+ /**
17
+ * Maximum age in seconds for a signed payload (default 300 = 5 minutes).
18
+ * Set to `Number.POSITIVE_INFINITY` to disable (not recommended).
19
+ */
20
+ maxAgeSeconds?: number;
21
+ }
22
+
23
+ /**
24
+ * Parse a provider-supplied `created_at` into an epoch-ms timestamp,
25
+ * falling back to "now" for anything unparseable. Without the
26
+ * `Number.isFinite` guard, a malformed value like `"not-a-date"`
27
+ * yields `new Date(...).getTime()` === `NaN`, which then leaks into
28
+ * the `mail.*` event payload and breaks any consumer that sorts,
29
+ * persists, or numerically compares the timestamp.
30
+ */
31
+ function parseTimestamp(raw: string | undefined): number {
32
+ if (raw !== undefined && raw !== "") {
33
+ const t = new Date(raw).getTime();
34
+ if (Number.isFinite(t)) return t;
35
+ }
36
+ return Date.now();
37
+ }
38
+
39
+ interface ResendPayload {
40
+ type?: string;
41
+ data?: {
42
+ email_id?: string;
43
+ to?: string | string[];
44
+ reason?: string;
45
+ created_at?: string;
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Resend event → Rover event. Only map events that correspond to durable,
51
+ * decision-grade outcomes. `email.delivery_delayed` is intermediate (not a
52
+ * final failure) and `email.complained` is a deliverability signal (not a
53
+ * delivery failure) — both are dropped to keep the event bus signal-dense.
54
+ */
55
+ const EVENT_MAP: Record<string, string> = {
56
+ "email.delivered": "mail.delivered",
57
+ "email.bounced": "mail.bounced",
58
+ };
59
+
60
+ export function createResendWebhookHandler(
61
+ options: ResendWebhookOptions,
62
+ ): WebhookMiddleware {
63
+ if (!options.secret) {
64
+ throw new Error(
65
+ "createResendWebhookHandler: secret is required for HMAC verification.",
66
+ );
67
+ }
68
+ const secretRaw = options.secret.startsWith("whsec_")
69
+ ? options.secret.slice(6)
70
+ : options.secret;
71
+ const keyBytes = Buffer.from(secretRaw, "base64");
72
+ const emitter = options.emitter;
73
+ const maxAgeSeconds = options.maxAgeSeconds ?? 300;
74
+
75
+ return async (ctx: WebhookHttpContext, _next): Promise<void> => {
76
+ const raw = await Promise.resolve(ctx.request.rawBody());
77
+ const id = ctx.request.header("svix-id");
78
+ const timestamp = ctx.request.header("svix-timestamp");
79
+ const signatureHeader = ctx.request.header("svix-signature");
80
+ if (!id || !timestamp || !signatureHeader) {
81
+ ctx.response.status(401).json({ error: "missing_signature" });
82
+ return;
83
+ }
84
+ const tsSeconds = Number(timestamp);
85
+ if (!Number.isFinite(tsSeconds)) {
86
+ ctx.response.status(401).json({ error: "invalid_signature" });
87
+ return;
88
+ }
89
+ const nowSeconds = Date.now() / 1000;
90
+ if (Math.abs(nowSeconds - tsSeconds) > maxAgeSeconds) {
91
+ ctx.response.status(401).json({ error: "stale_signature" });
92
+ return;
93
+ }
94
+
95
+ if (!verifySvixSignature(raw, id, timestamp, signatureHeader, keyBytes)) {
96
+ ctx.response.status(401).json({ error: "invalid_signature" });
97
+ return;
98
+ }
99
+
100
+ let payload: ResendPayload;
101
+ try {
102
+ payload = JSON.parse(raw.toString("utf8")) as ResendPayload;
103
+ } catch {
104
+ ctx.response.status(400).json({ error: "invalid_json" });
105
+ return;
106
+ }
107
+ emitMappedEvent(emitter, payload);
108
+ ctx.response.status(200).json({ ok: true });
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Verify a Svix signature header. Svix signs `<id>.<timestamp>.<body>` and emits
114
+ * a space-separated list of `v1,<base64sig>` candidates — accept if any matches
115
+ * (constant-time compare).
116
+ */
117
+ function verifySvixSignature(
118
+ raw: Buffer,
119
+ id: string,
120
+ timestamp: string,
121
+ signatureHeader: string,
122
+ keyBytes: Buffer,
123
+ ): boolean {
124
+ const toSign = Buffer.concat([
125
+ Buffer.from(`${id}.${timestamp}.`, "utf8"),
126
+ raw,
127
+ ]);
128
+ const expected = createHmac("sha256", keyBytes)
129
+ .update(toSign)
130
+ .digest("base64");
131
+
132
+ for (const part of signatureHeader.split(" ")) {
133
+ const [scheme, sig] = part.split(",");
134
+ if (scheme !== "v1" || !sig) continue;
135
+ const sigBuf = Buffer.from(sig, "base64");
136
+ const expBuf = Buffer.from(expected, "base64");
137
+ if (sigBuf.length !== expBuf.length) continue;
138
+ if (timingSafeEqual(sigBuf, expBuf)) return true;
139
+ }
140
+ return false;
141
+ }
142
+
143
+ /** Map a Resend payload to a mail event and emit it; emitter throws are swallowed. */
144
+ function emitMappedEvent(
145
+ emitter: WebhookEmitter,
146
+ payload: ResendPayload,
147
+ ): void {
148
+ const mapped = payload.type ? EVENT_MAP[payload.type] : undefined;
149
+ if (!mapped) return;
150
+ const to = Array.isArray(payload.data?.to)
151
+ ? (payload.data.to[0] ?? "")
152
+ : (payload.data?.to ?? "");
153
+ try {
154
+ emitter.emit(mapped, {
155
+ messageId: payload.data?.email_id ?? "",
156
+ to,
157
+ reason: payload.data?.reason,
158
+ timestamp: parseTimestamp(payload.data?.created_at),
159
+ });
160
+ } catch {
161
+ // Emitter failures must not flip the webhook response; providers will
162
+ // replay on non-2xx and flood the bus.
163
+ }
164
+ }
@@ -0,0 +1,133 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { createPublicKey, verify } from "node:crypto";
3
+ import type {
4
+ WebhookEmitter,
5
+ WebhookHttpContext,
6
+ WebhookMiddleware,
7
+ } from "./context.js";
8
+
9
+ export interface SendGridWebhookOptions {
10
+ /** Base64-DER or PEM-encoded Ed25519 public key, as shown in the SendGrid dashboard. */
11
+ publicKey: string;
12
+ emitter: WebhookEmitter;
13
+ /**
14
+ * Maximum age in seconds for a signed payload (default 300 = 5 minutes).
15
+ * Set to `Number.POSITIVE_INFINITY` to disable (not recommended).
16
+ */
17
+ maxAgeSeconds?: number;
18
+ }
19
+
20
+ interface SendGridEvent {
21
+ event?: string;
22
+ email?: string;
23
+ sg_message_id?: string;
24
+ reason?: string;
25
+ timestamp?: number;
26
+ }
27
+
28
+ const EVENT_MAP: Record<string, string> = {
29
+ delivered: "mail.delivered",
30
+ bounce: "mail.bounced",
31
+ dropped: "mail.failed",
32
+ deferred: "mail.failed",
33
+ blocked: "mail.failed",
34
+ };
35
+
36
+ export function createSendGridWebhookHandler(
37
+ options: SendGridWebhookOptions,
38
+ ): WebhookMiddleware {
39
+ if (!options.publicKey) {
40
+ throw new Error(
41
+ "createSendGridWebhookHandler: publicKey is required for Ed25519 verification.",
42
+ );
43
+ }
44
+ const pemKey = toPem(options.publicKey);
45
+ const key = createPublicKey(pemKey);
46
+ const emitter = options.emitter;
47
+ const maxAgeSeconds = options.maxAgeSeconds ?? 300;
48
+
49
+ return async (ctx: WebhookHttpContext, _next): Promise<void> => {
50
+ const raw = await Promise.resolve(ctx.request.rawBody());
51
+ const signature = ctx.request.header(
52
+ "x-twilio-email-event-webhook-signature",
53
+ );
54
+ const timestamp = ctx.request.header(
55
+ "x-twilio-email-event-webhook-timestamp",
56
+ );
57
+ if (!signature || !timestamp) {
58
+ ctx.response.status(401).json({ error: "missing_signature" });
59
+ return;
60
+ }
61
+ const tsSeconds = Number(timestamp);
62
+ if (!Number.isFinite(tsSeconds)) {
63
+ ctx.response.status(401).json({ error: "invalid_signature" });
64
+ return;
65
+ }
66
+ const nowSeconds = Date.now() / 1000;
67
+ if (Math.abs(nowSeconds - tsSeconds) > maxAgeSeconds) {
68
+ ctx.response.status(401).json({ error: "stale_signature" });
69
+ return;
70
+ }
71
+ const payloadToVerify = Buffer.concat([
72
+ Buffer.from(timestamp, "utf8"),
73
+ raw,
74
+ ]);
75
+ let sigBytes: Buffer;
76
+ try {
77
+ sigBytes = Buffer.from(signature, "base64");
78
+ } catch {
79
+ ctx.response.status(401).json({ error: "invalid_signature" });
80
+ return;
81
+ }
82
+ const ok = verify(null, payloadToVerify, key, sigBytes);
83
+ if (!ok) {
84
+ ctx.response.status(401).json({ error: "invalid_signature" });
85
+ return;
86
+ }
87
+
88
+ let events: SendGridEvent[];
89
+ try {
90
+ events = JSON.parse(raw.toString("utf8")) as SendGridEvent[];
91
+ } catch {
92
+ ctx.response.status(400).json({ error: "invalid_json" });
93
+ return;
94
+ }
95
+ if (!Array.isArray(events)) {
96
+ ctx.response.status(400).json({ error: "expected_event_array" });
97
+ return;
98
+ }
99
+ for (const ev of events) {
100
+ if (!ev.event) continue;
101
+ const mapped = EVENT_MAP[ev.event];
102
+ if (!mapped) continue;
103
+ try {
104
+ emitter.emit(mapped, {
105
+ messageId: ev.sg_message_id ?? "",
106
+ to: ev.email ?? "",
107
+ reason: ev.reason,
108
+ timestamp: normaliseTimestamp(ev.timestamp),
109
+ });
110
+ } catch {
111
+ // Never let emitter failures flip the webhook response.
112
+ }
113
+ }
114
+ ctx.response.status(200).json({ ok: true });
115
+ };
116
+ }
117
+
118
+ function toPem(key: string): string {
119
+ const normalised = key.replace(/\r\n/g, "\n");
120
+ if (normalised.includes("BEGIN PUBLIC KEY")) return normalised;
121
+ const der = normalised.replace(/\s/g, "");
122
+ return `-----BEGIN PUBLIC KEY-----\n${der}\n-----END PUBLIC KEY-----`;
123
+ }
124
+
125
+ function normaliseTimestamp(ts: number | undefined): number {
126
+ // Guard non-finite values (NaN / ±Infinity): a malformed `timestamp`
127
+ // field in the webhook JSON would otherwise multiply into NaN and
128
+ // leak into the emitted mail.* event, breaking consumers that sort
129
+ // / persist / compare timestamps numerically.
130
+ if (ts === undefined || !Number.isFinite(ts)) return Date.now();
131
+ if (ts > 1e12) return ts;
132
+ return ts * 1000;
133
+ }