@hogsend/engine 0.9.0 → 0.11.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/package.json +11 -6
- package/src/app.ts +28 -17
- package/src/container.ts +133 -41
- package/src/env.ts +35 -1
- package/src/index.ts +40 -8
- package/src/lib/auth.ts +78 -1
- package/src/lib/bootstrap-admin.ts +90 -0
- package/src/lib/create-admin.ts +104 -0
- package/src/lib/email-provider-registry.ts +45 -0
- package/src/lib/email-providers-from-env.ts +94 -0
- package/src/lib/email-service-types.ts +40 -4
- package/src/lib/headers.ts +13 -0
- package/src/lib/mailer.ts +120 -70
- package/src/lib/outbound.ts +11 -2
- package/src/lib/redis.ts +68 -0
- package/src/lib/reset-email.ts +139 -0
- package/src/lib/tracked.ts +34 -29
- package/src/lib/tracking-events.ts +26 -11
- package/src/middleware/rate-limit.ts +38 -3
- package/src/routes/admin/emails.ts +5 -1
- package/src/routes/tracking/click.ts +1 -1
- package/src/routes/tracking/open.ts +1 -1
- package/src/routes/webhooks/email-provider.ts +124 -0
- package/src/routes/webhooks/index.ts +7 -0
- package/src/routes/webhooks/resend.ts +14 -29
- package/src/routes/webhooks/sources.ts +15 -4
- package/src/workflows/send-email.ts +2 -1
package/src/lib/mailer.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
BatchEmailItem,
|
|
3
|
+
EmailEvent,
|
|
4
|
+
EmailEventType,
|
|
3
5
|
EmailProvider,
|
|
4
|
-
WebhookEvent,
|
|
5
|
-
WebhookEventType,
|
|
6
6
|
WebhookHandlerMap,
|
|
7
7
|
} from "@hogsend/core";
|
|
8
8
|
import type { Database } from "@hogsend/db";
|
|
@@ -13,23 +13,23 @@ import type {
|
|
|
13
13
|
TemplateName,
|
|
14
14
|
} from "@hogsend/email";
|
|
15
15
|
import { getTemplate, renderToHtml, renderToPlainText } from "@hogsend/email";
|
|
16
|
-
import { eq, sql } from "drizzle-orm";
|
|
17
|
-
import
|
|
18
|
-
EmailService,
|
|
19
|
-
EmailServiceConfig,
|
|
20
|
-
EmailServiceSendOptions,
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
16
|
+
import { eq, inArray, sql } from "drizzle-orm";
|
|
17
|
+
import {
|
|
18
|
+
type EmailService,
|
|
19
|
+
type EmailServiceConfig,
|
|
20
|
+
type EmailServiceSendOptions,
|
|
21
|
+
type EmailServiceWebhookResult,
|
|
22
|
+
type SendRawOptions,
|
|
23
|
+
type SendResult,
|
|
24
|
+
type TrackedSendResult,
|
|
25
|
+
trackedSendResult,
|
|
26
26
|
} from "./email-service-types.js";
|
|
27
27
|
import { hatchet } from "./hatchet.js";
|
|
28
28
|
import { createLogger } from "./logger.js";
|
|
29
29
|
import { emitOutbound } from "./outbound.js";
|
|
30
30
|
import type { PrepareTrackedHtmlFn } from "./tracked.js";
|
|
31
31
|
import { sendTrackedEmail } from "./tracked.js";
|
|
32
|
-
import {
|
|
32
|
+
import { resolveEmailSendContextByMessageId } from "./tracking-events.js";
|
|
33
33
|
|
|
34
34
|
// Fallback logger for the provider-webhook outbound emit — `config.logger` is
|
|
35
35
|
// optional, but `emitOutbound` requires one. Mirrors the engine-lib singleton
|
|
@@ -37,7 +37,7 @@ import { resolveEmailSendContextByResendId } from "./tracking-events.js";
|
|
|
37
37
|
const emitLogger = createLogger(process.env.LOG_LEVEL);
|
|
38
38
|
|
|
39
39
|
const WEBHOOK_TO_STATUS_FIELD: Partial<
|
|
40
|
-
Record<
|
|
40
|
+
Record<EmailEventType, keyof typeof emailSends.$inferSelect>
|
|
41
41
|
> = {
|
|
42
42
|
"email.sent": "sentAt",
|
|
43
43
|
"email.delivered": "deliveredAt",
|
|
@@ -47,7 +47,7 @@ const WEBHOOK_TO_STATUS_FIELD: Partial<
|
|
|
47
47
|
"email.complained": "complainedAt",
|
|
48
48
|
};
|
|
49
49
|
|
|
50
|
-
const WEBHOOK_TO_STATUS: Partial<Record<
|
|
50
|
+
const WEBHOOK_TO_STATUS: Partial<Record<EmailEventType, string>> = {
|
|
51
51
|
"email.sent": "sent",
|
|
52
52
|
"email.delivered": "delivered",
|
|
53
53
|
"email.opened": "opened",
|
|
@@ -56,6 +56,10 @@ const WEBHOOK_TO_STATUS: Partial<Record<WebhookEventType, string>> = {
|
|
|
56
56
|
"email.complained": "complained",
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
+
/** Max recipients we will iterate on a bounce/complaint, to avoid a fan-out
|
|
60
|
+
* webhook mass-suppressing addresses. Above this we log + skip suppression. */
|
|
61
|
+
const MAX_SUPPRESSION_RECIPIENTS = 100;
|
|
62
|
+
|
|
59
63
|
/**
|
|
60
64
|
* The engine-owned high-level mailer. It owns the full send pipeline —
|
|
61
65
|
* render → preference/suppression check → tracked-html rewrite → `email_sends`
|
|
@@ -78,6 +82,27 @@ export function createTrackedMailer(
|
|
|
78
82
|
return overrideFrom ?? config.defaultFrom;
|
|
79
83
|
}
|
|
80
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Drop `scheduledAt` unless the active provider declares
|
|
87
|
+
* `capabilities.scheduledSend`. A provider that can't natively schedule (e.g.
|
|
88
|
+
* Postmark/SES) would silently ignore it — so the engine strips it and logs a
|
|
89
|
+
* WARN pointing at the durable alternative (`ctx.sleepUntil`).
|
|
90
|
+
*/
|
|
91
|
+
function applyScheduledAtGate<T extends { scheduledAt?: string }>(
|
|
92
|
+
opts: T,
|
|
93
|
+
): T {
|
|
94
|
+
if (opts.scheduledAt && provider.capabilities?.scheduledSend !== true) {
|
|
95
|
+
(config.logger ?? emitLogger).warn(
|
|
96
|
+
`scheduledAt ignored: provider ${
|
|
97
|
+
provider.meta?.id ?? "resend"
|
|
98
|
+
} has no native scheduled send; use ctx.sleepUntil`,
|
|
99
|
+
);
|
|
100
|
+
const { scheduledAt: _dropped, ...rest } = opts;
|
|
101
|
+
return rest as T;
|
|
102
|
+
}
|
|
103
|
+
return opts;
|
|
104
|
+
}
|
|
105
|
+
|
|
81
106
|
const service: EmailService = {
|
|
82
107
|
async send<K extends TemplateName>(
|
|
83
108
|
options: EmailServiceSendOptions<K>,
|
|
@@ -118,25 +143,30 @@ export function createTrackedMailer(
|
|
|
118
143
|
props: options.props,
|
|
119
144
|
registry,
|
|
120
145
|
});
|
|
146
|
+
// HTML-ONLY wire — the engine ALWAYS renders React → HTML itself before
|
|
147
|
+
// the provider. React Email stays first-class for authoring/Studio; it
|
|
148
|
+
// never crosses the provider boundary.
|
|
149
|
+
const html = await renderToHtml(element);
|
|
121
150
|
const result = await provider.send({
|
|
122
151
|
from,
|
|
123
152
|
to: options.to,
|
|
124
153
|
subject: options.subject ?? defaultSubject,
|
|
125
|
-
|
|
154
|
+
html,
|
|
126
155
|
tags: options.tags,
|
|
127
156
|
headers: options.headers,
|
|
128
157
|
replyTo: options.replyTo,
|
|
129
158
|
});
|
|
130
159
|
|
|
131
|
-
return {
|
|
160
|
+
return trackedSendResult({
|
|
132
161
|
emailSendId: "",
|
|
133
|
-
|
|
162
|
+
messageId: result.id,
|
|
134
163
|
status: "sent",
|
|
135
|
-
};
|
|
164
|
+
});
|
|
136
165
|
},
|
|
137
166
|
|
|
138
167
|
async sendRaw(options: SendRawOptions): Promise<SendResult> {
|
|
139
|
-
|
|
168
|
+
const gated = applyScheduledAtGate(options);
|
|
169
|
+
return provider.send({ ...gated, from: resolveFrom(options.from) });
|
|
140
170
|
},
|
|
141
171
|
|
|
142
172
|
async sendBatch(options: {
|
|
@@ -167,23 +197,14 @@ export function createTrackedMailer(
|
|
|
167
197
|
},
|
|
168
198
|
|
|
169
199
|
async handleWebhook(
|
|
170
|
-
|
|
200
|
+
event: EmailEvent,
|
|
201
|
+
_providerId?: string,
|
|
171
202
|
): Promise<EmailServiceWebhookResult> {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
);
|
|
176
|
-
}
|
|
177
|
-
|
|
203
|
+
// The route owns provider resolution + signature verification and hands us
|
|
204
|
+
// an already-verified, provider-neutral EmailEvent. No secret gate here —
|
|
205
|
+
// each provider owns its own webhook secret at construction time.
|
|
178
206
|
const userHandlers: WebhookHandlerMap = config.webhookHandlers ?? {};
|
|
179
|
-
|
|
180
|
-
const event = provider.verifyWebhook({
|
|
181
|
-
payload: options.payload,
|
|
182
|
-
headers: options.headers,
|
|
183
|
-
});
|
|
184
|
-
|
|
185
207
|
const handled = await dispatchWebhook(event, userHandlers);
|
|
186
|
-
|
|
187
208
|
return { type: event.type, handled };
|
|
188
209
|
},
|
|
189
210
|
};
|
|
@@ -191,7 +212,7 @@ export function createTrackedMailer(
|
|
|
191
212
|
const bounceThreshold = config.bounceThreshold ?? 3;
|
|
192
213
|
|
|
193
214
|
async function dispatchWebhook(
|
|
194
|
-
event:
|
|
215
|
+
event: EmailEvent,
|
|
195
216
|
userHandlers: WebhookHandlerMap,
|
|
196
217
|
): Promise<boolean> {
|
|
197
218
|
switch (event.type) {
|
|
@@ -199,13 +220,13 @@ export function createTrackedMailer(
|
|
|
199
220
|
// `email.sent` is emitted FIRST-PARTY from the tracked mailer's
|
|
200
221
|
// provider-accepted branch (lib/tracked.ts) with the rich payload — the
|
|
201
222
|
// provider-webhook echo only updates the DB status, it does NOT emit.
|
|
202
|
-
await updateEmailStatus(event.type, event.
|
|
223
|
+
await updateEmailStatus(event.type, event.messageId);
|
|
203
224
|
break;
|
|
204
225
|
case "email.delivered":
|
|
205
|
-
await updateEmailStatus(event.type, event.
|
|
226
|
+
await updateEmailStatus(event.type, event.messageId);
|
|
206
227
|
// OUTBOUND `email.delivered` — the provider webhook is the SINGLE source
|
|
207
228
|
// for delivered/bounced (these have no first-party signal).
|
|
208
|
-
await emitProviderEmailEvent("email.delivered", event.
|
|
229
|
+
await emitProviderEmailEvent("email.delivered", event.messageId);
|
|
209
230
|
break;
|
|
210
231
|
case "email.opened":
|
|
211
232
|
case "email.clicked":
|
|
@@ -213,34 +234,45 @@ export function createTrackedMailer(
|
|
|
213
234
|
// open/click — it now fires PER-HIT (every open/click → a delivery to
|
|
214
235
|
// every destination, owner decision 1). The provider-webhook echo is
|
|
215
236
|
// SUPPRESSED here: it only updates the DB status, it does NOT emit
|
|
216
|
-
// outbound (no double-source).
|
|
217
|
-
|
|
237
|
+
// outbound (no double-source). This is the outbound-echo defence for a
|
|
238
|
+
// provider with native tracking left ON.
|
|
239
|
+
await updateEmailStatus(event.type, event.messageId);
|
|
218
240
|
break;
|
|
219
241
|
case "email.bounced":
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
242
|
+
// `bounce.class` is stored in `bounceType`, the human reason in
|
|
243
|
+
// `bounceReason`. Soft/transient bounces are recorded here too (status
|
|
244
|
+
// `bounced`, `class:'transient'`) — the old transient →
|
|
245
|
+
// `email.delivery_delayed` no-op is gone.
|
|
246
|
+
await updateEmailStatus(event.type, event.messageId, {
|
|
247
|
+
bounceType: event.bounce?.class,
|
|
248
|
+
bounceReason: event.bounce?.reason,
|
|
223
249
|
});
|
|
224
|
-
// OUTBOUND `email.bounced` with the bounce detail.
|
|
225
|
-
await emitProviderEmailEvent("email.bounced", event.
|
|
226
|
-
bounceType: event.
|
|
227
|
-
bounceReason: event.
|
|
250
|
+
// OUTBOUND `email.bounced` with the bounce detail (class + reason).
|
|
251
|
+
await emitProviderEmailEvent("email.bounced", event.messageId, {
|
|
252
|
+
bounceType: event.bounce?.class,
|
|
253
|
+
bounceReason: event.bounce?.reason,
|
|
228
254
|
});
|
|
229
|
-
|
|
255
|
+
// Suppress (increment bounceCount toward threshold) ONLY on a permanent
|
|
256
|
+
// bounce. Transient/unknown are recorded but never auto-suppress.
|
|
257
|
+
if (event.bounce?.class === "permanent") {
|
|
258
|
+
await handleBounce(event.recipients);
|
|
259
|
+
}
|
|
230
260
|
break;
|
|
231
261
|
case "email.complained":
|
|
232
|
-
await updateEmailStatus(event.type, event.
|
|
262
|
+
await updateEmailStatus(event.type, event.messageId);
|
|
233
263
|
// OUTBOUND `email.complained` — the provider webhook is the SINGLE
|
|
234
264
|
// source for complaints (no first-party signal exists).
|
|
235
|
-
await emitProviderEmailEvent("email.complained", event.
|
|
236
|
-
await handleComplaint(event.
|
|
265
|
+
await emitProviderEmailEvent("email.complained", event.messageId);
|
|
266
|
+
await handleComplaint(event.recipients);
|
|
237
267
|
break;
|
|
238
268
|
case "email.delivery_delayed":
|
|
269
|
+
// No-op: providers now map transient bounces to `email.bounced` with
|
|
270
|
+
// `class:'transient'`, so soft bounces are recorded there instead.
|
|
239
271
|
break;
|
|
240
272
|
}
|
|
241
273
|
|
|
242
274
|
const userHandler = userHandlers[event.type] as
|
|
243
|
-
| ((e:
|
|
275
|
+
| ((e: EmailEvent) => void | Promise<void>)
|
|
244
276
|
| undefined;
|
|
245
277
|
if (userHandler) {
|
|
246
278
|
await userHandler(event);
|
|
@@ -250,11 +282,28 @@ export function createTrackedMailer(
|
|
|
250
282
|
return false;
|
|
251
283
|
}
|
|
252
284
|
|
|
253
|
-
|
|
285
|
+
/** Recipients to actually act on: de-duped, falsy-stripped, count-capped. A
|
|
286
|
+
* fan-out webhook over the cap is logged + skipped to avoid mass-suppression. */
|
|
287
|
+
function validRecipients(recipients: string[]): string[] {
|
|
288
|
+
const unique = [...new Set(recipients.filter(Boolean))];
|
|
289
|
+
if (unique.length > MAX_SUPPRESSION_RECIPIENTS) {
|
|
290
|
+
(config.logger ?? emitLogger).warn(
|
|
291
|
+
"suppression skipped: recipient count exceeds cap",
|
|
292
|
+
{ count: unique.length, cap: MAX_SUPPRESSION_RECIPIENTS },
|
|
293
|
+
);
|
|
294
|
+
return [];
|
|
295
|
+
}
|
|
296
|
+
return unique;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function handleBounce(recipients: string[]): Promise<void> {
|
|
254
300
|
if (!db) return;
|
|
255
|
-
const
|
|
256
|
-
if (
|
|
301
|
+
const emails = validRecipients(recipients);
|
|
302
|
+
if (emails.length === 0) return;
|
|
257
303
|
|
|
304
|
+
// ONE statement for all recipients. The CASE-WHEN auto-suppress at threshold
|
|
305
|
+
// is evaluated PER ROW inside the single UPDATE, so semantics match the old
|
|
306
|
+
// per-email loop exactly.
|
|
258
307
|
await db
|
|
259
308
|
.update(emailPreferences)
|
|
260
309
|
.set({
|
|
@@ -264,14 +313,15 @@ export function createTrackedMailer(
|
|
|
264
313
|
suppressedAt: sql`CASE WHEN ${emailPreferences.bounceCount} + 1 >= ${bounceThreshold} THEN NOW() ELSE ${emailPreferences.suppressedAt} END`,
|
|
265
314
|
updatedAt: new Date(),
|
|
266
315
|
})
|
|
267
|
-
.where(
|
|
316
|
+
.where(inArray(emailPreferences.email, emails));
|
|
268
317
|
}
|
|
269
318
|
|
|
270
|
-
async function handleComplaint(
|
|
319
|
+
async function handleComplaint(recipients: string[]): Promise<void> {
|
|
271
320
|
if (!db) return;
|
|
272
|
-
const
|
|
273
|
-
if (
|
|
321
|
+
const emails = validRecipients(recipients);
|
|
322
|
+
if (emails.length === 0) return;
|
|
274
323
|
|
|
324
|
+
// ONE statement for all recipients (same semantics as the old per-email loop).
|
|
275
325
|
await db
|
|
276
326
|
.update(emailPreferences)
|
|
277
327
|
.set({
|
|
@@ -279,15 +329,15 @@ export function createTrackedMailer(
|
|
|
279
329
|
suppressedAt: new Date(),
|
|
280
330
|
updatedAt: new Date(),
|
|
281
331
|
})
|
|
282
|
-
.where(
|
|
332
|
+
.where(inArray(emailPreferences.email, emails));
|
|
283
333
|
}
|
|
284
334
|
|
|
285
335
|
/**
|
|
286
336
|
* Emit the provider-funnel outbound event (`email.delivered` /
|
|
287
|
-
* `email.bounced` / `email.complained`) for a
|
|
337
|
+
* `email.bounced` / `email.complained`) for a provider `messageId`. These three
|
|
288
338
|
* have no first-party signal — the provider webhook is their single source.
|
|
289
|
-
* Enriches via {@link
|
|
290
|
-
* (the only handle a provider webhook holds is the
|
|
339
|
+
* Enriches via {@link resolveEmailSendContextByMessageId}
|
|
340
|
+
* (the only handle a provider webhook holds is the message id). Fire-and-forget:
|
|
291
341
|
* a missing context (webhook racing the send-row commit) or a transient outbound
|
|
292
342
|
* error is logged and swallowed — never failing the webhook handler. No
|
|
293
343
|
* `dedupeKey`: the provider path is not a Hatchet-retryable producer, and the
|
|
@@ -295,18 +345,18 @@ export function createTrackedMailer(
|
|
|
295
345
|
*/
|
|
296
346
|
function emitProviderEmailEvent(
|
|
297
347
|
event: "email.delivered" | "email.bounced" | "email.complained",
|
|
298
|
-
|
|
348
|
+
messageId: string,
|
|
299
349
|
bounce?: { bounceType?: string; bounceReason?: string },
|
|
300
350
|
): void {
|
|
301
351
|
if (!db) return;
|
|
302
352
|
const log = config.logger ?? emitLogger;
|
|
303
353
|
const database = db;
|
|
304
|
-
void
|
|
354
|
+
void resolveEmailSendContextByMessageId(database, messageId)
|
|
305
355
|
.then((ctx) => {
|
|
306
356
|
if (!ctx) return;
|
|
307
357
|
const base = {
|
|
308
358
|
emailSendId: ctx.emailSendId,
|
|
309
|
-
|
|
359
|
+
messageId,
|
|
310
360
|
templateKey: ctx.templateKey,
|
|
311
361
|
userId: ctx.userId,
|
|
312
362
|
to: ctx.to,
|
|
@@ -346,15 +396,15 @@ export function createTrackedMailer(
|
|
|
346
396
|
})
|
|
347
397
|
.catch((err: unknown) => {
|
|
348
398
|
log.warn(`emitOutbound ${event} failed`, {
|
|
349
|
-
|
|
399
|
+
messageId,
|
|
350
400
|
error: err instanceof Error ? err.message : String(err),
|
|
351
401
|
});
|
|
352
402
|
});
|
|
353
403
|
}
|
|
354
404
|
|
|
355
405
|
async function updateEmailStatus(
|
|
356
|
-
eventType:
|
|
357
|
-
|
|
406
|
+
eventType: EmailEventType,
|
|
407
|
+
messageId: string,
|
|
358
408
|
extra?: { bounceType?: string; bounceReason?: string },
|
|
359
409
|
): Promise<void> {
|
|
360
410
|
if (!db) return;
|
|
@@ -372,7 +422,7 @@ export function createTrackedMailer(
|
|
|
372
422
|
...(extra?.bounceReason ? { bounceReason: extra.bounceReason } : {}),
|
|
373
423
|
updatedAt: new Date(),
|
|
374
424
|
})
|
|
375
|
-
.where(eq(emailSends.
|
|
425
|
+
.where(eq(emailSends.messageId, messageId));
|
|
376
426
|
}
|
|
377
427
|
|
|
378
428
|
return service;
|
package/src/lib/outbound.ts
CHANGED
|
@@ -15,7 +15,16 @@ import {
|
|
|
15
15
|
} from "./webhook-signing.js";
|
|
16
16
|
|
|
17
17
|
export {
|
|
18
|
+
type EmailSendContextByMessageId,
|
|
19
|
+
/**
|
|
20
|
+
* @deprecated Kept for one minor; use {@link EmailSendContextByMessageId}.
|
|
21
|
+
*/
|
|
18
22
|
type ResendEmailSendContext,
|
|
23
|
+
resolveEmailSendContextByMessageId,
|
|
24
|
+
/**
|
|
25
|
+
* @deprecated Kept for one minor; use
|
|
26
|
+
* {@link resolveEmailSendContextByMessageId}.
|
|
27
|
+
*/
|
|
19
28
|
resolveEmailSendContextByResendId,
|
|
20
29
|
} from "./tracking-events.js";
|
|
21
30
|
|
|
@@ -28,7 +37,7 @@ export type OutboundEventName = WebhookEventType;
|
|
|
28
37
|
|
|
29
38
|
interface EmailEventPayload {
|
|
30
39
|
emailSendId: string;
|
|
31
|
-
|
|
40
|
+
messageId: string | null;
|
|
32
41
|
templateKey: string | null;
|
|
33
42
|
userId: string | null;
|
|
34
43
|
to: string;
|
|
@@ -69,7 +78,7 @@ export interface OutboundPayloads {
|
|
|
69
78
|
};
|
|
70
79
|
"email.sent": {
|
|
71
80
|
emailSendId: string;
|
|
72
|
-
|
|
81
|
+
messageId: string;
|
|
73
82
|
templateKey: string | null;
|
|
74
83
|
to: string;
|
|
75
84
|
userId: string | null;
|
package/src/lib/redis.ts
CHANGED
|
@@ -28,3 +28,71 @@ export function getRedis(): Redis {
|
|
|
28
28
|
export function getRedisIfConnected(): Redis | undefined {
|
|
29
29
|
return _redis;
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Namespace for better-auth's secondary-storage keys so they never collide with
|
|
34
|
+
* the PostHog person-property cache or the worker heartbeat sharing this Redis.
|
|
35
|
+
*/
|
|
36
|
+
const AUTH_STORAGE_PREFIX = "hogsend:auth:";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The minimal shape better-auth's `secondaryStorage` option expects. Mirrors
|
|
40
|
+
* `@better-auth/core`'s `SecondaryStorage` so we don't pull a type out of a deep
|
|
41
|
+
* subpath: `get` returns the raw stored string (better-auth `JSON.parse`s it),
|
|
42
|
+
* `set` takes an optional TTL in SECONDS, `delete` removes the key.
|
|
43
|
+
*/
|
|
44
|
+
export interface AuthSecondaryStorage {
|
|
45
|
+
get: (key: string) => Promise<string | null>;
|
|
46
|
+
set: (key: string, value: string, ttl?: number) => Promise<void>;
|
|
47
|
+
delete: (key: string) => Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Adapt an ioredis client to better-auth's `secondaryStorage` contract so all
|
|
52
|
+
* better-auth session AND rate-limit counters live in Redis — shared across
|
|
53
|
+
* Railway replicas and surviving restarts. Without this, better-auth defaults
|
|
54
|
+
* `rateLimit.storage` to in-memory (per-instance, reset on redeploy), so the
|
|
55
|
+
* sign-in / request-password-reset limits are materially weaker than they look
|
|
56
|
+
* on a multi-replica deploy (security finding #2).
|
|
57
|
+
*
|
|
58
|
+
* Reuses the SHARED engine Redis singleton ({@link getRedis}) — it never opens a
|
|
59
|
+
* second pool. better-auth gives `set` a TTL in SECONDS, which we honour with
|
|
60
|
+
* `EX`; entries with no TTL persist (matching better-auth's own behaviour).
|
|
61
|
+
*
|
|
62
|
+
* Every operation is wrapped so a Redis blip degrades gracefully instead of
|
|
63
|
+
* crashing the auth flow: `get` returns `null` (better-auth treats it as a
|
|
64
|
+
* miss), `set`/`delete` no-op. We never want a transient cache fault to take
|
|
65
|
+
* down sign-in.
|
|
66
|
+
*/
|
|
67
|
+
export function createRedisSecondaryStorage(
|
|
68
|
+
redis: Redis,
|
|
69
|
+
): AuthSecondaryStorage {
|
|
70
|
+
const k = (key: string) => `${AUTH_STORAGE_PREFIX}${key}`;
|
|
71
|
+
return {
|
|
72
|
+
async get(key) {
|
|
73
|
+
try {
|
|
74
|
+
return await redis.get(k(key));
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
async set(key, value, ttl) {
|
|
80
|
+
try {
|
|
81
|
+
if (typeof ttl === "number" && ttl > 0) {
|
|
82
|
+
await redis.set(k(key), value, "EX", ttl);
|
|
83
|
+
} else {
|
|
84
|
+
await redis.set(k(key), value);
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// Degrade to no-op — never fail the auth flow on a cache write.
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
async delete(key) {
|
|
91
|
+
try {
|
|
92
|
+
await redis.del(k(key));
|
|
93
|
+
} catch {
|
|
94
|
+
// Degrade to no-op.
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { getEmailService } from "./email.js";
|
|
2
|
+
import type { EmailService } from "./email-service-types.js";
|
|
3
|
+
import { createLogger, type Logger } from "./logger.js";
|
|
4
|
+
|
|
5
|
+
// Fallback logger for the no-provider warning — callers may not pass one. Mirrors
|
|
6
|
+
// the engine-lib singleton pattern (mailer, define-journey, tracked).
|
|
7
|
+
const fallbackLogger = createLogger(process.env.LOG_LEVEL);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The TTL the reset link advertises in its copy. Mirrors the
|
|
11
|
+
* `resetPasswordTokenExpiresIn` we configure in `createAuth` (15 minutes) so the
|
|
12
|
+
* email never claims a window that doesn't match the server's enforcement.
|
|
13
|
+
*/
|
|
14
|
+
const RESET_TTL_MINUTES = 15;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The engine-owned, self-contained password-reset email. The engine ships NO
|
|
18
|
+
* business templates (those live in the consumer's `src/emails/`), so a reset
|
|
19
|
+
* email that required a consumer template would break the "works out of the box"
|
|
20
|
+
* guarantee. This builds a tiny inline HTML + plaintext body — no React Email
|
|
21
|
+
* dependency, no template-registry lookup — and sends it through the resolved
|
|
22
|
+
* provider via the mailer's RAW path.
|
|
23
|
+
*
|
|
24
|
+
* `sendRaw` is correct here (not `send`): a password reset is strictly
|
|
25
|
+
* transactional and must bypass template resolution AND the
|
|
26
|
+
* preference/suppression check — a recovering operator must always receive it,
|
|
27
|
+
* marketing opt-out or not. There is no tracking pixel and no unsubscribe footer
|
|
28
|
+
* for the same reason.
|
|
29
|
+
*
|
|
30
|
+
* Security:
|
|
31
|
+
* - NEVER logs the `url` or the token. On a delivery failure we log a generic
|
|
32
|
+
* warning (pointing the operator at the CLI) and RESOLVE without throwing, so
|
|
33
|
+
* better-auth's neutral "if this email exists…" response is preserved (no user
|
|
34
|
+
* enumeration, no leak of whether the address was real).
|
|
35
|
+
* - The `from` resolves from `EMAIL_FROM ?? RESEND_FROM_EMAIL` — but we don't
|
|
36
|
+
* pass it explicitly: the mailer's `sendRaw` defaults `from` to its configured
|
|
37
|
+
* `defaultFrom`, which is exactly that pair.
|
|
38
|
+
*/
|
|
39
|
+
export async function sendResetPasswordEmail(opts: {
|
|
40
|
+
to: string;
|
|
41
|
+
url: string;
|
|
42
|
+
/**
|
|
43
|
+
* The mailer to send through. Optional: defaults to the container-installed
|
|
44
|
+
* singleton (`getEmailService()`). Injectable so tests can pass a spy and
|
|
45
|
+
* assert the send fires without touching a real provider.
|
|
46
|
+
*/
|
|
47
|
+
emailService?: EmailService;
|
|
48
|
+
/** Optional structured logger; defaults to a stdout logger. */
|
|
49
|
+
logger?: Logger;
|
|
50
|
+
}): Promise<void> {
|
|
51
|
+
const { to, url } = opts;
|
|
52
|
+
const log = opts.logger ?? fallbackLogger;
|
|
53
|
+
|
|
54
|
+
let service: EmailService;
|
|
55
|
+
try {
|
|
56
|
+
service = opts.emailService ?? getEmailService();
|
|
57
|
+
} catch {
|
|
58
|
+
// The mailer singleton hasn't been installed (container never booted). Steer
|
|
59
|
+
// the operator to the guaranteed recovery path; never throw (preserves the
|
|
60
|
+
// neutral response). Do NOT log the url/token.
|
|
61
|
+
log.warn(
|
|
62
|
+
"password reset requested but no email service is configured — use `hogsend studio admin reset`",
|
|
63
|
+
);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const subject = "Reset your Hogsend Studio password";
|
|
68
|
+
const html = buildResetHtml(url);
|
|
69
|
+
const text = buildResetText(url);
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
await service.sendRaw({ to, subject, html, text });
|
|
73
|
+
} catch (error) {
|
|
74
|
+
// A provider error (missing/invalid key, network) must not surface to the
|
|
75
|
+
// caller — better-auth's neutral response stays intact. Log a generic
|
|
76
|
+
// warning that points at the CLI fallback. NEVER include the url/token.
|
|
77
|
+
log.warn(
|
|
78
|
+
"password reset email failed to send (no usable email provider?) — use `hogsend studio admin reset`",
|
|
79
|
+
{ error: error instanceof Error ? error.message : String(error) },
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Minimal, dependency-free HTML body. The URL appears as a button and a raw
|
|
85
|
+
* link (so it works even when buttons are stripped). No tracking, no footer. */
|
|
86
|
+
function buildResetHtml(url: string): string {
|
|
87
|
+
const safeUrl = escapeHtmlAttr(url);
|
|
88
|
+
const safeText = escapeHtml(url);
|
|
89
|
+
return `<!doctype html>
|
|
90
|
+
<html lang="en">
|
|
91
|
+
<body style="margin:0;padding:24px;background:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#18181b;">
|
|
92
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;padding:32px;">
|
|
93
|
+
<tr><td>
|
|
94
|
+
<h1 style="margin:0 0 16px;font-size:20px;font-weight:600;">Reset your password</h1>
|
|
95
|
+
<p style="margin:0 0 24px;font-size:14px;line-height:1.6;color:#3f3f46;">
|
|
96
|
+
We received a request to reset your Hogsend Studio password. Click the
|
|
97
|
+
button below to choose a new one.
|
|
98
|
+
</p>
|
|
99
|
+
<p style="margin:0 0 24px;">
|
|
100
|
+
<a href="${safeUrl}" style="display:inline-block;background:#18181b;color:#ffffff;text-decoration:none;font-size:14px;font-weight:600;padding:12px 20px;border-radius:6px;">Reset password</a>
|
|
101
|
+
</p>
|
|
102
|
+
<p style="margin:0 0 16px;font-size:13px;line-height:1.6;color:#71717a;">
|
|
103
|
+
Or paste this link into your browser:<br />
|
|
104
|
+
<a href="${safeUrl}" style="color:#2563eb;word-break:break-all;">${safeText}</a>
|
|
105
|
+
</p>
|
|
106
|
+
<p style="margin:0;font-size:12px;line-height:1.6;color:#a1a1aa;">
|
|
107
|
+
This link expires in ${RESET_TTL_MINUTES} minutes and can be used once.
|
|
108
|
+
If you didn't request a password reset, you can safely ignore this email.
|
|
109
|
+
</p>
|
|
110
|
+
</td></tr>
|
|
111
|
+
</table>
|
|
112
|
+
</body>
|
|
113
|
+
</html>`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Plain-text alternative (same content, no markup). */
|
|
117
|
+
function buildResetText(url: string): string {
|
|
118
|
+
return [
|
|
119
|
+
"Reset your password",
|
|
120
|
+
"",
|
|
121
|
+
"We received a request to reset your Hogsend Studio password.",
|
|
122
|
+
"Open this link to choose a new one:",
|
|
123
|
+
"",
|
|
124
|
+
url,
|
|
125
|
+
"",
|
|
126
|
+
`This link expires in ${RESET_TTL_MINUTES} minutes and can be used once.`,
|
|
127
|
+
"If you didn't request a password reset, you can safely ignore this email.",
|
|
128
|
+
].join("\n");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Escape for an HTML text node. */
|
|
132
|
+
function escapeHtml(s: string): string {
|
|
133
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Escape for a double-quoted HTML attribute (e.g. an `href`). */
|
|
137
|
+
function escapeHtmlAttr(s: string): string {
|
|
138
|
+
return escapeHtml(s).replace(/"/g, """);
|
|
139
|
+
}
|