SES volume

This commit is contained in:
2026-04-30 09:50:18 -05:00
parent cd2bbe9b7d
commit c44d3228c6
4 changed files with 485 additions and 180 deletions

View File

@@ -1,29 +1,23 @@
import { Router } from 'express';
import { requireAuth, requireSuperAdmin } from '../middleware/auth.js';
import { computeMonthlyBilling, listBillingEvents, PRICE_PER_INBOX } from '../services/billing.js';
import { getDomainVolumeForMonth, currentYm } from '../services/ses-events.js';
import {
getDomainVolumeForMonth,
getVolumeOverview,
currentYm,
previousYm,
} from '../services/ses-events.js';
export const billingRouter = Router();
billingRouter.use(requireAuth);
billingRouter.use(requireSuperAdmin);
/**
* GET /api/billing/summary?domain=foo.com
* Inbox-count based monthly summary ($5 per inbox per month).
*/
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,
});
res.json({ price_per_inbox: PRICE_PER_INBOX, months });
});
/**
* GET /api/billing/events?domain=foo.com
* Raw mailbox lifecycle event log (created/deleted).
*/
billingRouter.get('/events', async (req, res) => {
const events = await listBillingEvents({
domain: req.query.domain ? String(req.query.domain).toLowerCase() : undefined,
@@ -36,13 +30,7 @@ billingRouter.get('/events', async (req, res) => {
/**
* GET /api/billing/volume?domain=foo.com&ym=2026-04
* SES outbound volume for a domain in a specific month.
*
* Returns per-inbox + domain totals with send count, total bytes,
* bounce count and complaint count.
*
* Defaults to the current month if ym is omitted.
* Domain is required because volume is always per-domain.
* Per-inbox drilldown for a single domain.
*/
billingRouter.get('/volume', async (req, res) => {
const domain = req.query.domain ? String(req.query.domain).toLowerCase() : '';
@@ -51,16 +39,38 @@ billingRouter.get('/volume', async (req, res) => {
return;
}
const ym = req.query.ym
? String(req.query.ym)
: currentYm();
// Validate ym format
const ym = req.query.ym ? String(req.query.ym) : currentYm();
if (!/^\d{4}-\d{2}$/.test(ym)) {
res.status(400).json({ error: 'ym must be in YYYY-MM format' });
return;
}
const volume = await getDomainVolumeForMonth(domain, ym);
res.json(volume);
res.json(await getDomainVolumeForMonth(domain, ym));
});
/**
* GET /api/billing/volume-overview?ym=2026-04
* Cross-domain overview for super admin. One DynamoDB scan, aggregates
* per domain. ym defaults to the current month.
*
* Returns:
* {
* ym: 'YYYY-MM',
* total_send_count, total_bounce_count, total_complaint_count, total_inbox_count,
* rows: [{ domain, send_count, bytes_total, bounce_count, complaint_count, inbox_count }, ...]
* }
*/
billingRouter.get('/volume-overview', async (req, res) => {
// Limit to current/previous month per product decision — no point in
// accepting arbitrary ym values from the client right now.
const ym = req.query.ym ? String(req.query.ym) : currentYm();
const allowed = new Set([currentYm(), previousYm()]);
if (!allowed.has(ym)) {
res.status(400).json({
error: `ym must be one of: ${[...allowed].join(', ')}`,
});
return;
}
res.json(await getVolumeOverview(ym));
});

View File

@@ -1,5 +1,5 @@
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, QueryCommand } from '@aws-sdk/lib-dynamodb';
import { DynamoDBDocumentClient, QueryCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
import { config } from '../config.js';
import { pool } from '../db.js';
@@ -13,7 +13,7 @@ const doc = DynamoDBDocumentClient.from(
export interface InboxVolume {
email: string;
domain: string;
ym: string; // 'YYYY-MM'
ym: string;
send_count: number;
bytes_total: number;
bounce_count: number;
@@ -31,15 +31,27 @@ export interface DomainVolume {
}
interface RawEventRow {
pk?: string;
event_type: 'send' | 'bounce' | 'complaint';
size_bytes?: number;
}
/**
* Query all events for a single (email, ym) bucket and aggregate them
* into counts. This is the lowest-level helper used by the higher-level
* domain aggregator below.
*/
export function currentYm(): string {
const now = new Date();
return `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}`;
}
export function previousYm(): string {
const now = new Date();
// Construct first day of previous month in UTC, then format.
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
}
// ============================================================
// Single-domain volume (used for the per-inbox drilldown)
// ============================================================
async function aggregateInbox(domain: string, email: string, ym: string): Promise<InboxVolume> {
const pk = `${domain}#${email}#${ym}`;
@@ -49,16 +61,12 @@ async function aggregateInbox(domain: string, email: string, ym: string): Promis
let bounce_count = 0;
let complaint_count = 0;
// DynamoDB Query is paginated — loop until done. For a single
// mailbox-month the result set is normally small (< few thousand)
// so this loops at most a couple of times.
do {
const resp = await doc.send(new QueryCommand({
TableName: TABLE_NAME,
KeyConditionExpression: 'pk = :pk',
ExpressionAttributeValues: { ':pk': pk },
ExclusiveStartKey: lastEvaluatedKey,
// We only need a few fields for aggregation, not the full row.
ProjectionExpression: 'event_type, size_bytes',
}));
@@ -79,30 +87,25 @@ async function aggregateInbox(domain: string, email: string, ym: string): Promis
return { email, domain, ym, send_count, bytes_total, bounce_count, complaint_count };
}
/**
* Get the full domain volume for a given month.
*
* Looks up all mailboxes (active + deleted) that ever existed in the
* domain — we use the mailboxes table for that, which is cheap. Then
* for each mailbox we aggregate the events. Mailboxes with zero events
* are omitted from the per_inbox list (but they're free anyway).
*/
export async function getDomainVolumeForMonth(domain: string, ym: string): Promise<DomainVolume> {
const d = domain.toLowerCase();
// Pull every mailbox that ever existed for this domain. Even
// soft-deleted ones may have sent mails before deletion, and we
// want to show those in historical months.
const result = await pool.query(
`SELECT email_address FROM mailboxes WHERE domain=$1`,
[d],
);
const emails: string[] = result.rows.map((r: any) => String(r.email_address));
const emailsFromPg: string[] = result.rows.map((r: any) => String(r.email_address));
// Aggregate in parallel — DynamoDB is fine with this.
const perInbox = await Promise.all(emails.map((e) => aggregateInbox(d, e, ym)));
// Also discover any email addresses that have events in DynamoDB but are
// no longer in the local PostgreSQL (e.g. mailboxes that were created on
// a different node, or hard-deleted from PostgreSQL but still have events).
// We do a targeted Scan filtered by the domain prefix on pk.
const emailsFromDdb = await listEmailsForDomain(d, ym);
const allEmails = Array.from(new Set([...emailsFromPg, ...emailsFromDdb]));
const perInbox = await Promise.all(allEmails.map((e) => aggregateInbox(d, e, ym)));
// Drop empty entries; sort the rest by send_count desc.
const nonEmpty = perInbox.filter(
(v) => v.send_count > 0 || v.bounce_count > 0 || v.complaint_count > 0,
);
@@ -118,19 +121,148 @@ export async function getDomainVolumeForMonth(domain: string, ym: string): Promi
{ send_count: 0, bytes_total: 0, bounce_count: 0, complaint_count: 0 },
);
return {
domain: d,
ym,
...totals,
per_inbox: nonEmpty,
};
return { domain: d, ym, ...totals, per_inbox: nonEmpty };
}
/**
* Convenience: get volume for the current month across all mailboxes
* in a domain.
* Find the set of email addresses that have any event for the given
* (domain, ym) bucket. Used to ensure the per-inbox drilldown surfaces
* historical mailboxes that no longer exist in the local PostgreSQL.
*
* Uses a Scan with a begins_with filter on pk because we don't have
* a GSI on domain. For typical event volumes this is fast enough; a
* GSI can be added later if it becomes a hot spot.
*/
export function currentYm(): string {
const now = new Date();
return `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}`;
async function listEmailsForDomain(domain: string, ym: string): Promise<string[]> {
const prefix = `${domain}#`;
const suffix = `#${ym}`;
const emails = new Set<string>();
let lastEvaluatedKey: Record<string, unknown> | undefined;
do {
const resp = await doc.send(new ScanCommand({
TableName: TABLE_NAME,
FilterExpression: 'begins_with(pk, :p)',
ExpressionAttributeValues: { ':p': prefix },
ProjectionExpression: 'pk',
ExclusiveStartKey: lastEvaluatedKey,
}));
for (const row of (resp.Items ?? []) as { pk?: string }[]) {
if (!row.pk) continue;
// pk format: "domain#email#YYYY-MM"
if (!row.pk.endsWith(suffix)) continue;
const inner = row.pk.slice(prefix.length, row.pk.length - suffix.length);
if (inner) emails.add(inner);
}
lastEvaluatedKey = resp.LastEvaluatedKey;
} while (lastEvaluatedKey);
return [...emails];
}
// ============================================================
// All-domains overview (single Scan, no per-domain Query loop)
// ============================================================
export interface DomainOverviewRow {
domain: string;
send_count: number;
bytes_total: number;
bounce_count: number;
complaint_count: number;
inbox_count: number;
}
export interface VolumeOverview {
ym: string;
total_send_count: number;
total_bounce_count: number;
total_complaint_count: number;
total_inbox_count: number;
rows: DomainOverviewRow[];
}
/**
* Cross-domain overview. One Scan reads all events for the given month,
* aggregated per domain. We rely on the fact that pk encodes both the
* domain and the ym, so a server-side FilterExpression cuts the scanned
* set down to just this month.
*
* Note: DynamoDB Scan still reads the entire table for billing, but the
* filter reduces network bytes returned. For our event volumes that's
* acceptable. If the table grows past a few hundred thousand items per
* month we should add a GSI keyed on (ym).
*/
export async function getVolumeOverview(ym: string): Promise<VolumeOverview> {
const suffix = `#${ym}`;
let lastEvaluatedKey: Record<string, unknown> | undefined;
// Per-domain accumulators with a Set for unique emails.
const acc = new Map<string, {
send: number; bytes: number; bounce: number; complaint: number; inboxes: Set<string>;
}>();
do {
const resp = await doc.send(new ScanCommand({
TableName: TABLE_NAME,
// Filter at the server: only pk values ending in this month.
// DynamoDB doesn't support "ends_with" on a key, so we use
// contains() — works because '#2026-04' is unique enough.
FilterExpression: 'contains(pk, :ym)',
ExpressionAttributeValues: { ':ym': suffix },
ProjectionExpression: 'pk, event_type, size_bytes',
ExclusiveStartKey: lastEvaluatedKey,
}));
for (const row of (resp.Items ?? []) as RawEventRow[]) {
if (!row.pk || !row.pk.endsWith(suffix)) continue;
// Strip the trailing "#ym" then split into [domain, email].
const head = row.pk.slice(0, row.pk.length - suffix.length);
const sep = head.indexOf('#');
if (sep < 0) continue;
const domain = head.slice(0, sep);
const email = head.slice(sep + 1);
let entry = acc.get(domain);
if (!entry) {
entry = { send: 0, bytes: 0, bounce: 0, complaint: 0, inboxes: new Set() };
acc.set(domain, entry);
}
entry.inboxes.add(email);
if (row.event_type === 'send') {
entry.send++;
entry.bytes += Number(row.size_bytes ?? 0);
} else if (row.event_type === 'bounce') {
entry.bounce++;
} else if (row.event_type === 'complaint') {
entry.complaint++;
}
}
lastEvaluatedKey = resp.LastEvaluatedKey;
} while (lastEvaluatedKey);
const rows: DomainOverviewRow[] = [...acc.entries()].map(([domain, e]) => ({
domain,
send_count: e.send,
bytes_total: e.bytes,
bounce_count: e.bounce,
complaint_count: e.complaint,
inbox_count: e.inboxes.size,
})).sort((a, b) => b.send_count - a.send_count || a.domain.localeCompare(b.domain));
const totals = rows.reduce(
(s, r) => ({
total_send_count: s.total_send_count + r.send_count,
total_bounce_count: s.total_bounce_count + r.bounce_count,
total_complaint_count: s.total_complaint_count + r.complaint_count,
total_inbox_count: s.total_inbox_count + r.inbox_count,
}),
{ total_send_count: 0, total_bounce_count: 0, total_complaint_count: 0, total_inbox_count: 0 },
);
return { ym, ...totals, rows };
}

View File

@@ -1,8 +1,11 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
FiDollarSign, FiList, FiPlus, FiMinus, FiInbox, FiSend,
FiAlertCircle, FiAlertTriangle,
FiAlertCircle, FiAlertTriangle, FiArrowLeft, FiArrowRight,
} from 'react-icons/fi';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
} from 'recharts';
import Modal from './Modal';
import LoadingOverlay from './LoadingOverlay';
import { billingAPI } from '../services/api';
@@ -22,14 +25,28 @@ const currentYm = () => {
const d = new Date();
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
};
const previousYm = () => {
const now = new Date();
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
};
const ymLabel = (ym) => {
const [y, m] = ym.split('-').map(Number);
return `${MONTH_NAMES[m - 1]} ${y}`;
};
const BillingModal = ({ open, domains, onClose, onToast }) => {
const [activeTab, setActiveTab] = useState('summary');
const [domainFilter, setDomainFilter] = useState('');
const [ymFilter, setYmFilter] = useState(currentYm());
const [domainFilter, setDomainFilter] = useState(''); // for summary/events
const [ymFilter, setYmFilter] = useState(currentYm()); // for volume
const [summary, setSummary] = useState(null);
const [events, setEvents] = useState([]);
const [volume, setVolume] = useState(null);
// Volume tab state: overview vs drilldown
const [overview, setOverview] = useState(null);
const [drilldownDomain, setDrilldownDomain] = useState(null);
const [drilldownData, setDrilldownData] = useState(null);
const [loading, setLoading] = useState(false);
const sortedDomains = useMemo(
@@ -37,43 +54,43 @@ const BillingModal = ({ open, domains, onClose, onToast }) => {
[domains]
);
// Build a list of months to choose from, going back ~12 months from now.
const ymOptions = useMemo(() => {
const out = [];
const now = new Date();
for (let i = 0; i < 12; i++) {
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - i, 1));
const ym = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
out.push({ ym, label: `${MONTH_NAMES[d.getUTCMonth()]} ${d.getUTCFullYear()}` });
}
return out;
}, []);
// Volume tab is restricted to current + previous month (per product decision).
const ymOptions = useMemo(() => ([
{ ym: currentYm(), label: ymLabel(currentYm()) },
{ ym: previousYm(), label: ymLabel(previousYm()) },
]), []);
const reload = async () => {
if (!open) return;
const reloadSummary = async () => {
setLoading(true);
try {
const filter = domainFilter || undefined;
if (activeTab === 'summary') {
setSummary(await billingAPI.summary(filter));
} else if (activeTab === 'events') {
setEvents(await billingAPI.events({ domain: filter, limit: 500 }));
} else if (activeTab === 'volume') {
if (!filter) {
// Volume requires a specific domain.
setVolume(null);
} else {
setVolume(await billingAPI.volume({ domain: filter, ym: ymFilter }));
}
}
} catch (err) {
onToast?.(`Failed to load: ${err.message}`, 'error');
} finally {
setLoading(false);
}
setSummary(await billingAPI.summary(domainFilter || undefined));
} catch (err) { onToast?.(`Failed to load: ${err.message}`, 'error'); }
finally { setLoading(false); }
};
const reloadEvents = async () => {
setLoading(true);
try {
setEvents(await billingAPI.events({ domain: domainFilter || undefined, limit: 500 }));
} catch (err) { onToast?.(`Failed to load: ${err.message}`, 'error'); }
finally { setLoading(false); }
};
const reloadOverview = async () => {
setLoading(true);
try {
setOverview(await billingAPI.volumeOverview({ ym: ymFilter }));
} catch (err) { onToast?.(`Failed to load: ${err.message}`, 'error'); }
finally { setLoading(false); }
};
const reloadDrilldown = async () => {
if (!drilldownDomain) return;
setLoading(true);
try {
setDrilldownData(await billingAPI.volume({ domain: drilldownDomain, ym: ymFilter }));
} catch (err) { onToast?.(`Failed to load: ${err.message}`, 'error'); }
finally { setLoading(false); }
};
// Reset everything when modal opens
useEffect(() => {
if (open) {
setActiveTab('summary');
@@ -81,15 +98,23 @@ const BillingModal = ({ open, domains, onClose, onToast }) => {
setYmFilter(currentYm());
setSummary(null);
setEvents([]);
setVolume(null);
setOverview(null);
setDrilldownDomain(null);
setDrilldownData(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
// Load data depending on which view is active
useEffect(() => {
reload();
if (!open) return;
if (activeTab === 'summary') reloadSummary();
else if (activeTab === 'events') reloadEvents();
else if (activeTab === 'volume') {
if (drilldownDomain) reloadDrilldown();
else reloadOverview();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, activeTab, domainFilter, ymFilter]);
}, [open, activeTab, domainFilter, ymFilter, drilldownDomain]);
return (
<Modal
@@ -108,7 +133,12 @@ const BillingModal = ({ open, domains, onClose, onToast }) => {
return (
<button
key={t.id}
onClick={() => setActiveTab(t.id)}
onClick={() => {
setActiveTab(t.id);
// Always reset drilldown when switching tabs.
setDrilldownDomain(null);
setDrilldownData(null);
}}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors ${
isActive
? 'border-b-2 border-primary-600 text-primary-700 -mb-px'
@@ -125,6 +155,7 @@ const BillingModal = ({ open, domains, onClose, onToast }) => {
{/* Filters */}
<div className="flex items-center gap-3 mb-4 flex-wrap">
{(activeTab === 'summary' || activeTab === 'events') && (
<div className="flex items-center gap-2">
<label className="text-sm font-semibold text-gray-700">Domain:</label>
<select
@@ -132,12 +163,13 @@ const BillingModal = ({ open, domains, onClose, onToast }) => {
onChange={(e) => setDomainFilter(e.target.value)}
className="input-field max-w-xs py-2"
>
<option value="">{activeTab === 'volume' ? 'Select a domain...' : 'All domains'}</option>
<option value="">All domains</option>
{sortedDomains.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
</div>
)}
{activeTab === 'volume' && (
<div className="flex items-center gap-2">
@@ -159,12 +191,20 @@ const BillingModal = ({ open, domains, onClose, onToast }) => {
{!loading && activeTab === 'summary' && <SummaryView summary={summary} />}
{!loading && activeTab === 'events' && <EventsView events={events} />}
{!loading && activeTab === 'volume' && (
<VolumeView
volume={volume}
domain={domainFilter}
ymOptions={ymOptions}
{!loading && activeTab === 'volume' && !drilldownDomain && (
<OverviewView
overview={overview}
ymFilter={ymFilter}
onPickDomain={(d) => setDrilldownDomain(d)}
/>
)}
{!loading && activeTab === 'volume' && drilldownDomain && (
<DrilldownView
volume={drilldownData}
domain={drilldownDomain}
ymFilter={ymFilter}
onBack={() => { setDrilldownDomain(null); setDrilldownData(null); }}
/>
)}
</div>
@@ -173,36 +213,31 @@ const BillingModal = ({ open, domains, onClose, onToast }) => {
};
// ============================================================
// Sub: Summary
// Sub: Monthly summary (unchanged)
// ============================================================
const SummaryView = ({ summary }) => {
if (!summary) return null;
const { months, price_per_inbox } = summary;
if (!months || months.length === 0) {
return <p className="text-sm text-gray-500 text-center py-12">No billable activity yet.</p>;
}
const byYm = new Map();
for (const row of months) {
if (!byYm.has(row.ym)) byYm.set(row.ym, []);
byYm.get(row.ym).push(row);
}
const sortedYms = [...byYm.keys()].sort().reverse();
const latestYm = sortedYms[0];
const latestRows = byYm.get(latestYm) || [];
const latestTotalInboxes = latestRows.reduce((s, r) => s + r.inbox_count, 0);
const latestTotalUsd = latestRows.reduce((s, r) => s + r.amount_usd, 0);
const [latestY, latestM] = latestYm ? latestYm.split('-').map(Number) : [null, null];
return (
<div className="space-y-6">
{latestYm && (
<div className="bg-primary-50 border border-primary-200 rounded-lg p-4">
<div className="text-xs uppercase tracking-wide text-primary-700 font-semibold">
{MONTH_NAMES[latestM - 1]} {latestY}
{ymLabel(latestYm)}
</div>
<div className="mt-1 flex items-baseline gap-3">
<span className="text-3xl font-bold text-gray-900">${latestTotalUsd}</span>
@@ -216,16 +251,12 @@ const SummaryView = ({ summary }) => {
<div className="space-y-5">
{sortedYms.map((ym) => {
const rows = byYm.get(ym);
const [y, m] = ym.split('-').map(Number);
const monthTotalInboxes = rows.reduce((s, r) => s + r.inbox_count, 0);
const monthTotalUsd = rows.reduce((s, r) => s + r.amount_usd, 0);
return (
<div key={ym}>
<div className="flex items-baseline justify-between mb-2 border-b border-gray-200 pb-1">
<h3 className="text-sm font-bold text-gray-800">
{MONTH_NAMES[m - 1]} {y}
</h3>
<h3 className="text-sm font-bold text-gray-800">{ymLabel(ym)}</h3>
<span className="text-sm text-gray-600">
<span className="font-mono">{monthTotalInboxes}</span> inboxes ·{' '}
<span className="font-bold text-gray-900">${monthTotalUsd}</span>
@@ -237,9 +268,7 @@ const SummaryView = ({ summary }) => {
.sort((a, b) => b.amount_usd - a.amount_usd || a.domain.localeCompare(b.domain))
.map((r) => (
<tr key={`${r.ym}-${r.domain}`} className="hover:bg-gray-50">
<td className="py-2 pr-3">
<span className="font-medium text-gray-900">{r.domain}</span>
</td>
<td className="py-2 pr-3"><span className="font-medium text-gray-900">{r.domain}</span></td>
<td className="py-2 pr-3 text-gray-600 text-xs whitespace-nowrap">
<FiInbox className="inline w-3 h-3 mr-1 mb-0.5" />
{r.inbox_count} inbox{r.inbox_count !== 1 ? 'es' : ''}
@@ -260,13 +289,12 @@ const SummaryView = ({ summary }) => {
};
// ============================================================
// Sub: Events
// Sub: Event log (unchanged)
// ============================================================
const EventsView = ({ events }) => {
if (!events || events.length === 0) {
return <p className="text-sm text-gray-500 text-center py-12">No events yet.</p>;
}
return (
<div className="overflow-x-auto max-h-[60vh] custom-scrollbar">
<table className="w-full text-sm">
@@ -310,36 +338,181 @@ const EventsView = ({ events }) => {
};
// ============================================================
// Sub: Volume (SES outbound stats per inbox)
// Sub: Volume Overview — Chart + sortable table across all domains
// ============================================================
const VolumeView = ({ volume, domain, ymFilter }) => {
if (!domain) {
const OverviewView = ({ overview, ymFilter, onPickDomain }) => {
const [sortKey, setSortKey] = useState('send_count');
const [sortDir, setSortDir] = useState('desc');
if (!overview) return null;
const monthLabel = ymLabel(ymFilter);
if (!overview.rows || overview.rows.length === 0) {
return (
<div className="space-y-4">
<Headline ym={monthLabel} totals={overview} />
<p className="text-sm text-gray-500 text-center py-12">
Pick a domain to see SES outbound volume.
No SES events recorded for {monthLabel}.
</p>
</div>
);
}
// Top-10 for the chart (always sorted by send_count desc)
const top10 = [...overview.rows]
.sort((a, b) => b.send_count - a.send_count)
.slice(0, 10);
const sorted = [...overview.rows].sort((a, b) => {
const av = a[sortKey] ?? 0;
const bv = b[sortKey] ?? 0;
if (typeof av === 'string') {
return sortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
}
return sortDir === 'asc' ? av - bv : bv - av;
});
const headerCell = (key, label, alignRight = false) => (
<th
onClick={() => {
if (sortKey === key) setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
else { setSortKey(key); setSortDir('desc'); }
}}
className={`py-2 pr-4 font-semibold cursor-pointer select-none hover:text-gray-900 ${alignRight ? 'text-right' : ''}`}
title="Click to sort"
>
{label}
{sortKey === key && <span className="ml-1 text-gray-400">{sortDir === 'asc' ? '↑' : '↓'}</span>}
</th>
);
return (
<div className="space-y-5">
<Headline ym={monthLabel} totals={overview} />
{/* Chart */}
<div>
<h4 className="text-xs uppercase tracking-wide text-gray-500 font-semibold mb-2">
Top {Math.min(10, top10.length)} domains by send volume
</h4>
<div className="h-64 w-full">
<ResponsiveContainer>
<BarChart data={top10} layout="vertical" margin={{ top: 4, right: 24, bottom: 4, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="#e5e7eb" />
<XAxis type="number" stroke="#6b7280" fontSize={12} />
<YAxis
type="category"
dataKey="domain"
stroke="#6b7280"
fontSize={12}
width={140}
/>
<Tooltip
contentStyle={{ borderRadius: 8, border: '1px solid #e5e7eb', fontSize: 12 }}
formatter={(value, name) => [Number(value).toLocaleString(), 'Sends']}
/>
<Bar dataKey="send_count" fill="#0284c7" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Table */}
<div>
<h4 className="text-xs uppercase tracking-wide text-gray-500 font-semibold mb-2">
All domains ({overview.rows.length})
</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wide text-gray-500 border-b border-gray-200">
{headerCell('domain', 'Domain')}
{headerCell('inbox_count', 'Inboxes', true)}
{headerCell('send_count', 'Sent', true)}
{headerCell('bounce_count', 'Bounces', true)}
{headerCell('complaint_count', 'Complaints', true)}
<th className="py-2 pr-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sorted.map((r) => (
<tr key={r.domain} className="hover:bg-gray-50">
<td className="py-2 pr-4">
<span className="font-medium text-gray-900">{r.domain}</span>
</td>
<td className="py-2 pr-4 text-right font-mono text-gray-700">
{r.inbox_count.toLocaleString()}
</td>
<td className="py-2 pr-4 text-right font-mono">{r.send_count.toLocaleString()}</td>
<td className={`py-2 pr-4 text-right font-mono ${r.bounce_count > 0 ? 'text-amber-700' : 'text-gray-400'}`}>
{r.bounce_count.toLocaleString()}
</td>
<td className={`py-2 pr-4 text-right font-mono ${r.complaint_count > 0 ? 'text-red-700 font-semibold' : 'text-gray-400'}`}>
{r.complaint_count.toLocaleString()}
</td>
<td className="py-2 pr-2 text-right">
<button
onClick={() => onPickDomain(r.domain)}
className="btn-ghost px-2 py-1"
title="Show per-inbox breakdown"
>
Details <FiArrowRight className="inline w-3.5 h-3.5 ml-1" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
};
const Headline = ({ ym, totals }) => (
<div className="bg-primary-50 border border-primary-200 rounded-lg p-4">
<div className="text-xs uppercase tracking-wide text-primary-700 font-semibold">
{ym} · all domains
</div>
<div className="mt-2 grid grid-cols-1 sm:grid-cols-4 gap-4">
<Stat label="Inboxes" value={(totals.total_inbox_count ?? 0).toLocaleString()} icon={FiInbox} />
<Stat label="Sent" value={(totals.total_send_count ?? 0).toLocaleString()} icon={FiSend} />
<Stat
label="Bounces"
value={(totals.total_bounce_count ?? 0).toLocaleString()}
icon={FiAlertCircle}
color={totals.total_bounce_count > 0 ? 'text-amber-600' : ''}
/>
<Stat
label="Complaints"
value={(totals.total_complaint_count ?? 0).toLocaleString()}
icon={FiAlertTriangle}
color={totals.total_complaint_count > 0 ? 'text-red-600' : ''}
/>
</div>
</div>
);
// ============================================================
// Sub: Drilldown — per-inbox view for one domain
// ============================================================
const DrilldownView = ({ volume, domain, ymFilter, onBack }) => {
if (!volume) return null;
const monthLabel = ymLabel(ymFilter);
const monthLabel = (() => {
const [y, m] = ymFilter.split('-').map(Number);
return `${MONTH_NAMES[m - 1]} ${y}`;
})();
// Bounce/complaint warnings: SES soft-cap is roughly 5% bounce rate
// and 0.1% complaint rate. We show a small icon when the per-inbox
// numbers exceed those thresholds.
const isProblematic = (v) => {
if (v.send_count === 0) return false;
const bounceRate = v.bounce_count / v.send_count;
const complaintRate = v.complaint_count / v.send_count;
return bounceRate > 0.05 || complaintRate > 0.001;
return (v.bounce_count / v.send_count) > 0.05 || (v.complaint_count / v.send_count) > 0.001;
};
return (
<div className="space-y-5">
{/* Domain totals headline */}
<button onClick={onBack} className="btn-ghost -ml-2">
<FiArrowLeft className="w-4 h-4 mr-1.5" />
Back to overview
</button>
<div className="bg-primary-50 border border-primary-200 rounded-lg p-4">
<div className="text-xs uppercase tracking-wide text-primary-700 font-semibold">
{domain} · {monthLabel}
@@ -361,7 +534,6 @@ const VolumeView = ({ volume, domain, ymFilter }) => {
</div>
</div>
{/* Per-inbox breakdown */}
{volume.per_inbox.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-8">
No SES events recorded for {domain} in {monthLabel}.
@@ -384,10 +556,7 @@ const VolumeView = ({ volume, domain, ymFilter }) => {
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-gray-800">{v.email}</span>
{isProblematic(v) && (
<FiAlertTriangle
className="w-3.5 h-3.5 text-amber-500"
title="High bounce or complaint rate"
/>
<FiAlertTriangle className="w-3.5 h-3.5 text-amber-500" title="High bounce or complaint rate" />
)}
</div>
</td>

View File

@@ -46,29 +46,17 @@ export const mailboxesAPI = {
remove: async (email) =>
(await api.delete(`/api/mailboxes/${encodeURIComponent(email)}`)).data,
setPassword: async (email, password) =>
(await api.post(
`/api/mailboxes/${encodeURIComponent(email)}/password`,
{ password }
)).data,
(await api.post(`/api/mailboxes/${encodeURIComponent(email)}/password`, { password })).data,
setQuota: async (email, quota_gb) =>
(await api.post(
`/api/mailboxes/${encodeURIComponent(email)}/quota`,
{ quota_gb }
)).data,
(await api.post(`/api/mailboxes/${encodeURIComponent(email)}/quota`, { quota_gb })).data,
getRules: async (email) =>
(await api.get(`/api/mailboxes/${encodeURIComponent(email)}/rules`)).data,
putRules: async (email, payload) =>
(await api.put(
`/api/mailboxes/${encodeURIComponent(email)}/rules`,
payload
)).data,
(await api.put(`/api/mailboxes/${encodeURIComponent(email)}/rules`, payload)).data,
getBlocklist: async (email) =>
(await api.get(`/api/mailboxes/${encodeURIComponent(email)}/blocklist`)).data,
putBlocklist: async (email, blocked_patterns) =>
(await api.put(
`/api/mailboxes/${encodeURIComponent(email)}/blocklist`,
{ blocked_patterns }
)).data,
(await api.put(`/api/mailboxes/${encodeURIComponent(email)}/blocklist`, { blocked_patterns })).data,
};
export const auditAPI = {
@@ -107,6 +95,12 @@ export const billingAPI = {
if (ym) params.set('ym', ym);
return (await api.get(`/api/billing/volume?${params.toString()}`)).data;
},
volumeOverview: async ({ ym } = {}) => {
const params = new URLSearchParams();
if (ym) params.set('ym', ym);
const qs = params.toString();
return (await api.get(`/api/billing/volume-overview${qs ? '?' + qs : ''}`)).data;
},
};
export const healthAPI = {