@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 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: { 'Content-Type': 'application/json', Authorization: `Bearer ${deps.apiKey}` },
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
- logger.error('email_send_error', { ...base, error: serializeError(err), elapsedMs, ...preview });
89
- return { ok: false, error: `Network error: ${err instanceof Error ? err.message : String(err)}` };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.50.0",
3
+ "version": "3.51.0",
4
4
  "description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
5
5
  "type": "module",
6
6
  "license": "MIT",