idempotency (#282)
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
---
|
---
|
||||||
openapi: post /v1/emails
|
openapi: post /v1/emails
|
||||||
---
|
---
|
||||||
|
|
||||||
|
Send a transactional email via the public API.
|
||||||
|
|||||||
+303
-1044
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
|||||||
import { createRoute, z } from "@hono/zod-openapi";
|
import { createRoute, z } from "@hono/zod-openapi";
|
||||||
import { PublicAPIApp } from "~/server/public-api/hono";
|
import { PublicAPIApp } from "~/server/public-api/hono";
|
||||||
import { getTeamFromToken } from "~/server/public-api/auth";
|
|
||||||
import { sendBulkEmails } from "~/server/service/email-service";
|
import { sendBulkEmails } from "~/server/service/email-service";
|
||||||
import { EmailContent } from "~/types";
|
import { EmailContent } from "~/types";
|
||||||
import { emailSchema } from "../../schemas/email-schema"; // Corrected import path
|
import { emailSchema } from "../../schemas/email-schema";
|
||||||
|
import { IdempotencyService } from "~/server/service/idempotency-service";
|
||||||
|
|
||||||
// Define the schema for a single email within the bulk request
|
// 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'
|
// This is similar to the schema in send-email.ts but without the top-level 'required'
|
||||||
@@ -13,6 +13,24 @@ const route = createRoute({
|
|||||||
method: "post",
|
method: "post",
|
||||||
path: "/v1/emails/batch",
|
path: "/v1/emails/batch",
|
||||||
request: {
|
request: {
|
||||||
|
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(),
|
||||||
body: {
|
body: {
|
||||||
required: true,
|
required: true,
|
||||||
content: {
|
content: {
|
||||||
@@ -47,27 +65,41 @@ function sendBatch(app: PublicAPIApp) {
|
|||||||
const team = c.var.team;
|
const team = c.var.team;
|
||||||
const emailPayloads = c.req.valid("json");
|
const emailPayloads = c.req.valid("json");
|
||||||
|
|
||||||
// Add teamId and apiKeyId to each email payload
|
const normalizedPayloads = emailPayloads.map((payload) => ({
|
||||||
const emailsToSend: Array<
|
|
||||||
EmailContent & { teamId: number; apiKeyId?: number }
|
|
||||||
> = emailPayloads.map((payload) => ({
|
|
||||||
...payload,
|
...payload,
|
||||||
text: payload.text ?? undefined,
|
text: payload.text ?? undefined,
|
||||||
html:
|
html:
|
||||||
payload.html && payload.html !== "true" && payload.html !== "false"
|
payload.html && payload.html !== "true" && payload.html !== "false"
|
||||||
? payload.html
|
? payload.html
|
||||||
: undefined,
|
: undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const idemKey = c.req.header("Idempotency-Key") ?? undefined;
|
||||||
|
|
||||||
|
const responseData = await IdempotencyService.withIdempotency({
|
||||||
|
teamId: team.id,
|
||||||
|
idemKey,
|
||||||
|
payload: normalizedPayloads,
|
||||||
|
operation: async () => {
|
||||||
|
const emailsToSend: Array<
|
||||||
|
EmailContent & { teamId: number; apiKeyId?: number }
|
||||||
|
> = normalizedPayloads.map((payload) => ({
|
||||||
|
...payload,
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
apiKeyId: team.apiKeyId,
|
apiKeyId: team.apiKeyId,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Call the service function to send emails in bulk
|
|
||||||
const createdEmails = await sendBulkEmails(emailsToSend);
|
const createdEmails = await sendBulkEmails(emailsToSend);
|
||||||
|
|
||||||
// Map the result to the response format
|
return createdEmails.map((email) => ({
|
||||||
const responseData = createdEmails.map((email) => ({
|
|
||||||
emailId: email.id,
|
emailId: email.id,
|
||||||
}));
|
}));
|
||||||
|
},
|
||||||
|
extractEmailIds: (data) => data.map((item) => item.emailId),
|
||||||
|
formatCachedResponse: (emailIds) =>
|
||||||
|
emailIds.map((id) => ({ emailId: id })),
|
||||||
|
logContext: "bulk email send",
|
||||||
|
});
|
||||||
|
|
||||||
return c.json({ data: responseData });
|
return c.json({ data: responseData });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,11 +2,30 @@ import { createRoute, z } from "@hono/zod-openapi";
|
|||||||
import { PublicAPIApp } from "~/server/public-api/hono";
|
import { PublicAPIApp } from "~/server/public-api/hono";
|
||||||
import { sendEmail } from "~/server/service/email-service";
|
import { sendEmail } from "~/server/service/email-service";
|
||||||
import { emailSchema } from "../../schemas/email-schema";
|
import { emailSchema } from "../../schemas/email-schema";
|
||||||
|
import { IdempotencyService } from "~/server/service/idempotency-service";
|
||||||
|
|
||||||
const route = createRoute({
|
const route = createRoute({
|
||||||
method: "post",
|
method: "post",
|
||||||
path: "/v1/emails",
|
path: "/v1/emails",
|
||||||
request: {
|
request: {
|
||||||
|
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(),
|
||||||
body: {
|
body: {
|
||||||
required: true,
|
required: true,
|
||||||
content: {
|
content: {
|
||||||
@@ -31,24 +50,43 @@ const route = createRoute({
|
|||||||
function send(app: PublicAPIApp) {
|
function send(app: PublicAPIApp) {
|
||||||
app.openapi(route, async (c) => {
|
app.openapi(route, async (c) => {
|
||||||
const team = c.var.team;
|
const team = c.var.team;
|
||||||
|
const requestBody = c.req.valid("json");
|
||||||
|
|
||||||
let html = undefined;
|
let html: string | undefined;
|
||||||
|
const rawHtml = requestBody?.html?.toString();
|
||||||
const _html = c.req.valid("json")?.html?.toString();
|
if (rawHtml && rawHtml !== "true" && rawHtml !== "false") {
|
||||||
|
html = rawHtml;
|
||||||
if (_html && _html !== "true" && _html !== "false") {
|
|
||||||
html = _html;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clientPayload = {
|
||||||
|
...requestBody,
|
||||||
|
text: requestBody.text ?? undefined,
|
||||||
|
html,
|
||||||
|
};
|
||||||
|
|
||||||
|
const idemKey = c.req.header("Idempotency-Key") ?? undefined;
|
||||||
|
|
||||||
|
const result = await IdempotencyService.withIdempotency<
|
||||||
|
typeof clientPayload,
|
||||||
|
{ emailId?: string }
|
||||||
|
>({
|
||||||
|
teamId: team.id,
|
||||||
|
idemKey,
|
||||||
|
payload: clientPayload,
|
||||||
|
operation: async () => {
|
||||||
const email = await sendEmail({
|
const email = await sendEmail({
|
||||||
...c.req.valid("json"),
|
...clientPayload,
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
apiKeyId: team.apiKeyId,
|
apiKeyId: team.apiKeyId,
|
||||||
text: c.req.valid("json").text ?? undefined,
|
});
|
||||||
html: html,
|
return { emailId: email?.id };
|
||||||
|
},
|
||||||
|
extractEmailIds: (result) => (result.emailId ? [result.emailId] : []),
|
||||||
|
formatCachedResponse: (emailIds) => ({ emailId: emailIds[0] }),
|
||||||
|
logContext: "email send",
|
||||||
});
|
});
|
||||||
|
|
||||||
return c.json({ emailId: email?.id });
|
return c.json(result);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { getRedis } from "~/server/redis";
|
||||||
|
import { canonicalizePayload } from "~/server/utils/idempotency";
|
||||||
|
import { UnsendApiError } from "~/server/public-api/api-error";
|
||||||
|
import { logger } from "~/server/logger/log";
|
||||||
|
|
||||||
|
const IDEMPOTENCY_RESULT_TTL_SECONDS = 24 * 60 * 60; // 24h
|
||||||
|
const IDEMPOTENCY_LOCK_TTL_SECONDS = 60; // 60s
|
||||||
|
|
||||||
|
export type IdempotencyRecord = {
|
||||||
|
bodyHash: string;
|
||||||
|
emailIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IdempotencyHandlerOptions<TPayload, TResult> = {
|
||||||
|
teamId: number;
|
||||||
|
idemKey: string | undefined;
|
||||||
|
payload: TPayload;
|
||||||
|
operation: () => Promise<TResult>;
|
||||||
|
extractEmailIds: (result: TResult) => string[];
|
||||||
|
formatCachedResponse: (emailIds: string[]) => TResult;
|
||||||
|
logContext: 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));
|
||||||
|
},
|
||||||
|
|
||||||
|
async withIdempotency<TPayload, TResult>(
|
||||||
|
options: IdempotencyHandlerOptions<TPayload, TResult>,
|
||||||
|
): Promise<TResult> {
|
||||||
|
const {
|
||||||
|
teamId,
|
||||||
|
idemKey,
|
||||||
|
payload,
|
||||||
|
operation,
|
||||||
|
extractEmailIds,
|
||||||
|
formatCachedResponse,
|
||||||
|
logContext,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Validate idempotency key length
|
||||||
|
if (idemKey !== undefined && (idemKey.length < 1 || idemKey.length > 256)) {
|
||||||
|
throw new UnsendApiError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Invalid Idempotency-Key length",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no idempotency key, just execute the operation
|
||||||
|
if (!idemKey) {
|
||||||
|
return await operation();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate payload hash
|
||||||
|
const { bodyHash: payloadHash } = canonicalizePayload(payload);
|
||||||
|
|
||||||
|
// Check for existing result
|
||||||
|
const existing = await this.getResult(teamId, idemKey);
|
||||||
|
if (existing) {
|
||||||
|
if (existing.bodyHash === payloadHash) {
|
||||||
|
logger.info({ teamId }, `Idempotency hit for ${logContext}`);
|
||||||
|
return formatCachedResponse(existing.emailIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new UnsendApiError({
|
||||||
|
code: "NOT_UNIQUE",
|
||||||
|
message: "Idempotency-Key already used with a different payload",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to acquire lock
|
||||||
|
const lockAcquired = await this.acquireLock(teamId, idemKey);
|
||||||
|
if (!lockAcquired) {
|
||||||
|
// Check again in case another request completed
|
||||||
|
const again = await this.getResult(teamId, idemKey);
|
||||||
|
if (again) {
|
||||||
|
if (again.bodyHash === payloadHash) {
|
||||||
|
logger.info(
|
||||||
|
{ teamId },
|
||||||
|
`Idempotency hit after contention for ${logContext}`,
|
||||||
|
);
|
||||||
|
return formatCachedResponse(again.emailIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Execute the operation
|
||||||
|
const result = await operation();
|
||||||
|
|
||||||
|
// Store the result for future idempotency checks
|
||||||
|
await this.setResult(teamId, idemKey, {
|
||||||
|
bodyHash: payloadHash,
|
||||||
|
emailIds: extractEmailIds(result),
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
// Always release the lock
|
||||||
|
await this.releaseLock(teamId, idemKey);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IDEMPOTENCY_CONSTANTS = {
|
||||||
|
RESULT_TTL_SECONDS: IDEMPOTENCY_RESULT_TTL_SECONDS,
|
||||||
|
LOCK_TTL_SECONDS: IDEMPOTENCY_LOCK_TTL_SECONDS,
|
||||||
|
};
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { createHash } from "crypto";
|
||||||
|
|
||||||
|
type CanonicalValue =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null
|
||||||
|
| CanonicalValue[]
|
||||||
|
| { [key: string]: CanonicalValue };
|
||||||
|
|
||||||
|
function normalize(value: unknown): CanonicalValue | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => normalize(item) ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "object") {
|
||||||
|
const entries = Object.entries(value as Record<string, unknown>).sort(
|
||||||
|
([keyA], [keyB]) => (keyA < keyB ? -1 : keyA > keyB ? 1 : 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
const result: Record<string, CanonicalValue> = {};
|
||||||
|
for (const [key, val] of entries) {
|
||||||
|
const normalized = normalize(val);
|
||||||
|
if (normalized !== undefined) {
|
||||||
|
result[key] = normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "bigint") {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalizePayload(payload: unknown) {
|
||||||
|
const normalized = normalize(payload);
|
||||||
|
const canonical = JSON.stringify(normalized ?? null);
|
||||||
|
const bodyHash = createHash("sha256").update(canonical).digest("hex");
|
||||||
|
return { canonical, bodyHash };
|
||||||
|
}
|
||||||
|
|
||||||
@@ -37,6 +37,19 @@ resp, _ = client.emails.send(payload={
|
|||||||
"html": "<strong>Hi!</strong>",
|
"html": "<strong>Hi!</strong>",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Idempotent retries: same payload + same key returns the original response
|
||||||
|
resp, _ = client.emails.send(
|
||||||
|
payload=payload,
|
||||||
|
options={"idempotency_key": "signup-123"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Works for batch requests as well
|
||||||
|
resp, _ = client.emails.batch(
|
||||||
|
payload=[payload],
|
||||||
|
options={"idempotency_key": "bulk-welcome-1"},
|
||||||
|
)
|
||||||
|
# If the same key is reused with a different payload, the API responds with HTTP 409.
|
||||||
|
|
||||||
# 3) Campaigns
|
# 3) Campaigns
|
||||||
campaign_payload: types.CampaignCreate = {
|
campaign_payload: types.CampaignCreate = {
|
||||||
"name": "Welcome Series",
|
"name": "Welcome Series",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "usesend"
|
name = "usesend"
|
||||||
version = "0.2.7"
|
version = "0.2.8"
|
||||||
description = "Python SDK for the UseSend API"
|
description = "Python SDK for the UseSend API"
|
||||||
authors = ["UseSend"]
|
authors = ["UseSend"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
||||||
|
from typing_extensions import TypedDict
|
||||||
|
|
||||||
from .types import (
|
from .types import (
|
||||||
APIError,
|
APIError,
|
||||||
@@ -18,6 +19,17 @@ from .types import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailOptions(TypedDict, total=False):
|
||||||
|
"""Options for email operations."""
|
||||||
|
idempotency_key: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
def _idem_headers(idempotency_key: Optional[str]) -> Optional[Dict[str, str]]:
|
||||||
|
if idempotency_key:
|
||||||
|
return {"Idempotency-Key": idempotency_key}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class Emails:
|
class Emails:
|
||||||
"""Client for `/emails` endpoints."""
|
"""Client for `/emails` endpoints."""
|
||||||
|
|
||||||
@@ -25,11 +37,19 @@ class Emails:
|
|||||||
self.usesend = usesend
|
self.usesend = usesend
|
||||||
|
|
||||||
# Basic operations -------------------------------------------------
|
# Basic operations -------------------------------------------------
|
||||||
def send(self, payload: EmailCreate) -> Tuple[Optional[EmailCreateResponse], Optional[APIError]]:
|
def send(
|
||||||
|
self,
|
||||||
|
payload: EmailCreate,
|
||||||
|
options: Optional[EmailOptions] = None,
|
||||||
|
) -> Tuple[Optional[EmailCreateResponse], Optional[APIError]]:
|
||||||
"""Alias for :meth:`create`."""
|
"""Alias for :meth:`create`."""
|
||||||
return self.create(payload)
|
return self.create(payload, options)
|
||||||
|
|
||||||
def create(self, payload: Union[EmailCreate, Dict[str, Any]]) -> Tuple[Optional[EmailCreateResponse], Optional[APIError]]:
|
def create(
|
||||||
|
self,
|
||||||
|
payload: Union[EmailCreate, Dict[str, Any]],
|
||||||
|
options: Optional[EmailOptions] = None,
|
||||||
|
) -> Tuple[Optional[EmailCreateResponse], Optional[APIError]]:
|
||||||
if isinstance(payload, dict):
|
if isinstance(payload, dict):
|
||||||
payload = dict(payload)
|
payload = dict(payload)
|
||||||
|
|
||||||
@@ -42,10 +62,17 @@ class Emails:
|
|||||||
if isinstance(body.get("scheduledAt"), datetime):
|
if isinstance(body.get("scheduledAt"), datetime):
|
||||||
body["scheduledAt"] = body["scheduledAt"].isoformat()
|
body["scheduledAt"] = body["scheduledAt"].isoformat()
|
||||||
|
|
||||||
data, err = self.usesend.post("/emails", body)
|
idempotency_key = options.get("idempotency_key") if options else None
|
||||||
|
data, err = self.usesend.post(
|
||||||
|
"/emails", body, headers=_idem_headers(idempotency_key)
|
||||||
|
)
|
||||||
return (data, err) # type: ignore[return-value]
|
return (data, err) # type: ignore[return-value]
|
||||||
|
|
||||||
def batch(self, payload: Sequence[Union[EmailBatchItem, Dict[str, Any]]]) -> Tuple[Optional[EmailBatchResponse], Optional[APIError]]:
|
def batch(
|
||||||
|
self,
|
||||||
|
payload: Sequence[Union[EmailBatchItem, Dict[str, Any]]],
|
||||||
|
options: Optional[EmailOptions] = None,
|
||||||
|
) -> Tuple[Optional[EmailBatchResponse], Optional[APIError]]:
|
||||||
items: List[Dict[str, Any]] = []
|
items: List[Dict[str, Any]] = []
|
||||||
for item in payload:
|
for item in payload:
|
||||||
d = dict(item)
|
d = dict(item)
|
||||||
@@ -54,7 +81,10 @@ class Emails:
|
|||||||
if isinstance(d.get("scheduledAt"), datetime):
|
if isinstance(d.get("scheduledAt"), datetime):
|
||||||
d["scheduledAt"] = d["scheduledAt"].isoformat()
|
d["scheduledAt"] = d["scheduledAt"].isoformat()
|
||||||
items.append(d)
|
items.append(d)
|
||||||
data, err = self.usesend.post("/emails/batch", items)
|
idempotency_key = options.get("idempotency_key") if options else None
|
||||||
|
data, err = self.usesend.post(
|
||||||
|
"/emails/batch", items, headers=_idem_headers(idempotency_key)
|
||||||
|
)
|
||||||
return (data, err) # type: ignore[return-value]
|
return (data, err) # type: ignore[return-value]
|
||||||
|
|
||||||
def get(self, email_id: str) -> Tuple[Optional[Email], Optional[APIError]]:
|
def get(self, email_id: str) -> Tuple[Optional[Email], Optional[APIError]]:
|
||||||
|
|||||||
@@ -77,12 +77,25 @@ class UseSend:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Internal request helper
|
# Internal request helper
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
def _build_headers(self, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
||||||
|
headers = dict(self.headers)
|
||||||
|
if extra:
|
||||||
|
headers.update({k: v for k, v in extra.items() if v is not None})
|
||||||
|
return headers
|
||||||
|
|
||||||
def _request(
|
def _request(
|
||||||
self, method: str, path: str, json: Optional[Any] = None
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
json: Optional[Any] = None,
|
||||||
|
headers: Optional[Dict[str, str]] = None,
|
||||||
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||||
"""Perform an HTTP request and return ``(data, error)``."""
|
"""Perform an HTTP request and return ``(data, error)``."""
|
||||||
resp = self._session.request(
|
resp = self._session.request(
|
||||||
method, f"{self.url}{path}", headers=self.headers, json=json
|
method,
|
||||||
|
f"{self.url}{path}",
|
||||||
|
headers=self._build_headers(headers),
|
||||||
|
json=json,
|
||||||
)
|
)
|
||||||
default_error = {"code": "INTERNAL_SERVER_ERROR", "message": resp.reason}
|
default_error = {"code": "INTERNAL_SERVER_ERROR", "message": resp.reason}
|
||||||
|
|
||||||
@@ -104,22 +117,42 @@ class UseSend:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# HTTP verb helpers
|
# HTTP verb helpers
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def post(self, path: str, body: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
def post(
|
||||||
return self._request("POST", path, json=body)
|
self,
|
||||||
|
path: str,
|
||||||
|
body: Any,
|
||||||
|
headers: Optional[Dict[str, str]] = None,
|
||||||
|
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||||
|
return self._request("POST", path, json=body, headers=headers)
|
||||||
|
|
||||||
def get(self, path: str) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
def get(
|
||||||
return self._request("GET", path)
|
self, path: str, headers: Optional[Dict[str, str]] = None
|
||||||
|
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||||
|
return self._request("GET", path, headers=headers)
|
||||||
|
|
||||||
def put(self, path: str, body: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
def put(
|
||||||
return self._request("PUT", path, json=body)
|
self,
|
||||||
|
path: str,
|
||||||
|
body: Any,
|
||||||
|
headers: Optional[Dict[str, str]] = None,
|
||||||
|
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||||
|
return self._request("PUT", path, json=body, headers=headers)
|
||||||
|
|
||||||
def patch(self, path: str, body: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
def patch(
|
||||||
return self._request("PATCH", path, json=body)
|
self,
|
||||||
|
path: str,
|
||||||
|
body: Any,
|
||||||
|
headers: Optional[Dict[str, str]] = None,
|
||||||
|
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||||
|
return self._request("PATCH", path, json=body, headers=headers)
|
||||||
|
|
||||||
def delete(
|
def delete(
|
||||||
self, path: str, body: Optional[Any] = None
|
self,
|
||||||
|
path: str,
|
||||||
|
body: Optional[Any] = None,
|
||||||
|
headers: Optional[Dict[str, str]] = None,
|
||||||
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||||
return self._request("DELETE", path, json=body)
|
return self._request("DELETE", path, json=body, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
# Import here to avoid circular dependency during type checking
|
# Import here to avoid circular dependency during type checking
|
||||||
|
|||||||
@@ -48,6 +48,37 @@ usesend.emails.send({
|
|||||||
html: "<p>useSend is the best open source product to send emails</p>",
|
html: "<p>useSend is the best open source product to send emails</p>",
|
||||||
text: "useSend is the best open source product to send emails",
|
text: "useSend is the best open source product to send emails",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Safely retry sends with an idempotency key
|
||||||
|
await usesend.emails.send(
|
||||||
|
{
|
||||||
|
to: "hello@acme.com",
|
||||||
|
from: "hello@company.com",
|
||||||
|
subject: "useSend email",
|
||||||
|
html: "<p>useSend is the best open source product to send emails</p>",
|
||||||
|
},
|
||||||
|
{ idempotencyKey: "signup-123" },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Works for bulk sends too
|
||||||
|
await usesend.emails.batch(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
to: "a@example.com",
|
||||||
|
from: "hello@company.com",
|
||||||
|
subject: "Welcome",
|
||||||
|
html: "<p>Hello A</p>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "b@example.com",
|
||||||
|
from: "hello@company.com",
|
||||||
|
subject: "Welcome",
|
||||||
|
html: "<p>Hello B</p>",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ idempotencyKey: "bulk-welcome-1" },
|
||||||
|
);
|
||||||
|
// Reusing the same key with a different payload returns HTTP 409.
|
||||||
```
|
```
|
||||||
|
|
||||||
## Campaigns
|
## Campaigns
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "usesend-js",
|
"name": "usesend-js",
|
||||||
"version": "1.5.6",
|
"version": "1.5.7",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"module": "./dist/index.mjs",
|
"module": "./dist/index.mjs",
|
||||||
|
|||||||
@@ -67,16 +67,23 @@ type BatchEmailResponse = {
|
|||||||
error: ErrorResponse | null;
|
error: ErrorResponse | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type EmailRequestOptions = {
|
||||||
|
idempotencyKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export class Emails {
|
export class Emails {
|
||||||
constructor(private readonly usesend: UseSend) {
|
constructor(private readonly usesend: UseSend) {
|
||||||
this.usesend = usesend;
|
this.usesend = usesend;
|
||||||
}
|
}
|
||||||
|
|
||||||
async send(payload: SendEmailPayload) {
|
async send(payload: SendEmailPayload, options?: EmailRequestOptions) {
|
||||||
return this.create(payload);
|
return this.create(payload, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(payload: SendEmailPayload): Promise<CreateEmailResponse> {
|
async create(
|
||||||
|
payload: SendEmailPayload,
|
||||||
|
options?: EmailRequestOptions,
|
||||||
|
): Promise<CreateEmailResponse> {
|
||||||
if (payload.react) {
|
if (payload.react) {
|
||||||
payload.html = await render(payload.react as React.ReactElement);
|
payload.html = await render(payload.react as React.ReactElement);
|
||||||
delete payload.react;
|
delete payload.react;
|
||||||
@@ -84,7 +91,10 @@ export class Emails {
|
|||||||
|
|
||||||
const data = await this.usesend.post<CreateEmailResponseSuccess>(
|
const data = await this.usesend.post<CreateEmailResponseSuccess>(
|
||||||
"/emails",
|
"/emails",
|
||||||
payload
|
payload,
|
||||||
|
options?.idempotencyKey
|
||||||
|
? { headers: { "Idempotency-Key": options.idempotencyKey } }
|
||||||
|
: undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -96,11 +106,17 @@ export class Emails {
|
|||||||
* @param payload An array of email payloads. Max 100 emails.
|
* @param payload An array of email payloads. Max 100 emails.
|
||||||
* @returns A promise that resolves to the list of created email IDs or an error.
|
* @returns A promise that resolves to the list of created email IDs or an error.
|
||||||
*/
|
*/
|
||||||
async batch(payload: BatchEmailPayload): Promise<BatchEmailResponse> {
|
async batch(
|
||||||
|
payload: BatchEmailPayload,
|
||||||
|
options?: EmailRequestOptions,
|
||||||
|
): Promise<BatchEmailResponse> {
|
||||||
// Note: React element rendering is not supported in batch mode.
|
// Note: React element rendering is not supported in batch mode.
|
||||||
const response = await this.usesend.post<BatchEmailResponseSuccess>(
|
const response = await this.usesend.post<BatchEmailResponseSuccess>(
|
||||||
"/emails/batch",
|
"/emails/batch",
|
||||||
payload
|
payload,
|
||||||
|
options?.idempotencyKey
|
||||||
|
? { headers: { "Idempotency-Key": options.idempotencyKey } }
|
||||||
|
: undefined,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
data: response.data ? response.data.data : null,
|
data: response.data ? response.data.data : null,
|
||||||
|
|||||||
+61
-20
@@ -12,8 +12,12 @@ function isUseSendErrorResponse(error: { error: ErrorResponse }) {
|
|||||||
return error.error.code !== undefined;
|
return error.error.code !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RequestOptions = {
|
||||||
|
headers?: HeadersInit;
|
||||||
|
};
|
||||||
|
|
||||||
export class UseSend {
|
export class UseSend {
|
||||||
private readonly headers: Headers;
|
private readonly baseHeaders: Headers;
|
||||||
|
|
||||||
// readonly domains = new Domains(this);
|
// readonly domains = new Domains(this);
|
||||||
readonly emails = new Emails(this);
|
readonly emails = new Emails(this);
|
||||||
@@ -42,17 +46,36 @@ export class UseSend {
|
|||||||
this.url = `${url}/api/v1`;
|
this.url = `${url}/api/v1`;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.headers = new Headers({
|
this.baseHeaders = new Headers({
|
||||||
Authorization: `Bearer ${this.key}`,
|
Authorization: `Bearer ${this.key}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private mergeHeaders(extra?: HeadersInit) {
|
||||||
|
const headers = new Headers(this.baseHeaders);
|
||||||
|
if (!extra) {
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
const additional = new Headers(extra);
|
||||||
|
additional.forEach((value, key) => {
|
||||||
|
headers.set(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
async fetchRequest<T>(
|
async fetchRequest<T>(
|
||||||
path: string,
|
path: string,
|
||||||
options = {},
|
options: RequestInit = {},
|
||||||
): Promise<{ data: T | null; error: ErrorResponse | null }> {
|
): Promise<{ data: T | null; error: ErrorResponse | null }> {
|
||||||
const response = await fetch(`${this.url}${path}`, options);
|
const requestOptions: RequestInit = {
|
||||||
|
...options,
|
||||||
|
headers: this.mergeHeaders(options.headers),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${this.url}${path}`, requestOptions);
|
||||||
const defaultError = {
|
const defaultError = {
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
message: response.statusText,
|
message: response.statusText,
|
||||||
@@ -82,52 +105,70 @@ export class UseSend {
|
|||||||
return { data, error: null };
|
return { data, error: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
async post<T>(path: string, body: unknown) {
|
async post<T>(path: string, body: unknown, options?: RequestOptions) {
|
||||||
const requestOptions = {
|
const requestOptions: RequestInit = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: this.headers,
|
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (options?.headers) {
|
||||||
|
requestOptions.headers = options.headers;
|
||||||
|
}
|
||||||
|
|
||||||
return this.fetchRequest<T>(path, requestOptions);
|
return this.fetchRequest<T>(path, requestOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
async get<T>(path: string) {
|
async get<T>(path: string, options?: RequestOptions) {
|
||||||
const requestOptions = {
|
const requestOptions: RequestInit = {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: this.headers,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (options?.headers) {
|
||||||
|
requestOptions.headers = options.headers;
|
||||||
|
}
|
||||||
|
|
||||||
return this.fetchRequest<T>(path, requestOptions);
|
return this.fetchRequest<T>(path, requestOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
async put<T>(path: string, body: any) {
|
async put<T>(path: string, body: any, options?: RequestOptions) {
|
||||||
const requestOptions = {
|
const requestOptions: RequestInit = {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: this.headers,
|
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (options?.headers) {
|
||||||
|
requestOptions.headers = options.headers;
|
||||||
|
}
|
||||||
|
|
||||||
return this.fetchRequest<T>(path, requestOptions);
|
return this.fetchRequest<T>(path, requestOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
async patch<T>(path: string, body: any) {
|
async patch<T>(path: string, body: any, options?: RequestOptions) {
|
||||||
const requestOptions = {
|
const requestOptions: RequestInit = {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: this.headers,
|
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (options?.headers) {
|
||||||
|
requestOptions.headers = options.headers;
|
||||||
|
}
|
||||||
|
|
||||||
return this.fetchRequest<T>(path, requestOptions);
|
return this.fetchRequest<T>(path, requestOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete<T>(path: string, body?: unknown) {
|
async delete<T>(path: string, body?: unknown, options?: RequestOptions) {
|
||||||
const requestOptions = {
|
const requestOptions: RequestInit = {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: this.headers,
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (body !== undefined) {
|
||||||
|
requestOptions.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.headers) {
|
||||||
|
requestOptions.headers = options.headers;
|
||||||
|
}
|
||||||
|
|
||||||
return this.fetchRequest<T>(path, requestOptions);
|
return this.fetchRequest<T>(path, requestOptions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user