diff --git a/apps/docs/api-reference/emails/batch-email.mdx b/apps/docs/api-reference/emails/batch-email.mdx index 26d8f272..1ab3f57f 100644 --- a/apps/docs/api-reference/emails/batch-email.mdx +++ b/apps/docs/api-reference/emails/batch-email.mdx @@ -3,3 +3,13 @@ openapi: post /v1/emails/batch --- Send up to 100 emails in a single request. + +## Idempotency + +Bulk sends also accept the `Idempotency-Key` header. The key applies to the entire batch payload: + +- Same key + identical batch body (all items) → returns the original list of `emailId`s with `200 OK`. +- Same key + different payload → returns `409 Conflict` with `code: NOT_UNIQUE` so you can detect accidental reuse. +- Same key while another batch is still being processed → returns `409 Conflict`; retry after the first request finishes. + +Keys live for 24 hours. diff --git a/apps/docs/api-reference/emails/send-email.mdx b/apps/docs/api-reference/emails/send-email.mdx index 8875ee31..abde6835 100644 --- a/apps/docs/api-reference/emails/send-email.mdx +++ b/apps/docs/api-reference/emails/send-email.mdx @@ -1,3 +1,15 @@ --- openapi: post /v1/emails --- + +Send a transactional email via the public API. + +## Idempotency + +Pass the optional `Idempotency-Key` header to make the request safe to retry. The key can be up to 256 characters. The server stores the canonical request body and behaves as follows: + +- Same key + same request body → returns the original `emailId` with `200 OK` without re‑sending. +- Same key + different request body → returns `409 Conflict` with `code: NOT_UNIQUE` so you can detect the mismatch. +- Same key while another request is still being processed → returns `409 Conflict`; retry after a short delay or once the first request completes. + +Entries expire after 24 hours. Use a unique key per logical send (for example, an order or signup ID). diff --git a/apps/docs/api-reference/introduction.mdx b/apps/docs/api-reference/introduction.mdx index bf5955bf..9c8a4ef1 100644 --- a/apps/docs/api-reference/introduction.mdx +++ b/apps/docs/api-reference/introduction.mdx @@ -22,3 +22,7 @@ Authorization: Bearer us_12345 ``` You can create a new token/API key under your useSend [Developer Settings](https://app.usesend.com/dev-settings/api-keys). + +## Idempotency + +Use the optional `Idempotency-Key` header on `POST /v1/emails` and `POST /v1/emails/batch` to make requests safe to retry. Reusing a key with the same request body returns the original response; reusing it with different input returns `409 Conflict` so you can detect mistakes. Keys expire after 24 hours. diff --git a/apps/web/src/server/public-api/api/emails/batch-email.ts b/apps/web/src/server/public-api/api/emails/batch-email.ts index 422c2746..5ce56914 100644 --- a/apps/web/src/server/public-api/api/emails/batch-email.ts +++ b/apps/web/src/server/public-api/api/emails/batch-email.ts @@ -1,9 +1,12 @@ import { createRoute, z } from "@hono/zod-openapi"; import { PublicAPIApp } from "~/server/public-api/hono"; -import { getTeamFromToken } from "~/server/public-api/auth"; import { sendBulkEmails } from "~/server/service/email-service"; import { EmailContent } from "~/types"; import { emailSchema } from "../../schemas/email-schema"; // Corrected import path +import { IdempotencyService } from "~/server/service/idempotency-service"; +import { canonicalizePayload } from "~/server/utils/idempotency"; +import { UnsendApiError } from "~/server/public-api/api-error"; +import { logger } from "~/server/logger/log"; // Define the schema for a single email within the bulk request // This is similar to the schema in send-email.ts but without the top-level 'required' @@ -13,6 +16,12 @@ const route = createRoute({ method: "post", path: "/v1/emails/batch", request: { + headers: z + .object({ + "Idempotency-Key": z.string().min(1).max(256).optional(), + }) + .partial() + .openapi("Idempotency headers"), body: { required: true, content: { @@ -47,29 +56,104 @@ function sendBatch(app: PublicAPIApp) { const team = c.var.team; const emailPayloads = c.req.valid("json"); - // Add teamId and apiKeyId to each email payload - const emailsToSend: Array< - EmailContent & { teamId: number; apiKeyId?: number } - > = emailPayloads.map((payload) => ({ + const normalizedPayloads = emailPayloads.map((payload) => ({ ...payload, text: payload.text ?? undefined, html: payload.html && payload.html !== "true" && payload.html !== "false" ? payload.html : undefined, + })); + + const idemKey = c.req.header("Idempotency-Key") ?? undefined; + if (idemKey !== undefined && (idemKey.length < 1 || idemKey.length > 256)) { + throw new UnsendApiError({ + code: "BAD_REQUEST", + message: "Invalid Idempotency-Key length", + }); + } + + let payloadHash: string | undefined; + let lockAcquired = false; + + if (idemKey) { + ({ bodyHash: payloadHash } = canonicalizePayload(normalizedPayloads)); + + const existing = await IdempotencyService.getResult(team.id, idemKey); + if (existing) { + if (existing.bodyHash === payloadHash) { + logger.info( + { teamId: team.id }, + "Idempotency hit for bulk email send" + ); + const responseData = existing.emailIds.map((id) => ({ emailId: id })); + return c.json({ data: responseData }); + } + + throw new UnsendApiError({ + code: "NOT_UNIQUE", + message: "Idempotency-Key already used with a different payload", + }); + } + + lockAcquired = await IdempotencyService.acquireLock(team.id, idemKey); + if (!lockAcquired) { + const again = await IdempotencyService.getResult(team.id, idemKey); + if (again) { + if (again.bodyHash === payloadHash) { + logger.info( + { teamId: team.id }, + "Idempotency hit after contention for bulk email send" + ); + const responseData = again.emailIds.map((id) => ({ emailId: id })); + return c.json({ data: responseData }); + } + + throw new UnsendApiError({ + code: "NOT_UNIQUE", + message: "Idempotency-Key already used with a different payload", + }); + } + + throw new UnsendApiError({ + code: "NOT_UNIQUE", + message: + "Request with same Idempotency-Key is in progress. Retry later.", + }); + } + } + + // Add teamId and apiKeyId to each email payload + const emailsToSend: Array< + EmailContent & { teamId: number; apiKeyId?: number } + > = normalizedPayloads.map((payload) => ({ + ...payload, teamId: team.id, apiKeyId: team.apiKeyId, })); - // Call the service function to send emails in bulk - const createdEmails = await sendBulkEmails(emailsToSend); + try { + // Call the service function to send emails in bulk + const createdEmails = await sendBulkEmails(emailsToSend); - // Map the result to the response format - const responseData = createdEmails.map((email) => ({ - emailId: email.id, - })); + // Map the result to the response format + const responseData = createdEmails.map((email) => ({ + emailId: email.id, + })); + + if (idemKey && payloadHash) { + await IdempotencyService.setResult(team.id, idemKey, { + bodyHash: payloadHash, + emailIds: createdEmails.map((email) => email.id), + }); + } - return c.json({ data: responseData }); + return c.json({ data: responseData }); + } finally { + if (idemKey && lockAcquired) { + await IdempotencyService.releaseLock(team.id, idemKey); + } + } }); } diff --git a/apps/web/src/server/public-api/api/emails/send-email.ts b/apps/web/src/server/public-api/api/emails/send-email.ts index 5a6164ca..c56b8038 100644 --- a/apps/web/src/server/public-api/api/emails/send-email.ts +++ b/apps/web/src/server/public-api/api/emails/send-email.ts @@ -2,11 +2,21 @@ import { createRoute, z } from "@hono/zod-openapi"; import { PublicAPIApp } from "~/server/public-api/hono"; import { sendEmail } from "~/server/service/email-service"; import { emailSchema } from "../../schemas/email-schema"; +import { IdempotencyService } from "~/server/service/idempotency-service"; +import { canonicalizePayload } from "~/server/utils/idempotency"; +import { UnsendApiError } from "~/server/public-api/api-error"; +import { logger } from "~/server/logger/log"; const route = createRoute({ method: "post", path: "/v1/emails", request: { + headers: z + .object({ + "Idempotency-Key": z.string().min(1).max(256).optional(), + }) + .partial() + .openapi("Idempotency headers"), body: { required: true, content: { @@ -31,24 +41,93 @@ const route = createRoute({ function send(app: PublicAPIApp) { app.openapi(route, async (c) => { const team = c.var.team; + const requestBody = c.req.valid("json"); - let html = undefined; + let html: string | undefined; + const rawHtml = requestBody?.html?.toString(); + if (rawHtml && rawHtml !== "true" && rawHtml !== "false") { + html = rawHtml; + } - const _html = c.req.valid("json")?.html?.toString(); + const clientPayload = { + ...requestBody, + text: requestBody.text ?? undefined, + html, + }; - if (_html && _html !== "true" && _html !== "false") { - html = _html; + const idemKey = c.req.header("Idempotency-Key") ?? undefined; + if (idemKey !== undefined && (idemKey.length < 1 || idemKey.length > 256)) { + throw new UnsendApiError({ + code: "BAD_REQUEST", + message: "Invalid Idempotency-Key length", + }); } - const email = await sendEmail({ - ...c.req.valid("json"), - teamId: team.id, - apiKeyId: team.apiKeyId, - text: c.req.valid("json").text ?? undefined, - html: html, - }); + let payloadHash: string | undefined; + let lockAcquired = false; + + if (idemKey) { + ({ bodyHash: payloadHash } = canonicalizePayload(clientPayload)); + + const existing = await IdempotencyService.getResult(team.id, idemKey); + if (existing) { + if (existing.bodyHash === payloadHash) { + logger.info({ teamId: team.id }, "Idempotency hit for email send"); + return c.json({ emailId: existing.emailIds[0] }); + } + + throw new UnsendApiError({ + code: "NOT_UNIQUE", + message: "Idempotency-Key already used with a different payload", + }); + } + + lockAcquired = await IdempotencyService.acquireLock(team.id, idemKey); + if (!lockAcquired) { + const again = await IdempotencyService.getResult(team.id, idemKey); + if (again) { + if (again.bodyHash === payloadHash) { + logger.info( + { teamId: team.id }, + "Idempotency hit after contention for email send" + ); + return c.json({ emailId: again.emailIds[0] }); + } + + throw new UnsendApiError({ + code: "NOT_UNIQUE", + message: "Idempotency-Key already used with a different payload", + }); + } - return c.json({ emailId: email?.id }); + throw new UnsendApiError({ + code: "NOT_UNIQUE", + message: + "Request with same Idempotency-Key is in progress. Retry later.", + }); + } + } + + try { + const email = await sendEmail({ + ...clientPayload, + teamId: team.id, + apiKeyId: team.apiKeyId, + }); + + if (idemKey && payloadHash) { + await IdempotencyService.setResult(team.id, idemKey, { + bodyHash: payloadHash, + emailIds: [email.id], + }); + } + + return c.json({ emailId: email?.id }); + } finally { + if (idemKey && lockAcquired) { + await IdempotencyService.releaseLock(team.id, idemKey); + } + } }); } diff --git a/apps/web/src/server/service/idempotency-service.ts b/apps/web/src/server/service/idempotency-service.ts new file mode 100644 index 00000000..da61fee4 --- /dev/null +++ b/apps/web/src/server/service/idempotency-service.ts @@ -0,0 +1,78 @@ +import { getRedis } from "~/server/redis"; + +const IDEMPOTENCY_RESULT_TTL_SECONDS = 24 * 60 * 60; // 24h +const IDEMPOTENCY_LOCK_TTL_SECONDS = 60; // 60s + +export type IdempotencyRecord = { + bodyHash: string; + emailIds: string[]; +}; + +function resultKey(teamId: number, key: string) { + return `idem:${teamId}:${key}`; +} + +function lockKey(teamId: number, key: string) { + return `idemlock:${teamId}:${key}`; +} + +export const IdempotencyService = { + async getResult( + teamId: number, + key: string + ): Promise { + const redis = getRedis(); + const raw = await redis.get(resultKey(teamId, key)); + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + if ( + parsed && + typeof parsed === "object" && + typeof (parsed as any).bodyHash === "string" && + Array.isArray((parsed as any).emailIds) + ) { + return parsed as IdempotencyRecord; + } + return null; + } catch { + return null; + } + }, + + async setResult( + teamId: number, + key: string, + record: IdempotencyRecord + ): Promise { + const redis = getRedis(); + await redis.setex( + resultKey(teamId, key), + IDEMPOTENCY_RESULT_TTL_SECONDS, + JSON.stringify(record) + ); + }, + + async acquireLock(teamId: number, key: string): Promise { + const redis = getRedis(); + const ok = await redis.set( + lockKey(teamId, key), + "1", + "EX", + IDEMPOTENCY_LOCK_TTL_SECONDS, + "NX" + ); + return ok === "OK"; + }, + + async releaseLock(teamId: number, key: string): Promise { + const redis = getRedis(); + await redis.del(lockKey(teamId, key)); + }, +}; + +export const IDEMPOTENCY_CONSTANTS = { + RESULT_TTL_SECONDS: IDEMPOTENCY_RESULT_TTL_SECONDS, + LOCK_TTL_SECONDS: IDEMPOTENCY_LOCK_TTL_SECONDS, +}; + diff --git a/apps/web/src/server/utils/idempotency.ts b/apps/web/src/server/utils/idempotency.ts new file mode 100644 index 00000000..6fc2e514 --- /dev/null +++ b/apps/web/src/server/utils/idempotency.ts @@ -0,0 +1,69 @@ +import { createHash } from "crypto"; + +type CanonicalValue = + | string + | number + | boolean + | null + | CanonicalValue[] + | { [key: string]: CanonicalValue }; + +function normalize(value: unknown): CanonicalValue | undefined { + if (value === undefined) { + return undefined; + } + + if (value === null) { + return null; + } + + if (Array.isArray(value)) { + return value.map((item) => normalize(item) ?? null); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (typeof value === "object") { + const entries = Object.entries(value as Record).sort( + ([keyA], [keyB]) => (keyA < keyB ? -1 : keyA > keyB ? 1 : 0) + ); + + const result: Record = {}; + for (const [key, val] of entries) { + const normalized = normalize(val); + if (normalized !== undefined) { + result[key] = normalized; + } + } + + return result; + } + + if (typeof value === "string") { + return value; + } + + if (typeof value === "number") { + return value; + } + + if (typeof value === "boolean") { + return value; + } + + if (typeof value === "bigint") { + return value.toString(); + } + + return String(value); +} + +export function canonicalizePayload(payload: unknown) { + const normalized = normalize(payload); + const canonical = JSON.stringify(normalized ?? null); + const bodyHash = createHash("sha256").update(canonical).digest("hex"); + return { canonical, bodyHash }; +} + diff --git a/packages/python-sdk/README.md b/packages/python-sdk/README.md index 647fc871..51e78fdb 100644 --- a/packages/python-sdk/README.md +++ b/packages/python-sdk/README.md @@ -37,6 +37,19 @@ resp, _ = client.emails.send(payload={ "html": "Hi!", }) +# Idempotent retries: same payload + same key returns the original response +resp, _ = client.emails.send( + payload=payload, + idempotency_key="signup-123", +) + +# Works for batch requests as well +resp, _ = client.emails.batch( + payload=[payload], + idempotency_key="bulk-welcome-1", +) +# If the same key is reused with a different payload, the API responds with HTTP 409. + # 3) Campaigns campaign_payload: types.CampaignCreate = { "name": "Welcome Series", diff --git a/packages/python-sdk/usesend/emails.py b/packages/python-sdk/usesend/emails.py index 3756fcbe..d8fe89d3 100644 --- a/packages/python-sdk/usesend/emails.py +++ b/packages/python-sdk/usesend/emails.py @@ -18,6 +18,12 @@ ) +def _idem_headers(idempotency_key: Optional[str]) -> Optional[Dict[str, str]]: + if idempotency_key: + return {"Idempotency-Key": idempotency_key} + return None + + class Emails: """Client for `/emails` endpoints.""" @@ -25,11 +31,21 @@ def __init__(self, usesend: "UseSend") -> None: self.usesend = usesend # Basic operations ------------------------------------------------- - def send(self, payload: EmailCreate) -> Tuple[Optional[EmailCreateResponse], Optional[APIError]]: + def send( + self, + payload: EmailCreate, + *, + idempotency_key: Optional[str] = None, + ) -> Tuple[Optional[EmailCreateResponse], Optional[APIError]]: """Alias for :meth:`create`.""" - return self.create(payload) - - def create(self, payload: Union[EmailCreate, Dict[str, Any]]) -> Tuple[Optional[EmailCreateResponse], Optional[APIError]]: + return self.create(payload, idempotency_key=idempotency_key) + + def create( + self, + payload: Union[EmailCreate, Dict[str, Any]], + *, + idempotency_key: Optional[str] = None, + ) -> Tuple[Optional[EmailCreateResponse], Optional[APIError]]: if isinstance(payload, dict): payload = dict(payload) @@ -42,10 +58,17 @@ def create(self, payload: Union[EmailCreate, Dict[str, Any]]) -> Tuple[Optional[ if isinstance(body.get("scheduledAt"), datetime): body["scheduledAt"] = body["scheduledAt"].isoformat() - data, err = self.usesend.post("/emails", body) + data, err = self.usesend.post( + "/emails", body, headers=_idem_headers(idempotency_key) + ) return (data, err) # type: ignore[return-value] - def batch(self, payload: Sequence[Union[EmailBatchItem, Dict[str, Any]]]) -> Tuple[Optional[EmailBatchResponse], Optional[APIError]]: + def batch( + self, + payload: Sequence[Union[EmailBatchItem, Dict[str, Any]]], + *, + idempotency_key: Optional[str] = None, + ) -> Tuple[Optional[EmailBatchResponse], Optional[APIError]]: items: List[Dict[str, Any]] = [] for item in payload: d = dict(item) @@ -54,7 +77,9 @@ def batch(self, payload: Sequence[Union[EmailBatchItem, Dict[str, Any]]]) -> Tup if isinstance(d.get("scheduledAt"), datetime): d["scheduledAt"] = d["scheduledAt"].isoformat() items.append(d) - data, err = self.usesend.post("/emails/batch", items) + data, err = self.usesend.post( + "/emails/batch", items, headers=_idem_headers(idempotency_key) + ) return (data, err) # type: ignore[return-value] def get(self, email_id: str) -> Tuple[Optional[Email], Optional[APIError]]: diff --git a/packages/python-sdk/usesend/usesend.py b/packages/python-sdk/usesend/usesend.py index ba31a016..f65c1f46 100644 --- a/packages/python-sdk/usesend/usesend.py +++ b/packages/python-sdk/usesend/usesend.py @@ -77,12 +77,25 @@ def __init__( # ------------------------------------------------------------------ # Internal request helper # ------------------------------------------------------------------ + def _build_headers(self, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]: + headers = dict(self.headers) + if extra: + headers.update({k: v for k, v in extra.items() if v is not None}) + return headers + def _request( - self, method: str, path: str, json: Optional[Any] = None + self, + method: str, + path: str, + json: Optional[Any] = None, + headers: Optional[Dict[str, str]] = None, ) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: """Perform an HTTP request and return ``(data, error)``.""" resp = self._session.request( - method, f"{self.url}{path}", headers=self.headers, json=json + method, + f"{self.url}{path}", + headers=self._build_headers(headers), + json=json, ) default_error = {"code": "INTERNAL_SERVER_ERROR", "message": resp.reason} @@ -104,22 +117,42 @@ def _request( # ------------------------------------------------------------------ # HTTP verb helpers # ------------------------------------------------------------------ - def post(self, path: str, body: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: - return self._request("POST", path, json=body) + def post( + self, + path: str, + body: Any, + headers: Optional[Dict[str, str]] = None, + ) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: + return self._request("POST", path, json=body, headers=headers) - def get(self, path: str) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: - return self._request("GET", path) + def get( + self, path: str, headers: Optional[Dict[str, str]] = None + ) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: + return self._request("GET", path, headers=headers) - def put(self, path: str, body: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: - return self._request("PUT", path, json=body) + def put( + self, + path: str, + body: Any, + headers: Optional[Dict[str, str]] = None, + ) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: + return self._request("PUT", path, json=body, headers=headers) - def patch(self, path: str, body: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: - return self._request("PATCH", path, json=body) + def patch( + self, + path: str, + body: Any, + headers: Optional[Dict[str, str]] = None, + ) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: + return self._request("PATCH", path, json=body, headers=headers) def delete( - self, path: str, body: Optional[Any] = None + self, + path: str, + body: Optional[Any] = None, + headers: Optional[Dict[str, str]] = None, ) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: - return self._request("DELETE", path, json=body) + return self._request("DELETE", path, json=body, headers=headers) # Import here to avoid circular dependency during type checking diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 8c5c73fb..d42f8170 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -48,6 +48,37 @@ usesend.emails.send({ html: "

useSend is the best open source product to send emails

", text: "useSend is the best open source product to send emails", }); + +// Safely retry sends with an idempotency key +await usesend.emails.send( + { + to: "hello@acme.com", + from: "hello@company.com", + subject: "useSend email", + html: "

useSend is the best open source product to send emails

", + }, + { idempotencyKey: "signup-123" }, +); + +// Works for bulk sends too +await usesend.emails.batch( + [ + { + to: "a@example.com", + from: "hello@company.com", + subject: "Welcome", + html: "

Hello A

", + }, + { + to: "b@example.com", + from: "hello@company.com", + subject: "Welcome", + html: "

Hello B

", + }, + ], + { idempotencyKey: "bulk-welcome-1" }, +); +// Reusing the same key with a different payload returns HTTP 409. ``` ## Campaigns diff --git a/packages/sdk/src/email.ts b/packages/sdk/src/email.ts index 14ede274..99a814d2 100644 --- a/packages/sdk/src/email.ts +++ b/packages/sdk/src/email.ts @@ -67,16 +67,23 @@ type BatchEmailResponse = { error: ErrorResponse | null; }; +type EmailRequestOptions = { + idempotencyKey?: string; +}; + export class Emails { constructor(private readonly usesend: UseSend) { this.usesend = usesend; } - async send(payload: SendEmailPayload) { - return this.create(payload); + async send(payload: SendEmailPayload, options?: EmailRequestOptions) { + return this.create(payload, options); } - async create(payload: SendEmailPayload): Promise { + async create( + payload: SendEmailPayload, + options?: EmailRequestOptions, + ): Promise { if (payload.react) { payload.html = await render(payload.react as React.ReactElement); delete payload.react; @@ -84,7 +91,10 @@ export class Emails { const data = await this.usesend.post( "/emails", - payload + payload, + options?.idempotencyKey + ? { headers: { "Idempotency-Key": options.idempotencyKey } } + : undefined, ); return data; @@ -96,11 +106,17 @@ export class Emails { * @param payload An array of email payloads. Max 100 emails. * @returns A promise that resolves to the list of created email IDs or an error. */ - async batch(payload: BatchEmailPayload): Promise { + async batch( + payload: BatchEmailPayload, + options?: EmailRequestOptions, + ): Promise { // Note: React element rendering is not supported in batch mode. const response = await this.usesend.post( "/emails/batch", - payload + payload, + options?.idempotencyKey + ? { headers: { "Idempotency-Key": options.idempotencyKey } } + : undefined, ); return { data: response.data ? response.data.data : null, diff --git a/packages/sdk/src/usesend.ts b/packages/sdk/src/usesend.ts index 3f4b016f..8dfafe24 100644 --- a/packages/sdk/src/usesend.ts +++ b/packages/sdk/src/usesend.ts @@ -12,8 +12,12 @@ function isUseSendErrorResponse(error: { error: ErrorResponse }) { return error.error.code !== undefined; } +type RequestOptions = { + headers?: HeadersInit; +}; + export class UseSend { - private readonly headers: Headers; + private readonly baseHeaders: Headers; // readonly domains = new Domains(this); readonly emails = new Emails(this); @@ -42,17 +46,36 @@ export class UseSend { this.url = `${url}/api/v1`; } - this.headers = new Headers({ + this.baseHeaders = new Headers({ Authorization: `Bearer ${this.key}`, "Content-Type": "application/json", }); } + private mergeHeaders(extra?: HeadersInit) { + const headers = new Headers(this.baseHeaders); + if (!extra) { + return headers; + } + + const additional = new Headers(extra); + additional.forEach((value, key) => { + headers.set(key, value); + }); + + return headers; + } + async fetchRequest( path: string, - options = {}, + options: RequestInit = {}, ): Promise<{ data: T | null; error: ErrorResponse | null }> { - const response = await fetch(`${this.url}${path}`, options); + const requestOptions: RequestInit = { + ...options, + headers: this.mergeHeaders(options.headers), + }; + + const response = await fetch(`${this.url}${path}`, requestOptions); const defaultError = { code: "INTERNAL_SERVER_ERROR", message: response.statusText, @@ -82,52 +105,70 @@ export class UseSend { return { data, error: null }; } - async post(path: string, body: unknown) { - const requestOptions = { + async post(path: string, body: unknown, options?: RequestOptions) { + const requestOptions: RequestInit = { method: "POST", - headers: this.headers, body: JSON.stringify(body), }; + if (options?.headers) { + requestOptions.headers = options.headers; + } + return this.fetchRequest(path, requestOptions); } - async get(path: string) { - const requestOptions = { + async get(path: string, options?: RequestOptions) { + const requestOptions: RequestInit = { method: "GET", - headers: this.headers, }; + if (options?.headers) { + requestOptions.headers = options.headers; + } + return this.fetchRequest(path, requestOptions); } - async put(path: string, body: any) { - const requestOptions = { + async put(path: string, body: any, options?: RequestOptions) { + const requestOptions: RequestInit = { method: "PUT", - headers: this.headers, body: JSON.stringify(body), }; + if (options?.headers) { + requestOptions.headers = options.headers; + } + return this.fetchRequest(path, requestOptions); } - async patch(path: string, body: any) { - const requestOptions = { + async patch(path: string, body: any, options?: RequestOptions) { + const requestOptions: RequestInit = { method: "PATCH", - headers: this.headers, body: JSON.stringify(body), }; + if (options?.headers) { + requestOptions.headers = options.headers; + } + return this.fetchRequest(path, requestOptions); } - async delete(path: string, body?: unknown) { - const requestOptions = { + async delete(path: string, body?: unknown, options?: RequestOptions) { + const requestOptions: RequestInit = { method: "DELETE", - headers: this.headers, - body: JSON.stringify(body), }; + if (body !== undefined) { + requestOptions.body = JSON.stringify(body); + } + + if (options?.headers) { + requestOptions.headers = options.headers; + } + return this.fetchRequest(path, requestOptions); } } diff --git a/plan-idempotency.md b/plan-idempotency.md new file mode 100644 index 00000000..8917fc2f --- /dev/null +++ b/plan-idempotency.md @@ -0,0 +1,174 @@ +# Idempotency Keys for Email Send (Single + Bulk) + +This plan introduces idempotency for the public API email send endpoints. When an `Idempotency-Key` is provided: +- If the key is new, the request proceeds and the resulting email id(s) are cached in Redis for 24h. +- If the key already exists, the API does not send again and returns the same email id(s) as previously created. + +Keys are stored in Redis; the value will include a canonicalized request body (or its hash) to compare future requests, and the created email id(s), with a 24h TTL. + + +## Goals +- Prevent duplicate email sends for the same client request (network retries, client retries, race conditions) using a client-supplied idempotency key. +- Support both single send (`POST /v1/emails`) and bulk send (`POST /v1/emails/batch`). +- Keep responses backward‑compatible: on a hit, return the same response shape with the prior email id(s). +- Minimal surface changes: introduce an optional `Idempotency-Key` request header and SDK convenience options. + + + + +## API Contract Changes +- Header: `Idempotency-Key` (optional, string, 1–256 chars) +- Endpoints affected: + - POST `/v1/emails` + - POST `/v1/emails/batch` +- Semantics: + - On first request with a new key: process normally, cache result in Redis as array of email IDs, TTL 24h, then return 200 as today. + - On subsequent request with the same key within TTL: do not enqueue or send again; return 200 with the same `emailId` for single or the same array for bulk. + - If a concurrent request arrives while the first is still in flight: use a short Redis lock to serialize the operation; if lock cannot be acquired and no cached result yet, return a 409 with a retry hint (see Error Behavior) or poll briefly and then return. + + +## Redis Schema +- Keys + - `idem:{teamId}:{key}` -> JSON string object containing canonical payload or hash, and email IDs; EX 24h. + - `idemlock:{teamId}:{key}` -> ephemeral lock to prevent duplicate in-flight sends; EX 60s. +- TTL + - Result key: 24 hours (86,400 seconds) + - Lock key: 60 seconds (configurable constant) +- Value format (body-compare capable) + - `{ bodyHash: string, emailIds: string[] }` + - Optional for debugging: `{ body: string, emailIds: string[] }` where `body` is canonical JSON string; recommended to prefer `bodyHash` to keep values compact and avoid storing large HTML. + + +## Server Implementation Plan +1. Common util (service level) + - Add `IdempotencyService` with helpers using existing Redis client (`getRedis`): + - `getResult(teamId: number, key: string): Promise` + - `setResult(teamId: number, key: string, emailIds: string[]): Promise` (EX 24h) + - `acquireLock(teamId: number, key: string): Promise` (`SET NX EX 60`) + - `releaseLock(teamId: number, key: string): Promise` (best-effort `DEL`) + - Constants: `IDEMPOTENCY_RESULT_TTL_SECONDS = 86400`, `IDEMPOTENCY_LOCK_TTL_SECONDS = 60`. + +2. Single send endpoint + - File: apps/web/src/server/public-api/api/emails/send-email.ts + - Read optional `Idempotency-Key` from request headers; validate basic length. + - If present, compute canonical request body and hash (see Canonicalization) and check Redis via `getResult(team.id, key)`: + - If found and `stored.bodyHash === current.bodyHash`: return 200 `{ emailId: stored.emailIds[0] }`. + - If found and body hash mismatches: return 409 Conflict with message like "Idempotency-Key reused with a different payload" (no send). + - Else, try `acquireLock`; if lock not acquired and result still not present: + - Option A: short wait/poll (e.g., up to 2–3 seconds) for result to appear, then return if found. + - Option B: return 409 Conflict with body `{ code: "NOT_UNIQUE", message: "Request with same Idempotency-Key is in progress. Retry later.", retryAfterSeconds: 2 }`. + - On lock acquired: proceed to call `sendEmail(...)`; after success, store result as `setResult(team.id, key, { bodyHash, emailIds: [email.id] })`, then `releaseLock`. + - On failure: `releaseLock` and bubble error. + - OpenAPI docs: add header param to route definition so docs render in Mintlify. + +3. Bulk send endpoint + - File: apps/web/src/server/public-api/api/emails/batch-email.ts + - Same header handling as above. Build canonicalization over the entire request body (batch array) and compute a single `bodyHash` for the full payload. + - On cache hit: + - If `stored.bodyHash === current.bodyHash`: return `{ data: stored.emailIds.map((id) => ({ emailId: id })) }`. + - Else: return 409 Conflict as above. + - On miss: acquire lock, call `sendBulkEmails(...)`, collect returned emails’ IDs in original order, cache `{ bodyHash, emailIds }`, release lock, return as usual. + +4. Service layer (no behavioral change needed beyond calling order) + - No changes in `sendEmail`/`sendBulkEmails` logic; idempotency is enforced in the API handlers, not deep inside service. This ensures other internal callers can opt-in later explicitly if needed. + +5. Logging and metrics + - Log at `info` on idempotency hit with teamId/key, and at `warn` if lock contention is detected. + - Optionally export counters: hits, misses, lock-contention, failures. + +6. Configuration + - Reuse existing Redis connection (`apps/web/src/server/redis.ts`). + - TTLs as constants colocated with IdempotencyService; optionally env-overridable in the future. + +7. Error behavior + - 409 Conflict for two scenarios: + - `NOT_UNIQUE` when key exists with a different payload (body mismatch). + - `NOT_UNIQUE` when a request with the same key is in progress and no result yet (client may retry shortly). + - This path is rare and only occurs on truly concurrent requests before the first finishes and writes cache. + +### Canonicalization +- Build a stable representation of the request body before hashing: + - Sort object keys recursively; omit `react` fields (TS SDK-only helper). + - Normalize `html` field: ensure it’s a string or undefined (align with current route pre-processing). + - Normalize `text` as string or undefined; `scheduledAt` to ISO string; `replyTo` to array form. + - Preserve array order for `to`, `cc`, `bcc` (order affects hash by design). + - Remove undefined/null fields consistently so semantically equivalent payloads produce the same hash. + - Hash function: SHA-256 over the canonical JSON string. + + +## SDK Changes + +### TypeScript SDK (packages/sdk) +- Add per-request headers support in `UseSend`: + - Extend `post/get/patch/...` to accept optional `{ headers?: Record }` merged with defaults. +- Emails client changes (`packages/sdk/src/email.ts`): + - Overload `create(payload, options?)` where `options?: { idempotencyKey?: string }` and forward header. + - Same for `batch(payload, options?)`. + - Note: SDK does not compute or compare hashes; server is source of truth. Clients only pass the key. +- README updates with examples: + - Single send with `idempotencyKey`. + - Batch send with `idempotencyKey`. + +### Python SDK (packages/python-sdk) +- Add per-request header override support in client: + - Update `UseSend._request` and `post/patch/...` to accept optional `headers: Dict[str, str]` merged over `self.headers`. +- Emails client changes (`usesend/emails.py`): + - `create(payload, idempotency_key: Optional[str] = None)` and `batch(payload, idempotency_key: Optional[str] = None)`. + - If provided, pass `headers={"Idempotency-Key": idempotency_key}` to `UseSend.post`. +- README updates with minimal examples. + + +## Documentation Updates (Mintlify) +- Auto-generated API reference will include the header once we annotate the OpenAPI route definitions. +- Add short usage notes to the MDX pages: + - `apps/docs/api-reference/emails/send-email.mdx` + - `apps/docs/api-reference/emails/batch-email.mdx` +- Explicitly document behavior: + - If an `Idempotency-Key` is reused with the exact same request body (as per server canonicalization), the API returns 200 with the previous result. + - If the key is reused with a different body, the API returns 409 Conflict with a descriptive error. + - If a request with the same key is currently in progress, the API may return 409 Conflict; retry after a short delay. +- New guide section (optional but recommended): + - A brief "Idempotency" page under API Reference or Guides explaining semantics, TTL, and best practices. + + +## Verification Plan +- Unit-ish/integration checks locally: + - First request with key -> sends and caches; second request with same key within 24h -> returns same ID(s) without sending again. + - Confirm Redis keys exist (`idem:teamId:key`) with value as JSON array and TTL approx 24h. + - Concurrency test: fire two requests with same key nearly simultaneously; exactly one send occurs; the other returns cached result or 409 if truly concurrent before cache write. + - Bulk path preserves order of returned IDs and caches the same order. + - Missing/empty/oversized keys are ignored or rejected with 400 if invalid length. + + +## Backward Compatibility +- Header is optional; no behavior change for clients not using it. +- Response shapes unchanged. + + +## Rollout +- Ship server changes behind no flag (safe default, opt-in via header). +- Release TypeScript and Python SDK minor versions documenting the new options. +- Announce in changelog, add brief doc note linking to examples. + + +## Implementation Checklist (files to touch) +- Server + - apps/web/src/server/public-api/api/emails/send-email.ts + - apps/web/src/server/public-api/api/emails/batch-email.ts + - apps/web/src/server/service/idempotency-service.ts (new) +- SDKs + - packages/sdk/src/usesend.ts + - packages/sdk/src/email.ts + - packages/sdk/README.md + - packages/python-sdk/usesend/usesend.py + - packages/python-sdk/usesend/emails.py + - packages/python-sdk/README.md +- Docs + - apps/docs/api-reference/emails/send-email.mdx + - apps/docs/api-reference/emails/batch-email.mdx + - apps/docs/api-reference/introduction.mdx (add brief mention / link) [optional] + + +## Open Questions +- Do we want to return 409 on in-flight requests or block briefly (poll) to return 200 with the cached result as soon as it is available? Default in this plan: prefer quick 409 with a short retry hint. +- Do we need to enforce a stricter max key length or specific character set? Proposed: 1–256 chars and printable ASCII.