@vennyx/mila
v0.5.2
Published
Official TypeScript/JavaScript client for the mila REST API
Readme
@vennyx/mila
Official TypeScript/JavaScript client for the mila REST API.
Requirements: Node.js only — attachment encoding relies on
Buffer, so this package will not run in a browser or edge runtime as-is.
Install
npm install @vennyx/milaUsage
import { MilaClient } from '@vennyx/mila';
const client = new MilaClient({ apiKey: process.env.MILA_API_KEY! });
const domains = await client.domains.list();
const mailbox = await client.mailboxes.create(domains[0].id, {
local_part: 'support',
password_method: 'invitation',
password_recovery_email: '[email protected]',
});
await client.emails.send({
from: mailbox.address,
to: ['[email protected]'],
subject: 'Hello from mila',
text: 'Sent via @vennyx/mila.',
attachments: [{ filename: 'invoice.pdf', content: fs.readFileSync('./invoice.pdf') }],
});By default the client talks to https://api.mila.cx. Pass baseUrl to override it.
Works from both ESM and CommonJS:
import { MilaClient } from '@vennyx/mila'; // ESM
const { MilaClient } = require('@vennyx/mila'); // CommonJSCommonJS support is not cosmetic: a CJS-compiled TypeScript project cannot import an ESM-only package even at the type level (TypeScript reports TS1479), which is what a Medusa plugin, and anything else not yet on ESM, builds as.
Upgrading inside a monorepo
If npm install reports "up to date" but leaves you on the old version, the
lockfile is the thing holding it — not the cache. In a workspace repo the root
package-lock.json pins @vennyx/mila with the previous version's resolved
and integrity, and it wins over what a workspace's package.json asks for.
Measured, on a real upgrade that got stuck: --prefer-online, npm cache clean
--force, deleting node_modules/@vennyx/mila, and even npm install
@vennyx/mila@<version> --workspace=<name> all failed to move it. They refresh
the cache or the install tree; none of them re-resolves the pinned entry.
What works is deleting the @vennyx/mila entries from the root lockfile and
installing again. To check where you actually are:
npm ls @vennyx/mila # every copy, and their versions
node -p "require('@vennyx/mila/package.json').version" # what your code will loadError handling
Every non-2xx response throws a MilaApiError:
import { MilaApiError } from '@vennyx/mila';
try {
await client.mailboxes.get(domainId, 'not-a-real-id');
} catch (err) {
if (err instanceof MilaApiError) {
console.error(err.status, err.message);
}
}Resources
account, domains, mailboxes, aliases, catchall, rewrites,
identities, forwardings, emails.
Check the key before you trust it
A key from the wrong account does not look wrong. It authenticates, and
client.domains.list() returns a healthy-looking list — somebody else's.
Nothing fails until the first real send.
const { account, apiKey } = await client.account.get();
console.log(account.name); // "INKLEDER" — show this and have a human confirm it
console.log(apiKey.prefix); // "mila_ab12" — which key was pasted, never the secret
console.log(apiKey.scopes); // ["mail:send"] — can it do what you need?
console.log(apiKey.domainAccess); // "all" | "limited"If you are building a setup screen, show account.name. "The key is valid"
means nothing to someone who is not a developer; their company's name means
everything.
This needs no particular scope — whatever key the user pasted can ask, keys minted before scopes existed included.
When a send is refused because the account does not hold the sender's domain, the error carries a machine-readable code:
import { isMilaApiError } from '@vennyx/mila';
try {
await client.emails.send({ from: '[email protected]', /* ... */ });
} catch (err) {
if (isMilaApiError(err) && err.code === 'SENDER_DOMAIN_NOT_OWNED') {
// wrong account's key — say so in your own words
}
}Use isMilaApiError(err), not err instanceof MilaApiError. This package is
ESM-only, and instanceof is unreliable the moment two copies of the class
exist — a CommonJS consumer, a bundler that inlined one, or two versions in the
tree. The guard checks the error's shape instead, so it works in all of those.
Note for Medusa integrations: Medusa's notification module catches the
provider's error and re-throws a new one carrying only the message, so code
and cause are both gone before your code sees them. This was traced through a
real integration — provider → workflow step → notification module — not
inferred; carrying the code through your own provider and workflow step does not
help, because the loss happens after both. Calling client.emails.send directly
is unaffected, and so is any framework that re-throws the original error.
Adding a domain
The full self-serve lifecycle (admin-role API key required):
const domain = await client.domains.create({ name: 'example.com' }); // state: "pending"
const records = await client.domains.dnsRecords(domain.id); // what to publish, incl. the verification TXT
const checks = await client.domains.diagnostics(domain.id); // live expected-vs-found DNS check
if (checks.verification.ok && checks.mx.ok) {
await client.domains.activate(domain.id); // server re-checks DNS itself; 422 with the failing blocks otherwise
}