diff --git a/backend/migrations/003_mailbox_billing_events.sql b/backend/migrations/003_mailbox_billing_events.sql
new file mode 100644
index 0000000..15c48f9
--- /dev/null
+++ b/backend/migrations/003_mailbox_billing_events.sql
@@ -0,0 +1,59 @@
+-- ============================================================
+-- 003_mailbox_billing_events.sql
+-- Append-only event log for inbox billing.
+--
+-- Each row records that a mailbox was created or deleted at a
+-- specific point in time. The aggregation per month is computed
+-- on the fly from these events.
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS mailbox_billing_events (
+ id BIGSERIAL PRIMARY KEY,
+ occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ domain TEXT NOT NULL,
+ email TEXT NOT NULL,
+ action TEXT NOT NULL CHECK (action IN ('created', 'deleted')),
+ actor_email TEXT,
+ notes TEXT
+);
+
+CREATE INDEX IF NOT EXISTS idx_billing_events_domain_time
+ ON mailbox_billing_events(domain, occurred_at);
+
+CREATE INDEX IF NOT EXISTS idx_billing_events_email
+ ON mailbox_billing_events(email);
+
+-- ------------------------------------------------------------
+-- Backfill: synthesize 'created' events for every mailbox that
+-- already exists when this migration runs, so we have correct
+-- historical data right from the first deploy.
+--
+-- We use the mailbox's created_at as the event timestamp.
+-- For mailboxes that are already soft-deleted, also synthesize
+-- the matching 'deleted' event using deleted_at.
+--
+-- The WHERE NOT EXISTS guards make this migration idempotent
+-- in case it is re-run.
+-- ------------------------------------------------------------
+
+INSERT INTO mailbox_billing_events (occurred_at, domain, email, action, actor_email, notes)
+SELECT m.created_at, m.domain, m.email_address, 'created', NULL, 'backfill from migration 003'
+FROM mailboxes m
+WHERE NOT EXISTS (
+ SELECT 1 FROM mailbox_billing_events b
+ WHERE b.email = m.email_address
+ AND b.action = 'created'
+ AND b.occurred_at = m.created_at
+);
+
+INSERT INTO mailbox_billing_events (occurred_at, domain, email, action, actor_email, notes)
+SELECT m.deleted_at, m.domain, m.email_address, 'deleted', NULL, 'backfill from migration 003'
+FROM mailboxes m
+WHERE m.status = 'deleted'
+ AND m.deleted_at IS NOT NULL
+ AND NOT EXISTS (
+ SELECT 1 FROM mailbox_billing_events b
+ WHERE b.email = m.email_address
+ AND b.action = 'deleted'
+ AND b.occurred_at = m.deleted_at
+ );
diff --git a/backend/src/routes/billing.ts b/backend/src/routes/billing.ts
new file mode 100644
index 0000000..2a87e3b
--- /dev/null
+++ b/backend/src/routes/billing.ts
@@ -0,0 +1,49 @@
+import { Router } from 'express';
+import { requireAuth, requireSuperAdmin } from '../middleware/auth.js';
+import { computeMonthlyBilling, listBillingEvents, PRICE_PER_INBOX } from '../services/billing.js';
+
+export const billingRouter = Router();
+billingRouter.use(requireAuth);
+billingRouter.use(requireSuperAdmin);
+
+/**
+ * GET /api/billing/summary
+ * Optional query params:
+ * ?domain=foo.com -> restrict to one domain
+ *
+ * Returns:
+ * { price_per_inbox: 5,
+ * months: [
+ * { domain, ym, year, month, inbox_count, amount_usd, inbox_emails: [...] },
+ * ...
+ * ]
+ * }
+ */
+billingRouter.get('/summary', async (req, res) => {
+ const domain = req.query.domain ? String(req.query.domain).toLowerCase() : undefined;
+ const months = await computeMonthlyBilling({ domain });
+ res.json({
+ price_per_inbox: PRICE_PER_INBOX,
+ months,
+ });
+});
+
+/**
+ * GET /api/billing/events
+ * Raw event log. Useful for "Show me the create/delete history".
+ *
+ * Optional query params:
+ * ?domain=foo.com
+ * ?from=2026-01-01T00:00:00Z
+ * ?to=2026-02-01T00:00:00Z
+ * ?limit=200 (1..5000)
+ */
+billingRouter.get('/events', async (req, res) => {
+ const events = await listBillingEvents({
+ domain: req.query.domain ? String(req.query.domain).toLowerCase() : undefined,
+ fromIso: req.query.from ? String(req.query.from) : undefined,
+ toIso: req.query.to ? String(req.query.to) : undefined,
+ limit: req.query.limit ? Number(req.query.limit) : 500,
+ });
+ res.json(events);
+});
diff --git a/backend/src/routes/mailboxes.ts b/backend/src/routes/mailboxes.ts
index 72c8e9c..811a517 100644
--- a/backend/src/routes/mailboxes.ts
+++ b/backend/src/routes/mailboxes.ts
@@ -7,6 +7,7 @@ import { DmsService } from '../services/dms.js';
import { SyncService } from '../services/sync.js';
import { DynamoRulesService } from '../services/dynamodb.js';
import { audit } from '../services/audit.js';
+import { recordBillingEvent } from '../services/billing.js';
import { domainFromEmail, localPartFromEmail, normalizeEmail } from '../utils/email.js';
export const mailboxesRouter = Router();
@@ -102,6 +103,7 @@ mailboxesRouter.post('/', async (req, res) => {
await sync.syncFromDms();
await refreshQuotaForMailbox(email).catch((err) => console.warn(`Could not refresh quota for ${email}:`, err));
await audit(req.user!.email, 'mailbox.create', 'mailbox', email, { domain }, req.ip);
+ await recordBillingEvent('created', domain, email, req.user!.email);
res.status(201).json({ email, domain, local_part: localPartFromEmail(email) });
});
@@ -113,6 +115,7 @@ mailboxesRouter.delete('/:email', async (req, res) => {
await dms.syncSesDomain(domain).catch(() => undefined);
await pool.query(`UPDATE mailboxes SET status='deleted', deleted_at=now(), updated_at=now() WHERE email_address=$1`, [email]);
await audit(req.user!.email, 'mailbox.delete', 'mailbox', email, { domain }, req.ip);
+ await recordBillingEvent('deleted', domain, email, req.user!.email);
res.json({ ok: true });
});
@@ -125,8 +128,6 @@ mailboxesRouter.post('/:email/password', async (req, res) => {
res.json({ ok: true });
});
-// Set storage quota for a single mailbox.
-// quota_gb is an integer between 1 and 1024 (GB).
mailboxesRouter.post('/:email/quota', async (req, res) => {
const body = z.object({ quota_gb: z.number().int().min(1).max(1024) }).parse(req.body);
const email = normalizeEmail(req.params.email);
diff --git a/backend/src/server.ts b/backend/src/server.ts
index 7ae8fec..e0ce853 100644
--- a/backend/src/server.ts
+++ b/backend/src/server.ts
@@ -10,6 +10,7 @@ import { domainsRouter } from './routes/domains.js';
import { mailboxesRouter } from './routes/mailboxes.js';
import { auditRouter } from './routes/audit.js';
import { adminsRouter } from './routes/admins.js';
+import { billingRouter } from './routes/billing.js';
import { SyncService } from './services/sync.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -43,6 +44,7 @@ app.use('/api/domains', domainsRouter);
app.use('/api/mailboxes', mailboxesRouter);
app.use('/api/audit', auditRouter);
app.use('/api/admins', adminsRouter);
+app.use('/api/billing', billingRouter);
app.use((err: any, req: express.Request, res: express.Response, _next: express.NextFunction) => {
const status = err.status ?? err.statusCode ?? 500;
@@ -61,7 +63,6 @@ const publicDir = config.publicDir.startsWith('/')
console.log(`Serving frontend from: ${publicDir}`);
-// Avoid stale frontend JS while we are actively developing the MVP.
app.use((req, res, next) => {
if (
req.path.endsWith('.js') ||
diff --git a/backend/src/services/billing.ts b/backend/src/services/billing.ts
new file mode 100644
index 0000000..e921fc0
--- /dev/null
+++ b/backend/src/services/billing.ts
@@ -0,0 +1,208 @@
+import { pool } from '../db.js';
+
+const PRICE_PER_INBOX_USD = 5;
+
+export interface BillingEvent {
+ id: number;
+ occurred_at: string;
+ domain: string;
+ email: string;
+ action: 'created' | 'deleted';
+ actor_email: string | null;
+ notes: string | null;
+}
+
+export interface MonthlyDomainBilling {
+ domain: string;
+ year: number;
+ month: number; // 1-12
+ ym: string; // 'YYYY-MM'
+ inbox_count: number;
+ amount_usd: number;
+ inbox_emails: string[];
+}
+
+/**
+ * Record a billing event. Called from the create/delete mailbox routes.
+ * Failures are logged but never thrown — billing must not block the
+ * actual mailbox operation that just succeeded.
+ */
+export async function recordBillingEvent(
+ action: 'created' | 'deleted',
+ domain: string,
+ email: string,
+ actorEmail: string | null,
+ notes?: string,
+): Promise {
+ try {
+ await pool.query(
+ `INSERT INTO mailbox_billing_events (domain, email, action, actor_email, notes)
+ VALUES ($1, $2, $3, $4, $5)`,
+ [domain.toLowerCase(), email.toLowerCase(), action, actorEmail, notes ?? null],
+ );
+ } catch (err) {
+ console.warn('[billing] failed to record event:', err);
+ }
+}
+
+/**
+ * Get raw events for a domain (or all domains) within a time range.
+ */
+export async function listBillingEvents(opts: {
+ domain?: string;
+ fromIso?: string;
+ toIso?: string;
+ limit?: number;
+}): Promise {
+ const params: unknown[] = [];
+ const where: string[] = [];
+
+ if (opts.domain) {
+ params.push(opts.domain.toLowerCase());
+ where.push(`domain = $${params.length}`);
+ }
+ if (opts.fromIso) {
+ params.push(opts.fromIso);
+ where.push(`occurred_at >= $${params.length}`);
+ }
+ if (opts.toIso) {
+ params.push(opts.toIso);
+ where.push(`occurred_at < $${params.length}`);
+ }
+
+ const limit = Math.min(Math.max(opts.limit ?? 500, 1), 5000);
+
+ const sql = `
+ SELECT id, occurred_at, domain, email, action, actor_email, notes
+ FROM mailbox_billing_events
+ ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
+ ORDER BY occurred_at DESC, id DESC
+ LIMIT ${limit}
+ `;
+
+ const result = await pool.query(sql, params);
+ return result.rows;
+}
+
+/**
+ * Compute monthly billing aggregation across all months that contain
+ * at least one mailbox in the given domain.
+ *
+ * Rule: a mailbox is billable in a month if it existed at any point
+ * during that month — i.e. it was created on or before the last day
+ * of the month, AND (still active OR was deleted on or after the
+ * first day of that same month).
+ *
+ * In other words: every month between the create and the delete
+ * (inclusive on both ends) counts. If still active, every month
+ * since creation up to and including the current month counts.
+ */
+export async function computeMonthlyBilling(opts: {
+ domain?: string;
+}): Promise {
+ // Pull events. We need the full history, but for performance we
+ // could later add an "earliest month" cutoff. With <100k events
+ // this is well under 100ms.
+ const rows = await listBillingEvents({ domain: opts.domain, limit: 5000 });
+
+ // Group events per (domain, email) so we can pair create/delete
+ // and reconstruct lifespans.
+ const byMailbox = new Map();
+ for (const ev of rows) {
+ const key = `${ev.domain}\u0000${ev.email}`;
+ if (!byMailbox.has(key)) byMailbox.set(key, []);
+ byMailbox.get(key)!.push(ev);
+ }
+
+ const now = new Date();
+
+ // For each domain+email, walk through events sorted ascending and
+ // build a list of (createdAt, deletedAt|null) intervals. A mailbox
+ // can be re-created after deletion, so multiple intervals per
+ // (domain, email) are possible.
+ type Interval = { domain: string; email: string; from: Date; to: Date | null };
+ const intervals: Interval[] = [];
+
+ for (const [key, evs] of byMailbox) {
+ const [domain, email] = key.split('\u0000');
+ const sorted = [...evs].sort((a, b) =>
+ new Date(a.occurred_at).getTime() - new Date(b.occurred_at).getTime()
+ );
+
+ let openCreatedAt: Date | null = null;
+
+ for (const ev of sorted) {
+ if (ev.action === 'created') {
+ // If we already had an open interval (no matching delete), we
+ // close it implicitly on the new create. This shouldn't happen
+ // in practice but guards against malformed data.
+ if (openCreatedAt) {
+ intervals.push({ domain, email, from: openCreatedAt, to: new Date(ev.occurred_at) });
+ }
+ openCreatedAt = new Date(ev.occurred_at);
+ } else if (ev.action === 'deleted' && openCreatedAt) {
+ intervals.push({ domain, email, from: openCreatedAt, to: new Date(ev.occurred_at) });
+ openCreatedAt = null;
+ }
+ // delete without prior create is ignored (defensive).
+ }
+
+ if (openCreatedAt) {
+ // Still active: open-ended interval up to "now".
+ intervals.push({ domain, email, from: openCreatedAt, to: null });
+ }
+ }
+
+ // Now bucket each interval into the months it covers.
+ // Per (domain, ym) we keep a Set of emails to dedupe and to expose
+ // the actual list in the UI.
+ type Bucket = { domain: string; ym: string; year: number; month: number; emails: Set };
+ const buckets = new Map();
+
+ const ensureBucket = (domain: string, year: number, month: number): Bucket => {
+ const ym = `${year}-${String(month).padStart(2, '0')}`;
+ const key = `${domain}\u0000${ym}`;
+ let b = buckets.get(key);
+ if (!b) {
+ b = { domain, ym, year, month, emails: new Set() };
+ buckets.set(key, b);
+ }
+ return b;
+ };
+
+ for (const iv of intervals) {
+ const fromY = iv.from.getUTCFullYear();
+ const fromM = iv.from.getUTCMonth() + 1;
+ const end = iv.to ?? now;
+ const toY = end.getUTCFullYear();
+ const toM = end.getUTCMonth() + 1;
+
+ let y = fromY;
+ let m = fromM;
+ while (y < toY || (y === toY && m <= toM)) {
+ ensureBucket(iv.domain, y, m).emails.add(iv.email);
+ m++;
+ if (m > 12) { m = 1; y++; }
+ }
+ }
+
+ const result: MonthlyDomainBilling[] = [...buckets.values()].map((b) => ({
+ domain: b.domain,
+ year: b.year,
+ month: b.month,
+ ym: b.ym,
+ inbox_count: b.emails.size,
+ amount_usd: b.emails.size * PRICE_PER_INBOX_USD,
+ inbox_emails: [...b.emails].sort(),
+ }));
+
+ // Newest months first, then domain name.
+ result.sort((a, b) => {
+ if (a.ym !== b.ym) return a.ym < b.ym ? 1 : -1;
+ return a.domain.localeCompare(b.domain);
+ });
+
+ return result;
+}
+
+export const PRICE_PER_INBOX = PRICE_PER_INBOX_USD;
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 28aba5e..b9e44db 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
FiRefreshCw, FiList, FiLogOut, FiSettings, FiKey, FiTrash2, FiPlus, FiInbox,
- FiUsers, FiUser, FiHardDrive,
+ FiUsers, FiUser, FiHardDrive, FiDollarSign,
} from 'react-icons/fi';
import Login from './components/Login';
@@ -16,6 +16,7 @@ import AuditLogModal from './components/AuditLogModal';
import AdminUsersModal from './components/AdminUsersModal';
import ChangeMyPasswordModal from './components/ChangeMyPasswordModal';
import DomainQuotaModal from './components/DomainQuotaModal';
+import BillingModal from './components/BillingModal';
import { authAPI, domainsAPI, mailboxesAPI } from './services/api';
@@ -38,6 +39,7 @@ function App() {
const [showAdmins, setShowAdmins] = useState(false);
const [showChangePw, setShowChangePw] = useState(false);
const [showDomainQuota, setShowDomainQuota] = useState(false);
+ const [showBilling, setShowBilling] = useState(false);
const showToast = useCallback((message, type = 'success') => {
setToast({ message, type });
@@ -193,9 +195,12 @@ function App() {
My Password
- {/* Audit log: super_admin only. */}
{isSuperAdmin && (
<>
+
- {/* Set domain-wide quota: super_admin only. */}
{isSuperAdmin && (