Skip to content

Commit 629041e

Browse files
authored
Fix strict batch preflight (#38)
## What changed - split email creation into side-effect-free preparation and commit phases - preflight every message in a strict batch before persisting or queueing any sibling - document strict-only batch validation and add service/API regression coverage ## Why `createBatch` previously called `create` for every message concurrently. If one message referenced a missing or unpublished template, or an invalid uploaded attachment, another valid message could already be stored and queued even though the batch request returned an error. A caller retry could then duplicate delivery. The official Resend API uses strict batch validation by default, where one invalid message prevents the batch from being sent. HayaSend now matches that safety behavior for its service-level validation. ## Impact Successful single-email and batch response shapes are unchanged. Invalid batches now fail before any valid sibling is accepted. Permissive batch mode and distributed transaction handling for infrastructure failures remain out of scope. ## Validation - `npm run check` - `npm test` — 21 files, 144 tests - `npm run build` - `npm audit --omit=dev --audit-level=high` — 0 vulnerabilities - `cfn-lint==1.53.2 template.yaml` - Redocly 2.12.5 OpenAPI lint - Lambda entry-point bundles - `actionlint` - `gitleaks git --staged` - signed commit with DCO sign-off Signed-off-by: Yusuke Hayashi <yusuke8h@gmail.com>
1 parent bad7a25 commit 629041e

5 files changed

Lines changed: 108 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ No unreleased changes.
1818
a CLI for substituting operator assumptions.
1919
- Reject malformed scoped API keys before any DynamoDB or Secrets Manager
2020
lookup so unauthenticated input cannot turn storage limits into API errors.
21+
- Preflight every email in a strict batch before persisting or queueing any
22+
message, preventing partial sends when a template or attachment is invalid.
2123
- Initial Resend-compatible sending, scheduling, receiving, webhook, and AWS
2224
deployment foundation.
2325
- Add Resend-compatible hosted templates with aliases, typed variables,

docs/compatibility.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ migration path. It does not claim full Resend API coverage.
88
| Emails | send | Implemented | HTML, text, recipients, headers, tags |
99
| Emails | retrieve/list | Implemented | HayaSend adds internal status fields |
1010
| Emails | update/cancel | Implemented | queued or scheduled messages only |
11-
| Batch | send | Implemented | 1–100 messages |
11+
| Batch | send | Implemented | 1–100 messages; strict validation only, without permissive mode |
1212
| Attachments | base64 content | Implemented | constrained by the 9 MiB serialized request guardrail |
1313
| Attachments | direct upload | HayaSend extension | checksum-bound S3 PUT; 25 MiB decoded aggregate |
1414
| Attachments | remote path | Rejected | avoids server-side URL fetching |

src/services/email-service.ts

Lines changed: 66 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ import type {
1111
CreateEmailResult,
1212
EmailRecord,
1313
EmailStatus,
14+
IdempotencyClaim,
1415
Page,
1516
SendEmailInput,
17+
SuppressionRecord,
1618
WebhookEventType,
1719
} from "../core/types.js";
1820
import type { EmailScheduler } from "../ports/email-scheduler.js";
@@ -82,6 +84,13 @@ function validateInput(
8284
}
8385
}
8486

87+
interface PreparedEmail {
88+
record: EmailRecord;
89+
idempotency: IdempotencyClaim | undefined;
90+
scheduledAt: string | undefined;
91+
suppressedRecipients: SuppressionRecord[];
92+
}
93+
8594
export class EmailService {
8695
constructor(
8796
private readonly store: Store,
@@ -98,6 +107,46 @@ export class EmailService {
98107
idempotencyKey?: string,
99108
now = new Date(),
100109
): Promise<CreateEmailResult> {
110+
return this.commitPreparedEmail(
111+
await this.prepareEmail(input, idempotencyKey, now),
112+
now,
113+
);
114+
}
115+
116+
async createBatch(
117+
inputs: SendEmailInput[],
118+
idempotencyKey?: string,
119+
): Promise<CreateEmailResult[]> {
120+
if (inputs.length === 0 || inputs.length > 100) {
121+
throw new ValidationError(
122+
"A batch must contain between 1 and 100 emails.",
123+
);
124+
}
125+
if (Buffer.byteLength(JSON.stringify(inputs), "utf8") > 9 * 1024 * 1024) {
126+
throw new ValidationError(
127+
"The serialized batch request must not exceed 9 MiB.",
128+
);
129+
}
130+
const now = new Date();
131+
const prepared = await Promise.all(
132+
inputs.map((input, index) =>
133+
this.prepareEmail(
134+
input,
135+
idempotencyKey ? `${idempotencyKey}:${index}` : undefined,
136+
now,
137+
),
138+
),
139+
);
140+
return Promise.all(
141+
prepared.map((email) => this.commitPreparedEmail(email, now)),
142+
);
143+
}
144+
145+
private async prepareEmail(
146+
input: SendEmailInput,
147+
idempotencyKey: string | undefined,
148+
now: Date,
149+
): Promise<PreparedEmail> {
101150
const templateRequestHash = input.template ? requestHash(input) : undefined;
102151
if (input.template) {
103152
if (!this.templates) {
@@ -157,6 +206,23 @@ export class EmailService {
157206
expires_at: Math.floor(now.getTime() / 1_000) + 86_400,
158207
}
159208
: undefined;
209+
return {
210+
record,
211+
idempotency,
212+
scheduledAt,
213+
suppressedRecipients,
214+
};
215+
}
216+
217+
private async commitPreparedEmail(
218+
{
219+
record,
220+
idempotency,
221+
scheduledAt,
222+
suppressedRecipients,
223+
}: PreparedEmail,
224+
now: Date,
225+
): Promise<CreateEmailResult> {
160226
const created = await this.store.createEmail(record, idempotency);
161227
if (!created.replayed) {
162228
if (suppressedRecipients.length > 0) {
@@ -186,30 +252,6 @@ export class EmailService {
186252
return created;
187253
}
188254

189-
async createBatch(
190-
inputs: SendEmailInput[],
191-
idempotencyKey?: string,
192-
): Promise<CreateEmailResult[]> {
193-
if (inputs.length === 0 || inputs.length > 100) {
194-
throw new ValidationError(
195-
"A batch must contain between 1 and 100 emails.",
196-
);
197-
}
198-
if (Buffer.byteLength(JSON.stringify(inputs), "utf8") > 9 * 1024 * 1024) {
199-
throw new ValidationError(
200-
"The serialized batch request must not exceed 9 MiB.",
201-
);
202-
}
203-
return Promise.all(
204-
inputs.map((input, index) =>
205-
this.create(
206-
input,
207-
idempotencyKey ? `${idempotencyKey}:${index}` : undefined,
208-
),
209-
),
210-
);
211-
}
212-
213255
async get(id: string): Promise<EmailRecord> {
214256
const record = await this.store.getEmail(id);
215257
if (!record) {

tests/app.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,28 @@ describe("HTTP API", () => {
644644
expect(replayBody.id).not.toBe(deliveryId);
645645
});
646646

647+
it("rejects a strict batch before accepting any valid sibling", async () => {
648+
const { queue, request, store } = fixture();
649+
const response = await request("/emails/batch", {
650+
method: "POST",
651+
body: JSON.stringify([
652+
email,
653+
{
654+
to: "template-recipient@example.net",
655+
template: { id: "missing-template" },
656+
},
657+
]),
658+
});
659+
660+
expect(response.status).toBe(404);
661+
await expect(response.json()).resolves.toMatchObject({
662+
name: "not_found",
663+
message: "Template was not found.",
664+
});
665+
await expect(store.listEmails(100)).resolves.toMatchObject({ data: [] });
666+
expect(queue.jobs).toHaveLength(0);
667+
});
668+
647669
it("rejects unsupported attachment URLs rather than fetching them", async () => {
648670
const { request } = fixture();
649671
const response = await request("/emails", {

tests/email-service.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,23 @@ const input = {
107107
};
108108

109109
describe("EmailService", () => {
110+
it("preflights a strict batch before creating or queueing any email", async () => {
111+
const { queue, service, store } = fixture();
112+
113+
await expect(
114+
service.createBatch([
115+
input,
116+
{
117+
to: ["template-recipient@example.net"],
118+
template: { id: "missing-template" },
119+
},
120+
]),
121+
).rejects.toThrow("Template");
122+
123+
await expect(store.listEmails(100)).resolves.toMatchObject({ data: [] });
124+
expect(queue.jobs).toHaveLength(0);
125+
});
126+
110127
it("keeps a template send idempotent across later publications", async () => {
111128
const { queue, service, templates } = fixture();
112129
const template = await templates.create({

0 commit comments

Comments
 (0)