npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@kreativa/mailvault

v0.5.2

Published

Self-contained, single-tenant email + encrypted media library with passwordless auth that embeds into any Node server with its own MongoDB.

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.
  • 💬 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 sharp
import 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!,
  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]');

That's it — open http://localhost:3000/tools/ and the very first address to sign in becomes the owner.

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-secret
MAILVAULT_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 | | 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] sharp is 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 missing sharp makes uploads throw MailVaultImageProcessingError.

In Docker the usual failure is that sharp's platform-specific native binary (@img/sharp-*) was skipped: production installs commonly run npm install --omit=optional (or NODE_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 sharp

Build sharp inside the target image — installing on macOS/Windows and copying node_modules into 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
}));
  • Admins sign in with a one-time code emailed to them (no passwords).
  • Sign-in is invite-only: only existing admins, pending invitees, configured ownerEmails, or the very first sign-in (bootstrap → owner) may log in.
  • Requesting a code for a non-allowed address silently does nothing — no email, no account enumeration.
  • 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 a mailTransport) so the code reaches you, and set MAILVAULT_OWNER_EMAILS to 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 } | null

Templates 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 E.164 number, or an array of them
  message: 'Hej {{name}}, välkommen!',
  params: { name: 'Sam' },
  // from: 'Kreativa',                 // optional sender id override
});

// …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();

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() });
await tools.media.upload({ buffer, filename: 'hero.png', mimeType: 'image/png', folder: 'campaign' });
const stats = await tools.media.folderStats(); // { folders: [{folder, count, bytes}], totalBytes, totalCount }

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 | | 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 startupcreateMailVault checks sharp once at boot and logs a clear console.error when 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 as tools.imageProcessingAvailable: boolean, and the imageProcessingAvailable() 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 sharp can't load, the setup checklist shows "Image compression can't run" with a fix hint (getSetupStatus adds an image_processing_unavailable warning).

  • 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 under MAILVAULT_STORAGE_PATH is 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=true to 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 your MAILVAULT_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.

Non-secret operational settings live in the DB and are editable in the UI, so a non-developer can change them without a redeploy:

  • Sender — app name, from name, from address, reply-to (overrides the SMTP defaults at send time).
  • Images & uploads — max upload size (MB), compress on/off, image quality, delivery max width, keep-original on/off.
  • SMS (46elks) — API username, default sender id, and the API password (stored encrypted, never returned to the client).
await tools.settings.update({ fromName: 'Music Mystery', compressImages: true, deliveryMaxWidth: 1600 });

The 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. Encrypted files are served through the package's /media/:id/raw endpoint, 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 | | GET /media/:id/raw | stream a file (decrypts at-rest; ciphertext for e2e) | | GET /media-stats | per-folder + total storage usage | | GET/POST /folders, PATCH/DELETE /folders/:id | folder CRUD, rename, security mode | | 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 / 46elks | | 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, invite_admin, get_status, get_stats, and more. (MAILVAULT_STORAGE_PATH is optional — set it to enable media uploads via a local disk path.)

🔒 Security model

  • 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 source

source 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 --confirm

Encrypted 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 any email and read the 6-digit code straight from the terminal — the first address to sign in becomes the owner.