# Send a welcome email from a pinned template

Create a draft, publish an explicit version, and send data against that immutable version.

Canonical: https://1loc.dev/recipes/versioned-template/
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).

## Prerequisites

- Node.js 22.23.2; no third-party packages.
- For account mutations: a backend key with write:templates. For sandbox sending: send scope and a verified sender.
- Use a template name owned by your application; this example uses 1locWelcomeDemo.

## Permissions and effects

Mock mode has no network or account effects. draft/publish mutate templates with write:templates; the separate sandbox command uses send and consumes quota/rate without delivery.

## Run locally

1. Run npm run mock to execute a synthetic create → publish → sandbox-send flow entirely in memory.
2. In an authorized backend environment, npm run draft creates a real draft; npm run publish makes version 1 current. These are separate account mutations.
3. After reviewing the published template, npm run sandbox sends only templateId, version and data, plus sender/recipient.

Expected result: A version-1 published template is selected explicitly. The send body uses version: 1, not templateVersion, and never mixes a template with subject/text.

## Complete files

### README.md

````
# Send a welcome email from a pinned template

Maintained by Oveyon. MIT licensed. Version 1.0.0.
Canonical: https://1loc.dev/recipes/versioned-template/

Create a draft, publish an explicit version, and send data against that immutable version.

## 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.
- For account mutations: a backend key with write:templates. For sandbox sending: send scope and a verified sender.
- Use a template name owned by your application; this example uses 1locWelcomeDemo.

## Procedure

1. Run npm run mock to execute a synthetic create → publish → sandbox-send flow entirely in memory.
2. In an authorized backend environment, npm run draft creates a real draft; npm run publish makes version 1 current. These are separate account mutations.
3. After reviewing the published template, npm run sandbox sends only templateId, version and data, plus sender/recipient.

## Expected result

A version-1 published template is selected explicitly. The send body uses version: 1, not templateVersion, and never mixes a template with subject/text.

## Permissions and effects

Mock mode has no network or account effects. draft/publish mutate templates with write:templates; the separate sandbox command uses send and consumes quota/rate without delivery.

Optional backend command(s): npm run draft; npm run publish; 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

- template_not_found: the template or chosen published version is missing; creation alone does not publish.
- template_var_missing: supply the declared root variables in data.
- Template mutation failure: inspect the current draft/version before repeating; mutations do not use the send retry helper.

## Limits and verification

- Only local fixture tests and contract review were completed; no authenticated template was created or published.
- The sandbox header applies to sending. Creating and publishing templates change the account and are not simulated by that header.
- The starter exposes those mutations only through explicit draft/publish commands. Published version 1 stays pinned until the application is deliberately changed.

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-versioned-template",
  "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 versioned-template/main.ts",
    "test": "node --experimental-strip-types --test versioned-template/recipe.test.ts",
    "draft": "OVEYON_MODE=sandbox node --env-file-if-exists=.env --experimental-strip-types versioned-template/main.ts --create-draft",
    "publish": "OVEYON_MODE=sandbox node --env-file-if-exists=.env --experimental-strip-types versioned-template/main.ts --publish-v1",
    "sandbox": "OVEYON_MODE=sandbox node --env-file-if-exists=.env --experimental-strip-types versioned-template/main.ts --send-sandbox"
  }
}
```

### package-lock.json

```
{
  "name": "1loc-versioned-template",
  "version": "1.0.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "1loc-versioned-template",
      "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.
```

### versioned-template/main.ts

```
import { pathToFileURL } from 'node:url';
import { cliOptions, messageAddresses, sendMessage } from '../shared/client.ts';
import { mockApi } from '../shared/mock.ts';
import { templateAction, templateSend } from './template.ts';
export async function run() {
  const options = cliOptions(mockApi().transport);
  if (options.mode === 'mock') {
    await templateAction('create', options.apiKey, options.transport);
    await templateAction('publish', options.apiKey, options.transport);
  } else if (process.argv.includes('--create-draft')) {
    console.log(JSON.stringify(await templateAction('create', options.apiKey, options.transport))); return;
  } else if (process.argv.includes('--publish-v1')) {
    console.log(JSON.stringify(await templateAction('publish', options.apiKey, options.transport))); return;
  } else if (!process.argv.includes('--send-sandbox')) {
    throw new Error('Choose --create-draft, --publish-v1, or --send-sandbox explicitly. Template changes are real account mutations.');
  }
  const { from, to } = messageAddresses();
  const result = await sendMessage(templateSend(from, to), { ...options, idempotencyKey: '1loc:template:demo-001:v1' });
  console.log(JSON.stringify({ mode: options.mode, result, meaning: 'Pinned published version; sandbox does not deliver' }, null, 2));
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await run();
```

### versioned-template/template.ts

```
import type { Transport } from '../shared/client.ts';
export function templateDraft() { return { name: '1locWelcomeDemo', subject: 'Welcome, {{name}}', text: 'Hello {{name}}. Your demo workspace is ready.' }; }
export function templateSend(from: string, to: string) { return { from, to, templateId: '1locWelcomeDemo', version: 1, data: { name: 'Demo reader' } }; }
export async function templateAction(action: 'create' | 'publish', key: string, transport: Transport = fetch) {
  if (!key) throw new TypeError('Server-side API key required.');
  const path = action === 'create' ? '/templates' : '/templates/1locWelcomeDemo/publish';
  const result = await transport(`https://api.oveyon.com/v1${path}`, { method: 'POST', headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }, body: JSON.stringify(action === 'create' ? templateDraft() : { version: 1 }), redirect: 'error', signal: AbortSignal.timeout(10_000) });
  if (!result.ok) throw new Error(`Template ${action} failed with HTTP ${result.status}; inspect the template before repeating a mutation.`);
  return result.json();
}
```

### versioned-template/recipe.test.ts

```
import test from 'node:test';
import assert from 'node:assert/strict';
import { templateDraft, templateAction, templateSend } from './template.ts';
import { mockApi } from '../shared/mock.ts';
import { sendMessage } from '../shared/client.ts';
test('draft, publish and pinned send are three separate operations', async () => {
  const api = mockApi(); const payload = templateSend('sender@example.com', 'reader@example.net');
  await assert.rejects(sendMessage(payload, { apiKey: 'fixture', idempotencyKey: 'welcome:1', transport: api.transport }), /template_not_found/);
  await templateAction('create', 'fixture', api.transport);
  await assert.rejects(sendMessage(payload, { apiKey: 'fixture', idempotencyKey: 'welcome:1', transport: api.transport }), /template_not_found/);
  await templateAction('publish', 'fixture', api.transport);
  assert.equal((await sendMessage(payload, { apiKey: 'fixture', idempotencyKey: 'welcome:1', transport: api.transport })).status, 'sandbox');
  assert.equal(payload.version, 1); assert.equal('templateVersion' in payload, false); assert.equal('subject' in payload, false);
  assert.match(templateDraft().text, /\{\{name\}\}/);
});
```

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

- template_not_found: the template or chosen published version is missing; creation alone does not publish.
- template_var_missing: supply the declared root variables in data.
- Template mutation failure: inspect the current draft/version before repeating; mutations do not use the send retry helper.

## Limitations

- Only local fixture tests and contract review were completed; no authenticated template was created or published.
- The sandbox header applies to sending. Creating and publishing templates change the account and are not simulated by that header.
- The starter exposes those mutations only through explicit draft/publish commands. Published version 1 stays pinned until the application is deliberately changed.

## 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/versioned-template.tar.gz
