@zeroxsolutions/mail
v0.1.0
Published
The mail seam: a composed message, the port that sends it, its typed refusals, and the Cloudflare Email Sending adapter behind that port. Knows nothing of templates, locales or delivery records - those stay in the product.
Readme
@zeroxsolutions/mail
The mail seam: a composed message, the port that sends it, its typed refusals, and the Cloudflare Email Sending adapter behind that port.
What it does not own is the reason mail exists at all - templates, message keys, locales, delivery records, queues and retries are product decisions and stay in the product. This package takes a message someone else composed and hands back the id the provider filed it under.
The concern is named mail rather than messaging because one channel exists today. A second one
is what decides whether this splits or grows a subpath.
Install
pnpm add @zeroxsolutions/mailThe port
import type { IMailSender, MailMessage } from '@zeroxsolutions/mail';
const { messageId } = await sender.send({
from: { email: '[email protected]', name: 'Example' },
to: '[email protected]',
subject: 'Verify your email',
text: 'Open the link.',
html: '<p>Open the link.</p>',
});text and html are both required where the provider's own builder makes each optional: a message
with no plain-text alternative is filed as spam by a large share of receivers. from.name is the
one optional field, and omitting it is not the same as passing undefined - a bare address goes on
the envelope where a name-and-address pair would put an empty display name.
Cloudflare adapter
import { CloudflareMailSender } from '@zeroxsolutions/mail/cloudflare';
const sender = new CloudflareMailSender(env.EMAIL);The binding is passed in, so nothing here reads env or a global: the adapter is a plain class and
its spec runs under node with a fake. @cloudflare/workers-types is an optional peer - a consumer
that never imports ./cloudflare does not need it installed.
Cloudflare refuses a recipient the account has not onboarded. That is the account's own sending
state and not something this package can check, so it surfaces as MailRecipientNotAllowed.
Refusals
A refusal is one plain Error subclass per meaning a caller acts on differently - no wire status, no
code, no envelope. A caller maps them by class.
| Class | What happened |
| --- | --- |
| MailRecipientSuppressed | the recipient is on the provider's suppression list |
| MailRecipientNotAllowed | the account may not send to this recipient |
| MailSenderNotVerified | the sending address or its domain is not verified |
| MailMessageRejected | the message as composed breaks a provider rule; resending it unchanged fails again |
| MailRateLimited | the provider is throttling the send rate |
| MailDailyLimitExceeded | the daily sending quota is spent |
| MailDeliveryFailed | the provider could not deliver the message |
| MailProviderUnavailable | the provider failed on its own side |
| MailSendFailed | a refusal none of the above names |
The Cloudflare adapter translates every code the Email Sending binding documents onto one of these
through one table, and a code it does not know raises MailSendFailed. Each class extends Error
directly, so a caller catching MailSendFailed alone sees only the refusals nothing else names.
The cause often names the recipient ("no such mailbox [email protected]"), so a caller logs the
class, never cause and never String(error). Every class's own message is a constant for exactly
this reason, and a spec holds it that way.
Building & testing
pnpm nx build @zeroxsolutions/mail
pnpm nx test @zeroxsolutions/mail