Send your first transactional email
Build one server-side request, keep its idempotency key across retries, and start with a local mock.
Local fixtures, not a delivery claim.Local fixtures on Node.js 22.23.2 and contract review; authenticated sandbox and delivery not executed.
The public site does not accept credentials or send email. Authenticated use happens only in your own backend.
Before you start
- Node.js 22.23.2; no third-party packages are required.
- For the optional authenticated sandbox: a verified sending domain and a backend API key with send scope.
Run the local example
- Run npm run mock to inspect a synthetic acknowledgement without network access.
- Run npm test to exercise request construction, recoverable failures and permanent refusals.
- For one optional authenticated sandbox call, copy .env.example to .env locally and configure your backend key and verified sender. Then run npm run sandbox.
Expected result: Mock mode prints mock-message-0001 and status sandbox. A real API 202 acknowledges acceptance, sandbox freezing or a hold; it does not establish delivery.
The complete files
These exact files are included in the starter. Test fixtures use synthetic addresses and mock identifiers.
README.md
# Send your first transactional email
Maintained by Oveyon. MIT licensed. Version 1.0.0.
Canonical: https://1loc.dev/recipes/transactional-email/
Build one server-side request, keep its idempotency key across retries, and start with a local mock.
## Run locally
Node.js 22.23.2. No install is needed; there are no external dependencies.
```sh
npm run mock
npm test
```
These commands do not send email or make API requests. SQLite, when used, is experimental in Node 22.
## Prerequisites
- Node.js 22.23.2; no third-party packages are required.
- For the optional authenticated sandbox: a verified sending domain and a backend API key with send scope.
## Procedure
1. Run npm run mock to inspect a synthetic acknowledgement without network access.
2. Run npm test to exercise request construction, recoverable failures and permanent refusals.
3. For one optional authenticated sandbox call, copy .env.example to .env locally and configure your backend key and verified sender. Then run npm run sandbox.
## Expected result
Mock mode prints mock-message-0001 and status sandbox. A real API 202 acknowledges acceptance, sandbox freezing or a hold; it does not establish delivery.
## Permissions and effects
No account or network in mock mode. Optional sandbox needs send scope and consumes quota/rate. There is no live-delivery mode in this starter.
Optional backend command(s): npm run sandbox.
Keep credentials in .env on your own backend or in a secrets manager. Never commit .env. The public site does not accept credentials.
## Failures
- 401 or 403: correct backend credentials/scopes; do not retry unchanged.
- 422: inspect the stable error code and correct sender, recipient or policy.
- 429, 5xx, network uncertainty or 409 idempotency_in_progress: retry at most three times with the same key and body. Honor Retry-After; waits over 30 seconds return to the caller for durable scheduling.
## Limits and verification
- Only local fixtures were executed; an authenticated API response has not been observed.
- Every executable send path forces X-Oveyon-Sandbox: 1. Sandbox consumes quota/rate limits and does not deliver.
- The API idempotency window is 24 hours. This minimal recipe does not persist its event key across application restarts; use the order recipe for durable application state.
Local fixture tests executed on Node.js 22.23.2 on 2026-10-28. Contract snapshot reviewed; authenticated API/sandbox and real delivery were not executed.
## Sources
- https://api.oveyon.com/
- https://api.oveyon.com/v1/openapi.json
package.json
{
"name": "1loc-transactional-email",
"version": "1.0.0",
"private": true,
"type": "module",
"license": "MIT",
"engines": {
"node": ">=22.23.2 <23"
},
"scripts": {
"mock": "OVEYON_MODE=mock node --env-file-if-exists=.env --experimental-strip-types transactional-email/main.ts",
"test": "node --experimental-strip-types --test transactional-email/recipe.test.ts",
"sandbox": "OVEYON_MODE=sandbox node --env-file-if-exists=.env --experimental-strip-types transactional-email/main.ts"
}
}
package-lock.json
{
"name": "1loc-transactional-email",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "1loc-transactional-email",
"version": "1.0.0",
"license": "MIT",
"engines": {
"node": ">=22.23.2 <23"
}
}
}
}
.env.example
# Backend only. npm run mock never uses these values.
OVEYON_API_KEY=replace_with_a_server_side_key
OVEYON_FROM=Sender <[email protected]>
[email protected]
.gitignore
.env
.1loc-data/
*.sqlite
*.sqlite-shm
*.sqlite-wal
node_modules/
LICENSE
MIT License
Copyright (c) 2026 Oveyon
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
transactional-email/main.ts
import { pathToFileURL } from 'node:url';
import { cliOptions, messageAddresses, sendMessage } from '../shared/client.ts';
import { mockApi } from '../shared/mock.ts';
export async function run() {
const options = cliOptions(mockApi().transport);
const result = await sendMessage({ ...messageAddresses(), subject: 'Your workspace is ready', text: 'This is a synthetic welcome example.' }, { ...options, idempotencyKey: '1loc:welcome:demo-001' });
console.log(JSON.stringify({ mode: options.mode, result, meaning: 'API acknowledgement, not proof of delivery' }, null, 2));
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await run();
transactional-email/recipe.test.ts
import test from 'node:test';
import assert from 'node:assert/strict';
import { requestForSend, sendMessage, ApiError } from '../shared/client.ts';
import { mockApi } from '../shared/mock.ts';
const payload = { from: '[email protected]', to: '[email protected]', subject: 'Synthetic test', text: 'No email is sent.' };
test('send builds a sandbox-only request and keeps data out of its URL', () => {
const request = requestForSend(payload, 'fixture-not-a-key', 'event:demo-1');
assert.equal(request.url, 'https://api.oveyon.com/v1/send');
assert.equal(new Headers(request.init.headers).get('X-Oveyon-Sandbox'), '1');
assert.equal(new Headers(request.init.headers).get('Idempotency-Key'), 'event:demo-1');
assert.deepEqual(JSON.parse(request.init.body), payload);
assert.throws(() => requestForSend(payload, 'fixture', 'space in key'));
});
test('retry handles 429/503 using one unchanged idempotency key and body', async () => {
const api = mockApi([429, 503]); const waits: number[] = [];
const result = await sendMessage(payload, { apiKey: 'fixture', idempotencyKey: 'event:demo-1', transport: api.transport, wait: async ms => { waits.push(ms); } });
assert.equal(result.status, 'sandbox'); assert.deepEqual(waits, [0, 0]);
assert.equal(new Set(api.requests.map(r => r.init.body)).size, 1);
assert.equal(new Set(api.requests.map(r => new Headers(r.init.headers).get('Idempotency-Key'))).size, 1);
});
test('permanent refusals and unexpected responses stop immediately', async () => {
for (const status of [400, 401, 403, 422]) {
let calls = 0;
await assert.rejects(sendMessage(payload, { apiKey: 'fixture', idempotencyKey: 'event:demo-1', transport: async () => { calls++; return Response.json({ error: 'refused' }, { status }); } }), (error: unknown) => error instanceof ApiError && !error.retryable);
assert.equal(calls, 1);
}
});
test('long Retry-After does not cause an early retry; network failures stay unknown', async () => {
let calls = 0;
await assert.rejects(sendMessage(payload, { apiKey: 'fixture', idempotencyKey: 'event:demo-1', transport: async () => { calls++; return Response.json({ error: 'rate_limited' }, { status: 429, headers: { 'Retry-After': '3600' } }); } }));
assert.equal(calls, 1);
await assert.rejects(sendMessage(payload, { apiKey: 'fixture', idempotencyKey: 'event:demo-1', transport: async () => { throw new Error('offline'); }, wait: async () => {} }), (error: unknown) => error instanceof ApiError && error.code === 'network_result_unknown');
});
shared/client.ts
// MIT License — Copyright (c) 2026 Oveyon.
export type Transport = (url: string, init: RequestInit) => Promise<Response>;
export type SendPayload = { from: string; to: string; subject?: string; text?: string; templateId?: string; version?: number; data?: Record<string, unknown> };
export type Options = { apiKey: string; idempotencyKey: string; transport?: Transport; wait?: (ms: number) => Promise<void>; attempts?: number };
export class ApiError extends Error {
status: number;
code: string;
retryable: boolean;
constructor(status: number, code: string, retryable: boolean) {
super(`Oveyon request failed: ${status} ${code}`);
this.status = status; this.code = code; this.retryable = retryable;
}
}
export function requestForSend(payload: SendPayload, apiKey: string, key: string) {
if (!apiKey || /[\r\n]/.test(apiKey)) throw new TypeError('A server-side API key is required.');
if (!/^[\x21-\x7e]{1,190}$/.test(key)) throw new TypeError('Idempotency key must be 1–190 printable ASCII characters without spaces.');
if (!payload.from || !payload.to) throw new TypeError('from and to are required.');
if (payload.templateId && (payload.subject !== undefined || payload.text !== undefined)) throw new TypeError('A template cannot be combined with subject/text.');
if (!payload.templateId && (!payload.subject || !payload.text)) throw new TypeError('subject and text are required by this example.');
return {
url: 'https://api.oveyon.com/v1/send',
init: { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'Idempotency-Key': key, 'X-Oveyon-Sandbox': '1' }, body: JSON.stringify(payload), redirect: 'error' as const },
};
}
export async function sendMessage(payload: SendPayload, options: Options) {
const request = requestForSend(payload, options.apiKey, options.idempotencyKey);
const transport = options.transport ?? fetch;
const wait = options.wait ?? ((ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms)));
const attempts = options.attempts ?? 3;
if (!Number.isInteger(attempts) || attempts < 1 || attempts > 3) throw new TypeError('Use 1–3 attempts.');
for (let attempt = 0; attempt < attempts; attempt++) {
let response: Response;
try {
response = await transport(request.url, { ...request.init, signal: AbortSignal.timeout(10_000) });
} catch {
if (attempt + 1 === attempts) throw new ApiError(0, 'network_result_unknown', true);
await wait(1000 * 2 ** attempt);
continue;
}
let result: any;
try { result = await response.json(); }
catch { throw new ApiError(response.status, 'invalid_response', false); }
if (response.status === 200 && result.status === 'duplicate' && typeof result.id === 'string') return result;
if (response.status === 202 && ['accepted', 'sandbox', 'held'].includes(result.status) && typeof result.id === 'string') return result;
if (response.ok) throw new ApiError(response.status, 'unexpected_response', false);
const code = typeof result.error === 'string' ? result.error : 'unknown_error';
const retryable = response.status === 429 || response.status >= 500 || (response.status === 409 && code === 'idempotency_in_progress');
const error = new ApiError(response.status, code, retryable);
if (!retryable || attempt + 1 === attempts) throw error;
const retryAfter = response.headers.get('Retry-After');
const advertised = retryAfter === null ? NaN : /^\d+$/.test(retryAfter) ? Number(retryAfter) * 1000 : Date.parse(retryAfter) - Date.now();
const delay = Number.isFinite(advertised) ? Math.max(0, advertised) : 1000 * 2 ** attempt;
// Long waits belong in a durable job queue. Do not retry earlier than requested.
if (delay > 30_000) throw error;
await wait(delay);
}
throw new Error('Unreachable attempt state.');
}
export function cliOptions(transport: Transport) {
const mode = process.env.OVEYON_MODE ?? 'mock';
if (!['mock', 'sandbox'].includes(mode)) throw new TypeError('OVEYON_MODE supports mock or sandbox only.');
if (mode === 'sandbox' && !process.env.OVEYON_API_KEY) throw new TypeError('Set OVEYON_API_KEY on the backend before an explicit sandbox run.');
return { mode, apiKey: mode === 'mock' ? 'mock-key-not-a-credential' : process.env.OVEYON_API_KEY!, transport: mode === 'mock' ? transport : fetch };
}
export function messageAddresses() {
return { from: process.env.OVEYON_FROM ?? 'Demo <[email protected]>', to: process.env.OVEYON_TO ?? '[email protected]' };
}
shared/mock.ts
// Synthetic, in-memory API fixture. No HTTP request or email is sent.
import type { Transport } from './client.ts';
export function mockApi(failures: number[] = []) {
const requests: { url: string; init: RequestInit }[] = [];
const messages = new Map<string, { body: string; id: string }>();
const templates = new Map<string, { version: number; published: boolean }>();
const transport: Transport = async (url, init) => {
requests.push({ url, init });
const forced = failures.shift();
if (forced) return Response.json({ error: forced === 429 ? 'rate_limited' : 'injection_failed' }, { status: forced, headers: { 'Retry-After': '0' } });
const route = new URL(url).pathname;
const data = JSON.parse(String(init.body ?? '{}'));
if (route.endsWith('/templates')) {
if (templates.has(data.name)) return Response.json({ error: 'template_exists' }, { status: 409 });
templates.set(data.name, { version: 1, published: false });
return Response.json({ id: 1, name: data.name, version: 1, status: 'draft', vars: [{ name: 'name', kind: 'var', required: true }] }, { status: 201 });
}
if (route.endsWith('/publish')) {
const name = decodeURIComponent(route.split('/').at(-2)!);
const template = templates.get(name);
if (!template) return Response.json({ error: 'template_not_found' }, { status: 404 });
template.published = true;
return Response.json({ id: 1, name, version: 1, status: 'published' });
}
if (data.templateId && !templates.get(data.templateId)?.published) return Response.json({ error: 'template_not_found' }, { status: 422 });
const key = new Headers(init.headers).get('Idempotency-Key')!.toLowerCase();
const known = messages.get(key);
if (known) return known.body === init.body ? Response.json({ id: known.id, status: 'duplicate' }) : Response.json({ error: 'idempotency_conflict' }, { status: 409 });
const id = `mock-message-${String(messages.size + 1).padStart(4, '0')}`;
messages.set(key, { body: String(init.body), id });
return Response.json({ id, status: 'sandbox' }, { status: 202 });
};
return { requests, transport };
}
Failures to handle
- 401 or 403: correct backend credentials/scopes; do not retry unchanged.
- 422: inspect the stable error code and correct sender, recipient or policy.
- 429, 5xx, network uncertainty or 409 idempotency_in_progress: retry at most three times with the same key and body. Honor Retry-After; waits over 30 seconds return to the caller for durable scheduling.
Limits & operational decisions
- Only local fixtures were executed; an authenticated API response has not been observed.
- Every executable send path forces X-Oveyon-Sandbox: 1. Sandbox consumes quota/rate limits and does not deliver.
- The API idempotency window is 24 hours. This minimal recipe does not persist its event key across application restarts; use the order recipe for durable application state.
Copy for your agent
The context package contains this recipe’s complete files, prerequisites, execution effects, tests, limitations and sources. It is task information, with no hidden instructions.
Sources & verification
Maintained by Oveyon, the provider used in these examples. This relationship is commercial and explicit. Code is MIT licensed; no independent endorsement is implied.
Next step
Run the mock tests first. For an authenticated integration, create a key with the permissions stated in the recipe and verify the current contract in the official reference. An API acceptance response is not proof of inbox placement.