Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/docs/api-reference/emails/batch-email.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,13 @@ openapi: post /v1/emails/batch
---

Send up to 100 emails in a single request.

## Idempotency

Bulk sends also accept the `Idempotency-Key` header. The key applies to the entire batch payload:

- Same key + identical batch body (all items) → returns the original list of `emailId`s with `200 OK`.
- Same key + different payload → returns `409 Conflict` with `code: NOT_UNIQUE` so you can detect accidental reuse.
- Same key while another batch is still being processed → returns `409 Conflict`; retry after the first request finishes.

Keys live for 24 hours.
12 changes: 12 additions & 0 deletions apps/docs/api-reference/emails/send-email.mdx
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
---
openapi: post /v1/emails
---

Send a transactional email via the public API.

## Idempotency

Pass the optional `Idempotency-Key` header to make the request safe to retry. The key can be up to 256 characters. The server stores the canonical request body and behaves as follows:

- Same key + same request body → returns the original `emailId` with `200 OK` without re‑sending.
- Same key + different request body → returns `409 Conflict` with `code: NOT_UNIQUE` so you can detect the mismatch.
- Same key while another request is still being processed → returns `409 Conflict`; retry after a short delay or once the first request completes.

Entries expire after 24 hours. Use a unique key per logical send (for example, an order or signup ID).
4 changes: 4 additions & 0 deletions apps/docs/api-reference/introduction.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,7 @@ Authorization: Bearer us_12345
```

You can create a new token/API key under your useSend [Developer Settings](https://app.usesend.com/dev-settings/api-keys).

## Idempotency

Use the optional `Idempotency-Key` header on `POST /v1/emails` and `POST /v1/emails/batch` to make requests safe to retry. Reusing a key with the same request body returns the original response; reusing it with different input returns `409 Conflict` so you can detect mistakes. Keys expire after 24 hours.
108 changes: 96 additions & 12 deletions apps/web/src/server/public-api/api/emails/batch-email.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { createRoute, z } from "@hono/zod-openapi";
import { PublicAPIApp } from "~/server/public-api/hono";
import { getTeamFromToken } from "~/server/public-api/auth";
import { sendBulkEmails } from "~/server/service/email-service";
import { EmailContent } from "~/types";
import { emailSchema } from "../../schemas/email-schema"; // Corrected import path
import { IdempotencyService } from "~/server/service/idempotency-service";
import { canonicalizePayload } from "~/server/utils/idempotency";
import { UnsendApiError } from "~/server/public-api/api-error";
import { logger } from "~/server/logger/log";

// Define the schema for a single email within the bulk request
// This is similar to the schema in send-email.ts but without the top-level 'required'
Expand All @@ -13,6 +16,12 @@ const route = createRoute({
method: "post",
path: "/v1/emails/batch",
request: {
headers: z
.object({
"Idempotency-Key": z.string().min(1).max(256).optional(),
})
.partial()
.openapi("Idempotency headers"),
body: {
required: true,
content: {
Expand Down Expand Up @@ -47,29 +56,104 @@ function sendBatch(app: PublicAPIApp) {
const team = c.var.team;
const emailPayloads = c.req.valid("json");

// Add teamId and apiKeyId to each email payload
const emailsToSend: Array<
EmailContent & { teamId: number; apiKeyId?: number }
> = emailPayloads.map((payload) => ({
const normalizedPayloads = emailPayloads.map((payload) => ({
...payload,
text: payload.text ?? undefined,
html:
payload.html && payload.html !== "true" && payload.html !== "false"
? payload.html
: undefined,
}));

const idemKey = c.req.header("Idempotency-Key") ?? undefined;
if (idemKey !== undefined && (idemKey.length < 1 || idemKey.length > 256)) {
throw new UnsendApiError({
code: "BAD_REQUEST",
message: "Invalid Idempotency-Key length",
});
}

let payloadHash: string | undefined;
let lockAcquired = false;

if (idemKey) {
({ bodyHash: payloadHash } = canonicalizePayload(normalizedPayloads));

const existing = await IdempotencyService.getResult(team.id, idemKey);
if (existing) {
if (existing.bodyHash === payloadHash) {
logger.info(
{ teamId: team.id },
"Idempotency hit for bulk email send"
);
const responseData = existing.emailIds.map((id) => ({ emailId: id }));
return c.json({ data: responseData });
}

throw new UnsendApiError({
code: "NOT_UNIQUE",
message: "Idempotency-Key already used with a different payload",
});
}

lockAcquired = await IdempotencyService.acquireLock(team.id, idemKey);
if (!lockAcquired) {
const again = await IdempotencyService.getResult(team.id, idemKey);
if (again) {
if (again.bodyHash === payloadHash) {
logger.info(
{ teamId: team.id },
"Idempotency hit after contention for bulk email send"
);
const responseData = again.emailIds.map((id) => ({ emailId: id }));
return c.json({ data: responseData });
}

throw new UnsendApiError({
code: "NOT_UNIQUE",
message: "Idempotency-Key already used with a different payload",
});
}

throw new UnsendApiError({
code: "NOT_UNIQUE",
message:
"Request with same Idempotency-Key is in progress. Retry later.",
});
}
}
Comment on lines +99 to +124
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Adopt token-based lock API (pair with service change).

Mirror the safe lock ownership pattern here.

-    let payloadHash: string | undefined;
-    let lockAcquired = false;
+    let payloadHash: string | undefined;
+    let lockToken: string | null = null;
@@
-      lockAcquired = await IdempotencyService.acquireLock(team.id, idemKey);
-      if (!lockAcquired) {
+      lockToken = await IdempotencyService.acquireLock(team.id, idemKey);
+      if (!lockToken) {
         const again = await IdempotencyService.getResult(team.id, idemKey);
@@
-      if (idemKey && lockAcquired) {
-        await IdempotencyService.releaseLock(team.id, idemKey);
+      if (idemKey && lockToken) {
+        await IdempotencyService.releaseLock(team.id, idemKey, lockToken);
       }

Also applies to: 152-156, 76-78


// Add teamId and apiKeyId to each email payload
const emailsToSend: Array<
EmailContent & { teamId: number; apiKeyId?: number }
> = normalizedPayloads.map((payload) => ({
...payload,
teamId: team.id,
apiKeyId: team.apiKeyId,
}));

// Call the service function to send emails in bulk
const createdEmails = await sendBulkEmails(emailsToSend);
try {
// Call the service function to send emails in bulk
const createdEmails = await sendBulkEmails(emailsToSend);

// Map the result to the response format
const responseData = createdEmails.map((email) => ({
emailId: email.id,
}));
// Map the result to the response format
const responseData = createdEmails.map((email) => ({
emailId: email.id,
}));

if (idemKey && payloadHash) {
await IdempotencyService.setResult(team.id, idemKey, {
bodyHash: payloadHash,
emailIds: createdEmails.map((email) => email.id),
});
}

return c.json({ data: responseData });
return c.json({ data: responseData });
} finally {
if (idemKey && lockAcquired) {
await IdempotencyService.releaseLock(team.id, idemKey);
}
}
});
}

Expand Down
103 changes: 91 additions & 12 deletions apps/web/src/server/public-api/api/emails/send-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,21 @@ import { createRoute, z } from "@hono/zod-openapi";
import { PublicAPIApp } from "~/server/public-api/hono";
import { sendEmail } from "~/server/service/email-service";
import { emailSchema } from "../../schemas/email-schema";
import { IdempotencyService } from "~/server/service/idempotency-service";
import { canonicalizePayload } from "~/server/utils/idempotency";
import { UnsendApiError } from "~/server/public-api/api-error";
import { logger } from "~/server/logger/log";

const route = createRoute({
method: "post",
path: "/v1/emails",
request: {
headers: z
.object({
"Idempotency-Key": z.string().min(1).max(256).optional(),
Copy link

@cubic-dev-ai cubic-dev-ai bot Oct 25, 2025

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

})
.partial()
.openapi("Idempotency headers"),
body: {
required: true,
content: {
Expand All @@ -31,24 +41,93 @@ const route = createRoute({
function send(app: PublicAPIApp) {
app.openapi(route, async (c) => {
const team = c.var.team;
const requestBody = c.req.valid("json");

let html = undefined;
let html: string | undefined;
const rawHtml = requestBody?.html?.toString();
if (rawHtml && rawHtml !== "true" && rawHtml !== "false") {
html = rawHtml;
}

const _html = c.req.valid("json")?.html?.toString();
const clientPayload = {
...requestBody,
text: requestBody.text ?? undefined,
html,
};

if (_html && _html !== "true" && _html !== "false") {
html = _html;
const idemKey = c.req.header("Idempotency-Key") ?? undefined;
if (idemKey !== undefined && (idemKey.length < 1 || idemKey.length > 256)) {
throw new UnsendApiError({
code: "BAD_REQUEST",
message: "Invalid Idempotency-Key length",
});
}

const email = await sendEmail({
...c.req.valid("json"),
teamId: team.id,
apiKeyId: team.apiKeyId,
text: c.req.valid("json").text ?? undefined,
html: html,
});
let payloadHash: string | undefined;
let lockAcquired = false;

Comment on lines +66 to +68
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Adopt token-based lock API (pair with service change).

Switch from boolean locks to token ownership and safe release.

-    let payloadHash: string | undefined;
-    let lockAcquired = false;
+    let payloadHash: string | undefined;
+    let lockToken: string | null = null;
@@
-      lockAcquired = await IdempotencyService.acquireLock(team.id, idemKey);
-      if (!lockAcquired) {
+      lockToken = await IdempotencyService.acquireLock(team.id, idemKey);
+      if (!lockToken) {
         const again = await IdempotencyService.getResult(team.id, idemKey);
@@
-      if (idemKey && lockAcquired) {
-        await IdempotencyService.releaseLock(team.id, idemKey);
+      if (idemKey && lockToken) {
+        await IdempotencyService.releaseLock(team.id, idemKey, lockToken);
       }

Also applies to: 85-108, 126-130

🤖 Prompt for AI Agents
In apps/web/src/server/public-api/api/emails/send-email.ts around lines 66-68
(and similarly update ranges 85-108 and 126-130), replace the boolean lock
pattern with a token-based lock ownership API: change lockAcquired:boolean to a
lockToken:string|undefined, obtain a token when acquiring the lock (store it in
lockToken), pass that token into the lock-release call and only release when the
token matches, and ensure all early returns and finally/cleanup paths release
using the token-aware unlock function (or skip release if no token). Update
variable names and control flow so acquisition sets lockToken, failures do not
call boolean releases, and the unlock call uses the token to perform a safe,
owner-only release.

if (idemKey) {
({ bodyHash: payloadHash } = canonicalizePayload(clientPayload));

const existing = await IdempotencyService.getResult(team.id, idemKey);
if (existing) {
if (existing.bodyHash === payloadHash) {
logger.info({ teamId: team.id }, "Idempotency hit for email send");
return c.json({ emailId: existing.emailIds[0] });
}

throw new UnsendApiError({
code: "NOT_UNIQUE",
message: "Idempotency-Key already used with a different payload",
});
}

lockAcquired = await IdempotencyService.acquireLock(team.id, idemKey);
if (!lockAcquired) {
const again = await IdempotencyService.getResult(team.id, idemKey);
if (again) {
if (again.bodyHash === payloadHash) {
logger.info(
{ teamId: team.id },
"Idempotency hit after contention for email send"
);
return c.json({ emailId: again.emailIds[0] });
}

throw new UnsendApiError({
code: "NOT_UNIQUE",
message: "Idempotency-Key already used with a different payload",
});
}

return c.json({ emailId: email?.id });
throw new UnsendApiError({
code: "NOT_UNIQUE",
message:
"Request with same Idempotency-Key is in progress. Retry later.",
});
}
}

try {
const email = await sendEmail({
...clientPayload,
teamId: team.id,
apiKeyId: team.apiKeyId,
});

if (idemKey && payloadHash) {
await IdempotencyService.setResult(team.id, idemKey, {
bodyHash: payloadHash,
emailIds: [email.id],
});
}

return c.json({ emailId: email?.id });
} finally {
if (idemKey && lockAcquired) {
await IdempotencyService.releaseLock(team.id, idemKey);
}
}
});
}

Expand Down
78 changes: 78 additions & 0 deletions apps/web/src/server/service/idempotency-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { getRedis } from "~/server/redis";

const IDEMPOTENCY_RESULT_TTL_SECONDS = 24 * 60 * 60; // 24h
const IDEMPOTENCY_LOCK_TTL_SECONDS = 60; // 60s
Comment on lines +3 to +4
Copy link
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.


export type IdempotencyRecord = {
bodyHash: string;
emailIds: string[];
};

function resultKey(teamId: number, key: string) {
return `idem:${teamId}:${key}`;
}

function lockKey(teamId: number, key: string) {
return `idemlock:${teamId}:${key}`;
}

export const IdempotencyService = {
async getResult(
teamId: number,
key: string
): Promise<IdempotencyRecord | null> {
const redis = getRedis();
const raw = await redis.get(resultKey(teamId, key));
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
if (
parsed &&
typeof parsed === "object" &&
typeof (parsed as any).bodyHash === "string" &&
Array.isArray((parsed as any).emailIds)
) {
return parsed as IdempotencyRecord;
}
return null;
} catch {
return null;
}
},

async setResult(
teamId: number,
key: string,
record: IdempotencyRecord
): Promise<void> {
const redis = getRedis();
await redis.setex(
resultKey(teamId, key),
IDEMPOTENCY_RESULT_TTL_SECONDS,
JSON.stringify(record)
);
},

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

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

Copy link

@cubic-dev-ai cubic-dev-ai bot Oct 25, 2025

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

},
Comment on lines +56 to +71
Copy link
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
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));
},
async acquireLock(teamId: number, key: string): Promise<string | null> {
const redis = getRedis();
const token = randomUUID();
const ok = await redis.set(
lockKey(teamId, key),
token,
"EX",
IDEMPOTENCY_LOCK_TTL_SECONDS,
"NX"
);
return ok === "OK" ? token : null;
},
async releaseLock(teamId: number, key: string, token: string): Promise<void> {
const redis = getRedis();
// 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);
},

};

export const IDEMPOTENCY_CONSTANTS = {
RESULT_TTL_SECONDS: IDEMPOTENCY_RESULT_TTL_SECONDS,
LOCK_TTL_SECONDS: IDEMPOTENCY_LOCK_TTL_SECONDS,
};

Loading