CloudflareEmail - TundraSoft/tundra-connect GitHub Wiki
Typed client for Cloudflare's Email Sending REST API
(POST /accounts/{account_id}/email/sending/send) for Deno, Bun, Node.js and
Cloudflare Workers. Send transactional email with attachments. Addresses,
attachments and recipient limits are validated locally before anything leaves
your process.
Cloudflare Email Service can send mail two ways: the Workers send_email
binding, and this REST endpoint. This connect wraps the REST endpoint —
the binding is a Workers-runtime capability that cannot run on Deno, Bun or
Node, whereas the REST API runs everywhere this repo targets.
Authentication is a plain Bearer API token carrying the Send Email
permission, so no _authInjector override is needed. Cloudflare's standard
{success, errors, messages, result} envelope is unwrapped for you: send
resolves to the result payload, and a failure — including success: false
on an HTTP 200 — is raised as a CloudflareEmailError carrying the vendor's
own numeric code.
Beta: Email Sending is a Workers Paid beta. An account without the entitlement fails every send with
ACCOUNT_NOT_ENTITLED.
import { CloudflareEmail } from '@tundraconnect/cloudflare-email';
const client = new CloudflareEmail({
accountId: 'YOUR_ACCOUNT_ID',
auth: { type: 'BEARER', token: 'YOUR_API_TOKEN', prefix: 'Bearer' },
});
const result = await client.send({
from: '[email protected]',
to: '[email protected]',
subject: 'Welcome to our service!',
html: '<h1>Welcome!</h1><p>Thanks for signing up.</p>',
text: 'Welcome! Thanks for signing up.',
});
console.log(result.delivered, result.queued);Your tests don't need to fake HTTP. Have your code take a CloudflareEmail instance
and pass in a stand-in that returns the shapes from
@tundraconnect/cloudflare-email/schemas or throws a real CloudflareEmailError:
import { CloudflareEmailError } from '@tundraconnect/cloudflare-email/errors';
const outage = new CloudflareEmailError('SERVICE_UNAVAILABLE', { status: 503 });
console.log(outage.code);To exercise the client itself against a fake transport, subclass it and
reassign the protected _fetch.
| Topic | Description |
|---|---|
| API | Client configuration and endpoint methods |
| Errors | Error codes and diagnostic metadata |
| Schemas | Public Guardian schemas and inferred types |
- Email Sending REST API
- Send an email — API reference
- Cloudflare Email Service docs
- Create a Cloudflare account
Deno:
deno add jsr:@tundraconnect/cloudflare-emailBun:
bunx jsr add @tundraconnect/cloudflare-emailNode.js:
npx jsr add @tundraconnect/cloudflare-emailimport { CloudflareEmail } from '@tundraconnect/cloudflare-email';
import { CloudflareEmailError } from '@tundraconnect/cloudflare-email/errors';
const client = new CloudflareEmail({
accountId: Deno.env.get('CF_ACCOUNT_ID')!,
auth: {
type: 'BEARER',
token: Deno.env.get('CF_API_TOKEN')!,
prefix: 'Bearer',
},
});
try {
const result = await client.send({
// "Name <address>" is sent as { address, name } — a display name.
from: 'Billing <[email protected]>',
// A single address works too — both forms appear in Cloudflare's own docs.
to: [{ address: '[email protected]', name: 'Jane Customer' }],
cc: '[email protected]',
reply_to: '[email protected]',
subject: 'Your invoice',
text: 'Your invoice is attached.',
headers: { 'X-Campaign-ID': 'invoices' },
attachments: [{
content: btoa('invoice body'), // base64, NOT a data: URI
filename: 'invoice.txt',
type: 'text/plain',
}],
});
console.log('delivered:', result.delivered);
console.log('queued:', result.queued);
console.log('bounced:', result.permanent_bounces);
} catch (err) {
if (err instanceof CloudflareEmailError) {
// Branch on the stable code name, never on a message substring.
if (err.code === 'RATE_LIMITED') {
// back off and retry
}
console.error(err.code, err.getContextValue('vendorCode'));
}
throw err;
}| Rule | Raised as |
|---|---|
At least one of html / text
|
REQUEST_VALIDATION_ERROR |
to + cc + bcc ≤ 50 recipients |
REQUEST_VALIDATION_ERROR |
Valid from / to / cc / bcc / reply_to addresses |
REQUEST_VALIDATION_ERROR |
Attachment content is base64 |
REQUEST_VALIDATION_ERROR |
The 5 MiB total message size is enforced by Cloudflare, not locally — it
depends on the whole encoded message — and surfaces as MESSAGE_TOO_LARGE.
MIT. Part of Tundra Connect, typed vendor API clients for Deno, Bun, Node.js and Cloudflare Workers.