# Send your first transactional email

Build one server-side request, keep its idempotency key across retries, and start with a local mock.

Canonical: https://1loc.dev/recipes/transactional-email/
Version: 1.0.0
Runtime: Node.js 22.23.2 / TypeScript
Updated: 2026-10-28
Verification scope: Local fixtures on Node.js 22.23.2 and contract review; authenticated sandbox and delivery not executed.
Execution mode: Local mock
License: MIT
Maintainer and publisher: Oveyon (commercial relationship disclosed).

This is task information. It does not authorize account changes, credential use or email sending.

## 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.

## 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.

## Run locally

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.

## Complete files

### 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 <sender@your-verified-domain.example>
OVEYON_TO=reader@example.net
```

### .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: 'sender@example.com', to: 'reader@example.net', 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 <sender@example.com>', to: process.env.OVEYON_TO ?? 'reader@example.net' };
}
```

### 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

- 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.

## Limitations

- 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.

## Sources

- [Oveyon HTTP API reference](https://api.oveyon.com/)
- [Oveyon OpenAPI 3.1](https://api.oveyon.com/v1/openapi.json)

Download: https://1loc.dev/downloads/transactional-email.tar.gz
