@steve31415/baselib 3.50.0 → 3.51.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/dist/email.d.ts +16 -1
- package/dist/email.js +31 -4
- package/package.json +1 -1
package/dist/email.d.ts
CHANGED
|
@@ -36,10 +36,25 @@ export interface EmailDeps {
|
|
|
36
36
|
fetchImpl?: typeof fetch;
|
|
37
37
|
/** Merged into every log line so callers can attribute the send. */
|
|
38
38
|
context?: Record<string, unknown>;
|
|
39
|
+
/** Whole-request deadline (connect, response and body), default
|
|
40
|
+
* `DEFAULT_EMAIL_TIMEOUT_MS`. A timed-out send is an ordinary failure,
|
|
41
|
+
* but Resend may have accepted it and delivered the email: retry with
|
|
42
|
+
* the same `idempotencyKey` to be safe. */
|
|
43
|
+
timeoutMs?: number;
|
|
44
|
+
/** Sent as Resend's `Idempotency-Key` HTTP request header (not an email
|
|
45
|
+
* header — those are `EmailMessage.headers`). Up to 256 characters; for
|
|
46
|
+
* 24 hours a retry with the same key and the same message returns the
|
|
47
|
+
* original send's id without sending again. Derive it from what makes
|
|
48
|
+
* the email unique (e.g. `digest:<user>:<date>`), never from the
|
|
49
|
+
* attempt. See the header of this file for the verified semantics. */
|
|
50
|
+
idempotencyKey?: string;
|
|
39
51
|
}
|
|
52
|
+
export declare const DEFAULT_EMAIL_TIMEOUT_MS = 30000;
|
|
40
53
|
/**
|
|
41
54
|
* Send one email through Resend. Never throws: every failure is an ERROR
|
|
42
55
|
* log (with enough of the message to redo the send by hand) and an
|
|
43
|
-
* `{ ok: false, error }` result the caller surfaces where it matters.
|
|
56
|
+
* `{ ok: false, error }` result the caller surfaces where it matters. That
|
|
57
|
+
* includes a timeout (`deps.timeoutMs`), after which the email may
|
|
58
|
+
* nevertheless have been delivered.
|
|
44
59
|
*/
|
|
45
60
|
export declare function sendEmail(msg: EmailMessage, deps: EmailDeps): Promise<SendEmailResult>;
|
package/dist/email.js
CHANGED
|
@@ -14,22 +14,38 @@
|
|
|
14
14
|
// about external systems"): `POST https://api.resend.com/emails` with
|
|
15
15
|
// `{from, to: [to], subject, text?, html?}` answers 200 `{id}`; failures are
|
|
16
16
|
// 4xx/5xx with a JSON body.
|
|
17
|
+
//
|
|
18
|
+
// Idempotency (verified 2026-11-02 against
|
|
19
|
+
// https://resend.com/docs/dashboard/emails/idempotency-keys and a live probe
|
|
20
|
+
// to the `delivered@resend.dev` test sink): the HTTP request header
|
|
21
|
+
// `Idempotency-Key`, 1–256 characters, kept 24 hours. A repeat with the same
|
|
22
|
+
// key and the same body sends nothing and answers 200 with the original
|
|
23
|
+
// `{id}`, so a replayed send is an ordinary success here. A repeat with a
|
|
24
|
+
// different body is 409 `invalid_idempotent_request`; one that arrives while
|
|
25
|
+
// the first is still in flight is 409 `concurrent_idempotent_requests`
|
|
26
|
+
// (retry later); a key over 256 characters is 400 `invalid_idempotency_key`.
|
|
27
|
+
// All three are failures: none confirms that this message was accepted.
|
|
17
28
|
import { serializeError, truncate } from './log-core.js';
|
|
18
29
|
export const RESEND_API_URL = 'https://api.resend.com/emails';
|
|
30
|
+
export const DEFAULT_EMAIL_TIMEOUT_MS = 30_000;
|
|
19
31
|
/**
|
|
20
32
|
* Send one email through Resend. Never throws: every failure is an ERROR
|
|
21
33
|
* log (with enough of the message to redo the send by hand) and an
|
|
22
|
-
* `{ ok: false, error }` result the caller surfaces where it matters.
|
|
34
|
+
* `{ ok: false, error }` result the caller surfaces where it matters. That
|
|
35
|
+
* includes a timeout (`deps.timeoutMs`), after which the email may
|
|
36
|
+
* nevertheless have been delivered.
|
|
23
37
|
*/
|
|
24
38
|
export async function sendEmail(msg, deps) {
|
|
25
39
|
const { logger } = deps;
|
|
26
40
|
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
41
|
+
const timeoutMs = deps.timeoutMs ?? DEFAULT_EMAIL_TIMEOUT_MS;
|
|
27
42
|
const base = {
|
|
28
43
|
to: msg.to,
|
|
29
44
|
subject: truncate(msg.subject, 200),
|
|
30
45
|
textLength: msg.text?.length ?? 0,
|
|
31
46
|
htmlLength: msg.html?.length ?? 0,
|
|
32
47
|
attachmentCount: msg.attachments?.length ?? 0,
|
|
48
|
+
...(deps.idempotencyKey !== undefined ? { idempotencyKey: deps.idempotencyKey } : {}),
|
|
33
49
|
...deps.context,
|
|
34
50
|
};
|
|
35
51
|
const preview = { text: truncate(msg.text ?? '', 500), html: truncate(msg.html ?? '', 500) };
|
|
@@ -47,7 +63,12 @@ export async function sendEmail(msg, deps) {
|
|
|
47
63
|
try {
|
|
48
64
|
const res = await fetchImpl(RESEND_API_URL, {
|
|
49
65
|
method: 'POST',
|
|
50
|
-
headers: {
|
|
66
|
+
headers: {
|
|
67
|
+
'Content-Type': 'application/json',
|
|
68
|
+
Authorization: `Bearer ${deps.apiKey}`,
|
|
69
|
+
...(deps.idempotencyKey !== undefined ? { 'Idempotency-Key': deps.idempotencyKey } : {}),
|
|
70
|
+
},
|
|
71
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
51
72
|
body: JSON.stringify({
|
|
52
73
|
from: msg.from,
|
|
53
74
|
to: [msg.to],
|
|
@@ -85,7 +106,13 @@ export async function sendEmail(msg, deps) {
|
|
|
85
106
|
}
|
|
86
107
|
catch (err) {
|
|
87
108
|
const elapsedMs = Date.now() - start;
|
|
88
|
-
|
|
89
|
-
|
|
109
|
+
const timedOut = err instanceof Error && err.name === 'TimeoutError';
|
|
110
|
+
logger.error('email_send_error', { ...base, error: serializeError(err), timedOut, timeoutMs, elapsedMs, ...preview });
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
error: timedOut
|
|
114
|
+
? `Timed out after ${timeoutMs} ms waiting for Resend; the email may still have been delivered`
|
|
115
|
+
: `Network error: ${err instanceof Error ? err.message : String(err)}`,
|
|
116
|
+
};
|
|
90
117
|
}
|
|
91
118
|
}
|
package/package.json
CHANGED