Send a welcome email from a pinned template
Create a draft, publish an explicit version, and send data against that immutable version.
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.
- 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.
Run the local example
- Run npm run mock to execute a synthetic create → publish → sandbox-send flow entirely in memory.
- In an authorized backend environment, npm run draft creates a real draft; npm run publish makes version 1 current. These are separate account mutations.
- 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.
The complete files
These exact files are included in the starter. Test fixtures use synthetic addresses and mock identifiers.
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 <[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.
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('[email protected]', '[email protected]');
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 <[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
- 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 & operational decisions
- 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.
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.