Uh oh!
There was an error while loading. Please reload this page.
idempotency - #282
Conversation
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Note Other AI code review bot(s) detectedCodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review. WalkthroughThis PR introduces end-to-end idempotency support for email operations across the platform. It adds a Redis-backed IdempotencyService with distributed locking and result caching, payload canonicalization utilities, and integrates idempotent request handling into the send-email and batch-email API endpoints. Both Python and JavaScript SDKs are updated to accept optional Idempotency-Key parameters for client-side usage. The OpenAPI specification is updated to document the new header parameter, and package versions are bumped accordingly. Possibly related PRs
Suggested labels
Pre-merge checks and finishing touches❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Deploying usesend with |
| Latest commit: | 087b5cc |
| Status: | ✅ Deploy successful! |
| Preview URL: | https://6270621a.usesend.pages.dev |
| Branch Preview URL: | https://km-2025-10-25-idempotency-ke.usesend.pages.dev |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| async acquireLock(teamId: number, key: string): Promise<boolean> { | ||
| 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<void> { | ||
| const redis = getRedis(); | ||
| await redis.del(lockKey(teamId, key)); |
There was a problem hiding this comment.
Release lock without verifying ownership can drop another request's lock
The idempotency lock is saved with a fixed value and releaseLock unconditionally deletes the key. If an email send takes longer than the 60‑second TTL, the lock expires and a second request can acquire it. When the original long‑running request eventually calls releaseLock, it removes the second request’s lock as well, letting further concurrent sends with the same idempotency key proceed. To prevent clobbering another client's lock, the lock should store a unique token and only be deleted when the caller proves ownership.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (10)
packages/python-sdk/README.md (2)
40-51: Improve the positioning and clarity of the idempotency 409 behavior note.The comment on line 51 about HTTP 409 is positioned after the code block and lacks context. It should be integrated closer to (or within) the code examples it describes, with clearer language explaining when the conflict occurs.
Apply this diff to improve clarity and positioning:
~# 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. +# 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.+# Note: Reusing the same idempotency key with a different payload returns HTTP 409.+resp, _ = client.emails.batch(+ payload=[payload],+ idempotency_key="bulk-welcome-1",+)
96-99: Document idempotency_key parameter in "Available Resources" section.The idempotency feature is new and demonstrated in examples, but the method signatures in "Available Resources" should mention the optional
idempotency_keyparameter for completeness and discoverability.Consider updating the documentation:
-**Emails**: `client.emails.send()`, `client.emails.get()`+**Emails**: `client.emails.send(payload, idempotency_key=None)`, `client.emails.get()`, `client.emails.batch(payload, idempotency_key=None)`packages/sdk/src/usesend.ts (1)
15-17: Consider explicitly exporting RequestOptions.The
RequestOptionstype is used in public method signatures but isn't explicitly exported. While TypeScript infers it from usage, explicitly exporting types used in the public API improves developer experience and documentation generation.Apply this diff:
-type RequestOptions = {+export type RequestOptions = { headers?: HeadersInit; };packages/sdk/src/email.ts (1)
70-72: Consider explicitly exporting EmailRequestOptions.Similar to
RequestOptionsin usesend.ts, this type is used in public method signatures and should be explicitly exported for better API documentation and developer experience.Apply this diff:
-type EmailRequestOptions = {+export type EmailRequestOptions = { idempotencyKey?: string; };apps/web/src/server/utils/idempotency.ts (1)
63-68: Add explicit return type annotation.While TypeScript infers the return type correctly, adding an explicit annotation improves code documentation and catches potential type errors.
Apply this diff:
-export function canonicalizePayload(payload: unknown) {+export function canonicalizePayload(payload: unknown): { canonical: string; bodyHash: string } { const normalized = normalize(payload); const canonical = JSON.stringify(normalized ?? null); const bodyHash = createHash("sha256").update(canonical).digest("hex"); return { canonical, bodyHash }; }apps/web/src/server/public-api/api/emails/send-email.ts (1)
36-37: Nit: wrong response description.Change to “Create email” or “Email created”.
- description: "Retrieve the user",+ description: "Email created",apps/web/src/server/public-api/api/emails/batch-email.ts (2)
59-66: Unify html normalization; avoid special-casing "true"/"false".Batch currently drops "true"/"false" string values and may pass non-strings if validation ever loosens. Align to “string only”.
-const normalizedPayloads = emailPayloads.map((payload) => ({- ...payload,- text: payload.text ?? undefined,- html:- payload.html && payload.html !== "true" && payload.html !== "false"- ? payload.html- : undefined,-}));+const normalizedPayloads = emailPayloads.map((payload) => {+ const html = typeof payload.html === "string" ? payload.html : undefined;+ return {+ ...payload,+ text: payload.text ?? undefined,+ html,+ };+});
19-25: Minor:.partial()is redundant on an object with an optional field.You can drop
.partial()to simplify the header schema without changing behavior.-headers: z- .object({- "Idempotency-Key": z.string().min(1).max(256).optional(),- })- .partial()- .openapi("Idempotency headers"),+headers: z+ .object({+ "Idempotency-Key": z.string().min(1).max(256).optional(),+ })+ .openapi("Idempotency headers"),packages/python-sdk/usesend/emails.py (1)
21-24: Validate idempotency_key length client-side (1–256).Preempt server errors and give clearer feedback.
-def _idem_headers(idempotency_key: Optional[str]) -> Optional[Dict[str, str]]:- if idempotency_key:- return {"Idempotency-Key": idempotency_key}- return None+def _idem_headers(idempotency_key: Optional[str]) -> Optional[Dict[str, str]]:+ if idempotency_key is None:+ return None+ key = idempotency_key.strip()+ if not (1 <= len(key) <= 256):+ raise ValueError("idempotency_key must be 1..256 characters")+ return {"Idempotency-Key": key}packages/python-sdk/usesend/usesend.py (1)
95-99: Add request timeout to avoid indefinite hangs.Set a sensible default (e.g., connect/read total ~10s). This improves resilience for clients.
- resp = self._session.request(+ resp = self._session.request( method, f"{self.url}{path}", headers=self._build_headers(headers), json=json, + timeout=10, )
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (14)
apps/docs/api-reference/emails/batch-email.mdx(1 hunks)apps/docs/api-reference/emails/send-email.mdx(1 hunks)apps/docs/api-reference/introduction.mdx(1 hunks)apps/web/src/server/public-api/api/emails/batch-email.ts(3 hunks)apps/web/src/server/public-api/api/emails/send-email.ts(2 hunks)apps/web/src/server/service/idempotency-service.ts(1 hunks)apps/web/src/server/utils/idempotency.ts(1 hunks)packages/python-sdk/README.md(1 hunks)packages/python-sdk/usesend/emails.py(3 hunks)packages/python-sdk/usesend/usesend.py(2 hunks)packages/sdk/README.md(1 hunks)packages/sdk/src/email.ts(2 hunks)packages/sdk/src/usesend.ts(3 hunks)plan-idempotency.md(1 hunks)
🧰 Additional context used
📓 Path-based instructions (4)
**/*.{ts,tsx,md}
📄 CodeRabbit inference engine (AGENTS.md)
Format code with Prettier 3 (run pnpm format)
Files:
packages/sdk/README.mdpackages/python-sdk/README.mdapps/web/src/server/public-api/api/emails/batch-email.tsapps/web/src/server/service/idempotency-service.tsapps/web/src/server/utils/idempotency.tsapps/web/src/server/public-api/api/emails/send-email.tspackages/sdk/src/email.tsplan-idempotency.mdpackages/sdk/src/usesend.ts
**/*.{js,jsx,ts,tsx}
📄 CodeRabbit inference engine (.cursor/rules/general.mdc)
Include all required imports, and ensure proper naming of key components.
Files:
apps/web/src/server/public-api/api/emails/batch-email.tsapps/web/src/server/service/idempotency-service.tsapps/web/src/server/utils/idempotency.tsapps/web/src/server/public-api/api/emails/send-email.tspackages/sdk/src/email.tspackages/sdk/src/usesend.ts
**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{ts,tsx}: TypeScript-first: use .ts/.tsx for source code (avoid JavaScript source files)
Use 2-space indentation and semicolons (Prettier 3 enforces these)
Adhere to @usesend/eslint-config; fix all ESLint warnings (CI fails on warnings)
Do not use dynamic imports; always place imports at the top of the module
Files:
apps/web/src/server/public-api/api/emails/batch-email.tsapps/web/src/server/service/idempotency-service.tsapps/web/src/server/utils/idempotency.tsapps/web/src/server/public-api/api/emails/send-email.tspackages/sdk/src/email.tspackages/sdk/src/usesend.ts
apps/web/**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
apps/web/**/*.{ts,tsx}: In apps/web, use the/ alias for src imports (e.g., import { x } from "/utils/x")
Prefer using tRPC in apps/web unless explicitly asked otherwise
Files:
apps/web/src/server/public-api/api/emails/batch-email.tsapps/web/src/server/service/idempotency-service.tsapps/web/src/server/utils/idempotency.tsapps/web/src/server/public-api/api/emails/send-email.ts
🧬 Code graph analysis (7)
apps/web/src/server/public-api/api/emails/batch-email.ts (6)
apps/web/src/server/public-api/api-error.ts (1)
UnsendApiError(62-75)apps/web/src/server/utils/idempotency.ts (1)
canonicalizePayload(63-68)apps/web/src/server/service/idempotency-service.ts (1)
IdempotencyService(19-72)apps/web/src/server/logger/log.ts (1)
logger(31-63)apps/web/src/types/index.ts (1)
EmailContent(1-18)apps/web/src/server/service/email-service.ts (1)
sendBulkEmails(368-789)
apps/web/src/server/service/idempotency-service.ts (1)
apps/web/src/server/redis.ts (1)
getRedis(6-13)
packages/python-sdk/usesend/emails.py (5)
packages/sdk/src/email.ts (4)
Emails(74-153)send(79-81)create(83-101)batch(109-125)packages/python-sdk/usesend/types.py (3)
EmailCreateResponse(229-230)APIError(449-451)EmailBatchResponse(261-262)packages/python-sdk/usesend/contacts.py (1)
create(25-32)packages/python-sdk/usesend/usesend.py (1)
post(120-126)packages/sdk/src/usesend.ts (1)
post(108-119)
apps/web/src/server/public-api/api/emails/send-email.ts (5)
apps/web/src/server/public-api/api-error.ts (1)
UnsendApiError(62-75)apps/web/src/server/utils/idempotency.ts (1)
canonicalizePayload(63-68)apps/web/src/server/service/idempotency-service.ts (1)
IdempotencyService(19-72)apps/web/src/server/logger/log.ts (1)
logger(31-63)apps/web/src/server/service/email-service.ts (1)
sendEmail(55-302)
packages/sdk/src/email.ts (1)
packages/sdk/src/usesend.ts (1)
UseSend(19-174)
packages/python-sdk/usesend/usesend.py (4)
packages/python-sdk/usesend/emails.py (2)
update(89-95)get(85-87)packages/python-sdk/usesend/contacts.py (3)
update(42-49)get(34-40)delete(60-66)packages/python-sdk/usesend/campaigns.py (1)
get(32-38)packages/python-sdk/usesend/domains.py (2)
get(34-36)delete(38-40)
packages/sdk/src/usesend.ts (2)
packages/python-sdk/usesend/usesend.py (1)
UseSend(34-155)packages/sdk/types/index.ts (1)
ErrorResponse(1-4)
🪛 LanguageTool
apps/docs/api-reference/emails/send-email.mdx
[style] ~13-~13: Three successive sentences begin with the same word. Consider rewording the sentence or use a thesaurus to find a synonym.
Context: ...IQUE` so you can detect the mismatch. - Same key while another request is still bein...
(ENGLISH_WORD_REPEAT_BEGINNING_RULE)
plan-idempotency.md
[style] ~127-~127: ‘exact same’ might be wordy. Consider a shorter alternative.
Context: ...an Idempotency-Key is reused with the exact same request body (as per server canonicaliz...
(EN_WORDINESS_PREMIUM_EXACT_SAME)
apps/docs/api-reference/emails/batch-email.mdx
[style] ~13-~13: Three successive sentences begin with the same word. Consider rewording the sentence or use a thesaurus to find a synonym.
Context: ...` so you can detect accidental reuse. - Same key while another batch is still being ...
(ENGLISH_WORD_REPEAT_BEGINNING_RULE)
🪛 markdownlint-cli2 (0.18.1)
plan-idempotency.md
84-84: Unordered list indentation
Expected: 0; Actual: 2
(MD007, ul-indent)
85-85: Unordered list indentation
Expected: 2; Actual: 4
(MD007, ul-indent)
86-86: Unordered list indentation
Expected: 2; Actual: 4
(MD007, ul-indent)
87-87: Unordered list indentation
Expected: 0; Actual: 2
(MD007, ul-indent)
🔇 Additional comments (10)
plan-idempotency.md (1)
1-173: Comprehensive idempotency plan.The planning document is thorough and covers all necessary aspects: Redis schema, API contracts, SDK changes, canonicalization, error handling, and verification. The design choices (24h TTL, 60s locks, SHA-256 hashing, 409 conflicts) are appropriate for the use case.
The static analysis hints about "exact same" wordiness and list indentation are cosmetic and can be addressed optionally.
packages/sdk/src/usesend.ts (2)
55-67: Clean header merging implementation.The
mergeHeadershelper correctly combines base headers with per-request headers using the Headers API. The early return optimization for the no-extra-headers case is good.
108-172: HTTP methods consistently support per-request options.All HTTP verb methods now accept optional
RequestOptionsand correctly forward headers throughfetchRequest. Thedeletemethod appropriately handles optional body. The implementation is consistent and correct.packages/sdk/src/email.ts (2)
79-101: Idempotency support correctly integrated into email methods.The
sendandcreatemethods now acceptEmailRequestOptionsand properly forward the idempotency key as anIdempotency-Keyheader when provided. The conditional header construction (lines 95-97) is clean and correct.
109-125: Batch method consistently supports idempotency.The
batchmethod mirrors the same idempotency pattern ascreate, correctly propagating the key to the batch endpoint. The implementation is consistent with the single-send flow.apps/docs/api-reference/emails/send-email.mdx (1)
7-15: Clear and accurate idempotency documentation.The documentation clearly explains the three idempotency scenarios and the 24-hour expiry. The explanation is concise and actionable for API consumers.
The static analysis hint about repetitive sentence starts with "Same" is a minor style issue that could optionally be addressed by rewording, but the current text is clear.
packages/sdk/README.md (1)
52-82: Helpful idempotency examples.The examples clearly demonstrate how to use idempotency keys for both single and batch sends. The comment about HTTP 409 on payload mismatch is a useful warning for users.
apps/docs/api-reference/introduction.mdx (1)
26-28: Concise idempotency introduction.The brief overview appropriately introduces the idempotency feature at the API introduction level, with references to both affected endpoints. The explanation is clear and sufficient for this high-level page.
apps/docs/api-reference/emails/batch-email.mdx (1)
7-15: Consistent batch idempotency documentation.The documentation correctly explains that the idempotency key applies to the entire batch payload and describes the same three scenarios as the single-send endpoint. The consistency across endpoints is good.
The static analysis hint about repetitive "Same" is minor and optional to address.
apps/web/src/server/utils/idempotency.ts (1)
11-61: Solid canonicalization logic.The
normalizefunction handles the expected types for email payloads correctly:
- Sorts object keys for determinism
- Filters undefined values to ensure semantic equivalence
- Converts dates to ISO strings
- Preserves array order (by design for recipient lists)
The fallback to
String(value)at line 60 handles exotic types gracefully. For email payloads (which are JSON-serializable), this implementation is appropriate.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
| const IDEMPOTENCY_RESULT_TTL_SECONDS = 24 * 60 * 60; // 24h | ||
| const IDEMPOTENCY_LOCK_TTL_SECONDS = 60; // 60s |
There was a problem hiding this comment.
Consider longer or renewable lock TTL.
60s may be shorter than worst-case processing (DB writes + queueing). Increase TTL (e.g., 300s) or add periodic renewal to avoid duplicate sends when locks expire mid-flight.
🤖 Prompt for AI Agents
In apps/web/src/server/service/idempotency-service.ts around lines 3 to 4, the
idempotency lock TTL is only 60s which can expire during long-running processing
and cause duplicate sends; either increase IDEMPOTENCY_LOCK_TTL_SECONDS to a
higher value (e.g., 300) or implement periodic lock renewal (extend the lock
before it expires while processing) and ensure renewal failures are handled and
locks are released on completion or error.
| async acquireLock(teamId: number, key: string): Promise<boolean> { | ||
| 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<void> { | ||
| const redis = getRedis(); | ||
| await redis.del(lockKey(teamId, key)); | ||
| }, |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
Fix distributed lock: ensure ownership on release (use token + compare-and-del).
Current release deletes the lock unconditionally. If the lock expires mid-flight and another request re-acquires it, your DEL can drop the new owner’s lock, enabling concurrent sends. Use a unique token on SET NX EX and only delete if the stored value matches.
Apply this diff:
@@
-import { getRedis } from "~/server/redis";+import { getRedis } from "~/server/redis";+import { randomUUID } from "crypto";
@@
-const IDEMPOTENCY_LOCK_TTL_SECONDS = 60; // 60s+const IDEMPOTENCY_LOCK_TTL_SECONDS = 60; // 60s
@@
-export const IdempotencyService = {+export const IdempotencyService = {
@@
- async acquireLock(teamId: number, key: string): Promise<boolean> {+ async acquireLock(teamId: number, key: string): Promise<string | null> {
const redis = getRedis();
- const ok = await redis.set(- lockKey(teamId, key),- "1",+ const token = randomUUID();+ const ok = await redis.set(+ lockKey(teamId, key),+ token,
"EX",
IDEMPOTENCY_LOCK_TTL_SECONDS,
"NX"
);
- return ok === "OK";+ return ok === "OK" ? token : null;
},
- async releaseLock(teamId: number, key: string): Promise<void> {+ async releaseLock(teamId: number, key: string, token: string): Promise<void> {
const redis = getRedis();
- await redis.del(lockKey(teamId, key));+ // Delete only if we still own the lock+ const script = `+ if redis.call("get", KEYS[1]) == ARGV[1] then+ return redis.call("del", KEYS[1])+ else+ return 0+ end+ `;+ await redis.eval(script, 1, lockKey(teamId, key), token);
},
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| asyncacquireLock(teamId: number,key: string): Promise<boolean>{ | |
| constredis=getRedis(); | |
| constok=awaitredis.set( | |
| lockKey(teamId,key), | |
| "1", | |
| "EX", | |
| IDEMPOTENCY_LOCK_TTL_SECONDS, | |
| "NX" | |
| ); | |
| returnok==="OK"; | |
| }, | |
| asyncreleaseLock(teamId: number,key: string): Promise<void>{ | |
| constredis=getRedis(); | |
| awaitredis.del(lockKey(teamId,key)); | |
| }, | |
| asyncacquireLock(teamId: number,key: string): Promise<string|null>{ | |
| constredis=getRedis(); | |
| consttoken=randomUUID(); | |
| constok=awaitredis.set( | |
| lockKey(teamId,key), | |
| token, | |
| "EX", | |
| IDEMPOTENCY_LOCK_TTL_SECONDS, | |
| "NX" | |
| ); | |
| returnok==="OK" ? token : null; | |
| }, | |
| asyncreleaseLock(teamId: number,key: string,token: string): Promise<void>{ | |
| constredis=getRedis(); | |
| // Delete only if we still own the lock | |
| constscript= ` | |
| ifredis.call("get",KEYS[1])==ARGV[1]then | |
| returnredis.call("del",KEYS[1]) | |
| else | |
| return0 | |
| end | |
| `; | |
| awaitredis.eval(script,1,lockKey(teamId,key),token); | |
| }, |
There was a problem hiding this comment.
5 issues found across 14 files
Prompt for AI agents (all 5 issues)
Understand the root cause of the following 5 issues and fix them.
<file name="apps/web/src/server/public-api/api/emails/send-email.ts">
<violation number="1" location="apps/web/src/server/public-api/api/emails/send-email.ts:16">
Idempotency logic and Idempotency-Key header validation are duplicated in apps/web/src/server/public-api/api/emails/batch-email.ts. This critical logic should be extracted into a reusable middleware or utility function.</violation>
</file>
<file name="plan-idempotency.md">
<violation number="1" location="plan-idempotency.md:45">
`getResult` needs to return an object that includes the cached body hash so the later `stored.bodyHash` comparison can work; returning only `string[]` makes the documented idempotency check impossible.</violation>
<violation number="2" location="plan-idempotency.md:46">
`setResult` must accept a payload including both `bodyHash` and `emailIds`; otherwise the documented `setResult(... { bodyHash, emailIds })` call cannot work and the cache will lack the hash needed for idempotency.</violation>
</file>
<file name="apps/web/src/server/utils/idempotency.ts">
<violation number="1" location="apps/web/src/server/utils/idempotency.ts:33">
Creating the accumulator with `{}` drops `"__proto__"` keys because later assignments mutate the prototype instead of storing the value, so such payloads collide during canonicalization. Use a null-prototype object to preserve every key.</violation>
</file>
<file name="apps/web/src/server/service/idempotency-service.ts">
<violation number="1" location="apps/web/src/server/service/idempotency-service.ts:70">
Release deletes the lock unconditionally, which risks dropping a new owner’s lock if the original expires and is re-acquired. Use a token-based lock and compare-and-delete (e.g., Lua script) to ensure only the owner releases the lock.</violation>
</file>
React with 👍 or 👎 to teach cubic. Mention @cubic-dev-ai to give feedback, ask questions, or re-run the review.
| request: { | ||
| headers: z | ||
| .object({ | ||
| "Idempotency-Key": z.string().min(1).max(256).optional(), |
There was a problem hiding this comment.
Idempotency logic and Idempotency-Key header validation are duplicated in apps/web/src/server/public-api/api/emails/batch-email.ts. This critical logic should be extracted into a reusable middleware or utility function.
Prompt for AI agents
Address the following comment on apps/web/src/server/public-api/api/emails/send-email.ts at line 16:
<comment>Idempotency logic and Idempotency-Key header validation are duplicated in apps/web/src/server/public-api/api/emails/batch-email.ts. This critical logic should be extracted into a reusable middleware or utility function.</comment>
<file context>
@@ -2,11 +2,21 @@ import { createRoute, z } from "@hono/zod-openapi";
request: {
+ headers: z
+ .object({
+ "Idempotency-Key": z.string().min(1).max(256).optional(),
+ })
+ .partial()
</file context>
| 1. Common util (service level) | ||
| - Add `IdempotencyService` with helpers using existing Redis client (`getRedis`): | ||
| - `getResult(teamId: number, key: string): Promise<string[] | null>` | ||
| - `setResult(teamId: number, key: string, emailIds: string[]): Promise<void>` (EX 24h) |
There was a problem hiding this comment.
setResult must accept a payload including both bodyHash and emailIds; otherwise the documented setResult(... { bodyHash, emailIds }) call cannot work and the cache will lack the hash needed for idempotency.
Prompt for AI agents
Address the following comment on plan-idempotency.md at line 46:
<comment>`setResult` must accept a payload including both `bodyHash` and `emailIds`; otherwise the documented `setResult(... { bodyHash, emailIds })` call cannot work and the cache will lack the hash needed for idempotency.</comment>
<file context>
@@ -0,0 +1,174 @@
+1. Common util (service level)
+ - Add `IdempotencyService` with helpers using existing Redis client (`getRedis`):
+ - `getResult(teamId: number, key: string): Promise<string[] | null>`
+ - `setResult(teamId: number, key: string, emailIds: string[]): Promise<void>` (EX 24h)
+ - `acquireLock(teamId: number, key: string): Promise<boolean>` (`SET NX EX 60`)
+ - `releaseLock(teamId: number, key: string): Promise<void>` (best-effort `DEL`)
</file context>
| - `setResult(teamId: number, key: string, emailIds: string[]): Promise<void>` (EX 24h) | |
| - `setResult(teamId: number, key: string, payload: { bodyHash: string; emailIds: string[] }): Promise<void>` (EX 24h) |
| ## Server Implementation Plan | ||
| 1. Common util (service level) | ||
| - Add `IdempotencyService` with helpers using existing Redis client (`getRedis`): | ||
| - `getResult(teamId: number, key: string): Promise<string[] | null>` |
There was a problem hiding this comment.
getResult needs to return an object that includes the cached body hash so the later stored.bodyHash comparison can work; returning only string[] makes the documented idempotency check impossible.
Prompt for AI agents
Address the following comment on plan-idempotency.md at line 45:
<comment>`getResult` needs to return an object that includes the cached body hash so the later `stored.bodyHash` comparison can work; returning only `string[]` makes the documented idempotency check impossible.</comment>
<file context>
@@ -0,0 +1,174 @@
+## Server Implementation Plan
+1. Common util (service level)
+ - Add `IdempotencyService` with helpers using existing Redis client (`getRedis`):
+ - `getResult(teamId: number, key: string): Promise<string[] | null>`
+ - `setResult(teamId: number, key: string, emailIds: string[]): Promise<void>` (EX 24h)
+ - `acquireLock(teamId: number, key: string): Promise<boolean>` (`SET NX EX 60`)
</file context>
| ([keyA], [keyB]) => (keyA < keyB ? -1 : keyA > keyB ? 1 : 0) | ||
| ); | ||
| const result: Record<string, CanonicalValue> = {}; |
There was a problem hiding this comment.
Creating the accumulator with {} drops "__proto__" keys because later assignments mutate the prototype instead of storing the value, so such payloads collide during canonicalization. Use a null-prototype object to preserve every key.
Prompt for AI agents
Address the following comment on apps/web/src/server/utils/idempotency.ts at line 33:
<comment>Creating the accumulator with `{}` drops `"__proto__"` keys because later assignments mutate the prototype instead of storing the value, so such payloads collide during canonicalization. Use a null-prototype object to preserve every key.</comment>
<file context>
@@ -0,0 +1,69 @@
+ ([keyA], [keyB]) => (keyA < keyB ? -1 : keyA > keyB ? 1 : 0)
+ );
+
+ const result: Record<string, CanonicalValue> = {};
+ for (const [key, val] of entries) {
+ const normalized = normalize(val);
</file context>
| constresult: Record<string,CanonicalValue>={}; | |
| constresult: Record<string,CanonicalValue>=Object.create(null); |
| async releaseLock(teamId: number, key: string): Promise<void> { | ||
| const redis = getRedis(); | ||
| await redis.del(lockKey(teamId, key)); |
There was a problem hiding this comment.
Release deletes the lock unconditionally, which risks dropping a new owner’s lock if the original expires and is re-acquired. Use a token-based lock and compare-and-delete (e.g., Lua script) to ensure only the owner releases the lock.
Prompt for AI agents
Address the following comment on apps/web/src/server/service/idempotency-service.ts at line 70:
<comment>Release deletes the lock unconditionally, which risks dropping a new owner’s lock if the original expires and is re-acquired. Use a token-based lock and compare-and-delete (e.g., Lua script) to ensure only the owner releases the lock.</comment>
<file context>
@@ -0,0 +1,78 @@
+
+ async releaseLock(teamId: number, key: string): Promise<void> {
+ const redis = getRedis();
+ await redis.del(lockKey(teamId, key));
+ },
+};
</file context>
c5094c5 to
c3cb90bCompareThere was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (1)
apps/web/src/server/service/idempotency-service.ts (1)
69-84: Lock release can drop another owner’s lock after TTL expiry (token-based lock recommended)
acquireLockwrites a fixed value ("1") andreleaseLockalwaysDELs the key. If a long-running operation outlivesIDEMPOTENCY_LOCK_TTL_SECONDS, the lock can expire and be reacquired by another request; when the original request later callsreleaseLock, it will delete the new owner’s lock, allowing further concurrent sends with the same idempotency key. This matches the concerns in earlier review comments and is still unresolved.A safer pattern is to store a unique token in the lock and only delete the key if the stored token matches, e.g.:
-import { getRedis } from "~/server/redis";+import { getRedis } from "~/server/redis";+import { randomUUID } from "crypto"; @@ -const IDEMPOTENCY_LOCK_TTL_SECONDS = 60; // 60s+const IDEMPOTENCY_LOCK_TTL_SECONDS = 60; // 60s @@ - async acquireLock(teamId: number, key: string): Promise<boolean> {+ async acquireLock(teamId: number, key: string): Promise<string | null> { const redis = getRedis(); - const ok = await redis.set(- lockKey(teamId, key),- "1",+ const token = randomUUID();+ const ok = await redis.set(+ lockKey(teamId, key),+ token, "EX", IDEMPOTENCY_LOCK_TTL_SECONDS, "NX", ); - return ok === "OK";+ return ok === "OK" ? token : null; }, - async releaseLock(teamId: number, key: string): Promise<void> {+ async releaseLock(teamId: number, key: string, token: string): Promise<void> { const redis = getRedis(); - await redis.del(lockKey(teamId, key));+ const script = `+ if redis.call("get", KEYS[1]) == ARGV[1] then+ return redis.call("del", KEYS[1])+ else+ return 0+ end+ `;+ await redis.eval(script, 1, lockKey(teamId, key), token); },You’d then thread the returned
tokenthroughwithIdempotency(store it when acquiring the lock and pass it toreleaseLockin thefinallyblock) so only the actual lock owner can release it. Please verify this with your Redis client’sevalAPI and adapt the call signature if needed.
🧹 Nitpick comments (9)
packages/python-sdk/README.md (1)
40-52: Python SDK idempotency examples are accurate; consider documenting TTL and in‑progress behavior.The
options={"idempotency_key": ...}examples correctly match the newEmailOptionssurface and backend behavior. To keep this aligned with the API docs and OpenAPI description, consider briefly noting that:
- Entries expire after 24 hours, and
- Reusing the same key while another request is still in progress results in HTTP 409 as well.
This would make retry semantics fully explicit here.
packages/sdk/src/email.ts (1)
70-72: Idempotency options wiring for JS SDK emails looks correct.
EmailRequestOptionsand the updatedsend/create/batchmethods correctly:
- Accept an optional
idempotencyKey.- Forward it as an
Idempotency-Keyheader intoUseSend.post, which merges it with base auth/content-type headers.This matches the backend/API contract and keeps existing call sites compatible. If you expect more per-call flags later, you could factor the repeated header construction into a small helper on
Emails, but it’s not required right now.Also applies to: 79-101, 109-125
packages/python-sdk/usesend/emails.py (1)
6-7: Python SDK idempotency support is correctly integrated.
EmailOptions,_idem_headers, and the updatedsend/create/batchmethods cleanly propagate an optionalidempotency_keyinto theIdempotency-Keyheader while preserving existing payload normalization.If you want to reduce repetition, you could extract a tiny helper like
_idem_headers_from_options(options)to avoid duplicating theoptions.get("idempotency_key")pattern in bothcreateandbatch, but the current form is perfectly fine.Also applies to: 22-31, 40-47, 48-69, 71-88
apps/docs/api-reference/openapi.json (1)
1-7: Optional: add global or per-operation security to satisfy OpenAPI security tooling.Checkov is flagging the spec because
components.securitySchemes.Beareris defined but nosecurityrequirements are set globally or on operations. If you want those checks green and the spec to reflect actual auth behavior, consider either:
- Adding a top-level
"security": [{ "Bearer": [] }], or- Adding
securityblocks to the protected operations.This is pre-existing and non-blocking for the idempotency work but worth tracking separately.
apps/web/src/server/public-api/api/emails/send-email.ts (2)
11-28: Idempotency-Key header validation and documentation align with backend behavior.The zod header schema for
"Idempotency-Key"(1–256 chars plus detailed description) matches the idempotency service’s length checks and the OpenAPI description. The.partial()call is slightly redundant given the property is already.optional(), but it’s harmless—feel free to drop it later if you want the schema to read a bit more cleanly.
53-90: Idempotency wrapper around sendEmail is consistent and correctly canonicalizes the payload.Using
clientPayloadboth for the idempotency payload and the call intosendEmailensures that:
- The hashed body used for idempotency matches what’s actually executed, and
- Cached hits can safely rehydrate
{ emailId }without re-sending.HTML normalization (
rawHtml→htmlstring orundefined) and the explicittext: requestBody.text ?? undefinedkeep the payload shape stable across retries, which is important for consistent hashing.One small optional tweak: instead of
c.req.header("Idempotency-Key"), you could pull it fromc.req.valid("header")["Idempotency-Key"]to reuse the zod-validated value, though the central length guard inIdempotencyService.withIdempotencyalready protects against invalid keys.packages/python-sdk/usesend/usesend.py (1)
120-147: HTTP helpers gain per-call headers without breaking existing usageThe updated
post/get/put/patch/deletesignatures add optionalheaders(andbodyfordelete) while still working with existing calls that only passpathandbody. All methods now route through_requestwith the merged headers, which is exactly what the SDK needs for idempotent retries and other per-call customization.Also applies to: 149-155
apps/web/src/server/service/idempotency-service.ts (2)
32-54: Result storage/lookup is robust but silently drops malformed entries
getResultdefensively parses JSON and validatesbodyHash/emailIds, returningnullon malformed data, andsetResultusessetexwith the configured TTL, which is a safe baseline. If you ever need to debug corrupted entries, consider logging parse/shape failures at a low log level instead of returningnullsilently, but that’s not required for correctness.Also applies to: 56-67
86-171: Idempotency orchestration logic is solid; consider tuning lock TTL and error codesThe overall
withIdempotencyflow—key length validation, canonical hashing, result-hit fast path, contention re-check, and result storage usingextractEmailIds/formatCachedResponse—looks correct and matches the documented behavior for same-body hits vs conflicts. Two non-blocking points to consider:
- A 60s lock TTL may be tight if upstream work ever grows (e.g., heavier DB or queue interactions); if you expect slower operations, bumping
IDEMPOTENCY_LOCK_TTL_SECONDSor adding renewal would reduce the chance of mid-flight expiry.- Both “different payload” and “request in progress” paths use the same
NOT_UNIQUEcode; if clients ever need to distinguish these cases programmatically, introducing a distinct error code for “in progress” could help, though the current messages are clear enough for most consumers.Mechanically, aside from the lock-ownership issue noted above, the idempotent behavior and returned shapes are consistent with your batch/single-email routes.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (14)
apps/docs/api-reference/emails/send-email.mdx(1 hunks)apps/docs/api-reference/openapi.json(57 hunks)apps/web/src/server/public-api/api/emails/batch-email.ts(3 hunks)apps/web/src/server/public-api/api/emails/send-email.ts(2 hunks)apps/web/src/server/service/idempotency-service.ts(1 hunks)apps/web/src/server/utils/idempotency.ts(1 hunks)packages/python-sdk/README.md(1 hunks)packages/python-sdk/pyproject.toml(1 hunks)packages/python-sdk/usesend/emails.py(4 hunks)packages/python-sdk/usesend/usesend.py(2 hunks)packages/sdk/README.md(1 hunks)packages/sdk/package.json(2 hunks)packages/sdk/src/email.ts(2 hunks)packages/sdk/src/usesend.ts(3 hunks)
✅ Files skipped from review due to trivial changes (2)
- packages/sdk/README.md
- packages/sdk/package.json
🚧 Files skipped from review as they are similar to previous changes (2)
- apps/docs/api-reference/emails/send-email.mdx
- apps/web/src/server/utils/idempotency.ts
🧰 Additional context used
📓 Path-based instructions (4)
**/*.{ts,tsx,md}
📄 CodeRabbit inference engine (AGENTS.md)
Format code with Prettier 3 (run pnpm format)
Files:
packages/python-sdk/README.mdpackages/sdk/src/email.tsapps/web/src/server/public-api/api/emails/send-email.tsapps/web/src/server/public-api/api/emails/batch-email.tspackages/sdk/src/usesend.tsapps/web/src/server/service/idempotency-service.ts
**/*.{js,jsx,ts,tsx}
📄 CodeRabbit inference engine (.cursor/rules/general.mdc)
Include all required imports, and ensure proper naming of key components.
Files:
packages/sdk/src/email.tsapps/web/src/server/public-api/api/emails/send-email.tsapps/web/src/server/public-api/api/emails/batch-email.tspackages/sdk/src/usesend.tsapps/web/src/server/service/idempotency-service.ts
**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{ts,tsx}: TypeScript-first: use .ts/.tsx for source code (avoid JavaScript source files)
Use 2-space indentation and semicolons (Prettier 3 enforces these)
Adhere to @usesend/eslint-config; fix all ESLint warnings (CI fails on warnings)
Do not use dynamic imports; always place imports at the top of the module
Files:
packages/sdk/src/email.tsapps/web/src/server/public-api/api/emails/send-email.tsapps/web/src/server/public-api/api/emails/batch-email.tspackages/sdk/src/usesend.tsapps/web/src/server/service/idempotency-service.ts
apps/web/**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
apps/web/**/*.{ts,tsx}: In apps/web, use the/ alias for src imports (e.g., import { x } from "/utils/x")
Prefer using tRPC in apps/web unless explicitly asked otherwise
Files:
apps/web/src/server/public-api/api/emails/send-email.tsapps/web/src/server/public-api/api/emails/batch-email.tsapps/web/src/server/service/idempotency-service.ts
🧠 Learnings (3)
📓 Common learnings
Learnt from: KMKoushik
Repo: usesend/useSend PR: 224
File: apps/web/src/server/public-api/api/emails/get-email.ts:63-74
Timestamp: 2025-09-10T12:33:42.667Z
Learning: In the useSend project using Prisma Client 6.6.0, findUnique works with composite where clauses containing id, teamId, and optional domainId fields in apps/web/src/server/public-api/api/emails/get-email.ts, as confirmed by the project maintainer KMKoushik.
📚 Learning: 2025-09-10T12:33:42.667Z
Learnt from: KMKoushik
Repo: usesend/useSend PR: 224
File: apps/web/src/server/public-api/api/emails/get-email.ts:63-74
Timestamp: 2025-09-10T12:33:42.667Z
Learning: In the useSend project, Prisma's findUnique method works with composite where clauses including id, teamId, and optional domainId fields in apps/web/src/server/public-api/api/emails/get-email.ts
Applied to files:
packages/sdk/src/email.tsapps/web/src/server/public-api/api/emails/send-email.tsapps/web/src/server/public-api/api/emails/batch-email.ts
📚 Learning: 2025-09-10T12:33:42.667Z
Learnt from: KMKoushik
Repo: usesend/useSend PR: 224
File: apps/web/src/server/public-api/api/emails/get-email.ts:63-74
Timestamp: 2025-09-10T12:33:42.667Z
Learning: In the useSend project using Prisma Client 6.6.0, findUnique works with composite where clauses containing id, teamId, and optional domainId fields in apps/web/src/server/public-api/api/emails/get-email.ts, as confirmed by the project maintainer KMKoushik.
Applied to files:
apps/web/src/server/public-api/api/emails/send-email.tsapps/web/src/server/public-api/api/emails/batch-email.ts
🧬 Code graph analysis (7)
packages/sdk/src/email.ts (2)
packages/python-sdk/usesend/emails.py (1)
Emails(33-104)packages/sdk/src/usesend.ts (1)
UseSend(19-174)
apps/web/src/server/public-api/api/emails/send-email.ts (2)
apps/web/src/server/service/idempotency-service.ts (1)
IdempotencyService(32-172)apps/web/src/server/service/email-service.ts (1)
sendEmail(55-302)
apps/web/src/server/public-api/api/emails/batch-email.ts (3)
apps/web/src/server/service/idempotency-service.ts (1)
IdempotencyService(32-172)apps/web/src/types/index.ts (1)
EmailContent(1-18)apps/web/src/server/service/email-service.ts (1)
sendBulkEmails(368-789)
packages/python-sdk/usesend/usesend.py (5)
packages/python-sdk/usesend/emails.py (2)
update(94-100)get(90-92)packages/python-sdk/usesend/contacts.py (3)
update(42-49)get(34-40)delete(60-66)packages/sdk/src/usesend.ts (5)
post(108-119)get(121-131)put(133-144)patch(146-157)delete(159-173)packages/python-sdk/usesend/campaigns.py (1)
get(32-38)packages/python-sdk/usesend/domains.py (2)
get(34-36)delete(38-40)
packages/sdk/src/usesend.ts (2)
packages/python-sdk/usesend/usesend.py (1)
UseSend(34-155)packages/sdk/types/index.ts (1)
ErrorResponse(1-4)
packages/python-sdk/usesend/emails.py (2)
packages/sdk/src/email.ts (3)
send(79-81)create(83-101)batch(109-125)packages/python-sdk/usesend/usesend.py (1)
post(120-126)
apps/web/src/server/service/idempotency-service.ts (4)
apps/web/src/server/redis.ts (1)
getRedis(6-13)apps/web/src/server/public-api/api-error.ts (1)
UnsendApiError(62-75)apps/web/src/server/utils/idempotency.ts (1)
canonicalizePayload(63-68)apps/web/src/server/logger/log.ts (1)
logger(31-63)
🪛 Checkov (3.2.334)
apps/docs/api-reference/openapi.json
[high] 1-1699: Ensure that the global security field has rules defined
(CKV_OPENAPI_4)
[high] 1-1699: Ensure that security operations is not empty.
(CKV_OPENAPI_5)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Cloudflare Pages
🔇 Additional comments (7)
packages/python-sdk/pyproject.toml (1)
3-3: Version bump to 0.2.8 fits the added idempotency surface.The patch-level version increment is appropriate for the non-breaking addition of idempotency options and docs.
apps/docs/api-reference/openapi.json (1)
793-805: Single-send Idempotency-Key header is well documented.The new
Idempotency-Keyheader onPOST /v1/emailscorrectly documents length constraints and retry behavior (same body → 200 with original result; differing body or in-progress request → 409). This aligns with the backend idempotency service and SDK behavior.packages/sdk/src/usesend.ts (1)
15-17: Per-call RequestOptions and header merging are implemented correctly.
RequestOptionsplusbaseHeaders/mergeHeadersgive you a clean way to add headers likeIdempotency-Keywithout risking loss ofAuthorizationorContent-Type. The updated verb methods (post/get/put/patch/delete) remain backward compatible while allowing the email client to pass per-call headers.This aligns well with the new idempotency features and the coding guidelines for keeping imports at the top and using TypeScript-first modules.
Also applies to: 20-20, 49-67, 71-77, 108-173
apps/web/src/server/public-api/api/emails/batch-email.ts (2)
5-6: Centralized schema and service imports look goodUsing the shared
emailSchemaandIdempotencyServicekeeps the batch route aligned with single-send behavior and avoids schema drift; no issues here.
68-75: Idempotent batching flow and payload normalization look correctNormalizing
text/htmlbefore hashing and sending, then passingnormalizedPayloadsboth towithIdempotencyand tosendBulkEmails(after augmenting withteamId/apiKeyId) gives you stable idempotency semantics tied to the user payload while keeping team-specific data out of the hash. The cached and fresh paths both return the same{ emailId }[]structure, so the API surface remains consistent.Also applies to: 79-102
packages/python-sdk/usesend/usesend.py (1)
80-85: Header merge helper is straightforward and preserves defaults
_build_headerscloningself.headersand overlaying non-Noneextras gives a clear override model without mutating the client’s defaults._requestnow consistently uses this helper, so per-call headers (like Idempotency-Key) can be added without affecting other calls.Also applies to: 87-99
apps/web/src/server/service/idempotency-service.ts (1)
174-177: Exported constants match documented TTLs
IDEMPOTENCY_CONSTANTSexposing a 24h result TTL and 60s lock TTL is useful for aligning docs and tests with runtime behavior; this matches the header description about 24-hour expiry.
| "post": { | ||
| "parameters": [ | ||
| { | ||
| "schema": { | ||
| "type": "string", | ||
| "minLength": 1, | ||
| "maxLength": 256, | ||
| "description": "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:\n\n- Same key + same request body → returns the original emailId with 200 OK without re-sending.\n- Same key + different request body → returns 409 Conflict with code: NOT_UNIQUE so you can detect the mismatch.\n- Same key while another request is still being processed → returns 409 Conflict; retry after a short delay or once the first request completes.\n\nEntries expire after 24 hours. Use a unique key per logical send (for example, an order or signup ID)." | ||
| }, | ||
| "required": false, | ||
| "name": "Idempotency-Key", | ||
| "in": "header" | ||
| } | ||
| ], |
There was a problem hiding this comment.
Clarify batch Idempotency-Key description to mention multiple emailIds.
For POST /v1/emails/batch, the Idempotency-Key header description still talks about returning “the original emailId” (singular), but the response shape is a list of { emailId } objects. To avoid confusion, consider updating the text to say it returns the original list of emailIds (or “original response”) on a hit.
🤖 Prompt for AI Agents
In apps/docs/api-reference/openapi.json around lines 905 to 918, the
Idempotency-Key header description references returning “the original emailId”
(singular) which is misleading for the batch endpoint; update the text to
reference the original list of emailIds or “the original response” (plural) and
adjust wording so it clearly states the server returns the same list of emailIds
(or the original response body) on an idempotency hit, while keeping the rest of
the behavior and constraints unchanged.
| headers: z | ||
| .object({ | ||
| "Idempotency-Key": z | ||
| .string() | ||
| .min(1) | ||
| .max(256) | ||
| .optional() | ||
| .openapi({ | ||
| description: `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).`, | ||
| }), | ||
| }) | ||
| .partial(), |
There was a problem hiding this comment.
Tighten Idempotency-Key doc wording for batch responses
The header description says “returns the original emailId” (singular), but this route returns data: [{ emailId }] (a list). To avoid confusion, consider updating the wording to “emailIds” or “list of emailIds” so it matches the actual batch response shape.
🤖 Prompt for AI Agents
In apps/web/src/server/public-api/api/emails/batch-email.ts around lines 16 to
33, the Idempotency-Key openapi description incorrectly says “returns the
original emailId” (singular) while this endpoint returns a list in data: [{
emailId }]; update the wording to use “emailIds” or “a list of emailIds” (e.g.,
“returns the original emailIds” or “returns the original list of emailIds”) so
the description matches the batch response shape and clarifies the returned
structure.
Summary by cubic
Adds idempotency to POST /v1/emails and /v1/emails/batch so retries don’t send duplicates. SDKs support passing Idempotency-Key; docs describe behavior and 24h expiry.
Written for commit 087b5cc. Summary will update automatically on new commits.
Summary by CodeRabbit
New Features
Documentation
Chores