idempotency - #282

Merged
KMKoushik merged 6 commits into
mainfrom
km/2025-10-25-idempotency-keys
Nov 17, 2025
Merged

idempotency#282
KMKoushik merged 6 commits into
mainfrom
km/2025-10-25-idempotency-keys

Conversation

@KMKoushik

@KMKoushikKMKoushik commented Oct 25, 2025

Copy link
Copy Markdown
Member

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.

  • New Features
    • Accept Idempotency-Key (1–256 chars) on single and batch sends; canonicalize the request body and cache results in Redis for 24h.
    • Same key + same body → 200 with original emailId(s) without re-sending.
    • Same key + different body → 409 Conflict with code NOT_UNIQUE.
    • Concurrent requests use a short Redis lock; if a request with the same key is in progress and no result yet → 409.
    • Batch: key applies to the entire payload; returns the original list of emailIds on hits.
    • SDKs: TypeScript and Python add idempotencyKey options/headers for send and batch.
    • Docs: API reference and introduction updated with clear idempotency guidance.

Written for commit 087b5cc. Summary will update automatically on new commits.

Summary by CodeRabbit

  • New Features

    • Added idempotency support to email APIs. Use optional Idempotency-Key header for safe retries—reusing the same key returns cached results; different payloads with the same key return 409 conflict.
    • Python and TypeScript SDKs now support idempotency keys.
  • Documentation

    • Added idempotency usage examples to SDK documentation.
  • Chores

    • Version bumps: Python SDK (0.2.7 → 0.2.8), TypeScript SDK (1.5.6 → 1.5.7).

@vercel

vercelBot commented Oct 25, 2025

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentPreviewCommentsUpdated (UTC)
unsend-marketingReadyReadyPreviewCommentNov 17, 2025 0:02am

@coderabbitai

coderabbitaiBot commented Oct 25, 2025

Copy link
Copy Markdown
Contributor

Note

Other AI code review bot(s) detected

CodeRabbit 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.

Walkthrough

This 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

  • PR #213: Modifies the same Emails.send/create/batch methods in the Python SDK to support idempotency key parameters
  • PR #198: Alters the same sendEmail and sendBulkEmails flows that are now wrapped with idempotency logic in the backend

Suggested labels

codex

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 10.00% which is insufficient. The required threshold is 80.00%.You can run @coderabbitai generate docstrings to improve docstring coverage.
Title check❓ InconclusiveThe title 'idempotency' is vague and generic, lacking specificity about what was changed or improved.Consider a more descriptive title like 'Add idempotency support to email API endpoints' or 'Implement idempotent retry handling for POST /v1/emails and /batch'.
✅ Passed checks (1 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch km/2025-10-25-idempotency-keys

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Oct 25, 2025

Copy link
Copy Markdown

Deploying usesend with Cloudflare Pages Cloudflare Pages

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

View logs

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +56 to +70
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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_key parameter 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 RequestOptions type 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 RequestOptions in 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

📥 Commits

Reviewing files that changed from the base of the PR and between 8e569f8 and c5094c5.

📒 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.md
  • packages/python-sdk/README.md
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • packages/sdk/src/email.ts
  • plan-idempotency.md
  • packages/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.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • packages/sdk/src/email.ts
  • packages/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.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • packages/sdk/src/email.ts
  • packages/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.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/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 mergeHeaders helper 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 RequestOptions and correctly forward headers through fetchRequest. The delete method 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 send and create methods now accept EmailRequestOptions and properly forward the idempotency key as an Idempotency-Key header when provided. The conditional header construction (lines 95-97) is clean and correct.


109-125: Batch method consistently supports idempotency.

The batch method mirrors the same idempotency pattern as create, 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 normalize function 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.

Comment threadapps/web/src/server/public-api/api/emails/batch-email.ts Outdated
Comment threadapps/web/src/server/public-api/api/emails/send-email.ts Outdated
Comment on lines +3 to +4
const IDEMPOTENCY_RESULT_TTL_SECONDS = 24 * 60 * 60; // 24h
const IDEMPOTENCY_LOCK_TTL_SECONDS = 60; // 60s

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +56 to +71
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));
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ 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.

Suggested change
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);
},

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 `&quot;__proto__&quot;` 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(),

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 &quot;@hono/zod-openapi&quot;;
request: {
+ headers: z
+ .object({
+ &quot;Idempotency-Key&quot;: z.string().min(1).max(256).optional(),
+ })
+ .partial()
</file context>
Fix with Cubic

Comment threadplan-idempotency.md Outdated
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)

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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&lt;string[] | null&gt;`
+ - `setResult(teamId: number, key: string, emailIds: string[]): Promise&lt;void&gt;` (EX 24h)
+ - `acquireLock(teamId: number, key: string): Promise&lt;boolean&gt;` (`SET NX EX 60`)
+ - `releaseLock(teamId: number, key: string): Promise&lt;void&gt;` (best-effort `DEL`)
</file context>
Suggested change
- `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)
Fix with Cubic

Comment threadplan-idempotency.md Outdated
## 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>`

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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&lt;string[] | null&gt;`
+ - `setResult(teamId: number, key: string, emailIds: string[]): Promise&lt;void&gt;` (EX 24h)
+ - `acquireLock(teamId: number, key: string): Promise&lt;boolean&gt;` (`SET NX EX 60`)
</file context>
Fix with Cubic

([keyA], [keyB]) => (keyA < keyB ? -1 : keyA > keyB ? 1 : 0)
);

const result: Record<string, CanonicalValue> = {};

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 `&quot;__proto__&quot;` 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]) =&gt; (keyA &lt; keyB ? -1 : keyA &gt; keyB ? 1 : 0)
+ );
+
+ const result: Record&lt;string, CanonicalValue&gt; = {};
+ for (const [key, val] of entries) {
+ const normalized = normalize(val);
</file context>
Suggested change
constresult: Record<string,CanonicalValue>={};
constresult: Record<string,CanonicalValue>=Object.create(null);
Fix with Cubic


async releaseLock(teamId: number, key: string): Promise<void> {
const redis = getRedis();
await redis.del(lockKey(teamId, key));

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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&lt;void&gt; {
+ const redis = getRedis();
+ await redis.del(lockKey(teamId, key));
+ },
+};
</file context>
Fix with Cubic

@KMKoushikKMKoushik changed the title idempotency vibeCodedidempotencyNov 16, 2025
@KMKoushik
KMKoushikforce-pushed the km/2025-10-25-idempotency-keys branch from c5094c5 to c3cb90bCompareNovember 17, 2025 00:00

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

acquireLock writes a fixed value ("1") and releaseLock always DELs the key. If a long-running operation outlives IDEMPOTENCY_LOCK_TTL_SECONDS, the lock can expire and be reacquired by another request; when the original request later calls releaseLock, 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 token through withIdempotency (store it when acquiring the lock and pass it to releaseLock in the finally block) so only the actual lock owner can release it. Please verify this with your Redis client’s eval API 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 new EmailOptions surface 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.

EmailRequestOptions and the updated send/create/batch methods correctly:

  • Accept an optional idempotencyKey.
  • Forward it as an Idempotency-Key header into UseSend.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 updated send/create/batch methods cleanly propagate an optional idempotency_key into the Idempotency-Key header 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 the options.get("idempotency_key") pattern in both create and batch, 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.Bearer is defined but no security requirements 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 security blocks 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 clientPayload both for the idempotency payload and the call into sendEmail ensures that:

  • The hashed body used for idempotency matches what’s actually executed, and
  • Cached hits can safely rehydrate { emailId } without re-sending.

HTML normalization (rawHtmlhtml string or undefined) and the explicit text: requestBody.text ?? undefined keep 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 from c.req.valid("header")["Idempotency-Key"] to reuse the zod-validated value, though the central length guard in IdempotencyService.withIdempotency already protects against invalid keys.

packages/python-sdk/usesend/usesend.py (1)

120-147: HTTP helpers gain per-call headers without breaking existing usage

The updated post/get/put/patch/delete signatures add optional headers (and body for delete) while still working with existing calls that only pass path and body. All methods now route through _request with 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

getResult defensively parses JSON and validates bodyHash/emailIds, returning null on malformed data, and setResult uses setex with 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 returning null silently, 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 codes

The overall withIdempotency flow—key length validation, canonical hashing, result-hit fast path, contention re-check, and result storage using extractEmailIds/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_SECONDS or adding renewal would reduce the chance of mid-flight expiry.
  • Both “different payload” and “request in progress” paths use the same NOT_UNIQUE code; 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

📥 Commits

Reviewing files that changed from the base of the PR and between c5094c5 and 087b5cc.

📒 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.md
  • packages/sdk/src/email.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • packages/sdk/src/usesend.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • packages/sdk/src/usesend.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • packages/sdk/src/usesend.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/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.ts
  • apps/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-Key header on POST /v1/emails correctly 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.

RequestOptions plus baseHeaders/mergeHeaders give you a clean way to add headers like Idempotency-Key without risking loss of Authorization or Content-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 good

Using the shared emailSchema and IdempotencyService keeps the batch route aligned with single-send behavior and avoids schema drift; no issues here.


68-75: Idempotent batching flow and payload normalization look correct

Normalizing text/html before hashing and sending, then passing normalizedPayloads both to withIdempotency and to sendBulkEmails (after augmenting with teamId/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_headers cloning self.headers and overlaying non-None extras gives a clear override model without mutating the client’s defaults. _request now 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_CONSTANTS exposing 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.

Comment on lines 905 to +918
"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"
}
],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +16 to +33
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(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

@KMKoushik
KMKoushik merged commit cb48965 into mainNov 17, 2025
8 checks passed
@KMKoushik
KMKoushik deleted the km/2025-10-25-idempotency-keys branch November 17, 2025 00:42
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@KMKoushik
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

idempotency - #282

Merged
KMKoushik merged 6 commits into
mainfrom
km/2025-10-25-idempotency-keys
Nov 17, 2025
Merged

idempotency#282
KMKoushik merged 6 commits into
mainfrom
km/2025-10-25-idempotency-keys

Conversation

@KMKoushik

@KMKoushikKMKoushik commented Oct 25, 2025

Copy link
Copy Markdown
Member

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.

  • New Features
    • Accept Idempotency-Key (1–256 chars) on single and batch sends; canonicalize the request body and cache results in Redis for 24h.
    • Same key + same body → 200 with original emailId(s) without re-sending.
    • Same key + different body → 409 Conflict with code NOT_UNIQUE.
    • Concurrent requests use a short Redis lock; if a request with the same key is in progress and no result yet → 409.
    • Batch: key applies to the entire payload; returns the original list of emailIds on hits.
    • SDKs: TypeScript and Python add idempotencyKey options/headers for send and batch.
    • Docs: API reference and introduction updated with clear idempotency guidance.

Written for commit 087b5cc. Summary will update automatically on new commits.

Summary by CodeRabbit

  • New Features

    • Added idempotency support to email APIs. Use optional Idempotency-Key header for safe retries—reusing the same key returns cached results; different payloads with the same key return 409 conflict.
    • Python and TypeScript SDKs now support idempotency keys.
  • Documentation

    • Added idempotency usage examples to SDK documentation.
  • Chores

    • Version bumps: Python SDK (0.2.7 → 0.2.8), TypeScript SDK (1.5.6 → 1.5.7).

@vercel

vercelBot commented Oct 25, 2025

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentPreviewCommentsUpdated (UTC)
unsend-marketingReadyReadyPreviewCommentNov 17, 2025 0:02am

@coderabbitai

coderabbitaiBot commented Oct 25, 2025

Copy link
Copy Markdown
Contributor

Note

Other AI code review bot(s) detected

CodeRabbit 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.

Walkthrough

This 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

  • PR #213: Modifies the same Emails.send/create/batch methods in the Python SDK to support idempotency key parameters
  • PR #198: Alters the same sendEmail and sendBulkEmails flows that are now wrapped with idempotency logic in the backend

Suggested labels

codex

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 10.00% which is insufficient. The required threshold is 80.00%.You can run @coderabbitai generate docstrings to improve docstring coverage.
Title check❓ InconclusiveThe title 'idempotency' is vague and generic, lacking specificity about what was changed or improved.Consider a more descriptive title like 'Add idempotency support to email API endpoints' or 'Implement idempotent retry handling for POST /v1/emails and /batch'.
✅ Passed checks (1 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch km/2025-10-25-idempotency-keys

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Oct 25, 2025

Copy link
Copy Markdown

Deploying usesend with Cloudflare Pages Cloudflare Pages

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

View logs

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +56 to +70
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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_key parameter 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 RequestOptions type 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 RequestOptions in 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

📥 Commits

Reviewing files that changed from the base of the PR and between 8e569f8 and c5094c5.

📒 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.md
  • packages/python-sdk/README.md
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • packages/sdk/src/email.ts
  • plan-idempotency.md
  • packages/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.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • packages/sdk/src/email.ts
  • packages/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.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • packages/sdk/src/email.ts
  • packages/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.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/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 mergeHeaders helper 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 RequestOptions and correctly forward headers through fetchRequest. The delete method 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 send and create methods now accept EmailRequestOptions and properly forward the idempotency key as an Idempotency-Key header when provided. The conditional header construction (lines 95-97) is clean and correct.


109-125: Batch method consistently supports idempotency.

The batch method mirrors the same idempotency pattern as create, 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 normalize function 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.

Comment threadapps/web/src/server/public-api/api/emails/batch-email.ts Outdated
Comment threadapps/web/src/server/public-api/api/emails/send-email.ts Outdated
Comment on lines +3 to +4
const IDEMPOTENCY_RESULT_TTL_SECONDS = 24 * 60 * 60; // 24h
const IDEMPOTENCY_LOCK_TTL_SECONDS = 60; // 60s

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +56 to +71
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));
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ 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.

Suggested change
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);
},

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 `&quot;__proto__&quot;` 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(),

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 &quot;@hono/zod-openapi&quot;;
request: {
+ headers: z
+ .object({
+ &quot;Idempotency-Key&quot;: z.string().min(1).max(256).optional(),
+ })
+ .partial()
</file context>
Fix with Cubic

Comment threadplan-idempotency.md Outdated
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)

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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&lt;string[] | null&gt;`
+ - `setResult(teamId: number, key: string, emailIds: string[]): Promise&lt;void&gt;` (EX 24h)
+ - `acquireLock(teamId: number, key: string): Promise&lt;boolean&gt;` (`SET NX EX 60`)
+ - `releaseLock(teamId: number, key: string): Promise&lt;void&gt;` (best-effort `DEL`)
</file context>
Suggested change
- `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)
Fix with Cubic

Comment threadplan-idempotency.md Outdated
## 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>`

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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&lt;string[] | null&gt;`
+ - `setResult(teamId: number, key: string, emailIds: string[]): Promise&lt;void&gt;` (EX 24h)
+ - `acquireLock(teamId: number, key: string): Promise&lt;boolean&gt;` (`SET NX EX 60`)
</file context>
Fix with Cubic

([keyA], [keyB]) => (keyA < keyB ? -1 : keyA > keyB ? 1 : 0)
);

const result: Record<string, CanonicalValue> = {};

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 `&quot;__proto__&quot;` 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]) =&gt; (keyA &lt; keyB ? -1 : keyA &gt; keyB ? 1 : 0)
+ );
+
+ const result: Record&lt;string, CanonicalValue&gt; = {};
+ for (const [key, val] of entries) {
+ const normalized = normalize(val);
</file context>
Suggested change
constresult: Record<string,CanonicalValue>={};
constresult: Record<string,CanonicalValue>=Object.create(null);
Fix with Cubic


async releaseLock(teamId: number, key: string): Promise<void> {
const redis = getRedis();
await redis.del(lockKey(teamId, key));

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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&lt;void&gt; {
+ const redis = getRedis();
+ await redis.del(lockKey(teamId, key));
+ },
+};
</file context>
Fix with Cubic

@KMKoushikKMKoushik changed the title idempotency vibeCodedidempotencyNov 16, 2025
@KMKoushik
KMKoushikforce-pushed the km/2025-10-25-idempotency-keys branch from c5094c5 to c3cb90bCompareNovember 17, 2025 00:00

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

acquireLock writes a fixed value ("1") and releaseLock always DELs the key. If a long-running operation outlives IDEMPOTENCY_LOCK_TTL_SECONDS, the lock can expire and be reacquired by another request; when the original request later calls releaseLock, 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 token through withIdempotency (store it when acquiring the lock and pass it to releaseLock in the finally block) so only the actual lock owner can release it. Please verify this with your Redis client’s eval API 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 new EmailOptions surface 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.

EmailRequestOptions and the updated send/create/batch methods correctly:

  • Accept an optional idempotencyKey.
  • Forward it as an Idempotency-Key header into UseSend.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 updated send/create/batch methods cleanly propagate an optional idempotency_key into the Idempotency-Key header 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 the options.get("idempotency_key") pattern in both create and batch, 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.Bearer is defined but no security requirements 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 security blocks 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 clientPayload both for the idempotency payload and the call into sendEmail ensures that:

  • The hashed body used for idempotency matches what’s actually executed, and
  • Cached hits can safely rehydrate { emailId } without re-sending.

HTML normalization (rawHtmlhtml string or undefined) and the explicit text: requestBody.text ?? undefined keep 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 from c.req.valid("header")["Idempotency-Key"] to reuse the zod-validated value, though the central length guard in IdempotencyService.withIdempotency already protects against invalid keys.

packages/python-sdk/usesend/usesend.py (1)

120-147: HTTP helpers gain per-call headers without breaking existing usage

The updated post/get/put/patch/delete signatures add optional headers (and body for delete) while still working with existing calls that only pass path and body. All methods now route through _request with 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

getResult defensively parses JSON and validates bodyHash/emailIds, returning null on malformed data, and setResult uses setex with 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 returning null silently, 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 codes

The overall withIdempotency flow—key length validation, canonical hashing, result-hit fast path, contention re-check, and result storage using extractEmailIds/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_SECONDS or adding renewal would reduce the chance of mid-flight expiry.
  • Both “different payload” and “request in progress” paths use the same NOT_UNIQUE code; 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

📥 Commits

Reviewing files that changed from the base of the PR and between c5094c5 and 087b5cc.

📒 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.md
  • packages/sdk/src/email.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • packages/sdk/src/usesend.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • packages/sdk/src/usesend.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • packages/sdk/src/usesend.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/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.ts
  • apps/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-Key header on POST /v1/emails correctly 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.

RequestOptions plus baseHeaders/mergeHeaders give you a clean way to add headers like Idempotency-Key without risking loss of Authorization or Content-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 good

Using the shared emailSchema and IdempotencyService keeps the batch route aligned with single-send behavior and avoids schema drift; no issues here.


68-75: Idempotent batching flow and payload normalization look correct

Normalizing text/html before hashing and sending, then passing normalizedPayloads both to withIdempotency and to sendBulkEmails (after augmenting with teamId/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_headers cloning self.headers and overlaying non-None extras gives a clear override model without mutating the client’s defaults. _request now 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_CONSTANTS exposing 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.

Comment on lines 905 to +918
"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"
}
],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +16 to +33
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(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

@KMKoushik
KMKoushik merged commit cb48965 into mainNov 17, 2025
8 checks passed
@KMKoushik
KMKoushik deleted the km/2025-10-25-idempotency-keys branch November 17, 2025 00:42
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@KMKoushik
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

idempotency - #282

Merged
KMKoushik merged 6 commits into
mainfrom
km/2025-10-25-idempotency-keys
Nov 17, 2025
Merged

idempotency#282
KMKoushik merged 6 commits into
mainfrom
km/2025-10-25-idempotency-keys

Conversation

@KMKoushik

@KMKoushikKMKoushik commented Oct 25, 2025

Copy link
Copy Markdown
Member

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.

  • New Features
    • Accept Idempotency-Key (1–256 chars) on single and batch sends; canonicalize the request body and cache results in Redis for 24h.
    • Same key + same body → 200 with original emailId(s) without re-sending.
    • Same key + different body → 409 Conflict with code NOT_UNIQUE.
    • Concurrent requests use a short Redis lock; if a request with the same key is in progress and no result yet → 409.
    • Batch: key applies to the entire payload; returns the original list of emailIds on hits.
    • SDKs: TypeScript and Python add idempotencyKey options/headers for send and batch.
    • Docs: API reference and introduction updated with clear idempotency guidance.

Written for commit 087b5cc. Summary will update automatically on new commits.

Summary by CodeRabbit

  • New Features

    • Added idempotency support to email APIs. Use optional Idempotency-Key header for safe retries—reusing the same key returns cached results; different payloads with the same key return 409 conflict.
    • Python and TypeScript SDKs now support idempotency keys.
  • Documentation

    • Added idempotency usage examples to SDK documentation.
  • Chores

    • Version bumps: Python SDK (0.2.7 → 0.2.8), TypeScript SDK (1.5.6 → 1.5.7).

@vercel

vercelBot commented Oct 25, 2025

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentPreviewCommentsUpdated (UTC)
unsend-marketingReadyReadyPreviewCommentNov 17, 2025 0:02am

@coderabbitai

coderabbitaiBot commented Oct 25, 2025

Copy link
Copy Markdown
Contributor

Note

Other AI code review bot(s) detected

CodeRabbit 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.

Walkthrough

This 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

  • PR #213: Modifies the same Emails.send/create/batch methods in the Python SDK to support idempotency key parameters
  • PR #198: Alters the same sendEmail and sendBulkEmails flows that are now wrapped with idempotency logic in the backend

Suggested labels

codex

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 10.00% which is insufficient. The required threshold is 80.00%.You can run @coderabbitai generate docstrings to improve docstring coverage.
Title check❓ InconclusiveThe title 'idempotency' is vague and generic, lacking specificity about what was changed or improved.Consider a more descriptive title like 'Add idempotency support to email API endpoints' or 'Implement idempotent retry handling for POST /v1/emails and /batch'.
✅ Passed checks (1 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch km/2025-10-25-idempotency-keys

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Oct 25, 2025

Copy link
Copy Markdown

Deploying usesend with Cloudflare Pages Cloudflare Pages

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

View logs

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +56 to +70
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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_key parameter 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 RequestOptions type 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 RequestOptions in 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

📥 Commits

Reviewing files that changed from the base of the PR and between 8e569f8 and c5094c5.

📒 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.md
  • packages/python-sdk/README.md
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • packages/sdk/src/email.ts
  • plan-idempotency.md
  • packages/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.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • packages/sdk/src/email.ts
  • packages/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.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • packages/sdk/src/email.ts
  • packages/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.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/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 mergeHeaders helper 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 RequestOptions and correctly forward headers through fetchRequest. The delete method 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 send and create methods now accept EmailRequestOptions and properly forward the idempotency key as an Idempotency-Key header when provided. The conditional header construction (lines 95-97) is clean and correct.


109-125: Batch method consistently supports idempotency.

The batch method mirrors the same idempotency pattern as create, 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 normalize function 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.

Comment threadapps/web/src/server/public-api/api/emails/batch-email.ts Outdated
Comment threadapps/web/src/server/public-api/api/emails/send-email.ts Outdated
Comment on lines +3 to +4
const IDEMPOTENCY_RESULT_TTL_SECONDS = 24 * 60 * 60; // 24h
const IDEMPOTENCY_LOCK_TTL_SECONDS = 60; // 60s

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +56 to +71
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));
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ 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.

Suggested change
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);
},

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 `&quot;__proto__&quot;` 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(),

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 &quot;@hono/zod-openapi&quot;;
request: {
+ headers: z
+ .object({
+ &quot;Idempotency-Key&quot;: z.string().min(1).max(256).optional(),
+ })
+ .partial()
</file context>
Fix with Cubic

Comment threadplan-idempotency.md Outdated
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)

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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&lt;string[] | null&gt;`
+ - `setResult(teamId: number, key: string, emailIds: string[]): Promise&lt;void&gt;` (EX 24h)
+ - `acquireLock(teamId: number, key: string): Promise&lt;boolean&gt;` (`SET NX EX 60`)
+ - `releaseLock(teamId: number, key: string): Promise&lt;void&gt;` (best-effort `DEL`)
</file context>
Suggested change
- `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)
Fix with Cubic

Comment threadplan-idempotency.md Outdated
## 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>`

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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&lt;string[] | null&gt;`
+ - `setResult(teamId: number, key: string, emailIds: string[]): Promise&lt;void&gt;` (EX 24h)
+ - `acquireLock(teamId: number, key: string): Promise&lt;boolean&gt;` (`SET NX EX 60`)
</file context>
Fix with Cubic

([keyA], [keyB]) => (keyA < keyB ? -1 : keyA > keyB ? 1 : 0)
);

const result: Record<string, CanonicalValue> = {};

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 `&quot;__proto__&quot;` 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]) =&gt; (keyA &lt; keyB ? -1 : keyA &gt; keyB ? 1 : 0)
+ );
+
+ const result: Record&lt;string, CanonicalValue&gt; = {};
+ for (const [key, val] of entries) {
+ const normalized = normalize(val);
</file context>
Suggested change
constresult: Record<string,CanonicalValue>={};
constresult: Record<string,CanonicalValue>=Object.create(null);
Fix with Cubic


async releaseLock(teamId: number, key: string): Promise<void> {
const redis = getRedis();
await redis.del(lockKey(teamId, key));

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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&lt;void&gt; {
+ const redis = getRedis();
+ await redis.del(lockKey(teamId, key));
+ },
+};
</file context>
Fix with Cubic

@KMKoushikKMKoushik changed the title idempotency vibeCodedidempotencyNov 16, 2025
@KMKoushik
KMKoushikforce-pushed the km/2025-10-25-idempotency-keys branch from c5094c5 to c3cb90bCompareNovember 17, 2025 00:00

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

acquireLock writes a fixed value ("1") and releaseLock always DELs the key. If a long-running operation outlives IDEMPOTENCY_LOCK_TTL_SECONDS, the lock can expire and be reacquired by another request; when the original request later calls releaseLock, 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 token through withIdempotency (store it when acquiring the lock and pass it to releaseLock in the finally block) so only the actual lock owner can release it. Please verify this with your Redis client’s eval API 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 new EmailOptions surface 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.

EmailRequestOptions and the updated send/create/batch methods correctly:

  • Accept an optional idempotencyKey.
  • Forward it as an Idempotency-Key header into UseSend.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 updated send/create/batch methods cleanly propagate an optional idempotency_key into the Idempotency-Key header 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 the options.get("idempotency_key") pattern in both create and batch, 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.Bearer is defined but no security requirements 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 security blocks 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 clientPayload both for the idempotency payload and the call into sendEmail ensures that:

  • The hashed body used for idempotency matches what’s actually executed, and
  • Cached hits can safely rehydrate { emailId } without re-sending.

HTML normalization (rawHtmlhtml string or undefined) and the explicit text: requestBody.text ?? undefined keep 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 from c.req.valid("header")["Idempotency-Key"] to reuse the zod-validated value, though the central length guard in IdempotencyService.withIdempotency already protects against invalid keys.

packages/python-sdk/usesend/usesend.py (1)

120-147: HTTP helpers gain per-call headers without breaking existing usage

The updated post/get/put/patch/delete signatures add optional headers (and body for delete) while still working with existing calls that only pass path and body. All methods now route through _request with 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

getResult defensively parses JSON and validates bodyHash/emailIds, returning null on malformed data, and setResult uses setex with 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 returning null silently, 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 codes

The overall withIdempotency flow—key length validation, canonical hashing, result-hit fast path, contention re-check, and result storage using extractEmailIds/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_SECONDS or adding renewal would reduce the chance of mid-flight expiry.
  • Both “different payload” and “request in progress” paths use the same NOT_UNIQUE code; 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

📥 Commits

Reviewing files that changed from the base of the PR and between c5094c5 and 087b5cc.

📒 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.md
  • packages/sdk/src/email.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • packages/sdk/src/usesend.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • packages/sdk/src/usesend.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • packages/sdk/src/usesend.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/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.ts
  • apps/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-Key header on POST /v1/emails correctly 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.

RequestOptions plus baseHeaders/mergeHeaders give you a clean way to add headers like Idempotency-Key without risking loss of Authorization or Content-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 good

Using the shared emailSchema and IdempotencyService keeps the batch route aligned with single-send behavior and avoids schema drift; no issues here.


68-75: Idempotent batching flow and payload normalization look correct

Normalizing text/html before hashing and sending, then passing normalizedPayloads both to withIdempotency and to sendBulkEmails (after augmenting with teamId/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_headers cloning self.headers and overlaying non-None extras gives a clear override model without mutating the client’s defaults. _request now 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_CONSTANTS exposing 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.

Comment on lines 905 to +918
"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"
}
],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +16 to +33
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(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

@KMKoushik
KMKoushik merged commit cb48965 into mainNov 17, 2025
8 checks passed
@KMKoushik
KMKoushik deleted the km/2025-10-25-idempotency-keys branch November 17, 2025 00:42
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@KMKoushik
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

idempotency - #282

Merged
KMKoushik merged 6 commits into
mainfrom
km/2025-10-25-idempotency-keys
Nov 17, 2025
Merged

idempotency#282
KMKoushik merged 6 commits into
mainfrom
km/2025-10-25-idempotency-keys

Conversation

@KMKoushik

@KMKoushikKMKoushik commented Oct 25, 2025

Copy link
Copy Markdown
Member

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.

  • New Features
    • Accept Idempotency-Key (1–256 chars) on single and batch sends; canonicalize the request body and cache results in Redis for 24h.
    • Same key + same body → 200 with original emailId(s) without re-sending.
    • Same key + different body → 409 Conflict with code NOT_UNIQUE.
    • Concurrent requests use a short Redis lock; if a request with the same key is in progress and no result yet → 409.
    • Batch: key applies to the entire payload; returns the original list of emailIds on hits.
    • SDKs: TypeScript and Python add idempotencyKey options/headers for send and batch.
    • Docs: API reference and introduction updated with clear idempotency guidance.

Written for commit 087b5cc. Summary will update automatically on new commits.

Summary by CodeRabbit

  • New Features

    • Added idempotency support to email APIs. Use optional Idempotency-Key header for safe retries—reusing the same key returns cached results; different payloads with the same key return 409 conflict.
    • Python and TypeScript SDKs now support idempotency keys.
  • Documentation

    • Added idempotency usage examples to SDK documentation.
  • Chores

    • Version bumps: Python SDK (0.2.7 → 0.2.8), TypeScript SDK (1.5.6 → 1.5.7).

@vercel

vercelBot commented Oct 25, 2025

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentPreviewCommentsUpdated (UTC)
unsend-marketingReadyReadyPreviewCommentNov 17, 2025 0:02am

@coderabbitai

coderabbitaiBot commented Oct 25, 2025

Copy link
Copy Markdown
Contributor

Note

Other AI code review bot(s) detected

CodeRabbit 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.

Walkthrough

This 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

  • PR #213: Modifies the same Emails.send/create/batch methods in the Python SDK to support idempotency key parameters
  • PR #198: Alters the same sendEmail and sendBulkEmails flows that are now wrapped with idempotency logic in the backend

Suggested labels

codex

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 10.00% which is insufficient. The required threshold is 80.00%.You can run @coderabbitai generate docstrings to improve docstring coverage.
Title check❓ InconclusiveThe title 'idempotency' is vague and generic, lacking specificity about what was changed or improved.Consider a more descriptive title like 'Add idempotency support to email API endpoints' or 'Implement idempotent retry handling for POST /v1/emails and /batch'.
✅ Passed checks (1 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch km/2025-10-25-idempotency-keys

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Oct 25, 2025

Copy link
Copy Markdown

Deploying usesend with Cloudflare Pages Cloudflare Pages

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

View logs

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +56 to +70
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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_key parameter 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 RequestOptions type 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 RequestOptions in 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

📥 Commits

Reviewing files that changed from the base of the PR and between 8e569f8 and c5094c5.

📒 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.md
  • packages/python-sdk/README.md
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • packages/sdk/src/email.ts
  • plan-idempotency.md
  • packages/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.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • packages/sdk/src/email.ts
  • packages/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.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • packages/sdk/src/email.ts
  • packages/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.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/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 mergeHeaders helper 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 RequestOptions and correctly forward headers through fetchRequest. The delete method 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 send and create methods now accept EmailRequestOptions and properly forward the idempotency key as an Idempotency-Key header when provided. The conditional header construction (lines 95-97) is clean and correct.


109-125: Batch method consistently supports idempotency.

The batch method mirrors the same idempotency pattern as create, 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 normalize function 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.

Comment threadapps/web/src/server/public-api/api/emails/batch-email.ts Outdated
Comment threadapps/web/src/server/public-api/api/emails/send-email.ts Outdated
Comment on lines +3 to +4
const IDEMPOTENCY_RESULT_TTL_SECONDS = 24 * 60 * 60; // 24h
const IDEMPOTENCY_LOCK_TTL_SECONDS = 60; // 60s

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +56 to +71
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));
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ 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.

Suggested change
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);
},

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 `&quot;__proto__&quot;` 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(),

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 &quot;@hono/zod-openapi&quot;;
request: {
+ headers: z
+ .object({
+ &quot;Idempotency-Key&quot;: z.string().min(1).max(256).optional(),
+ })
+ .partial()
</file context>
Fix with Cubic

Comment threadplan-idempotency.md Outdated
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)

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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&lt;string[] | null&gt;`
+ - `setResult(teamId: number, key: string, emailIds: string[]): Promise&lt;void&gt;` (EX 24h)
+ - `acquireLock(teamId: number, key: string): Promise&lt;boolean&gt;` (`SET NX EX 60`)
+ - `releaseLock(teamId: number, key: string): Promise&lt;void&gt;` (best-effort `DEL`)
</file context>
Suggested change
- `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)
Fix with Cubic

Comment threadplan-idempotency.md Outdated
## 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>`

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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&lt;string[] | null&gt;`
+ - `setResult(teamId: number, key: string, emailIds: string[]): Promise&lt;void&gt;` (EX 24h)
+ - `acquireLock(teamId: number, key: string): Promise&lt;boolean&gt;` (`SET NX EX 60`)
</file context>
Fix with Cubic

([keyA], [keyB]) => (keyA < keyB ? -1 : keyA > keyB ? 1 : 0)
);

const result: Record<string, CanonicalValue> = {};

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 `&quot;__proto__&quot;` 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]) =&gt; (keyA &lt; keyB ? -1 : keyA &gt; keyB ? 1 : 0)
+ );
+
+ const result: Record&lt;string, CanonicalValue&gt; = {};
+ for (const [key, val] of entries) {
+ const normalized = normalize(val);
</file context>
Suggested change
constresult: Record<string,CanonicalValue>={};
constresult: Record<string,CanonicalValue>=Object.create(null);
Fix with Cubic


async releaseLock(teamId: number, key: string): Promise<void> {
const redis = getRedis();
await redis.del(lockKey(teamId, key));

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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&lt;void&gt; {
+ const redis = getRedis();
+ await redis.del(lockKey(teamId, key));
+ },
+};
</file context>
Fix with Cubic

@KMKoushikKMKoushik changed the title idempotency vibeCodedidempotencyNov 16, 2025
@KMKoushik
KMKoushikforce-pushed the km/2025-10-25-idempotency-keys branch from c5094c5 to c3cb90bCompareNovember 17, 2025 00:00

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

acquireLock writes a fixed value ("1") and releaseLock always DELs the key. If a long-running operation outlives IDEMPOTENCY_LOCK_TTL_SECONDS, the lock can expire and be reacquired by another request; when the original request later calls releaseLock, 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 token through withIdempotency (store it when acquiring the lock and pass it to releaseLock in the finally block) so only the actual lock owner can release it. Please verify this with your Redis client’s eval API 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 new EmailOptions surface 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.

EmailRequestOptions and the updated send/create/batch methods correctly:

  • Accept an optional idempotencyKey.
  • Forward it as an Idempotency-Key header into UseSend.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 updated send/create/batch methods cleanly propagate an optional idempotency_key into the Idempotency-Key header 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 the options.get("idempotency_key") pattern in both create and batch, 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.Bearer is defined but no security requirements 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 security blocks 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 clientPayload both for the idempotency payload and the call into sendEmail ensures that:

  • The hashed body used for idempotency matches what’s actually executed, and
  • Cached hits can safely rehydrate { emailId } without re-sending.

HTML normalization (rawHtmlhtml string or undefined) and the explicit text: requestBody.text ?? undefined keep 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 from c.req.valid("header")["Idempotency-Key"] to reuse the zod-validated value, though the central length guard in IdempotencyService.withIdempotency already protects against invalid keys.

packages/python-sdk/usesend/usesend.py (1)

120-147: HTTP helpers gain per-call headers without breaking existing usage

The updated post/get/put/patch/delete signatures add optional headers (and body for delete) while still working with existing calls that only pass path and body. All methods now route through _request with 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

getResult defensively parses JSON and validates bodyHash/emailIds, returning null on malformed data, and setResult uses setex with 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 returning null silently, 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 codes

The overall withIdempotency flow—key length validation, canonical hashing, result-hit fast path, contention re-check, and result storage using extractEmailIds/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_SECONDS or adding renewal would reduce the chance of mid-flight expiry.
  • Both “different payload” and “request in progress” paths use the same NOT_UNIQUE code; 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

📥 Commits

Reviewing files that changed from the base of the PR and between c5094c5 and 087b5cc.

📒 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.md
  • packages/sdk/src/email.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • packages/sdk/src/usesend.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • packages/sdk/src/usesend.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • packages/sdk/src/usesend.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/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.ts
  • apps/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-Key header on POST /v1/emails correctly 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.

RequestOptions plus baseHeaders/mergeHeaders give you a clean way to add headers like Idempotency-Key without risking loss of Authorization or Content-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 good

Using the shared emailSchema and IdempotencyService keeps the batch route aligned with single-send behavior and avoids schema drift; no issues here.


68-75: Idempotent batching flow and payload normalization look correct

Normalizing text/html before hashing and sending, then passing normalizedPayloads both to withIdempotency and to sendBulkEmails (after augmenting with teamId/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_headers cloning self.headers and overlaying non-None extras gives a clear override model without mutating the client’s defaults. _request now 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_CONSTANTS exposing 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.

Comment on lines 905 to +918
"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"
}
],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +16 to +33
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(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

@KMKoushik
KMKoushik merged commit cb48965 into mainNov 17, 2025
8 checks passed
@KMKoushik
KMKoushik deleted the km/2025-10-25-idempotency-keys branch November 17, 2025 00:42
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@KMKoushik
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

idempotency - #282

Merged
KMKoushik merged 6 commits into
mainfrom
km/2025-10-25-idempotency-keys
Nov 17, 2025
Merged

idempotency#282
KMKoushik merged 6 commits into
mainfrom
km/2025-10-25-idempotency-keys

Conversation

@KMKoushik

@KMKoushikKMKoushik commented Oct 25, 2025

Copy link
Copy Markdown
Member

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.

  • New Features
    • Accept Idempotency-Key (1–256 chars) on single and batch sends; canonicalize the request body and cache results in Redis for 24h.
    • Same key + same body → 200 with original emailId(s) without re-sending.
    • Same key + different body → 409 Conflict with code NOT_UNIQUE.
    • Concurrent requests use a short Redis lock; if a request with the same key is in progress and no result yet → 409.
    • Batch: key applies to the entire payload; returns the original list of emailIds on hits.
    • SDKs: TypeScript and Python add idempotencyKey options/headers for send and batch.
    • Docs: API reference and introduction updated with clear idempotency guidance.

Written for commit 087b5cc. Summary will update automatically on new commits.

Summary by CodeRabbit

  • New Features

    • Added idempotency support to email APIs. Use optional Idempotency-Key header for safe retries—reusing the same key returns cached results; different payloads with the same key return 409 conflict.
    • Python and TypeScript SDKs now support idempotency keys.
  • Documentation

    • Added idempotency usage examples to SDK documentation.
  • Chores

    • Version bumps: Python SDK (0.2.7 → 0.2.8), TypeScript SDK (1.5.6 → 1.5.7).

@vercel

vercelBot commented Oct 25, 2025

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentPreviewCommentsUpdated (UTC)
unsend-marketingReadyReadyPreviewCommentNov 17, 2025 0:02am

@coderabbitai

coderabbitaiBot commented Oct 25, 2025

Copy link
Copy Markdown
Contributor

Note

Other AI code review bot(s) detected

CodeRabbit 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.

Walkthrough

This 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

  • PR #213: Modifies the same Emails.send/create/batch methods in the Python SDK to support idempotency key parameters
  • PR #198: Alters the same sendEmail and sendBulkEmails flows that are now wrapped with idempotency logic in the backend

Suggested labels

codex

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 10.00% which is insufficient. The required threshold is 80.00%.You can run @coderabbitai generate docstrings to improve docstring coverage.
Title check❓ InconclusiveThe title 'idempotency' is vague and generic, lacking specificity about what was changed or improved.Consider a more descriptive title like 'Add idempotency support to email API endpoints' or 'Implement idempotent retry handling for POST /v1/emails and /batch'.
✅ Passed checks (1 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch km/2025-10-25-idempotency-keys

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Oct 25, 2025

Copy link
Copy Markdown

Deploying usesend with Cloudflare Pages Cloudflare Pages

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

View logs

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +56 to +70
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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_key parameter 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 RequestOptions type 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 RequestOptions in 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

📥 Commits

Reviewing files that changed from the base of the PR and between 8e569f8 and c5094c5.

📒 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.md
  • packages/python-sdk/README.md
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • packages/sdk/src/email.ts
  • plan-idempotency.md
  • packages/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.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • packages/sdk/src/email.ts
  • packages/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.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • packages/sdk/src/email.ts
  • packages/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.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/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 mergeHeaders helper 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 RequestOptions and correctly forward headers through fetchRequest. The delete method 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 send and create methods now accept EmailRequestOptions and properly forward the idempotency key as an Idempotency-Key header when provided. The conditional header construction (lines 95-97) is clean and correct.


109-125: Batch method consistently supports idempotency.

The batch method mirrors the same idempotency pattern as create, 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 normalize function 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.

Comment threadapps/web/src/server/public-api/api/emails/batch-email.ts Outdated
Comment threadapps/web/src/server/public-api/api/emails/send-email.ts Outdated
Comment on lines +3 to +4
const IDEMPOTENCY_RESULT_TTL_SECONDS = 24 * 60 * 60; // 24h
const IDEMPOTENCY_LOCK_TTL_SECONDS = 60; // 60s

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +56 to +71
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));
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ 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.

Suggested change
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);
},

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 `&quot;__proto__&quot;` 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(),

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 &quot;@hono/zod-openapi&quot;;
request: {
+ headers: z
+ .object({
+ &quot;Idempotency-Key&quot;: z.string().min(1).max(256).optional(),
+ })
+ .partial()
</file context>
Fix with Cubic

Comment threadplan-idempotency.md Outdated
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)

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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&lt;string[] | null&gt;`
+ - `setResult(teamId: number, key: string, emailIds: string[]): Promise&lt;void&gt;` (EX 24h)
+ - `acquireLock(teamId: number, key: string): Promise&lt;boolean&gt;` (`SET NX EX 60`)
+ - `releaseLock(teamId: number, key: string): Promise&lt;void&gt;` (best-effort `DEL`)
</file context>
Suggested change
- `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)
Fix with Cubic

Comment threadplan-idempotency.md Outdated
## 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>`

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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&lt;string[] | null&gt;`
+ - `setResult(teamId: number, key: string, emailIds: string[]): Promise&lt;void&gt;` (EX 24h)
+ - `acquireLock(teamId: number, key: string): Promise&lt;boolean&gt;` (`SET NX EX 60`)
</file context>
Fix with Cubic

([keyA], [keyB]) => (keyA < keyB ? -1 : keyA > keyB ? 1 : 0)
);

const result: Record<string, CanonicalValue> = {};

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 `&quot;__proto__&quot;` 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]) =&gt; (keyA &lt; keyB ? -1 : keyA &gt; keyB ? 1 : 0)
+ );
+
+ const result: Record&lt;string, CanonicalValue&gt; = {};
+ for (const [key, val] of entries) {
+ const normalized = normalize(val);
</file context>
Suggested change
constresult: Record<string,CanonicalValue>={};
constresult: Record<string,CanonicalValue>=Object.create(null);
Fix with Cubic


async releaseLock(teamId: number, key: string): Promise<void> {
const redis = getRedis();
await redis.del(lockKey(teamId, key));

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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&lt;void&gt; {
+ const redis = getRedis();
+ await redis.del(lockKey(teamId, key));
+ },
+};
</file context>
Fix with Cubic

@KMKoushikKMKoushik changed the title idempotency vibeCodedidempotencyNov 16, 2025
@KMKoushik
KMKoushikforce-pushed the km/2025-10-25-idempotency-keys branch from c5094c5 to c3cb90bCompareNovember 17, 2025 00:00

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

acquireLock writes a fixed value ("1") and releaseLock always DELs the key. If a long-running operation outlives IDEMPOTENCY_LOCK_TTL_SECONDS, the lock can expire and be reacquired by another request; when the original request later calls releaseLock, 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 token through withIdempotency (store it when acquiring the lock and pass it to releaseLock in the finally block) so only the actual lock owner can release it. Please verify this with your Redis client’s eval API 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 new EmailOptions surface 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.

EmailRequestOptions and the updated send/create/batch methods correctly:

  • Accept an optional idempotencyKey.
  • Forward it as an Idempotency-Key header into UseSend.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 updated send/create/batch methods cleanly propagate an optional idempotency_key into the Idempotency-Key header 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 the options.get("idempotency_key") pattern in both create and batch, 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.Bearer is defined but no security requirements 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 security blocks 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 clientPayload both for the idempotency payload and the call into sendEmail ensures that:

  • The hashed body used for idempotency matches what’s actually executed, and
  • Cached hits can safely rehydrate { emailId } without re-sending.

HTML normalization (rawHtmlhtml string or undefined) and the explicit text: requestBody.text ?? undefined keep 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 from c.req.valid("header")["Idempotency-Key"] to reuse the zod-validated value, though the central length guard in IdempotencyService.withIdempotency already protects against invalid keys.

packages/python-sdk/usesend/usesend.py (1)

120-147: HTTP helpers gain per-call headers without breaking existing usage

The updated post/get/put/patch/delete signatures add optional headers (and body for delete) while still working with existing calls that only pass path and body. All methods now route through _request with 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

getResult defensively parses JSON and validates bodyHash/emailIds, returning null on malformed data, and setResult uses setex with 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 returning null silently, 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 codes

The overall withIdempotency flow—key length validation, canonical hashing, result-hit fast path, contention re-check, and result storage using extractEmailIds/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_SECONDS or adding renewal would reduce the chance of mid-flight expiry.
  • Both “different payload” and “request in progress” paths use the same NOT_UNIQUE code; 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

📥 Commits

Reviewing files that changed from the base of the PR and between c5094c5 and 087b5cc.

📒 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.md
  • packages/sdk/src/email.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • packages/sdk/src/usesend.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • packages/sdk/src/usesend.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • packages/sdk/src/usesend.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/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.ts
  • apps/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-Key header on POST /v1/emails correctly 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.

RequestOptions plus baseHeaders/mergeHeaders give you a clean way to add headers like Idempotency-Key without risking loss of Authorization or Content-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 good

Using the shared emailSchema and IdempotencyService keeps the batch route aligned with single-send behavior and avoids schema drift; no issues here.


68-75: Idempotent batching flow and payload normalization look correct

Normalizing text/html before hashing and sending, then passing normalizedPayloads both to withIdempotency and to sendBulkEmails (after augmenting with teamId/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_headers cloning self.headers and overlaying non-None extras gives a clear override model without mutating the client’s defaults. _request now 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_CONSTANTS exposing 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.

Comment on lines 905 to +918
"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"
}
],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +16 to +33
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(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

@KMKoushik
KMKoushik merged commit cb48965 into mainNov 17, 2025
8 checks passed
@KMKoushik
KMKoushik deleted the km/2025-10-25-idempotency-keys branch November 17, 2025 00:42
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@KMKoushik
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

idempotency - #282

Merged
KMKoushik merged 6 commits into
mainfrom
km/2025-10-25-idempotency-keys
Nov 17, 2025
Merged

idempotency#282
KMKoushik merged 6 commits into
mainfrom
km/2025-10-25-idempotency-keys

Conversation

@KMKoushik

@KMKoushikKMKoushik commented Oct 25, 2025

Copy link
Copy Markdown
Member

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.

  • New Features
    • Accept Idempotency-Key (1–256 chars) on single and batch sends; canonicalize the request body and cache results in Redis for 24h.
    • Same key + same body → 200 with original emailId(s) without re-sending.
    • Same key + different body → 409 Conflict with code NOT_UNIQUE.
    • Concurrent requests use a short Redis lock; if a request with the same key is in progress and no result yet → 409.
    • Batch: key applies to the entire payload; returns the original list of emailIds on hits.
    • SDKs: TypeScript and Python add idempotencyKey options/headers for send and batch.
    • Docs: API reference and introduction updated with clear idempotency guidance.

Written for commit 087b5cc. Summary will update automatically on new commits.

Summary by CodeRabbit

  • New Features

    • Added idempotency support to email APIs. Use optional Idempotency-Key header for safe retries—reusing the same key returns cached results; different payloads with the same key return 409 conflict.
    • Python and TypeScript SDKs now support idempotency keys.
  • Documentation

    • Added idempotency usage examples to SDK documentation.
  • Chores

    • Version bumps: Python SDK (0.2.7 → 0.2.8), TypeScript SDK (1.5.6 → 1.5.7).

@vercel

vercelBot commented Oct 25, 2025

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentPreviewCommentsUpdated (UTC)
unsend-marketingReadyReadyPreviewCommentNov 17, 2025 0:02am

@coderabbitai

coderabbitaiBot commented Oct 25, 2025

Copy link
Copy Markdown
Contributor

Note

Other AI code review bot(s) detected

CodeRabbit 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.

Walkthrough

This 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

  • PR #213: Modifies the same Emails.send/create/batch methods in the Python SDK to support idempotency key parameters
  • PR #198: Alters the same sendEmail and sendBulkEmails flows that are now wrapped with idempotency logic in the backend

Suggested labels

codex

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 10.00% which is insufficient. The required threshold is 80.00%.You can run @coderabbitai generate docstrings to improve docstring coverage.
Title check❓ InconclusiveThe title 'idempotency' is vague and generic, lacking specificity about what was changed or improved.Consider a more descriptive title like 'Add idempotency support to email API endpoints' or 'Implement idempotent retry handling for POST /v1/emails and /batch'.
✅ Passed checks (1 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch km/2025-10-25-idempotency-keys

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Oct 25, 2025

Copy link
Copy Markdown

Deploying usesend with Cloudflare Pages Cloudflare Pages

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

View logs

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +56 to +70
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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_key parameter 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 RequestOptions type 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 RequestOptions in 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

📥 Commits

Reviewing files that changed from the base of the PR and between 8e569f8 and c5094c5.

📒 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.md
  • packages/python-sdk/README.md
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • packages/sdk/src/email.ts
  • plan-idempotency.md
  • packages/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.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • packages/sdk/src/email.ts
  • packages/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.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • packages/sdk/src/email.ts
  • packages/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.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/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 mergeHeaders helper 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 RequestOptions and correctly forward headers through fetchRequest. The delete method 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 send and create methods now accept EmailRequestOptions and properly forward the idempotency key as an Idempotency-Key header when provided. The conditional header construction (lines 95-97) is clean and correct.


109-125: Batch method consistently supports idempotency.

The batch method mirrors the same idempotency pattern as create, 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 normalize function 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.

Comment threadapps/web/src/server/public-api/api/emails/batch-email.ts Outdated
Comment threadapps/web/src/server/public-api/api/emails/send-email.ts Outdated
Comment on lines +3 to +4
const IDEMPOTENCY_RESULT_TTL_SECONDS = 24 * 60 * 60; // 24h
const IDEMPOTENCY_LOCK_TTL_SECONDS = 60; // 60s

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +56 to +71
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));
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ 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.

Suggested change
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);
},

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 `&quot;__proto__&quot;` 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(),

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 &quot;@hono/zod-openapi&quot;;
request: {
+ headers: z
+ .object({
+ &quot;Idempotency-Key&quot;: z.string().min(1).max(256).optional(),
+ })
+ .partial()
</file context>
Fix with Cubic

Comment threadplan-idempotency.md Outdated
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)

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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&lt;string[] | null&gt;`
+ - `setResult(teamId: number, key: string, emailIds: string[]): Promise&lt;void&gt;` (EX 24h)
+ - `acquireLock(teamId: number, key: string): Promise&lt;boolean&gt;` (`SET NX EX 60`)
+ - `releaseLock(teamId: number, key: string): Promise&lt;void&gt;` (best-effort `DEL`)
</file context>
Suggested change
- `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)
Fix with Cubic

Comment threadplan-idempotency.md Outdated
## 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>`

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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&lt;string[] | null&gt;`
+ - `setResult(teamId: number, key: string, emailIds: string[]): Promise&lt;void&gt;` (EX 24h)
+ - `acquireLock(teamId: number, key: string): Promise&lt;boolean&gt;` (`SET NX EX 60`)
</file context>
Fix with Cubic

([keyA], [keyB]) => (keyA < keyB ? -1 : keyA > keyB ? 1 : 0)
);

const result: Record<string, CanonicalValue> = {};

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 `&quot;__proto__&quot;` 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]) =&gt; (keyA &lt; keyB ? -1 : keyA &gt; keyB ? 1 : 0)
+ );
+
+ const result: Record&lt;string, CanonicalValue&gt; = {};
+ for (const [key, val] of entries) {
+ const normalized = normalize(val);
</file context>
Suggested change
constresult: Record<string,CanonicalValue>={};
constresult: Record<string,CanonicalValue>=Object.create(null);
Fix with Cubic


async releaseLock(teamId: number, key: string): Promise<void> {
const redis = getRedis();
await redis.del(lockKey(teamId, key));

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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&lt;void&gt; {
+ const redis = getRedis();
+ await redis.del(lockKey(teamId, key));
+ },
+};
</file context>
Fix with Cubic

@KMKoushikKMKoushik changed the title idempotency vibeCodedidempotencyNov 16, 2025
@KMKoushik
KMKoushikforce-pushed the km/2025-10-25-idempotency-keys branch from c5094c5 to c3cb90bCompareNovember 17, 2025 00:00

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

acquireLock writes a fixed value ("1") and releaseLock always DELs the key. If a long-running operation outlives IDEMPOTENCY_LOCK_TTL_SECONDS, the lock can expire and be reacquired by another request; when the original request later calls releaseLock, 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 token through withIdempotency (store it when acquiring the lock and pass it to releaseLock in the finally block) so only the actual lock owner can release it. Please verify this with your Redis client’s eval API 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 new EmailOptions surface 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.

EmailRequestOptions and the updated send/create/batch methods correctly:

  • Accept an optional idempotencyKey.
  • Forward it as an Idempotency-Key header into UseSend.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 updated send/create/batch methods cleanly propagate an optional idempotency_key into the Idempotency-Key header 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 the options.get("idempotency_key") pattern in both create and batch, 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.Bearer is defined but no security requirements 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 security blocks 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 clientPayload both for the idempotency payload and the call into sendEmail ensures that:

  • The hashed body used for idempotency matches what’s actually executed, and
  • Cached hits can safely rehydrate { emailId } without re-sending.

HTML normalization (rawHtmlhtml string or undefined) and the explicit text: requestBody.text ?? undefined keep 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 from c.req.valid("header")["Idempotency-Key"] to reuse the zod-validated value, though the central length guard in IdempotencyService.withIdempotency already protects against invalid keys.

packages/python-sdk/usesend/usesend.py (1)

120-147: HTTP helpers gain per-call headers without breaking existing usage

The updated post/get/put/patch/delete signatures add optional headers (and body for delete) while still working with existing calls that only pass path and body. All methods now route through _request with 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

getResult defensively parses JSON and validates bodyHash/emailIds, returning null on malformed data, and setResult uses setex with 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 returning null silently, 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 codes

The overall withIdempotency flow—key length validation, canonical hashing, result-hit fast path, contention re-check, and result storage using extractEmailIds/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_SECONDS or adding renewal would reduce the chance of mid-flight expiry.
  • Both “different payload” and “request in progress” paths use the same NOT_UNIQUE code; 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

📥 Commits

Reviewing files that changed from the base of the PR and between c5094c5 and 087b5cc.

📒 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.md
  • packages/sdk/src/email.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • packages/sdk/src/usesend.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • packages/sdk/src/usesend.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • packages/sdk/src/usesend.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/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.ts
  • apps/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-Key header on POST /v1/emails correctly 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.

RequestOptions plus baseHeaders/mergeHeaders give you a clean way to add headers like Idempotency-Key without risking loss of Authorization or Content-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 good

Using the shared emailSchema and IdempotencyService keeps the batch route aligned with single-send behavior and avoids schema drift; no issues here.


68-75: Idempotent batching flow and payload normalization look correct

Normalizing text/html before hashing and sending, then passing normalizedPayloads both to withIdempotency and to sendBulkEmails (after augmenting with teamId/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_headers cloning self.headers and overlaying non-None extras gives a clear override model without mutating the client’s defaults. _request now 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_CONSTANTS exposing 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.

Comment on lines 905 to +918
"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"
}
],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +16 to +33
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(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

@KMKoushik
KMKoushik merged commit cb48965 into mainNov 17, 2025
8 checks passed
@KMKoushik
KMKoushik deleted the km/2025-10-25-idempotency-keys branch November 17, 2025 00:42
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@KMKoushik
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

idempotency - #282

Merged
KMKoushik merged 6 commits into
mainfrom
km/2025-10-25-idempotency-keys
Nov 17, 2025
Merged

idempotency#282
KMKoushik merged 6 commits into
mainfrom
km/2025-10-25-idempotency-keys

Conversation

@KMKoushik

@KMKoushikKMKoushik commented Oct 25, 2025

Copy link
Copy Markdown
Member

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.

  • New Features
    • Accept Idempotency-Key (1–256 chars) on single and batch sends; canonicalize the request body and cache results in Redis for 24h.
    • Same key + same body → 200 with original emailId(s) without re-sending.
    • Same key + different body → 409 Conflict with code NOT_UNIQUE.
    • Concurrent requests use a short Redis lock; if a request with the same key is in progress and no result yet → 409.
    • Batch: key applies to the entire payload; returns the original list of emailIds on hits.
    • SDKs: TypeScript and Python add idempotencyKey options/headers for send and batch.
    • Docs: API reference and introduction updated with clear idempotency guidance.

Written for commit 087b5cc. Summary will update automatically on new commits.

Summary by CodeRabbit

  • New Features

    • Added idempotency support to email APIs. Use optional Idempotency-Key header for safe retries—reusing the same key returns cached results; different payloads with the same key return 409 conflict.
    • Python and TypeScript SDKs now support idempotency keys.
  • Documentation

    • Added idempotency usage examples to SDK documentation.
  • Chores

    • Version bumps: Python SDK (0.2.7 → 0.2.8), TypeScript SDK (1.5.6 → 1.5.7).

@vercel

vercelBot commented Oct 25, 2025

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentPreviewCommentsUpdated (UTC)
unsend-marketingReadyReadyPreviewCommentNov 17, 2025 0:02am

@coderabbitai

coderabbitaiBot commented Oct 25, 2025

Copy link
Copy Markdown
Contributor

Note

Other AI code review bot(s) detected

CodeRabbit 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.

Walkthrough

This 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

  • PR #213: Modifies the same Emails.send/create/batch methods in the Python SDK to support idempotency key parameters
  • PR #198: Alters the same sendEmail and sendBulkEmails flows that are now wrapped with idempotency logic in the backend

Suggested labels

codex

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 10.00% which is insufficient. The required threshold is 80.00%.You can run @coderabbitai generate docstrings to improve docstring coverage.
Title check❓ InconclusiveThe title 'idempotency' is vague and generic, lacking specificity about what was changed or improved.Consider a more descriptive title like 'Add idempotency support to email API endpoints' or 'Implement idempotent retry handling for POST /v1/emails and /batch'.
✅ Passed checks (1 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch km/2025-10-25-idempotency-keys

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Oct 25, 2025

Copy link
Copy Markdown

Deploying usesend with Cloudflare Pages Cloudflare Pages

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

View logs

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +56 to +70
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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_key parameter 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 RequestOptions type 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 RequestOptions in 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

📥 Commits

Reviewing files that changed from the base of the PR and between 8e569f8 and c5094c5.

📒 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.md
  • packages/python-sdk/README.md
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • packages/sdk/src/email.ts
  • plan-idempotency.md
  • packages/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.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • packages/sdk/src/email.ts
  • packages/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.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • packages/sdk/src/email.ts
  • packages/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.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/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 mergeHeaders helper 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 RequestOptions and correctly forward headers through fetchRequest. The delete method 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 send and create methods now accept EmailRequestOptions and properly forward the idempotency key as an Idempotency-Key header when provided. The conditional header construction (lines 95-97) is clean and correct.


109-125: Batch method consistently supports idempotency.

The batch method mirrors the same idempotency pattern as create, 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 normalize function 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.

Comment threadapps/web/src/server/public-api/api/emails/batch-email.ts Outdated
Comment threadapps/web/src/server/public-api/api/emails/send-email.ts Outdated
Comment on lines +3 to +4
const IDEMPOTENCY_RESULT_TTL_SECONDS = 24 * 60 * 60; // 24h
const IDEMPOTENCY_LOCK_TTL_SECONDS = 60; // 60s

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +56 to +71
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));
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ 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.

Suggested change
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);
},

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 `&quot;__proto__&quot;` 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(),

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 &quot;@hono/zod-openapi&quot;;
request: {
+ headers: z
+ .object({
+ &quot;Idempotency-Key&quot;: z.string().min(1).max(256).optional(),
+ })
+ .partial()
</file context>
Fix with Cubic

Comment threadplan-idempotency.md Outdated
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)

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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&lt;string[] | null&gt;`
+ - `setResult(teamId: number, key: string, emailIds: string[]): Promise&lt;void&gt;` (EX 24h)
+ - `acquireLock(teamId: number, key: string): Promise&lt;boolean&gt;` (`SET NX EX 60`)
+ - `releaseLock(teamId: number, key: string): Promise&lt;void&gt;` (best-effort `DEL`)
</file context>
Suggested change
- `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)
Fix with Cubic

Comment threadplan-idempotency.md Outdated
## 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>`

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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&lt;string[] | null&gt;`
+ - `setResult(teamId: number, key: string, emailIds: string[]): Promise&lt;void&gt;` (EX 24h)
+ - `acquireLock(teamId: number, key: string): Promise&lt;boolean&gt;` (`SET NX EX 60`)
</file context>
Fix with Cubic

([keyA], [keyB]) => (keyA < keyB ? -1 : keyA > keyB ? 1 : 0)
);

const result: Record<string, CanonicalValue> = {};

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 `&quot;__proto__&quot;` 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]) =&gt; (keyA &lt; keyB ? -1 : keyA &gt; keyB ? 1 : 0)
+ );
+
+ const result: Record&lt;string, CanonicalValue&gt; = {};
+ for (const [key, val] of entries) {
+ const normalized = normalize(val);
</file context>
Suggested change
constresult: Record<string,CanonicalValue>={};
constresult: Record<string,CanonicalValue>=Object.create(null);
Fix with Cubic


async releaseLock(teamId: number, key: string): Promise<void> {
const redis = getRedis();
await redis.del(lockKey(teamId, key));

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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&lt;void&gt; {
+ const redis = getRedis();
+ await redis.del(lockKey(teamId, key));
+ },
+};
</file context>
Fix with Cubic

@KMKoushikKMKoushik changed the title idempotency vibeCodedidempotencyNov 16, 2025
@KMKoushik
KMKoushikforce-pushed the km/2025-10-25-idempotency-keys branch from c5094c5 to c3cb90bCompareNovember 17, 2025 00:00

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

acquireLock writes a fixed value ("1") and releaseLock always DELs the key. If a long-running operation outlives IDEMPOTENCY_LOCK_TTL_SECONDS, the lock can expire and be reacquired by another request; when the original request later calls releaseLock, 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 token through withIdempotency (store it when acquiring the lock and pass it to releaseLock in the finally block) so only the actual lock owner can release it. Please verify this with your Redis client’s eval API 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 new EmailOptions surface 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.

EmailRequestOptions and the updated send/create/batch methods correctly:

  • Accept an optional idempotencyKey.
  • Forward it as an Idempotency-Key header into UseSend.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 updated send/create/batch methods cleanly propagate an optional idempotency_key into the Idempotency-Key header 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 the options.get("idempotency_key") pattern in both create and batch, 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.Bearer is defined but no security requirements 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 security blocks 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 clientPayload both for the idempotency payload and the call into sendEmail ensures that:

  • The hashed body used for idempotency matches what’s actually executed, and
  • Cached hits can safely rehydrate { emailId } without re-sending.

HTML normalization (rawHtmlhtml string or undefined) and the explicit text: requestBody.text ?? undefined keep 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 from c.req.valid("header")["Idempotency-Key"] to reuse the zod-validated value, though the central length guard in IdempotencyService.withIdempotency already protects against invalid keys.

packages/python-sdk/usesend/usesend.py (1)

120-147: HTTP helpers gain per-call headers without breaking existing usage

The updated post/get/put/patch/delete signatures add optional headers (and body for delete) while still working with existing calls that only pass path and body. All methods now route through _request with 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

getResult defensively parses JSON and validates bodyHash/emailIds, returning null on malformed data, and setResult uses setex with 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 returning null silently, 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 codes

The overall withIdempotency flow—key length validation, canonical hashing, result-hit fast path, contention re-check, and result storage using extractEmailIds/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_SECONDS or adding renewal would reduce the chance of mid-flight expiry.
  • Both “different payload” and “request in progress” paths use the same NOT_UNIQUE code; 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

📥 Commits

Reviewing files that changed from the base of the PR and between c5094c5 and 087b5cc.

📒 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.md
  • packages/sdk/src/email.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • packages/sdk/src/usesend.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • packages/sdk/src/usesend.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • packages/sdk/src/usesend.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/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.ts
  • apps/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-Key header on POST /v1/emails correctly 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.

RequestOptions plus baseHeaders/mergeHeaders give you a clean way to add headers like Idempotency-Key without risking loss of Authorization or Content-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 good

Using the shared emailSchema and IdempotencyService keeps the batch route aligned with single-send behavior and avoids schema drift; no issues here.


68-75: Idempotent batching flow and payload normalization look correct

Normalizing text/html before hashing and sending, then passing normalizedPayloads both to withIdempotency and to sendBulkEmails (after augmenting with teamId/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_headers cloning self.headers and overlaying non-None extras gives a clear override model without mutating the client’s defaults. _request now 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_CONSTANTS exposing 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.

Comment on lines 905 to +918
"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"
}
],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +16 to +33
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(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

@KMKoushik
KMKoushik merged commit cb48965 into mainNov 17, 2025
8 checks passed
@KMKoushik
KMKoushik deleted the km/2025-10-25-idempotency-keys branch November 17, 2025 00:42
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@KMKoushik
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

idempotency - #282

Merged
KMKoushik merged 6 commits into
mainfrom
km/2025-10-25-idempotency-keys
Nov 17, 2025
Merged

idempotency#282
KMKoushik merged 6 commits into
mainfrom
km/2025-10-25-idempotency-keys

Conversation

@KMKoushik

@KMKoushikKMKoushik commented Oct 25, 2025

Copy link
Copy Markdown
Member

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.

  • New Features
    • Accept Idempotency-Key (1–256 chars) on single and batch sends; canonicalize the request body and cache results in Redis for 24h.
    • Same key + same body → 200 with original emailId(s) without re-sending.
    • Same key + different body → 409 Conflict with code NOT_UNIQUE.
    • Concurrent requests use a short Redis lock; if a request with the same key is in progress and no result yet → 409.
    • Batch: key applies to the entire payload; returns the original list of emailIds on hits.
    • SDKs: TypeScript and Python add idempotencyKey options/headers for send and batch.
    • Docs: API reference and introduction updated with clear idempotency guidance.

Written for commit 087b5cc. Summary will update automatically on new commits.

Summary by CodeRabbit

  • New Features

    • Added idempotency support to email APIs. Use optional Idempotency-Key header for safe retries—reusing the same key returns cached results; different payloads with the same key return 409 conflict.
    • Python and TypeScript SDKs now support idempotency keys.
  • Documentation

    • Added idempotency usage examples to SDK documentation.
  • Chores

    • Version bumps: Python SDK (0.2.7 → 0.2.8), TypeScript SDK (1.5.6 → 1.5.7).

@vercel

vercelBot commented Oct 25, 2025

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentPreviewCommentsUpdated (UTC)
unsend-marketingReadyReadyPreviewCommentNov 17, 2025 0:02am

@coderabbitai

coderabbitaiBot commented Oct 25, 2025

Copy link
Copy Markdown
Contributor

Note

Other AI code review bot(s) detected

CodeRabbit 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.

Walkthrough

This 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

  • PR #213: Modifies the same Emails.send/create/batch methods in the Python SDK to support idempotency key parameters
  • PR #198: Alters the same sendEmail and sendBulkEmails flows that are now wrapped with idempotency logic in the backend

Suggested labels

codex

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 10.00% which is insufficient. The required threshold is 80.00%.You can run @coderabbitai generate docstrings to improve docstring coverage.
Title check❓ InconclusiveThe title 'idempotency' is vague and generic, lacking specificity about what was changed or improved.Consider a more descriptive title like 'Add idempotency support to email API endpoints' or 'Implement idempotent retry handling for POST /v1/emails and /batch'.
✅ Passed checks (1 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch km/2025-10-25-idempotency-keys

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Oct 25, 2025

Copy link
Copy Markdown

Deploying usesend with Cloudflare Pages Cloudflare Pages

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

View logs

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +56 to +70
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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_key parameter 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 RequestOptions type 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 RequestOptions in 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

📥 Commits

Reviewing files that changed from the base of the PR and between 8e569f8 and c5094c5.

📒 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.md
  • packages/python-sdk/README.md
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • packages/sdk/src/email.ts
  • plan-idempotency.md
  • packages/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.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • packages/sdk/src/email.ts
  • packages/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.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • packages/sdk/src/email.ts
  • packages/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.ts
  • apps/web/src/server/service/idempotency-service.ts
  • apps/web/src/server/utils/idempotency.ts
  • apps/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 mergeHeaders helper 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 RequestOptions and correctly forward headers through fetchRequest. The delete method 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 send and create methods now accept EmailRequestOptions and properly forward the idempotency key as an Idempotency-Key header when provided. The conditional header construction (lines 95-97) is clean and correct.


109-125: Batch method consistently supports idempotency.

The batch method mirrors the same idempotency pattern as create, 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 normalize function 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.

Comment threadapps/web/src/server/public-api/api/emails/batch-email.ts Outdated
Comment threadapps/web/src/server/public-api/api/emails/send-email.ts Outdated
Comment on lines +3 to +4
const IDEMPOTENCY_RESULT_TTL_SECONDS = 24 * 60 * 60; // 24h
const IDEMPOTENCY_LOCK_TTL_SECONDS = 60; // 60s

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +56 to +71
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));
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ 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.

Suggested change
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);
},

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 `&quot;__proto__&quot;` 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(),

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 &quot;@hono/zod-openapi&quot;;
request: {
+ headers: z
+ .object({
+ &quot;Idempotency-Key&quot;: z.string().min(1).max(256).optional(),
+ })
+ .partial()
</file context>
Fix with Cubic

Comment threadplan-idempotency.md Outdated
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)

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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&lt;string[] | null&gt;`
+ - `setResult(teamId: number, key: string, emailIds: string[]): Promise&lt;void&gt;` (EX 24h)
+ - `acquireLock(teamId: number, key: string): Promise&lt;boolean&gt;` (`SET NX EX 60`)
+ - `releaseLock(teamId: number, key: string): Promise&lt;void&gt;` (best-effort `DEL`)
</file context>
Suggested change
- `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)
Fix with Cubic

Comment threadplan-idempotency.md Outdated
## 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>`

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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&lt;string[] | null&gt;`
+ - `setResult(teamId: number, key: string, emailIds: string[]): Promise&lt;void&gt;` (EX 24h)
+ - `acquireLock(teamId: number, key: string): Promise&lt;boolean&gt;` (`SET NX EX 60`)
</file context>
Fix with Cubic

([keyA], [keyB]) => (keyA < keyB ? -1 : keyA > keyB ? 1 : 0)
);

const result: Record<string, CanonicalValue> = {};

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 `&quot;__proto__&quot;` 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]) =&gt; (keyA &lt; keyB ? -1 : keyA &gt; keyB ? 1 : 0)
+ );
+
+ const result: Record&lt;string, CanonicalValue&gt; = {};
+ for (const [key, val] of entries) {
+ const normalized = normalize(val);
</file context>
Suggested change
constresult: Record<string,CanonicalValue>={};
constresult: Record<string,CanonicalValue>=Object.create(null);
Fix with Cubic


async releaseLock(teamId: number, key: string): Promise<void> {
const redis = getRedis();
await redis.del(lockKey(teamId, key));

@cubic-dev-aicubic-dev-aiBotOct 25, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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&lt;void&gt; {
+ const redis = getRedis();
+ await redis.del(lockKey(teamId, key));
+ },
+};
</file context>
Fix with Cubic

@KMKoushikKMKoushik changed the title idempotency vibeCodedidempotencyNov 16, 2025
@KMKoushik
KMKoushikforce-pushed the km/2025-10-25-idempotency-keys branch from c5094c5 to c3cb90bCompareNovember 17, 2025 00:00

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

acquireLock writes a fixed value ("1") and releaseLock always DELs the key. If a long-running operation outlives IDEMPOTENCY_LOCK_TTL_SECONDS, the lock can expire and be reacquired by another request; when the original request later calls releaseLock, 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 token through withIdempotency (store it when acquiring the lock and pass it to releaseLock in the finally block) so only the actual lock owner can release it. Please verify this with your Redis client’s eval API 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 new EmailOptions surface 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.

EmailRequestOptions and the updated send/create/batch methods correctly:

  • Accept an optional idempotencyKey.
  • Forward it as an Idempotency-Key header into UseSend.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 updated send/create/batch methods cleanly propagate an optional idempotency_key into the Idempotency-Key header 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 the options.get("idempotency_key") pattern in both create and batch, 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.Bearer is defined but no security requirements 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 security blocks 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 clientPayload both for the idempotency payload and the call into sendEmail ensures that:

  • The hashed body used for idempotency matches what’s actually executed, and
  • Cached hits can safely rehydrate { emailId } without re-sending.

HTML normalization (rawHtmlhtml string or undefined) and the explicit text: requestBody.text ?? undefined keep 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 from c.req.valid("header")["Idempotency-Key"] to reuse the zod-validated value, though the central length guard in IdempotencyService.withIdempotency already protects against invalid keys.

packages/python-sdk/usesend/usesend.py (1)

120-147: HTTP helpers gain per-call headers without breaking existing usage

The updated post/get/put/patch/delete signatures add optional headers (and body for delete) while still working with existing calls that only pass path and body. All methods now route through _request with 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

getResult defensively parses JSON and validates bodyHash/emailIds, returning null on malformed data, and setResult uses setex with 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 returning null silently, 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 codes

The overall withIdempotency flow—key length validation, canonical hashing, result-hit fast path, contention re-check, and result storage using extractEmailIds/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_SECONDS or adding renewal would reduce the chance of mid-flight expiry.
  • Both “different payload” and “request in progress” paths use the same NOT_UNIQUE code; 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

📥 Commits

Reviewing files that changed from the base of the PR and between c5094c5 and 087b5cc.

📒 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.md
  • packages/sdk/src/email.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • packages/sdk/src/usesend.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • packages/sdk/src/usesend.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • packages/sdk/src/usesend.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/batch-email.ts
  • apps/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.ts
  • apps/web/src/server/public-api/api/emails/send-email.ts
  • apps/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.ts
  • apps/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-Key header on POST /v1/emails correctly 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.

RequestOptions plus baseHeaders/mergeHeaders give you a clean way to add headers like Idempotency-Key without risking loss of Authorization or Content-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 good

Using the shared emailSchema and IdempotencyService keeps the batch route aligned with single-send behavior and avoids schema drift; no issues here.


68-75: Idempotent batching flow and payload normalization look correct

Normalizing text/html before hashing and sending, then passing normalizedPayloads both to withIdempotency and to sendBulkEmails (after augmenting with teamId/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_headers cloning self.headers and overlaying non-None extras gives a clear override model without mutating the client’s defaults. _request now 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_CONSTANTS exposing 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.

Comment on lines 905 to +918
"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"
}
],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +16 to +33
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(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

@KMKoushik
KMKoushik merged commit cb48965 into mainNov 17, 2025
8 checks passed
@KMKoushik
KMKoushik deleted the km/2025-10-25-idempotency-keys branch November 17, 2025 00:42
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@KMKoushik