@kreativa/mailvault
v0.15.0
Published
Self-contained, single-tenant email + encrypted media library with passwordless auth that embeds into any Node server with its own MongoDB.
Maintainers
Readme
Transactional email · encrypted media · passwordless admin auth A self-contained, single-tenant toolkit you embed directly into a Node/Express server — with a polished built-in admin UI.
MailVault runs inside your existing server process (no extra service to deploy) and uses its own database within whatever MongoDB you point it at. It depends on nothing external to your project, so it can live inside a customer's app without coupling them to any shared infrastructure.
✨ What you get
- 📧 Transactional email — Handlebars templates composed from reusable header + body + footer blocks, with a live preview and one-click test sends.
- 🔐 Encrypted media library — image compression + hi-res/lo-res variants, with per-folder public, at-rest, or true browser-side end-to-end encryption.
- 🔑 Passwordless, invite-only auth — admins sign in with a one-time code emailed to them; stateless JWT, no password store, no account enumeration.
- 🔗 File sharing — links that are open, password-protected or limited to verified email addresses, with expiry, download limits, an access log and a ready-made download page.
- 💬 SMS — send through 46elks or Brevo, same templating as email.
- 👥 Contacts & lists — provider-agnostic newsletter/SMS audiences, encrypted at rest.
- 🖥️ Built-in admin UI — a single self-contained page (no build step) with a Dashboard, setup-health checks, and per-day activity.
- 🤖 MCP server — let Claude (or any assistant) manage templates, media and admins out of the box.
- 📦 Zero coupling — its own Mongo database, secrets from the environment, nothing shared.
📸 A look inside
🚀 Quick start
npm install @kreativa/mailvault
# add the router + image compression only if you use them:
npm install express sharpimport express from 'express';
import { createMailVault } from '@kreativa/mailvault';
import { createMailVaultRouter } from '@kreativa/mailvault/server';
const tools = await createMailVault({
mongoUri: process.env.MAILVAULT_MONGODB_URI!,
encryptionKey: process.env.MAILVAULT_ENCRYPTION_KEY!,
jwtSecret: process.env.MAILVAULT_JWT_SECRET!,
ownerEmails: ['[email protected]'], // replace with your administrator's email
smtp: { /* … */ },
});
const app = express();
app.use('/tools', createMailVaultRouter(tools)); // admin UI + REST API at /tools
app.listen(3000);
// …or use it programmatically anywhere in your app:
await tools.email.send({ templateSlug: 'welcome', to: '[email protected]', params: { name: 'Sam' } });
await tools.auth.requestOtp('[email protected]');Open http://localhost:3000/tools/ and sign in with the configured owner address.
Unknown addresses cannot claim the installation.
Want to see it first? A zero-setup demo (ephemeral in-memory Mongo, OTP printed to the console) is one command away — see Local development.
⚙️ Configuration
Two things must come from the environment (they bootstrap and protect everything else):
# Connection string to ANY MongoDB. The package opens its own connection and
# uses its own database — point it at an existing cluster or a dedicated one.
MAILVAULT_MONGODB_URI=mongodb://localhost:27017/mailvault
# Master secret (>= 16 chars; use >= 32 random chars in production). Derives the
# AES key that encrypts data at rest and the HMAC used for lookups + OTP hashing.
MAILVAULT_ENCRYPTION_KEY=change-me-to-a-long-random-secretMAILVAULT_DB_NAME=mailvault # default: mailvault
MAILVAULT_JWT_SECRET=another-long-secret # signs login tokens
[email protected] # always-allowed bootstrap admin(s)
MAILVAULT_OTP_TTL_MINUTES=10
# SMTP (used to send mail; the "from" identity is editable later in the UI)
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASSWORD=...
SMTP_FROM_NAME="Music Mystery"
[email protected]
# SMS providers (optional; can also be configured later in the UI). Configure
# 46elks and/or Brevo; SMS_PROVIDER picks which one sends when both are present.
ELKS_API_USERNAME=u...
ELKS_API_PASSWORD=...
ELKS_FROM="Kreativa" # alphanumeric sender id (≤11 chars) or a number you own
BREVO_API_KEY=xkeysib-...
BREVO_SMS_SENDER="Kreativa"
SMS_PROVIDER=elks # "elks" | "brevo" — omit to auto-select (46elks preferred)configFromEnv() reads all of the above:
import { createMailVault, configFromEnv } from '@kreativa/mailvault';
const tools = await createMailVault({ ...configFromEnv(), storage: myStorageAdapter });Or pass an explicit config object — see MailVaultConfig / mailVaultConfigSchema.
| Package | Needed for |
| ---------------- | ----------------------------------------------------- |
| express | the HTTP router / admin UI (/server entry point) |
| sharp | image compression + resizing in the media library |
| ffmpeg-static | video thumbnails, if ffmpeg isn't already on PATH |
| ffprobe-static | duration/dimension probing, same condition |
| multer | bundled — multipart uploads via the router |
The core (@kreativa/mailvault) has no need for Express or sharp — a programmatic-only consumer never loads them.
[!IMPORTANT]
sharpis a hard requirement once image compression is enabled. It is an optional peer here only so non-image consumers don't pull it in — but with "Compress images" on in Settings, every image upload needs it, and a missingsharpmakes uploads throwMailVaultImageProcessingError.In Docker the usual failure is that sharp's platform-specific native binary (
@img/sharp-*) was skipped: production installs commonly runnpm install --omit=optional(orNODE_ENV=production), which drops the optional@img/*packages even when they're in your lockfile. Force them in:# node:*-slim / debian / ubuntu (glibc): pulls @img/sharp-linux-x64 RUN npm ci --include=optional # …or, if you must install per-platform explicitly: # RUN npm install --include=optional sharp # Alpine (musl) needs the musl build instead: # RUN npm ci --include=optional && npm install --cpu=x64 --os=linux --libc=musl sharpBuild sharp inside the target image — installing on macOS/Windows and copying
node_modulesinto a Linux container ships the wrong binary. A handy fail-fast after install:RUN node -e "require('sharp'); console.log('sharp OK')". MailVault also logs a clear error at startup and shows a dashboard warning when compression is on but sharp can't load (see Diagnosing upload failures).
🖥️ The admin console
Mount the router and you get a built-in admin UI at the mount root (e.g. /tools/) plus a JSON API under it. The UI is a single self-contained page (vanilla JS, no build step) themed with a grouped Email / Media / Workspace navigation.
app.use('/tools', createMailVaultRouter(tools, {
protectAdmin: true, // default — require login for everything but OTP endpoints
serveUi: true, // default — serve the admin UI at the mount root
maxUploadBytes: 100 * 1024 * 1024, // hard ceiling; the effective limit is a setting
ui: { // optional — make the console look like part of your product
productName: 'I-Protect GO',
logoUrl: 'https://cdn.example.com/mark.svg',
accent: '#e94f37',
hideCredit: true,
defaultLanguage: 'sv',
},
}));Your app already knows who its superadmins are. Instead of inviting each of them into MailVault's own admin list and having them request a second one-time code, mint a console session for the person you have just authenticated and send them to it:
import { createConsoleLink } from '@kreativa/mailvault/server';
// In a handler that has ALREADY checked the caller is a superadmin:
const { url, expiresAt } = await createConsoleLink(tools, {
consoleUrl: 'https://api.example.com/tools',
email: user.email,
ttlSeconds: 60 * 60,
});
// → https://api.example.com/tools/#token=… — open it in a new tabThe token travels in the URL fragment, which browsers never send to a server; the
console stores it and clears it from the address bar on arrival. The person is
created as a MailVault admin on first sight, so your role model stays the source
of truth. tools.auth.issueToken({ email, role?, ttlSeconds? }) is the
underlying call. Never expose either to unauthenticated callers — whoever can
call them can sign in as anyone.
- Admins sign in with a one-time code emailed to them (no passwords).
- Sign-in is invite-only: only existing admins, pending invitees, configured
ownerEmailsmay log in. Fresh installs require an explicit owner; public first-owner registration is disabled. - Requesting a code for a non-allowed address sends no email. The console reports that the address is not allowed; it does not create an account.
- A successful verify returns a stateless JWT; send it as
Authorization: Bearer <token>. No shared session store. - Roles are
owner|admin. The owner can't be removed; you can't remove yourself.
First-run bootstrap (no SMTP yet). Login codes are delivered only by email, but you can't configure SMTP in the UI until you've logged in. To break the deadlock: in production, provide
SMTP_*env vars (or amailTransport) so the code reaches you, and setMAILVAULT_OWNER_EMAILSto the address that may claim the owner account. Outside production (NODE_ENV !== 'production'), if no transport is configured MailVault prints outgoing email to the console instead of throwing — read the OTP from your server logs and sign in. In production it still refuses rather than silently dropping mail.
await tools.auth.requestOtp('[email protected]'); // emails a code
const result = await tools.auth.verifyOtp('[email protected]', '123456');
if (result) { result.token; result.user; /* role, email, … */ }
await tools.auth.inviteAdmin('[email protected]', 'admin', '[email protected]');
const me = tools.auth.verifyToken(token); // { sub, email } | nullTemplates are composed as header + body + footer (Handlebars). Headers and footers are reusable blocks; one of each can be the default.
await tools.email.send({
to: '[email protected]',
templateSlug: 'welcome',
params: { name: 'Sam' },
});
// Render without sending (used by the live preview):
const { html, subject } = await tools.email.render({ templateSlug: 'welcome', params: { name: 'Sam' } });Send text messages through 46elks or Brevo. The provider is pluggable: configure either (or both) via ELKS_* / BREVO_* env vars or Settings, and a smsProvider preference (settings → SMS_PROVIDER env) decides which one sends when both are present (omit it to auto-select, 46elks first). Secrets (the 46elks password, the Brevo API key) are encrypted at rest, exactly like the SMTP password. The same tools.sms.send(...) call works regardless of provider, and the message text supports the same {{variable}} Handlebars placeholders as email.
await tools.sms.send({
to: '+46701234567', // one number or an array; 0701234567 / 0046701234567 are normalised to E.164
message: 'Hej {{name}}, välkommen!',
params: { name: 'Sam' },
// from: 'Kreativa', // optional sender id override
// whenDelivered: 'https://api.example.com/smsstatus?token=…', // optional delivery-report callback
});
// …or send from a stored SMS template (managed in the admin console):
await tools.sms.send({ to: '+46701234567', templateSlug: 'reminder', params: { name: 'Sam', code: '4821' } });
// Render the text without sending (live preview):
await tools.sms.render({ to: '+46701234567', templateSlug: 'reminder', params: { name: 'Sam', code: '4821' } });
// Confirm the 46elks credentials work:
await tools.sms.verify();Recipients are normalised to E.164 before sending: 0701234567, 070-123 45 67 and 0046701234567 all become +46701234567 (numbers without a country code are assumed Swedish, +46). The sender id follows the providers' rule — a phone number you own, or a name of at most 11 letters/digits; accented letters are folded to their base letter (Cityhäftet → Cityhaftet) and anything else (spaces, hyphens, extra characters) is stripped automatically, so an app name like I-Protect GO is sent as IProtectGO instead of failing with 403 Too long alphanumeric from number.
Pass whenDelivered to get delivery reports: the provider calls that URL for each message (46elks whendelivered, Brevo webUrl) with its own payload, so your host handles the request. The id in each send result is the provider's message id to match the report against. MailVault adds nothing to the URL — put any secret you want to verify in it yourself.
SMS templates are a simpler version of email templates — just a name, slug and message body (no header/footer/subject). Manage them in the admin console's SMS section, or via tools.sms.templates (list / create / getBySlug / update / delete).
Subscribers and the named lists they belong to live in MailVault — provider-agnostic, so you keep your audience even if you switch email/SMS providers. Contact email and phone are encrypted at rest (blind-indexed for dedupe), exactly like admin emails; free-form attributes are stored for use as {{merge}} variables. A global opt-out is remembered (the contact is kept, just excluded from sends).
// Subscribe + add to a list (the list is created on first use):
await tools.contacts.addToNewsletter({
email: '[email protected]',
attributes: { name: 'Sam' },
list: 'newsletter',
});
// Remove from one list, or (no `list`) unsubscribe globally:
await tools.contacts.removeFromNewsletter({ email: '[email protected]', list: 'newsletter' });
await tools.contacts.removeFromNewsletter({ email: '[email protected]' });
// Read for a campaign:
const { contacts } = await tools.contacts.contacts.list({ listId, subscribed: true });Manage contacts and lists in the admin console's Contacts section, or from an external app via @kreativa/tools-client's createContactsApi() (addToNewsletter / removeFromNewsletter).
Pass any storage adapter implementing StoragePort (upload / download / delete). The package ships ready-made adapters under @kreativa/mailvault/storage (see Storage adapters below), or bring your own. With sharp installed and compression enabled in Settings, image uploads produce a lighter delivery version for emails while optionally keeping the full-resolution original. The library reports storage used per folder and in total.
import { storageFromEnv } from '@kreativa/mailvault/storage';
const tools = await createMailVault({ ...configFromEnv(), storage: await storageFromEnv() });
const folder = await tools.media.folders.ensure({ name: 'content', security: 'public' }); // same folder on every boot
await tools.media.upload({ buffer, filename: 'hero.png', mimeType: 'image/png', folder: folder.slug, title: 'Hero', tags: ['campaign'] });
const stats = await tools.media.folderStats(); // { folders: [{folder, count, bytes, derivedCount}], totalBytes, totalCount }Browsing: search and summary
list() returns everything; a person browsing wants a query, a kind, a sort and a
page. search() is that, and the console's Library page is built on it:
const page = await tools.media.search({
query: 'knäböj', // filename, title and tags, case-insensitive
folder: 'content',
kind: 'video', // 'image' | 'video' | 'audio' | 'other'
security: 'public', // 'public' | 'at-rest' | 'e2e'
derived: false, // hide generated stills (each film carries its still as `thumbnail`)
sort: 'largest', // 'newest' | 'oldest' | 'largest' | 'smallest' | 'name'
limit: 50, offset: 0,
withUsage: true, // attach `usedBy` from your usage provider (below)
});
page.files[0].thumbnail; // the film's still, even though stills are hidden
page.total; page.more;
const summary = await tools.media.summary({ folder: 'content' });
summary.byKind.video; // { count: 42, bytes: 96_000_000 } — uploads only
summary.derived; // generated stills, on their own line
summary.encrypted; // at-rest + e2e together
summary.originals; // files that also keep a full-resolution original
summary.duplicates; // { groups, files, bytes } — the same bytes uploaded twiceEvery upload gets a contentHash (SHA-256 of the uploaded bytes). Pass
reuseDuplicate: true to upload() to get the existing record back instead of
storing a second copy, and findDuplicates() to list what is already doubled.
Records from before hashing existed are filled in by backfillHashes() (also a
button in Settings → Library maintenance). update(id, { title, tags })
changes the editor-facing fields after upload.
Where is this file used?
MailVault stores the bytes; your app knows which exercise or article points at
them. Tell it, and a delete of a referenced file is refused everywhere — the
console, REST, MCP and media.delete() alike — with the references attached so a
person sees where rather than just no:
const tools = await createMailVault({
...configFromEnv(),
storage: await storageFromEnv(),
mediaUsage: async (ids) => {
// Return only the ids that are referenced; the shape is yours to fill.
const rows = await db.collection('exercises').find({ videoId: { $in: ids } }).toArray();
const out: Record<string, MediaReference[]> = {};
for (const row of rows) (out[row.videoId] ??= []).push({ kind: 'exerciseVideo', id: row._id.toHexString(), title: row.name });
return out;
},
});
try {
await tools.media.delete(id);
} catch (err) {
if (err instanceof MailVaultMediaInUseError) err.references; // [{ kind, id, title }]
}
await tools.media.delete(id, { force: true }); // when you have checked yourself
// With a provider in place, MailVault can also remove what nothing points at:
const dry = await tools.media.cleanupUnused({ dryRun: true }); // older than 24 h by default
await tools.media.cleanupUnused({ olderThan: new Date(Date.now() - 7 * 864e5) });The console shows a small "in use" badge on such files, lists the references in the viewer, and the maintenance card offers the cleanup with a preview first.
Serving bytes from your own route
Encrypted files (and stores without a public URL) are served through the
console's /media/:id/raw, which sits behind the admin token. A player in your
app needs the same bytes from an unauthenticated path, with byte ranges for
seeking, HEAD for sizing and a cache policy — so mount the same handler:
import { createMediaHandler } from '@kreativa/mailvault/server';
import { toPublicMediaFile } from '@kreativa/mailvault';
// Public files only: encrypted ones answer 404 here, exactly like unknown ids.
app.get('/media/:id', createMediaHandler(tools, { publicOnly: true }));
app.head('/media/:id', createMediaHandler(tools, { publicOnly: true }));
// And the projection to hand clients — no storage keys, no crypto material:
const dto = toPublicMediaFile(file, { rawUrl: (f) => `/media/${f.id}`, thumbnail: still });Tell MailVault about that route and every public file gets it as its url —
the console's "Copy link" included, which otherwise has nothing to copy on local
disk or a bucket that is not web-exposed. A URL the storage returns
(MAILVAULT_S3_PUBLIC_BASE_URL) still wins, and encrypted files never get one:
const tools = await createMailVault({
storage: await storageFromEnv(),
publicMediaUrl: (file) => `https://api.example.com/media/${file.id}`,
});Events
tools.events.on('media.deleted', ({ file }) => invalidateSnapshot(file.id));
tools.events.on('media.uploaded', ({ file }) => { /* … */ }); // also 'media.updated'Listeners run after the fact and never affect the operation; one that throws is
logged. Generated stills do not emit media.uploaded — the film's own event
carries its thumbnailId.
Library maintenance
Settings → Library maintenance in the console, or programmatically:
// Records vs. the store: missing, empty or wrongly sized objects, and — with
// `orphans` — objects no record points at. Reports only unless told otherwise.
let cursor: string | null = null;
do {
const r = await tools.media.reconcile({ orphans: true, cursor });
r.issues; // [{ kind: 'missing' | 'empty' | 'size_mismatch' | 'orphan', id?, filename?, key, expected?, actual? }]
cursor = r.cursor;
} while (cursor);
await tools.media.reconcile({ removeMissing: true, orphans: true, deleteOrphans: true });
tools.media.capabilities(); // { usage, reconcile, orphans } — what the adapter and host make possiblereconcile() needs a storage adapter with stat() (the bundled local and S3
adapters have it, plus list() for the orphan walk); a bare StoragePort
reports supported: false rather than pretending.
Video thumbnails
Turn on “Generate thumbnails from uploaded videos” in Settings and every video upload also gets a still — plus a probed duration, width and height. Audio uploads are probed for duration too, which is otherwise something the host has to track itself.
A thumbnail is its own media file, not a hidden variant: it has an id, a URL, a size and a cache entry like any other image, so anything that can render an image can render it with no special handling. The two records point at each other:
const film = await tools.media.upload({ buffer, filename: 'ovning.mp4', mimeType: 'video/mp4', folder: 'content' });
film.duration; // 16.5
film.thumbnailId; // "65f…" — a real media id
film.width; // 1920
const still = await tools.media.get(film.thumbnailId);
still.mimeType; // "image/jpeg"
still.derivedFrom; // { id: film.id, kind: 'thumbnail' }Deleting the film deletes its still; deleting the still on its own clears the film's pointer. In the console a film's tile uses its still as the poster (with a play marker and the duration), and the still is shown as part of the film rather than as a loose image; clicking any file opens it fullscreen, where a film and its still link to each other. pairFilmThumbnails(files) exposes the same pairing to hosts: it trusts the recorded pointers first and, for records that predate them, falls back to MailVault's own <film>-thumbnail.jpg name in the same folder. A still inherits its source's folder and security, so one taken from an at-rest film is encrypted exactly like the film is. e2e folders are never touched — the server cannot read those bytes, so there is nothing to take a still from.
Turning the setting on only affects new uploads. For everything already in the library, use the backfill — Settings → Video & thumbnails → Generate missing thumbnails — and Regenerate all to replace every still, e.g. after changing the still's time or width. Programmatically:
await tools.media.generateThumbnails(); // missing stills, 200 at a time
await tools.media.generateThumbnails({ folder: 'content', limit: 25 });
// → { scanned, created, skipped, failed, errors, more, cursor }
// Every page continues where the last one ended:
let cursor: string | null = null;
do {
const page = await tools.media.generateThumbnails({ force: true, cursor }); // replace existing stills
cursor = page.cursor;
} while (cursor);It is idempotent (files that already have a still are skipped), chunked (more says whether a further pass has work, cursor is where it starts), and runs regardless of the setting — the setting governs what happens automatically on upload, the backfill is an operator asking for the work now. Always pass the cursor on: under force nothing drops out of the query, so a pass without one starts over at the newest film, and a plain run would otherwise meet the same unreadable films at the top of every page.
[!NOTE] ffmpeg is optional, and never fails an upload. Unlike image compression, a missing still is a degraded result rather than a wrong one — so when ffmpeg can't be run, the video is stored exactly as uploaded, a warning is logged once, and the dashboard shows an informational notice. MailVault looks for the binaries in this order:
MAILVAULT_FFMPEG_PATH/MAILVAULT_FFPROBE_PATHffmpeg/ffprobeonPATH— the recommended production setup- the optional
ffmpeg-static/ffprobe-staticpackages# debian/ubuntu based images (node:*-slim included) RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \ && rm -rf /var/lib/apt/lists/* # alpine # RUN apk add --no-cache ffmpeg
createMailVaultreportsvideoProcessingAvailableso a host can health-check it programmatically, alongsideimageProcessingAvailable.
Diagnosing upload failures
media.upload(...) throws typed, discriminable errors so a host can tell the causes apart without parsing error strings. All extend MailVaultMediaError (itself an Error, so existing catch blocks keep working) and carry a stable code:
| Class | code | When | Extra fields |
| ----- | ------ | ---- | ------------ |
| MailVaultStorageDisabledError | storage_disabled | No storage adapter, or one rejected at startup as non-persistent | reason ('ephemeral' | 'not-configured'), storageStatus |
| MailVaultStorageWriteError | storage_write_failed | The backend write failed (disk or remote) | errno (e.g. EACCES/EROFS/ENOSPC), syscall, path, storageKey, cause |
| MailVaultImageProcessingError | image_processing_failed | Compression/resizing failed — corrupt image, or sharp not installed/native binary missing | cause |
| MailVaultVideoProcessingError | video_processing_failed | ffmpeg couldn't read the file or take a still. Never escapes upload() — it is caught and logged, so the video is still stored; it surfaces per file in generateThumbnails().errors | stage (probe | thumbnail), cause |
| MailVaultFolderNotFoundError | folder_not_found | Upload targeted a folder slug that doesn't exist | slug |
import { MailVaultImageProcessingError } from '@kreativa/mailvault';
try {
await tools.media.upload({ buffer, filename, mimeType, folder });
} catch (err) {
if (err instanceof MailVaultImageProcessingError) {
// e.g. surface "image compression unavailable" to the user
}
throw err;
}The most common production cause is sharp missing in the container (its native binary must match the image's platform — installing on macOS and copying node_modules into a Linux image won't work). MailVault surfaces this in two places so you don't have to read logs:
At startup —
createMailVaultchecks sharp once at boot and logs a clearconsole.errorwhen compression is on but it can't load, so it shows up in your logs immediately rather than only on the first upload. The result is also exposed on the instance astools.imageProcessingAvailable: boolean, and theimageProcessingAvailable()helper is exported for an ad-hoc check:const tools = await createMailVault({ ...configFromEnv(), storage }); if (!tools.imageProcessingAvailable) { // health-check failed: sharp missing — compression-enabled uploads will throw }Proactively, on the dashboard — when image compression is enabled in Settings but
sharpcan't load, the setup checklist shows "Image compression can't run" with a fix hint (getSetupStatusadds animage_processing_unavailablewarning).On the failing upload — the router returns
MailVaultImageProcessingError's message in its{ success: false, error }response, so the admin UI's upload toast shows the real reason instead of a generic failure.
Import from @kreativa/mailvault/storage — kept out of the core so a programmatic-only consumer never loads a cloud SDK:
| Adapter | Backend | Extra dependency |
| ------- | ------- | ---------------- |
| createLocalStorage(dir) | local disk | none |
| createS3Storage(opts) | any S3-compatible store (AWS S3, Cloudflare R2, MinIO) | @aws-sdk/client-s3 (optional peer) |
import { createLocalStorage, createS3Storage, storageFromEnv } from '@kreativa/mailvault/storage';
// Local disk:
const storage = createLocalStorage('/var/data/media');
// S3 / R2 / MinIO (async — lazily loads @aws-sdk/client-s3):
const storage = await createS3Storage({
bucket: 'media',
accessKeyId: process.env.MAILVAULT_S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.MAILVAULT_S3_SECRET_ACCESS_KEY!,
endpoint: 'https://<account>.r2.cloudflarestorage.com', // omit for AWS; MinIO host for MinIO
// forcePathStyle defaults to true whenever an endpoint is set (MinIO needs it)
});storageFromEnv() picks one from the environment — S3 when MAILVAULT_S3_BUCKET is set, else local disk when MAILVAULT_STORAGE_PATH is set, else none (media off):
# S3-compatible (shared across every environment — the recommended setup):
MAILVAULT_S3_BUCKET=media
MAILVAULT_S3_ACCESS_KEY_ID=...
MAILVAULT_S3_SECRET_ACCESS_KEY=...
MAILVAULT_S3_ENDPOINT=https://minio.example.com # MinIO/R2; omit for AWS
MAILVAULT_S3_REGION=auto # default: auto (correct for R2)
MAILVAULT_S3_FORCE_PATH_STYLE=true # default: true when an endpoint is set
MAILVAULT_S3_PREFIX=music-mystery # optional key namespace
MAILVAULT_S3_PUBLIC_BASE_URL=https://cdn.example.com # optional; sets `url` on public files
# …or local disk:
MAILVAULT_STORAGE_PATH=/mailvault-files[!IMPORTANT] Using local disk? Mount a persistent volume for
MAILVAULT_STORAGE_PATH. A container's filesystem is ephemeral — anything written underMAILVAULT_STORAGE_PATHis wiped on every redeploy unless the path is backed by a persistent volume. Uploaded media would silently disappear.MailVault detects this at startup: if the storage path lives on the container's ephemeral layer rather than a mounted volume, it logs an error, disables media storage (so nothing is written to a path that won't survive), and shows a warning on the dashboard's setup checklist. Fix the mount — or set
MAILVAULT_STORAGE_ALLOW_EPHEMERAL=trueto override (e.g. a throwaway dev instance). Where persistence can't be determined (running directly on a host, or outside Linux) the check is skipped.On Coolify: open the service → **Configuration → Persistent Storage →
- Add**. Set Name (e.g.
mailvault-files), leave Source Path empty for a managed Docker volume, and set Destination Path to exactly yourMAILVAULT_STORAGE_PATH(e.g./mailvault-files). Redeploy after adding it.With plain Docker the equivalent is
-v mailvault-files:/mailvault-files. If you'd rather not manage a volume at all, use the S3-compatible adapter above instead — it needs no persistent disk.
Media folders are first-class and carry a per-folder security mode:
- Public — stored as-is, served via the storage adapter's direct URL.
- At rest — encrypted on the server with the master key; served decrypted on
the fly through
/media/:id/raw. - End-to-end — the browser encrypts before upload (passphrase → PBKDF2 → AES-GCM); the server only ever stores ciphertext. Switching a populated folder to/from E2E requires emptying it first; toggling at-rest ↔ public re-processes the existing files.
Send a file to someone outside the console with a link: open to anyone who has
it, protected with a password, limited to chosen email addresses (they confirm
with a one-time code), with an expiry and a download limit. Links work for
public and at-rest files; e2e files can't be shared, since only the
browser holding the passphrase can decrypt them.
const tools = await createMailVault({
...configFromEnv(),
// Where your app serves share links. Needed for `url` and for emailing links.
shareUrl: (token) => `https://app.example.com/s/${token}`,
});
// Share a file that is already in the library…
const { token, url, share } = await tools.shares.create({
fileId,
password: 'sommar2026', // optional
expiresInSeconds: 7 * 24 * 3600, // optional, default: never
maxDownloads: 5, // optional, default: unlimited
allowedEmails: ['[email protected]'], // optional: only these, verified by code
});
// …or upload and share in one step (lands in an at-rest "Shared files" folder,
// and the file is deleted when the link is revoked).
await tools.shares.uploadAndShare({ buffer, filename: 'offert.pdf', mimeType: 'application/pdf' }, { password: 'x' });
// Email the link (Swedish system template `system-share-link`, editable in the console).
await tools.shares.send({ token, to: '[email protected]', message: 'Här är offerten.' });
await tools.shares.revoke(share.id);
await tools.shares.purgeExpired(); // run on a schedule: revokes expired/used-up links, deletes their files when asked toServing the links. Mount the ready-made public handler, which renders a download page (Swedish/English, your branding, no JavaScript) with the password and email-code steps:
import { createShareHandler } from '@kreativa/mailvault/server';
app.use('/s', createShareHandler(tools, { branding: { productName: 'Atrium', accent: '#6e7bff' } }));…or serve them from your own route, after your own checks, with
tools.shares.info(token) and tools.shares.open(token, { password, emailProof, range }),
which streams the file and throws a MailVaultShareError whose code
(expired, password_invalid, exhausted, …) says why not.
How it is protected. The token is 256 random bits and stored only as an HMAC,
so a database dump does not reveal working links. Passwords are hashed with
scrypt. A download is counted in the same write that checks the limit, so two
simultaneous downloads can't both take the last one; seeking inside a video
isn't counted. Every attempt is logged (tools.shares.accessLog(id), IP stored
as an HMAC), and the public handler rate-limits wrong passwords and code
requests per IP. share.created, share.downloaded and share.revoked events
let the host send download receipts or audit.
Non-secret operational settings live in the DB and are editable in the UI, so a non-developer can change them without a redeploy:
- General — the app name used in system emails and SMS.
- Images & uploads — max upload size (MB), compress on/off, image quality, delivery max width, keep-original on/off.
- Email delivery (SMTP) — the sender identity (from name, from address, reply-to) next to the connection it is sent over (host, port, username, password, TLS), plus a Test connection button.
- SMS — provider (46elks or Brevo, or auto), each provider's sender id and credentials (stored encrypted, never returned to the client).
await tools.settings.update({ fromName: 'Music Mystery', compressImages: true, deliveryMaxWidth: 1600 });Sender resolution. The From address comes from Settings first, then env
SMTP_FROM_ADDRESS; the two are independent, so keeping the credentials in the
UI does not discard an env-configured identity. Nothing is ever inferred from the
SMTP username, so with no address anywhere a send fails immediately with the fix
rather than being rejected by the mail server. tools.email.resolveSender()
returns what would be used (and where it came from), and
tools.email.checkDelivery() answers the fuller question — the transport
connects and a usable From address exists:
const { connected, canSend, warnings, effectiveFrom } = await tools.email.checkDelivery();
// connected: true, canSend: false, warnings: ['from_missing'] → authenticates, delivers nothingThe admin console opens on a Dashboard that shows:
- Setup health — actionable warnings (SMTP not configured, open first-run bootstrap, weak encryption key, no media storage, missing sender address), each with a one-line fix.
- Email activity — emails sent in the last 7 / 30 days, with a per-day chart.
- Media — files stored, storage used, uploads in the last 30 days, and total
downloads served. The Library page itself has search, kind and
security filters, sort, a stats strip (films / images / audio / other, encrypted,
thumbnails, originals, duplicates) and hides generated stills unless asked. Encrypted files are served through the package's
/media/:id/rawendpoint, so their accesses are counted; public files served by a direct storage URL are not.
The same data is available programmatically (getSetupStatus, getDashboardStats) and over REST (GET /status, GET /stats).
🔌 REST API
| Method & path | Purpose |
| ------------------------------------- | ---------------------------------------- |
| POST /auth/request-otp | email a one-time code (public) |
| POST /auth/verify-otp | exchange code → { token, user } |
| GET /auth/me | current admin |
| GET/POST /templates, …/:id | template CRUD |
| GET/POST /headers, /footers, …/:id | reusable block CRUD |
| POST /send, POST /send/preview | send / render an email |
| POST /sms, POST /sms/preview | send / render an SMS (46elks or Brevo) |
| GET/POST /sms-templates, …/:id | SMS template CRUD |
| POST /newsletter/subscribe, …/unsubscribe | add / remove a contact (by email/phone) |
| GET/POST /contact-lists, PUT/DELETE …/:id | contact list CRUD |
| GET/POST /contacts, …/:id, …/:id/(un)subscribe | contact management |
| GET/POST /media, DELETE /media/:id| list / upload (multipart) / delete files. Delete answers 409 with references while the host still uses the file (?force=true overrides) |
| GET /media/search | q, folder, kind, security, derived, sort, limit, offset, usage=true → { files, total, more } |
| GET /media/summary | counts + bytes by kind and security, stills, originals, duplicates, capabilities |
| PATCH /media/:id | { title?, tags? } |
| GET /media/usage?ids=a,b | where the host uses these files |
| GET /media/duplicates | uploads whose bytes exist more than once |
| GET /media/:id/raw | stream a file (decrypts at-rest; ciphertext for e2e) |
| GET /media-stats | per-folder + total storage usage |
| POST /media/thumbnails | backfill video thumbnails — {folder?, limit?, force?, cursor?} |
| POST /media/cleanup | remove unreferenced uploads — {dryRun (default true), folder?, olderThanHours?, limit?} |
| POST /media/reconcile | records vs. store — {cursor?, orphans?, removeMissing?, deleteOrphans?, folder?, limit?} |
| POST /media/hashes | fingerprint records that predate hashing — {cursor?, folder?, limit?} |
| GET/POST /folders, PATCH/DELETE /folders/:id | folder CRUD, rename, security mode |
| GET/POST /shares, GET/DELETE /shares/:id | list / create / inspect (with access log) / revoke share links. POST takes fileId, password?, expiresAt?/expiresInSeconds?, maxDownloads?, allowedEmails?, deleteFileOnRevoke?, sendTo?, message? |
| POST /shares/upload | upload (multipart) and share in one step |
| POST /shares/send | email a link — { token, to, message? } |
| GET /status | setup health / configuration warnings |
| GET /stats | dashboard metrics (emails, media, counts)|
| GET/PUT /settings, POST /settings/test-smtp, POST /settings/test-sms | read / update settings, test SMTP / SMS. test-smtp answers {success, error?, warning?} — warning: 'from_missing' means it connected but has no sender address, so nothing can be delivered |
| GET /admins, DELETE /admins/:id | list / remove admins |
| POST /admins/invite, GET /invites, DELETE /invites/:id | invites |
All responses are { success, data?, error? }. Everything except the two /auth/* endpoints requires a bearer token when protectAdmin is on.
📦 Programmatic-only (no HTTP)
Import from the root entry and you never pull in Express:
import { createMailVault, configFromEnv } from '@kreativa/mailvault';
const tools = await createMailVault(configFromEnv());
await tools.email.send({ templateSlug: 'receipt', to, params });
await tools.close(); // closes the Mongo connection🤖 Use it from Claude (MCP)
The package ships an MCP server so an assistant (e.g. Claude Code) can manage templates, headers/footers, folders, media, admins and read status/stats — as soon as the package is installed. Add it to your MCP config:
{
"mcpServers": {
"kreativa-tools": {
"command": "mailvault-mcp",
"env": {
"MAILVAULT_MONGODB_URI": "mongodb://…",
"MAILVAULT_ENCRYPTION_KEY": "…",
"MAILVAULT_STORAGE_PATH": "/var/data/kreativa-media"
}
}
}
}MailVault includes list_templates, create_template, update_template, send_email, create_folder, upload_media, search_media, media_summary, update_media, delete_media, find_duplicate_media, cleanup_unused_media, verify_media_storage, fingerprint_media, create_share_link, list_share_links, revoke_share_link, invite_admin, get_status, get_stats, and more. (MAILVAULT_STORAGE_PATH is optional — set it to enable media uploads via a local disk path. MAILVAULT_SHARE_URL, e.g. https://app.example.com/s, lets create_share_link return and email full links.)
🔒 Security model
Validation uses Zod 4.6.7 or newer, including the MCP input schemas. Consumers that compose MailVault's exported schemas must use Zod 4 as well. Plain configuration, contact attributes and template parameters keep their existing input shapes.
Security upgrade migration
- Configure
MAILVAULT_OWNER_EMAILSbefore the first OTP login, or provision an admin through the authenticated host'sauth.issueToken. Existing admins and invitations keep working. There is no public first-owner registration. - Console JWTs are bound to the MailVault issuer and audience. Previous console sessions and outstanding OTP codes require a fresh login after upgrading.
createMediaHandler(tools)now serves public files only. Use{ publicOnly: false }solely behind the host's own authentication and file authorization. The built-in console already supplies its admin guard.- Email history is metadata only: rendered subjects and raw delivery errors are not stored or returned. This prevents codes/links embedded in subjects from leaking into the admin console. Existing rows are masked on read; their old raw fields remain in MongoDB and backups until the operator removes them under their retention policy. SMS provider errors receive the same treatment.
- Local-storage keys must be relative, with no
./..segments, backslashes or symlinks below the configured root. Only the service account should be able to write to that directory; this is not a sandbox against a compromised OS user. - Video processing accepts standalone media containers, not uploaded playlists. ffmpeg/ffprobe cannot fetch network protocols while processing an upload.
OTP issuance is limited to five emails per recipient per fixed 15-minute window
in MongoDB, shared across processes. Every code has an atomic five-attempt budget
and a single successful consumer. The HTTP login endpoints also limit requests
by IP per process (10 code requests and 30 verifications per 15 minutes by
default; configurable with authRateLimit). Set Express trust proxy to the
actual trusted proxy hops; never trust arbitrary forwarded headers. Edge limits
are still needed to control distributed traffic and request-body floods.
Console responses and protected share flows use no-store. Raw files use a
sandbox CSP and force active content such as HTML/SVG to download. Public object
storage/CDN URLs are outside these HTTP guards; serve untrusted files from a
separate origin with suitable response headers.
The routers emit structured JSON security events (type: security,
service: mailvault) for login outcomes, rejected authentication and rate
limits. No tokens, codes, emails or request bodies are included. Forward these
logs to a separate service and alert on repeated failures or limits; emitting a
log does not itself activate an external alert or prove that no intrusion occurred.
- The package opens its own MongoDB connection and uses its own database — it never touches the host's collections.
- Emails are encrypted at rest (AES-256-GCM) and looked up via an HMAC blind index, so the plaintext is never stored yet records stay queryable.
- OTP codes are stored only as HMAC hashes — single-use, time-limited, attempt-limited.
- Login is invite-only and issues a stateless JWT (no session store).
- Secrets (Mongo URI, encryption key, JWT secret, SMTP credentials) come from the environment; only non-secret operational settings live in the database.
To move a project off the shared multi-tenant deployment and onto an embedded copy, the package provides a generic, idempotent migration:
import { importData, verifyImport, purgeSource } from '@kreativa/mailvault';
const report = await importData(tools, source); // copy templates + media
const check = await verifyImport(tools, source); // confirm everything landed
if (check.ok) await purgeSource(tools, source); // then delete from the sourcesource is a MigrationSource you provide (it reads — and optionally deletes — the old data). A ready-to-run script for the standalone lives at apps/email-service/src/scripts/migrate-to-embedded.ts:
# dry run (copy + verify, nothing deleted)
pnpm --filter email-service migrate:embedded -- --project <projectId> --with-media
# then, once verified, purge the standalone
pnpm --filter email-service migrate:embedded -- --project <projectId> --with-media --confirmEncrypted standalone media (E2E / server-encrypted) can't be migrated server-side and is reported for manual handling.
🧪 Local development
A runnable demo lives in the monorepo (apps/tools-demo): it boots an ephemeral in-memory MongoDB, prints emails (including OTP codes) to the console, and serves the admin UI — zero setup:
pnpm dev:demo # → http://localhost:4000/tools/Sign in with [email protected] and read the 6-digit code straight from the terminal.
The demo explicitly configures that address as its owner.
