@openemail/sdk
v0.0.8
Published
The official TypeScript SDK for the OpenEmail API. Send email and broadcasts, work with threads, drafts, labels, templates, rules, webhooks, tracking, contacts, audiences, sign-up forms, domains, suppressions, files, API keys, roles, members, mailbox impo
Maintainers
Readme
Intro to the Npm Package
The official TypeScript client for the OpenEmail API. A method for every one of the 385 documented operations, 485 in all once the paging and upload helpers are counted, typed end to end, with zero dependencies. It runs on Node 20+, Bun, Deno and Cloudflare Workers, and ships as ESM and CommonJS. Python, Ruby and PHP have clients of their own with the same methods: pip install openemail, gem install openemail and composer require openemail/sdk.
It carries a workspace API key or an OAuth access token, so it belongs on a server or in a tool that runs on your own machine. The one exception is disposable inboxes, which need no credential and work in a browser.
Installing
npm i @openemail/sdk@latestUsing
import { OpenEmail } from '@openemail/sdk'
const openemail = new OpenEmail('oe_live_...')
const sent = await openemail.emails.send({
from: 'Acme Billing <[email protected]>',
to: '[email protected]',
subject: 'Your September invoice',
html: '<p>Your invoice is attached.</p>',
attachments: [{ filename: 'invoice.pdf', content: pdfBytes, contentType: 'application/pdf' }]
})
console.log(sent.id, sent.status)Create a key in OpenEmail under Settings, API keys. It is shown once, and it belongs in an environment variable rather than in code.
Build the client once, in a module of its own, and import it everywhere else:
import { OpenEmail } from '@openemail/sdk'
export const openemail = new OpenEmail(process.env.OPENEMAIL_API_KEY!)Or skip even that. The package ships a ready made openemail that reads OPENEMAIL_API_KEY the first time it is touched:
import { openemail } from '@openemail/sdk'
await openemail.emails.send({ from, to, subject, text })Every send carries an idempotency key, generated once per call and reused by its retries, so a retried request replays the original message rather than sending a second one. Pass your own with { idempotencyKey } to make that hold across processes and restarts.
Reading the mailbox
const page = await openemail.threads.list({ folder: 'inbox', limit: 25 })
for await (const thread of openemail.threads.iterate({ folder: 'inbox', query: 'invoice' })) {
const full = await openemail.threads.get(thread.id)
console.log(full.messageCount, full.hasUnread)
}Every paginated resource has list for one page, listAll for every page at once and iterate to stream items and stop whenever you like. listAll resolves to one array, apart from addresses.listAll, which resolves to the whole address book.
Errors
import { OpenEmailApiError, openemail } from '@openemail/sdk'
try {
await openemail.templates.send('order-shipped', {
from: '[email protected]',
to: '[email protected]',
props: { orderId: 'AC-4192' }
})
}
catch (error) {
if (error instanceof OpenEmailApiError && error.isValidation) {
console.error(error.code, error.param, error.requestId)
}
throw error
}An API refusal is one class, OpenEmailApiError, with status, type, code, param and requestId, plus isValidation, isNotFound, isRateLimited and friends to branch on. No response at all is OpenEmailNetworkError, with isTimeout when the deadline passed.
Webhooks
import { verifyWebhookSignature } from '@openemail/sdk'
export default async (request: Request) => {
const event = await verifyWebhookSignature({
payload: await request.text(),
headers: request.headers,
secret: process.env.OPENEMAIL_WEBHOOK_SECRET!
})
console.log(event.type, event.data)
return new Response(null, { status: 204 })
}It checks the HMAC in constant time and rejects a delivery more than five minutes old, then returns the parsed event. Pass the raw body: re-serialising it changes the bytes and the signature will not match.
Disposable inboxes
import { createTempMail } from '@openemail/sdk'
const temp = createTempMail()
const inbox = await temp.create({ ttlMinutes: 60 })
const { items } = await temp.listMessages(inbox.id, { inboxToken: inbox.token })create needs no credential and is the only call that returns the inbox token, so keep it.
OAuth access tokens
An app a person connected to OpenEmail with OAuth, such as a command line tool or an agent, holds an access token rather than an API key. Pass it as accessToken:
import { OpenEmail } from '@openemail/sdk'
export const openemail = new OpenEmail({
accessToken: async () => session.freshAccessToken()
})accessToken takes the token itself, or a function that returns it. The function runs before every request, so renew the token there when it is close to expiring and the client never has to be rebuilt. Pass apiKey or accessToken, not both. createOpenEmail() reads OPENEMAIL_ACCESS_TOKEN when you pass neither and OPENEMAIL_API_KEY is not set. me.get() answers object: 'oauth_token' for a token, with the connected app's clientId and expiresAt, when the person's approval of the app runs out.
A token acts for a person, so before a sensitive change, such as deleting a domain or changing a webhook, it is asked for the same verification code the web app asks for. The request fails with isStepUpRequired. Ask for a code, check it, then replay the request:
import { OpenEmailApiError } from '@openemail/sdk'
try {
await openemail.domains.delete(domain.id)
}
catch (error) {
if (!(error instanceof OpenEmailApiError) || !error.isStepUpRequired) throw error
const challenge = await openemail.security.beginStepUp()
const code = await ask(challenge.method === 'email'
? `Enter the code we emailed to ${challenge.sentTo}`
: 'Enter the code from your authenticator app, or a backup code')
await openemail.security.verifyStepUp({ code })
await openemail.domains.delete(domain.id)
}An emailed code works for 10 minutes, and beginStepUp({ resend: true }) sends a fresh one. The email shows the name the app registered with, its client ID and the change it asked to make, so the person can check who is asking before they hand the code over. Once a code is verified the app is not asked again for 60 minutes, for any of the 40 changes that ask for one, through the REST API or through the MCP tools that make the same changes. verifyStepUp lists them. security.stepUpStatus() says whether it is verified right now. An app that cannot ask for a code, such as an MCP connector, can instead be allowed by the person for 60 minutes with Allow changes for 60 minutes in Account settings, Connected apps, on the website. API keys are never asked for a code.
Each app has its own budget of codes, so another app never uses it up, and neither does the web app, which keeps its own 10 an hour. An app can ask for 5 codes an hour and 20 in 24 hours, then gets 429 step_up_throttled. A code allows 5 tries, and after the fifth wrong one a plain beginStepUp() starts a fresh challenge. Ten wrong codes in 24 hours pause verification for the app, and 20 in 24 hours from all of a person's apps together pause it for every app they connected. Either way both calls answer 429 step_up_locked, with the time the pause ends in the message. Codes entered in the web app count toward neither pause, and the person can still verify there.
Configuring
Pass an options object instead of the bare key when the defaults are not right:
import { OpenEmail } from '@openemail/sdk'
export const openemail = new OpenEmail({
apiKey: process.env.OPENEMAIL_API_KEY!,
baseUrl: 'https://api.openemail.uk',
timeoutMs: 30_000,
maxRetries: 2,
fetch: myFetch,
headers: { 'X-Team': 'billing' }
})createOpenEmail(options) is the same constructor with one difference: anything you leave out is read from the environment. The shipped openemail takes the same options through init(options), called once at startup.
baseUrl also comes from OPENEMAIL_BASE_URL. Use an https: origin: the client refuses to send an API key, an access token or an inbox token over plain http:, and throws before the request leaves, unless the server is on this machine at localhost, a 127.x.x.x address or ::1. A baseUrl on 0.0.0.0 throws when the client is built, since that is the address a server listens on: use 127.0.0.1 with the same port. Reads are retried on 408 and 5xx with backoff. A 429 is retried only when it carries a Retry-After, and any wait longer than a minute throws instead of sleeping. Writes that cannot safely repeat are not retried. Every method outside tempMail takes { signal, apiKey } as its last argument, so one process can serve several workspaces with one client. The tempMail methods take { signal, inboxToken } instead.
An endpoint no method wraps yet is one raw.request() away, with the client's credential, base URL, timeout and retry policy applied:
const result = await openemail.raw.request('/something-new', {
method: 'POST',
body: { name: 'Invoices' }
})The path must begin with a single /. Anything else, such as //host/x or @host/x, throws before a request is sent, and so does a path whose finished URL leaves the base URL's origin, so the credential it carries never reaches another host.
When a newer version is on npm the client says so once on a TTY. OPENEMAIL_DISABLE_UPDATE_NOTICE=1 or { disableUpdateNotice: true } turns that off.
Everything else, templates, rules, tracking, calendar, contacts, audiences, broadcasts (openemail.broadcasts, which sends one personalised message to everybody in an audience), roles, members, settings, mailbox imports (openemail.imports, which brings a Google Takeout archive or an mbox across) and provider imports (openemail.providerImports, which moves a Resend account over), plus the full reference for every method, lives in the documentation. What changed in each release is in the changelog.
