Skip to content
Closed
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
5 changes: 4 additions & 1 deletion apps/docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@
},
{
"group": "Guides",
"pages": ["guides/use-with-react-email"]
"pages": [
"guides/use-with-react-email",
"guides/email-idempotency"
]
},
{
"group": "Community SDKs",
Expand Down
49 changes: 49 additions & 0 deletions apps/docs/guides/email-idempotency.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
title: Email idempotency
description: Deduplicate email send requests by reusing the same queued job.
---

## Overview

When you provide an `idempotencyKey` with the email send endpoint, useSend will
short-circuit duplicate requests and return the first queued email. The key is
Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

This line claims all duplicate requests return the first queued email, but the API sends an "already in progress" error when the original request hasn't finished. Please clarify the pending-request behavior.

Prompt for AI agents
Address the following comment on apps/docs/guides/email-idempotency.mdx at line 9:

<comment>This line claims all duplicate requests return the first queued email, but the API sends an &quot;already in progress&quot; error when the original request hasn&#39;t finished. Please clarify the pending-request behavior.</comment>

<file context>
@@ -0,0 +1,49 @@
+## Overview
+
+When you provide an `idempotencyKey` with the email send endpoint, useSend will
+short-circuit duplicate requests and return the first queued email. The key is
+scoped to the team and automatically expires after three days so you do not need
+any manual cleanup.
</file context>
Fix with Cubic

scoped to the team and automatically expires after three days so you do not need
any manual cleanup.

## JavaScript SDK example

```ts
import { UseSend } from "usesend-js";

const usesend = new UseSend(process.env.USESEND_API_KEY!);

await usesend.emails.send({
to: "recipient@example.com",
from: "hello@example.com",
subject: "Receipt",
html: "<p>Thanks for your order!</p>",
idempotencyKey: "order-123",
});
```

## Python SDK example

```py
from usesend import UseSend

client = UseSend("us_your_api_key")

client.emails.send({
"to": "recipient@example.com",
"from": "hello@example.com",
"subject": "Receipt",
"html": "<p>Thanks for your order!</p>",
"idempotencyKey": "order-123",
})
```

:::note
Repeated calls with the same key will reuse the cached response until the
three-day expiration window passes. If a send fails, a new request can reuse the
same key after the failure record is cleared automatically.
:::
8 changes: 5 additions & 3 deletions apps/web/src/server/public-api/api/emails/send-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,21 @@ function send(app: PublicAPIApp) {
app.openapi(route, async (c) => {
const team = c.var.team;

const body = c.req.valid("json");

let html = undefined;

const _html = c.req.valid("json")?.html?.toString();
const _html = body?.html?.toString();

if (_html && _html !== "true" && _html !== "false") {
html = _html;
}

const email = await sendEmail({
...c.req.valid("json"),
...body,
teamId: team.id,
apiKeyId: team.apiKeyId,
text: c.req.valid("json").text ?? undefined,
text: body.text ?? undefined,
html: html,
});

Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/server/public-api/schemas/email-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ export const emailSchema = z
.object({
to: z.string().or(z.array(z.string())),
from: z.string(),
idempotencyKey: z
.string()
.trim()
.min(1)
.max(255)
.optional()
.openapi({
description:
"Optional key to deduplicate send requests. Duplicate keys reuse results.",
}),
subject: z.string().min(1).optional().openapi({
description: "Optional when templateId is provided",
}),
Expand Down
49 changes: 49 additions & 0 deletions apps/web/src/server/redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,52 @@ export async function withCache<T>(

return value;
}

export type RedisSetJsonOptions = {
ttlSeconds?: number;
mode?: "NX" | "XX";
};

export async function setJsonValue(
key: string,
value: unknown,
options?: RedisSetJsonOptions
): Promise<"OK" | null> {
const redis = getRedis();
const payload = JSON.stringify(value);
const mode = options?.mode;
const ttlSeconds = options?.ttlSeconds;

if (mode && ttlSeconds) {
return redis.set(key, payload, mode, "EX", ttlSeconds);
}

if (mode) {
return redis.set(key, payload, mode);
}

if (ttlSeconds) {
return redis.set(key, payload, "EX", ttlSeconds);
}

return redis.set(key, payload);
}
Comment on lines +57 to +80
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 | 🔴 Critical

🧩 Analysis chain

Verify ioredis SET command argument order.

The setJsonValue implementation on line 68 passes arguments as redis.set(key, payload, mode, "EX", ttlSeconds) where mode is "NX" or "XX". While Redis accepts these flags in any order, ensure that ioredis v5.6.1 TypeScript overloads support this exact signature to avoid type errors or runtime issues.

Alternatively, test this syntax in the integration tests or verify against ioredis documentation:


🏁 Script executed:

#!/bin/bash
# Verify ioredis SET command syntax supports NX/XX with EX in this argument order

# Search for ioredis SET usage patterns in the codebase
rg -n -C3 'redis\.set\(' --type=ts

# Check ioredis type definitions
fd -e d.ts -x cat {} \; | rg -A5 'interface Redis' | head -50

Length of output: 960


🌐 Web query:

ioredis v5 SET command with NX and EX options syntax

💡 Result:

Use positional arguments (they're passed straight to Redis). Example:

  • redis.set("key", "value", "EX", 60, "NX"); // set if not exists, expires in 60s. [1][2]

Notes:

  • EX takes seconds (or use "PX" and milliseconds). NX and XX are mutually exclusive. [2]

Sources:
[1] ioredis README examples.
[2] Redis SET command documentation.


Fix argument order for redis.set() with NX/XX and EX options on line 68.

The call on line 68 passes arguments in incorrect order: redis.set(key, payload, mode, "EX", ttlSeconds). According to Redis protocol, expiration options (EX, seconds) must come before NX/XX flags. Correct the order to: redis.set(key, payload, "EX", ttlSeconds, mode).

🤖 Prompt for AI Agents
In apps/web/src/server/redis.ts around lines 57 to 80, the redis.set call that
combines mode (NX/XX) and expiration currently passes arguments in the wrong
order; change the call from redis.set(key, payload, mode, "EX", ttlSeconds) to
pass the expiration before the mode, i.e. redis.set(key, payload, "EX",
ttlSeconds, mode), ensuring all other branches remain consistent and return the
redis.set result.


export async function getJsonValue<T>(key: string): Promise<T | null> {
const redis = getRedis();
const cached = await redis.get(key);
if (!cached) {
return null;
}

try {
return JSON.parse(cached) as T;
} catch {
return null;
}
}

export async function deleteKey(key: string): Promise<number> {
const redis = getRedis();
return redis.del(key);
}
196 changes: 196 additions & 0 deletions apps/web/src/server/service/email-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,65 @@ import { logger } from "../logger/log";
import { SuppressionService } from "./suppression-service";
import { sanitizeCustomHeaders } from "~/server/utils/email-headers";
import { Prisma } from "@prisma/client";
import { deleteKey, getJsonValue, setJsonValue } from "../redis";

const IDEMPOTENCY_TTL_SECONDS = 60 * 60 * 24 * 3; // 3 days
const IDEMPOTENCY_WAIT_ATTEMPTS = 5;
const IDEMPOTENCY_WAIT_DELAY_MS = 100;

type EmailIdempotencyStatus = "PENDING" | "CREATED" | "QUEUED" | "FAILED";

type EmailIdempotencyRecord = {
status: EmailIdempotencyStatus;
emailId?: string;
createdAt: string;
queuedAt?: string;
lastUpdatedAt: string;
error?: string;
};

async function waitForIdempotentRecord(
redisKey: string
): Promise<EmailIdempotencyRecord | null> {
for (let attempt = 0; attempt < IDEMPOTENCY_WAIT_ATTEMPTS; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, IDEMPOTENCY_WAIT_DELAY_MS));
const record = await getJsonValue<EmailIdempotencyRecord>(redisKey);
if (!record || record.status === "FAILED" || record.emailId) {
return record ?? null;
}
}

return getJsonValue<EmailIdempotencyRecord>(redisKey);
}

async function resolveEmailFromRecord(
record: EmailIdempotencyRecord | null,
redisKey: string,
teamId: number
) {
if (!record || !record.emailId) {
return null;
}

const email = await db.email.findUnique({ where: { id: record.emailId } });

if (!email) {
await deleteKey(redisKey);
return null;
}

logger.info(
{
emailId: email.id,
teamId,
redisKey,
status: record.status,
},
"Returning cached idempotent email send result (entries expire after 3 days)."
);

return email;
}

async function checkIfValidEmail(emailId: string) {
const email = await db.email.findUnique({
Expand Down Expand Up @@ -72,10 +131,98 @@ export async function sendEmail(
apiKeyId,
inReplyToId,
headers,
idempotencyKey,
} = emailContent;
let subject = subjectFromApiCall;
let html = htmlFromApiCall;

const normalizedIdempotencyKey = idempotencyKey?.trim();
const idempotencyRedisKey = normalizedIdempotencyKey
? `email-idempotency:${teamId}:${normalizedIdempotencyKey}`
: null;
let idempotencyRecordSnapshot: EmailIdempotencyRecord | null = null;
let idempotencyCreatedAt = new Date().toISOString();

if (idempotencyRedisKey) {
let existingRecord = await getJsonValue<EmailIdempotencyRecord>(
idempotencyRedisKey
);

if (existingRecord?.status === "FAILED") {
await deleteKey(idempotencyRedisKey);
existingRecord = null;
} else {
const cachedEmail = await resolveEmailFromRecord(
existingRecord,
idempotencyRedisKey,
teamId
);

if (cachedEmail) {
return cachedEmail;
}

if (existingRecord?.status === "PENDING") {
const awaitedRecord = await waitForIdempotentRecord(
idempotencyRedisKey
);
const awaitedEmail = await resolveEmailFromRecord(
awaitedRecord,
idempotencyRedisKey,
teamId
);

if (awaitedEmail) {
return awaitedEmail;
}

if (awaitedRecord?.status === "FAILED") {
await deleteKey(idempotencyRedisKey);
existingRecord = null;
} else {
existingRecord = awaitedRecord;
}
}
}

const nowIso = new Date().toISOString();
const pendingRecord: EmailIdempotencyRecord = {
status: "PENDING",
createdAt: existingRecord?.createdAt ?? nowIso,
lastUpdatedAt: nowIso,
};
Comment on lines +189 to +193
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 Clear idempotency key when validation fails before queuing

The code writes a PENDING idempotency record in Redis as soon as an idempotencyKey is supplied, but there is no matching cleanup when the function exits early before the queueing try block (e.g., invalid API key, suppressed recipients, or any other validation error thrown later in sendEmail). In those cases the Redis key remains stuck with status PENDING for the full three‑day TTL, so subsequent requests with the same idempotency key will always hit the NOT_UNIQUE branch even though no email was actually processed. This blocks legitimate retries and leaks keys until the TTL expires. Consider wrapping the validation and early returns in a try/finally that deletes or marks the key as FAILED so clients can retry safely.

Useful? React with 👍 / 👎.


const setResult = await setJsonValue(idempotencyRedisKey, pendingRecord, {
Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

Errors thrown after creating the PENDING idempotency record can leave the key stuck for up to 3 days, causing subsequent requests with the same idempotencyKey to fail with NOT_UNIQUE. Wrap the processing that follows creation of the PENDING record in a try-catch and, on any error, update the Redis record to status "FAILED" (preserving createdAt and setting lastUpdatedAt) before rethrowing to avoid blocking retries.

Prompt for AI agents
Address the following comment on apps/web/src/server/service/email-service.ts at line 195:

<comment>Errors thrown after creating the PENDING idempotency record can leave the key stuck for up to 3 days, causing subsequent requests with the same idempotencyKey to fail with NOT_UNIQUE. Wrap the processing that follows creation of the PENDING record in a try-catch and, on any error, update the Redis record to status &quot;FAILED&quot; (preserving createdAt and setting lastUpdatedAt) before rethrowing to avoid blocking retries.</comment>

<file context>
@@ -72,10 +131,98 @@ export async function sendEmail(
+      lastUpdatedAt: nowIso,
+    };
+
+    const setResult = await setJsonValue(idempotencyRedisKey, pendingRecord, {
+      ttlSeconds: IDEMPOTENCY_TTL_SECONDS,
+      mode: &quot;NX&quot;,
</file context>
Fix with Cubic

ttlSeconds: IDEMPOTENCY_TTL_SECONDS,
mode: "NX",
});

if (setResult !== "OK") {
const latestRecord = await waitForIdempotentRecord(idempotencyRedisKey);
const latestEmail = await resolveEmailFromRecord(
latestRecord,
idempotencyRedisKey,
teamId
);

if (latestEmail) {
return latestEmail;
}

if (latestRecord?.status === "FAILED") {
await deleteKey(idempotencyRedisKey);
}

throw new UnsendApiError({
code: "NOT_UNIQUE",
message: "A request with this idempotency key is already in progress.",
});
}

idempotencyCreatedAt = pendingRecord.createdAt;
idempotencyRecordSnapshot = pendingRecord;
}
Comment on lines +139 to +224
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 | 🔴 Critical

Critical: Wrap idempotency flow in try-catch to clean up PENDING records.

If an error occurs after creating the PENDING record (line 195) but before updating to CREATED (line 430)—such as validation failures on lines 236-246 or lines 377-389—the PENDING record remains stuck in Redis for 3 days. Subsequent requests with the same idempotencyKey will wait, then throw NOT_UNIQUE errors until the TTL expires.

Wrap the entire flow from line 224 onward in a try-catch block to update the record to FAILED on any error:

     idempotencyCreatedAt = pendingRecord.createdAt;
     idempotencyRecordSnapshot = pendingRecord;
   }
+
+  try {

   let domain: Awaited<ReturnType<typeof validateDomainFromEmail>>;
   
   // ... (rest of the email creation flow)
   
   return email;
+
+  } catch (error: any) {
+    if (idempotencyRedisKey) {
+      const failedAt = new Date().toISOString();
+      const failureRecord: EmailIdempotencyRecord = {
+        status: "FAILED",
+        createdAt: idempotencyCreatedAt,
+        lastUpdatedAt: failedAt,
+        error: error instanceof Error ? error.message : String(error),
+      };
+      await setJsonValue(idempotencyRedisKey, failureRecord, {
+        ttlSeconds: IDEMPOTENCY_TTL_SECONDS,
+      });
+    }
+    throw error;
+  }
 }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/web/src/server/service/email-service.ts around lines 139 to 224, the
idempotency PENDING record can be left in Redis if an error occurs after it is
created; wrap the entire processing flow that follows creation of the PENDING
record (starting just after the setJsonValue that writes PENDING) in a try-catch
so that any thrown error results in updating the idempotency Redis key to a
FAILED EmailIdempotencyRecord (set status: "FAILED" and lastUpdatedAt to nowIso,
keep createdAt from the snapshot) before rethrowing; ensure the update is
written atomically (overwrite) and handle cases where the key was removed by
other flows (no-op if delete occurs), and rethrow the original error so upstream
error handling remains unchanged.


let domain: Awaited<ReturnType<typeof validateDomainFromEmail>>;

// If this is an API call with an API key, validate domain access
Expand Down Expand Up @@ -271,6 +418,22 @@ export async function sendEmail(
},
});

if (idempotencyRedisKey) {
const createdAtIso = new Date().toISOString();
const createdRecord: EmailIdempotencyRecord = {
status: "CREATED",
emailId: email.id,
createdAt: idempotencyCreatedAt,
lastUpdatedAt: createdAtIso,
};

await setJsonValue(idempotencyRedisKey, createdRecord, {
ttlSeconds: IDEMPOTENCY_TTL_SECONDS,
});

idempotencyRecordSnapshot = createdRecord;
}

try {
await EmailQueueService.queueEmail(
email.id,
Expand All @@ -280,6 +443,23 @@ export async function sendEmail(
undefined,
delay
);

if (idempotencyRedisKey) {
const queuedAt = new Date().toISOString();
const queuedRecord: EmailIdempotencyRecord = {
status: "QUEUED",
emailId: email.id,
createdAt: idempotencyRecordSnapshot?.createdAt ?? idempotencyCreatedAt,
queuedAt,
lastUpdatedAt: queuedAt,
};

await setJsonValue(idempotencyRedisKey, queuedRecord, {
Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

If the Redis cache write fails after queueEmail succeeds, this line throws and we immediately mark the send as FAILED even though the job is already queued, so callers see an error and may retry, generating duplicate sends. Guard the idempotency cache update so Redis failures don't flip the request into the failure path.

Prompt for AI agents
Address the following comment on apps/web/src/server/service/email-service.ts at line 457:

<comment>If the Redis cache write fails after queueEmail succeeds, this line throws and we immediately mark the send as FAILED even though the job is already queued, so callers see an error and may retry, generating duplicate sends. Guard the idempotency cache update so Redis failures don&#39;t flip the request into the failure path.</comment>

<file context>
@@ -280,6 +443,23 @@ export async function sendEmail(
+        lastUpdatedAt: queuedAt,
+      };
+
+      await setJsonValue(idempotencyRedisKey, queuedRecord, {
+        ttlSeconds: IDEMPOTENCY_TTL_SECONDS,
+      });
</file context>
Fix with Cubic

ttlSeconds: IDEMPOTENCY_TTL_SECONDS,
});

idempotencyRecordSnapshot = queuedRecord;
}
} catch (error: any) {
await db.emailEvent.create({
data: {
Expand All @@ -295,6 +475,22 @@ export async function sendEmail(
where: { id: email.id },
data: { latestStatus: "FAILED" },
});

if (idempotencyRedisKey) {
const failedAt = new Date().toISOString();
const failureRecord: EmailIdempotencyRecord = {
status: "FAILED",
emailId: email.id,
createdAt:
idempotencyRecordSnapshot?.createdAt ?? idempotencyCreatedAt,
lastUpdatedAt: failedAt,
error: error instanceof Error ? error.message : String(error),
};

await setJsonValue(idempotencyRedisKey, failureRecord, {
ttlSeconds: IDEMPOTENCY_TTL_SECONDS,
});
}
throw error;
}

Expand Down
1 change: 1 addition & 0 deletions apps/web/src/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export type EmailContent = {
to: string | string[];
from: string;
idempotencyKey?: string;
subject?: string;
templateId?: string;
variables?: Record<string, string>;
Expand Down
Loading