pyxle-mail
pyxle-mail is Pyxle's official email plugin: one mail.service your loaders, actions, and API routes call as await mail.send(...), delivered over SMTP, Resend, or any backend satisfying the MailProvider contract. You write the send once and choose the provider by config, not code. With no configuration it logs the email instead of sending it, so local development works with zero setup.
Version 0.1.0. SMTP and the console provider need nothing beyond the package; Resend ships as an extra. It depends on no other plugin — no database, no auth — so its position in the
pluginslist doesn't matter.
Install
pip install pyxle-mail # SMTP + console, zero extra deps
pip install "pyxle-mail[resend]" # + httpx, for the Resend providerQuickstart
Add the plugin to pyxle.config.json (see the plugins guide for how plugin loading works):
{
"plugins": ["pyxle-mail"]
}That's the whole wire-up. At startup the plugin builds a provider from your settings and registers the mail.service. With no configuration it registers the console provider — it logs a summary and sends nothing, so there's no account, key, or SMTP server to stand up for local work.
Send from any loader, action, or API route through get_mail_service():
from pyxle.runtime import action
from pyxle_mail import get_mail_service
@action
async def invite(request):
await get_mail_service().send(
to="[email protected]",
subject="You're invited",
html="<p>Welcome aboard.</p>",
text="Welcome aboard.",
)
return {"ok": True}Configure a provider (below) when you want real delivery.
Plugin services
The plugin registers two services:
| Service name | Type | What it does |
|---|---|---|
mail.service |
MailService |
The consumer-facing surface — builds, validates, and delivers messages. |
mail.settings |
MailSettings |
The resolved configuration. |
get_mail_service() is the typed shortcut for the first; it raises pyxle.plugins.PluginServiceError if pyxle-mail isn't in your plugins list.
Providers
A provider is what actually puts a message on the wire. The service wraps one provider and is what your app calls; swapping providers is a config change, never a code change.
| Provider | When | Needs |
|---|---|---|
console |
Local dev, dry-run — logs a summary, sends nothing. The default. | nothing |
smtp |
Any mail server (Gmail, Fastmail, MailHog, a corporate relay). | host (+ usually user/password) |
resend |
Resend — modern transactional API. | pyxle-mail[resend] + API key |
dryRun: true forces the console provider regardless of provider — the safe switch for staging or a first deploy, where you want the wiring exercised but no mail to actually leave.
Example: Resend in production
PYXLE_MAIL_PROVIDER=resend
PYXLE_MAIL_RESEND_API_KEY=re_...
[email protected]
PYXLE_MAIL_FROM_NAME="Your App"Resend requires a verified sender domain (SPF/DKIM DNS records) before it will deliver from anything but its test address.
Sending mail
mail = get_mail_service()
result = await mail.send(
to=["[email protected]", "[email protected]"], # str or list
subject="Monthly digest",
html="<h1>Hi</h1>",
text="Hi", # at least one of html / text
reply_to="[email protected]",
cc="[email protected]", # str or list
headers={"List-Unsubscribe": "<https://you/u?t=…>"}, # one-click unsubscribe
)
result.message_id # the provider's id (Resend id, SMTP Message-ID, …)
result.provider # "console" | "smtp" | "resend" | a community name
result.accepted # the recipients the provider took responsibility forThe sender (from_address / from_name) and reply_to fall back to the configured defaults, so most calls pass only to, subject, and a body. Recipients are validated and normalised before they reach the provider.
send raises InvalidMessage for a bad message — no recipient, no body, an empty subject, or a malformed address — before any network call, and SendError if the provider rejects the message or the transport fails. Both inherit MailError.
When you'd rather build the message yourself, send_message(EmailMessage) is the lower-level entry point; it applies the default sender the same way.
The MailProvider contract
pyxle-mail's swappable piece is the provider — the analogue of pyxle-db's DatabaseLike. Any object satisfying pyxle_mail.MailProvider can back the service: the bundled SMTP/Resend/console providers, or a community adapter for SendGrid, Mailgun, SES, Postmark, and so on. The protocol has two members:
from pyxle_mail import EmailMessage, MailProvider, SendError, SendResult
class MyProvider:
name = "myprovider" # short id, used in logs and SendResult
async def send(self, message: EmailMessage) -> SendResult:
# message.from_address is already filled from settings.
# Deliver it, or raise SendError on rejection / transport failure.
...
return SendResult(message_id="…", provider=self.name, accepted=message.to)The protocol is runtime_checkable, so isinstance(obj, MailProvider) verifies the members are present (signatures are checked statically):
assert isinstance(MyProvider(), MailProvider)EmailMessage and SendResult are both frozen and provider-agnostic, so the same message round-trips through any provider unchanged.
Settings
Configure in pyxle.config.json (camelCase), override per environment with PYXLE_MAIL_* variables. Precedence: config > environment > default. Keep secrets (SMTP password, Resend API key) in the environment, never in the committed config.
{
"plugins": [
{
"name": "pyxle-mail",
"settings": { "provider": "smtp", "smtpHost": "smtp.example.com", "fromAddress": "[email protected]" }
}
]
}| Config key | Env variable | Default | What it does |
|---|---|---|---|
fromAddress |
PYXLE_MAIL_FROM |
— | Default sender. Required for any real provider. |
fromName |
PYXLE_MAIL_FROM_NAME |
— | Optional display name. |
replyTo |
PYXLE_MAIL_REPLY_TO |
— | Default Reply-To. |
provider |
PYXLE_MAIL_PROVIDER |
"console" |
console | smtp | resend. |
dryRun |
PYXLE_MAIL_DRY_RUN |
false |
Force the console provider regardless of provider. |
smtpHost |
PYXLE_MAIL_SMTP_HOST |
— | SMTP server host. |
smtpPort |
PYXLE_MAIL_SMTP_PORT |
587 |
SMTP port. |
smtpUsername |
PYXLE_MAIL_SMTP_USERNAME |
— | SMTP auth user. |
smtpPassword |
PYXLE_MAIL_SMTP_PASSWORD |
— | SMTP auth password (env only). |
smtpUseTls |
PYXLE_MAIL_SMTP_TLS |
true |
STARTTLS (port 587). |
smtpUseSsl |
PYXLE_MAIL_SMTP_SSL |
false |
Implicit TLS (port 465). |
resendApiKey |
PYXLE_MAIL_RESEND_API_KEY |
— | Resend API key (env only). |
A misconfigured real provider fails loud at startup, not on the first send: resend with no key, smtp with no host, or any real provider with no fromAddress refuses to boot. Unknown settings keys are rejected too, so a typo can't silently take the default.
Errors
All inherit from MailError:
| Exception | Raised when |
|---|---|
MailError |
Base class for everything below. |
MailConfigError |
Unknown provider, or a real provider missing its key/host/from-address. |
InvalidMessage |
No recipient, no body, empty subject, or a malformed address. |
SendError |
The provider rejected the message, or the transport failed. |
What pyxle-mail is not
Honest scope, so you can plan around it:
- Not a newsletter / campaign system. It sends transactional and one-off mail. Audiences, scheduling, and analytics are out of scope.
- Not a template engine. Pass rendered HTML/text — use Pyxle components, Jinja, or f-strings to produce it.
- Not a queue. A
sendawaits the provider. For high volume or retries, enqueue with a background-jobs plugin and send from the worker.
See also
- pyxle-auth — its password-reset and email-verification flows hand you a token for your mailer; this is that mailer.
- Plugin standards — the bar every official plugin meets.
- Plugins guide — how plugin loading, ordering, and services work.
- API routes — where cookie-setting endpoints live.
- The plugins directory — every official and community plugin.