@eliophot/emdash-plugin-smtp (0.1.12)
Installation
@eliophot:registry=npm install @eliophot/emdash-plugin-smtp@0.1.12"@eliophot/emdash-plugin-smtp": "0.1.12"About this package
@eliophot/emdash-plugin-smtp
Native EmDash CMS plugin that delivers transactional email through one of three providers, chosen at runtime from the admin panel:
- Brevo (formerly Sendinblue) -
https://api.brevo.com/v3/smtp/email - Mailjet -
https://api.mailjet.com/v3.1/send - Mailchimp Transactional / Mandrill -
https://mandrillapp.com/api/1.0/messages/send
It implements the exclusive email:deliver hook, plus email:beforeSend and email:afterSend for full audit logging. Every delivery attempt - including retries and failures - is logged to ctx.log and persisted in a dedicated storage collection that powers the admin "Email Logs" page.
Plugin id:
eliophot-smtpFormat:native(registers inplugins: [], notsandboxed: []) Capabilities:email:provide,email:intercept,network:fetch
Table of contents
- Requirements
- Forgejo private registry - first-time setup
- Install in your EmDash site
- Register the plugin in
astro.config.mjs - Configure the provider
- Send a test email
- Logs and observability
- API routes
- Local development
- Publishing a new version to Forgejo
- Troubleshooting
Requirements
- EmDash
^0.1.0 - Node.js
>= 18.18(uses nativefetch,btoa) - pnpm
>= 8 - A Forgejo account on
https://git.eliophot.devwith a Personal Access Token (PAT) - An account on at least one of: Brevo, Mailjet, Mailchimp Transactional (Mandrill)
Forgejo private registry - first-time setup
The package lives at:
https://git.eliophot.dev/eliophot/-/packages/npm/@eliophot%2Femdash-plugin-smtp
The npm registry URL exposed by Forgejo is:
https://git.eliophot.dev/api/packages/eliophot/npm/
1. Generate a Personal Access Token
Open https://git.eliophot.dev/user/settings/applications and create a token with these scopes:
| Action you need | Required scope |
|---|---|
pnpm add / pnpm install (consume packages) |
read:package |
pnpm publish (push new versions of this plugin) |
read:package + write:package |
pnpm unpublish |
additionally delete:package |
Copy the token - Forgejo only shows it once.
2. Configure pnpm/npm to use the registry for the @eliophot scope
Two equivalent options. Pick one.
Option A - copy .npmrc.example into your site repo
This repo ships an .npmrc.example file. From your EmDash site repo (the one that consumes the plugin), copy it as .npmrc:
cp path/to/.npmrc.example .npmrc
export FORGEJO_TOKEN=fjpat_xxxxxxxxxxxxxxxxxxxx # bash / zsh
# or, in PowerShell:
$env:FORGEJO_TOKEN = "fjpat_xxxxxxxxxxxxxxxxxxxx"
The .npmrc references ${FORGEJO_TOKEN}, so the literal token never lands in git.
Option B - interactive pnpm login
pnpm config set @eliophot:registry https://git.eliophot.dev/api/packages/eliophot/npm/
pnpm login --scope=@eliophot --registry=https://git.eliophot.dev/api/packages/eliophot/npm/
# username : your Forgejo username (e.g. eliophot)
# password : the Personal Access Token from step 1
# email : whatever Forgejo has on file
This writes the token to your user-level ~/.npmrc (or %USERPROFILE%\.npmrc on Windows).
3. Verify
pnpm view @eliophot/emdash-plugin-smtp versions
If you see a version array, you're ready.
Install in your EmDash site
pnpm add @eliophot/emdash-plugin-smtp
Register the plugin in astro.config.mjs
This is a native plugin - register it in plugins: [], not sandboxed: []:
// astro.config.mjs
import { defineConfig } from "astro/config";
import emdash from "emdash/integration";
import { smtpPlugin } from "@eliophot/emdash-plugin-smtp";
export default defineConfig({
integrations: [
emdash({
plugins: [
smtpPlugin({
// Optional: defaults to "brevo" on first install
defaultProvider: "brevo",
// Optional: number of retries on transient errors (default 1)
maxRetries: 1,
}),
],
}),
],
});
The plugin follows the native plugin two-file pattern:
src/index.ts- thePluginDescriptorfactory (smtpPlugin()), imported at build time inastro.config.mjs.src/sandbox-entry.ts- thecreatePlugin(options)factory that wrapsdefinePlugin(), loaded at request time by EmDash via theentrypointfield.
The plugin registers two admin pages and one widget:
/_emdash/admin/plugins/eliophot-smtp/settings- provider selection, credentials, test send/_emdash/admin/plugins/eliophot-smtp/logs- paginated audit log of every delivery attempt- Dashboard widget "Email Status" - active provider, credentials health, 24h/7d counters
Configure the provider
Open Admin → Plugins → Email Settings and fill in:
- Active provider - Brevo, Mailjet or Mandrill.
- Default sender -
From email,From name, optionalReply-To. Used wheneverctx.email.send()is called without afromaddress. - Provider credentials - only the active provider's keys are required, but you may pre-fill all three so you can switch instantly.
- Logging - retention (days,
0= forever) andctx.logverbosity.
Where to find each provider's API credentials
| Provider | Where | Field(s) needed |
|---|---|---|
| Brevo | https://app.brevo.com/settings/keys/api |
brevoApiKey |
| Mailjet | https://app.mailjet.com/account/apikeys |
mailjetApiKey (public) + mailjetApiSecret |
| Mailchimp Transactional (Mandrill) | https://mandrillapp.com/settings |
mandrillApiKey |
Security note
API keys are stored as secret-typed settings in EmDash KV (encrypted at rest, never returned by the API after save - the admin UI shows •••configured••• for already-set secrets). All hook handlers redact these values before logging via src/lib/redact.ts.
Send a test email
From Email Settings, scroll to Send test email, enter a recipient and click Send test. Under the hood this calls POST /_emdash/api/plugins/eliophot-smtp/test-send and surfaces the provider's status, latency and providerMessageId.
Programmatic equivalent:
await ctx.email!.send({
to: "you@example.com",
subject: "Hello from EmDash",
text: "It works!",
// html: "<p>It works!</p>", // optional
});
The email:beforeSend hook injects the default from / replyTo from settings if you omit them, so the call above is enough.
Logs and observability
Console logs (ctx.log)
Every step of the pipeline emits a structured log line. Useful events:
| Event | Level | Where |
|---|---|---|
email:beforeSend |
info | normalisation succeeded |
email:beforeSend:invalid |
error | message rejected (no recipient/subject/body) |
email:deliver:attempt |
info | each attempt, including retries |
email:<provider>:success |
info | delivery accepted by provider |
email:<provider>:retry |
warn | transient failure, will retry |
email:<provider>:failure |
error | final failure after retries |
email:deliver:misconfigured |
error | ProviderConfigurationError (missing key) |
cron:smtp-log-purge:done |
info | retention purge completed |
The verbosity is filtered server-side by the Server log level setting.
Persistent log entries (storage.emailLogs)
Each delivery attempt writes a document to the emailLogs storage collection (declared in the descriptor with composite indexes on ["provider", "timestamp"] and ["status", "timestamp"]). The shape:
{
timestamp: "2026-04-28T09:55:12.000Z",
provider: "brevo",
status: "success" | "failure" | "retry" | "skipped",
source: string, // who called ctx.email.send()
subject: string,
from: string, // "Acme <hello@example.com>"
to: string[],
cc?: string[], bcc?: string[],
attempts: number, // 1 = first try
latencyMs: number,
providerMessageId?: string,
responseStatus?: number,
responseSnippet?: string, // truncated to 2 KB
error?: { name, message, stack },
requestSummary: { hasHtml, hasText, attachmentsCount, tags, headers }
// never contains credentials or body
}
The admin Email Logs page lets you filter by provider/status, paginate, and inspect the full JSON of any entry. Old entries are purged daily at 03:00 UTC by a built-in cron hook (configurable retention).
API routes
Mounted under /_emdash/api/plugins/eliophot-smtp/<route>.
| Method | Route | Purpose |
|---|---|---|
GET |
status |
Active provider + counters (24h/7d success/failure) + credentials health |
GET |
logs?limit=&cursor=&provider=&status= |
Paginated audit log |
GET |
settings |
Current settings (secrets masked) |
POST |
settings/save |
Persist a partial settings update (skips empty/placeholder values) |
POST |
test-send |
Send a test message through the active provider |
GET |
providers |
List of supported providers |
These routes have no built-in public auth - they sit behind EmDash's admin session middleware. Don't expose them externally.
Curl example:
curl -s "https://your-site.com/_emdash/api/plugins/eliophot-smtp/status" \
-H "Cookie: <admin session>" | jq
Local development
git clone https://git.eliophot.dev/eliophot/emdash-eliophot-plugin-smtp.git
cd emdash-eliophot-plugin-smtp
pnpm install
pnpm build # one-shot build
pnpm dev # tsdown --watch
pnpm typecheck # tsc --noEmit
To link a local checkout into a site you're developing in parallel:
# in the plugin repo
pnpm link --global
# in the site repo
pnpm link --global @eliophot/emdash-plugin-smtp
Then run the site (pnpm dev) and trigger a send through the admin panel - tsdown --watch rebuilds on file changes.
Project layout
src/
├── index.ts # PluginDescriptor factory + SmtpPluginOptions (Vite, build-time)
├── sandbox-entry.ts # createPlugin(options) → definePlugin(): hooks + routes (runtime)
├── admin.tsx # exports { pages, widgets } for native admin
├── components/
│ ├── SettingsPage.tsx
│ ├── LogsPage.tsx
│ └── StatusWidget.tsx
├── providers/
│ ├── types.ts # MailProvider interface + errors
│ ├── brevo.ts
│ ├── mailjet.ts
│ ├── mandrill.ts
│ └── factory.ts
└── lib/
├── api.ts # usePluginAPI() fetch wrapper for admin components
├── logger.ts # ctx.log + storage.emailLogs persistence
├── normalize.ts # raw message → NormalizedMessage
└── redact.ts # secret masking before logging
Publishing a new version to Forgejo
You need a PAT with the write:package scope (see Forgejo setup, step 1).
One-time login
pnpm login --scope=@eliophot \
--registry=https://git.eliophot.dev/api/packages/eliophot/npm/
Bump and publish
# bump the version (writes package.json + creates a git tag)
pnpm version patch # or `minor` / `major`
# build + publish; `prepublishOnly` runs `clean && build` for you
pnpm publish
publishConfig.registry in package.json already points at https://git.eliophot.dev/api/packages/eliophot/npm/, so pnpm publish always targets Forgejo (never the public npm registry).
Don't forget to push the git tag
git push --follow-tags
The package becomes visible at:
https://git.eliophot.dev/eliophot/-/packages/npm/@eliophot%2Femdash-plugin-smtp
Forgejo gotcha: you cannot publish over an existing version. Either bump the version or unpublish the old one first (
pnpm unpublish @eliophot/emdash-plugin-smtp@<version>).
Automatic publishing via Forgejo Actions (CI)
A Forgejo Actions workflow is included at .forgejo/workflows/publish.yaml. It runs automatically on every push to main that modifies package.json, and can also be triggered manually via workflow_dispatch.
The workflow:
- Compares the
versionfield inpackage.jsonbetween the current and previous commit. - If the version changed, it installs dependencies, builds and publishes to the Forgejo npm registry.
- If the version is unchanged, the publish step is skipped.
Required setup:
| Item | Where to configure |
|---|---|
NPM_TOKEN secret (PAT with write:package scope) |
Repo → Settings → Secrets |
RUNNER_NAME variable (name of the Forgejo runner) |
Repo → Settings → Variables |
With this in place, you just need to bump the version, commit and push - CI handles the rest:
pnpm version patch
git push --follow-tags
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
pnpm add returns 404 Not Found for @eliophot/... |
Registry not configured for the @eliophot scope |
Re-check step 2 of the Forgejo setup. pnpm config get @eliophot:registry should print the Forgejo URL. |
pnpm add returns 401 Unauthorized |
Missing or expired token | Regenerate the PAT, re-export FORGEJO_TOKEN, retry. |
pnpm publish returns 409 Conflict |
Version already exists in the registry | Bump the version, or unpublish the previous one. |
email:deliver:misconfigured log on every send |
Active provider has no API key | Open Email Settings, fill the secret, save. |
403 Forbidden from ctx.http.fetch |
Host not in allowedHosts (only relevant in sandboxed mode - this plugin runs trusted) |
This plugin is native; if you switched modes, restore the descriptor's allowedHosts. |
| Test email succeeds but no message arrives | Provider rejected silently (e.g. unverified sender domain) | Check the provider dashboard's "Activity" feed; the plugin logs the responseSnippet from the API. |
| Log retention not running | ctx.cron not available on your platform |
The purge is best-effort; you can call DELETE manually on the storage from a script if needed. |
Verbose pnpm/npm
pnpm config set loglevel verbose
pnpm config ls -l
Verbose plugin logging
Open Email Settings → Logging → Server log level and set it to debug. The next sends will emit the full request endpoint and recipient list (still redacted) to ctx.log.
License
MIT - see LICENSE.
Dependencies
Development dependencies
| ID | Version |
|---|---|
| @emdash-cms/admin | ^0.1.0 |
| @types/node | ^25.6.0 |
| @types/react | ^18.3.0 |
| emdash | ^0.1.0 |
| react | ^18.3.0 |
| rimraf | ^5.0.0 |
| tsdown | ^0.21.10 |
| typescript | ^5.5.0 |
Peer dependencies
| ID | Version |
|---|---|
| emdash | ^0.1.0 |
| react | ^18.0.0 |