From 3f0e770d21c0f2678daf2acd32fddc6159fe67d0 Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Wed, 29 Apr 2026 17:14:50 -0500 Subject: [PATCH] ses sender --- backend/src/routes/billing.ts | 55 +++-- backend/src/services/ses-events.ts | 136 +++++++++++++ frontend/src/components/BillingModal.jsx | 247 +++++++++++++++++++---- frontend/src/services/api.js | 9 +- 4 files changed, 381 insertions(+), 66 deletions(-) create mode 100644 backend/src/services/ses-events.ts diff --git a/backend/src/routes/billing.ts b/backend/src/routes/billing.ts index 2a87e3b..446669b 100644 --- a/backend/src/routes/billing.ts +++ b/backend/src/routes/billing.ts @@ -1,23 +1,15 @@ 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'; 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: [...] }, - * ... - * ] - * } + * 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; @@ -29,14 +21,8 @@ billingRouter.get('/summary', async (req, res) => { }); /** - * 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) + * GET /api/billing/events?domain=foo.com + * Raw mailbox lifecycle event log (created/deleted). */ billingRouter.get('/events', async (req, res) => { const events = await listBillingEvents({ @@ -47,3 +33,34 @@ billingRouter.get('/events', async (req, res) => { }); res.json(events); }); + +/** + * 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. + */ +billingRouter.get('/volume', async (req, res) => { + const domain = req.query.domain ? String(req.query.domain).toLowerCase() : ''; + if (!domain) { + res.status(400).json({ error: 'domain query parameter is required' }); + return; + } + + const ym = req.query.ym + ? String(req.query.ym) + : currentYm(); + + // Validate ym format + 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); +}); diff --git a/backend/src/services/ses-events.ts b/backend/src/services/ses-events.ts new file mode 100644 index 0000000..aa16839 --- /dev/null +++ b/backend/src/services/ses-events.ts @@ -0,0 +1,136 @@ +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, QueryCommand } from '@aws-sdk/lib-dynamodb'; +import { config } from '../config.js'; +import { pool } from '../db.js'; + +const TABLE_NAME = 'ses-events'; + +const doc = DynamoDBDocumentClient.from( + new DynamoDBClient({ region: config.awsRegion }), + { marshallOptions: { removeUndefinedValues: true } }, +); + +export interface InboxVolume { + email: string; + domain: string; + ym: string; // 'YYYY-MM' + send_count: number; + bytes_total: number; + bounce_count: number; + complaint_count: number; +} + +export interface DomainVolume { + domain: string; + ym: string; + send_count: number; + bytes_total: number; + bounce_count: number; + complaint_count: number; + per_inbox: InboxVolume[]; +} + +interface RawEventRow { + 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. + */ +async function aggregateInbox(domain: string, email: string, ym: string): Promise { + const pk = `${domain}#${email}#${ym}`; + + let lastEvaluatedKey: Record | undefined; + let send_count = 0; + let bytes_total = 0; + 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', + })); + + for (const row of (resp.Items ?? []) as RawEventRow[]) { + if (row.event_type === 'send') { + send_count++; + bytes_total += Number(row.size_bytes ?? 0); + } else if (row.event_type === 'bounce') { + bounce_count++; + } else if (row.event_type === 'complaint') { + complaint_count++; + } + } + + lastEvaluatedKey = resp.LastEvaluatedKey; + } while (lastEvaluatedKey); + + 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 { + 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)); + + // Aggregate in parallel — DynamoDB is fine with this. + const perInbox = await Promise.all(emails.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, + ); + nonEmpty.sort((a, b) => b.send_count - a.send_count || a.email.localeCompare(b.email)); + + const totals = nonEmpty.reduce( + (acc, v) => ({ + send_count: acc.send_count + v.send_count, + bytes_total: acc.bytes_total + v.bytes_total, + bounce_count: acc.bounce_count + v.bounce_count, + complaint_count: acc.complaint_count + v.complaint_count, + }), + { send_count: 0, bytes_total: 0, bounce_count: 0, complaint_count: 0 }, + ); + + return { + domain: d, + ym, + ...totals, + per_inbox: nonEmpty, + }; +} + +/** + * Convenience: get volume for the current month across all mailboxes + * in a domain. + */ +export function currentYm(): string { + const now = new Date(); + return `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}`; +} diff --git a/frontend/src/components/BillingModal.jsx b/frontend/src/components/BillingModal.jsx index 204420e..fa765c3 100644 --- a/frontend/src/components/BillingModal.jsx +++ b/frontend/src/components/BillingModal.jsx @@ -1,5 +1,8 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { FiDollarSign, FiList, FiPlus, FiMinus, FiInbox } from 'react-icons/fi'; +import { + FiDollarSign, FiList, FiPlus, FiMinus, FiInbox, FiSend, + FiAlertCircle, FiAlertTriangle, +} from 'react-icons/fi'; import Modal from './Modal'; import LoadingOverlay from './LoadingOverlay'; import { billingAPI } from '../services/api'; @@ -11,14 +14,31 @@ const MONTH_NAMES = [ const TABS = [ { id: 'summary', label: 'Monthly summary', icon: FiDollarSign }, + { id: 'volume', label: 'SES volume', icon: FiSend }, { id: 'events', label: 'Event log', icon: FiList }, ]; +const formatBytes = (n) => { + n = Number(n || 0); + if (n <= 0) return '0 B'; + const u = ['B', 'KB', 'MB', 'GB', 'TB']; + let i = 0; + while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } + return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${u[i]}`; +}; + +const currentYm = () => { + const d = new Date(); + return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`; +}; + const BillingModal = ({ open, domains, onClose, onToast }) => { const [activeTab, setActiveTab] = useState('summary'); - const [domainFilter, setDomainFilter] = useState(''); // '' = all + const [domainFilter, setDomainFilter] = useState(''); + const [ymFilter, setYmFilter] = useState(currentYm()); const [summary, setSummary] = useState(null); const [events, setEvents] = useState([]); + const [volume, setVolume] = useState(null); const [loading, setLoading] = useState(false); const sortedDomains = useMemo( @@ -26,20 +46,38 @@ 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; + }, []); + const reload = async () => { if (!open) return; setLoading(true); try { const filter = domainFilter || undefined; + if (activeTab === 'summary') { - const data = await billingAPI.summary(filter); - setSummary(data); - } else { - const data = await billingAPI.events({ domain: filter, limit: 500 }); - setEvents(data); + 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 billing data: ${err.message}`, 'error'); + onToast?.(`Failed to load: ${err.message}`, 'error'); } finally { setLoading(false); } @@ -49,8 +87,10 @@ const BillingModal = ({ open, domains, onClose, onToast }) => { if (open) { setActiveTab('summary'); setDomainFilter(''); + setYmFilter(currentYm()); setSummary(null); setEvents([]); + setVolume(null); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); @@ -58,18 +98,17 @@ const BillingModal = ({ open, domains, onClose, onToast }) => { useEffect(() => { reload(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, activeTab, domainFilter]); + }, [open, activeTab, domainFilter, ymFilter]); return (
- {/* Tabs */}
{TABS.map((t) => { @@ -93,29 +132,49 @@ const BillingModal = ({ open, domains, onClose, onToast }) => {
- {/* Domain filter */} -
- - + {/* Filters */} +
+
+ + +
+ + {activeTab === 'volume' && ( +
+ + +
+ )}
{loading && } - {!loading && activeTab === 'summary' && ( - - )} - - {!loading && activeTab === 'events' && ( - + {!loading && activeTab === 'summary' && } + {!loading && activeTab === 'events' && } + {!loading && activeTab === 'volume' && ( + )}
@@ -131,21 +190,15 @@ const SummaryView = ({ summary }) => { const { months, price_per_inbox } = summary; if (!months || months.length === 0) { - return ( -

- No billable activity yet. -

- ); + return

No billable activity yet.

; } - // Group by year-month for the section headers, then list domains within. const byYm = new Map(); for (const row of months) { if (!byYm.has(row.ym)) byYm.set(row.ym, []); byYm.get(row.ym).push(row); } - // Total across the most recent month for the headline number. const sortedYms = [...byYm.keys()].sort().reverse(); const latestYm = sortedYms[0]; const latestRows = byYm.get(latestYm) || []; @@ -155,7 +208,6 @@ const SummaryView = ({ summary }) => { return (
- {/* Headline */} {latestYm && (
@@ -170,7 +222,6 @@ const SummaryView = ({ summary }) => {
)} - {/* Per month / per domain */}
{sortedYms.map((ym) => { const rows = byYm.get(ym); @@ -222,11 +273,7 @@ const SummaryView = ({ summary }) => { // ============================================================ const EventsView = ({ events }) => { if (!events || events.length === 0) { - return ( -

- No events yet. -

- ); + return

No events yet.

; } return ( @@ -271,4 +318,116 @@ const EventsView = ({ events }) => { ); }; +// ============================================================ +// Sub: Volume (SES outbound stats per inbox) +// ============================================================ +const VolumeView = ({ volume, domain, ymFilter }) => { + if (!domain) { + return ( +

+ Pick a domain to see SES outbound volume. +

+ ); + } + if (!volume) return null; + + 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 ( +
+ {/* Domain totals headline */} +
+
+ {domain} · {monthLabel} +
+
+ + + 0 ? 'text-amber-600' : ''} + /> + 0 ? 'text-red-600' : ''} + /> +
+
+ + {/* Per-inbox breakdown */} + {volume.per_inbox.length === 0 ? ( +

+ No SES events recorded for {domain} in {monthLabel}. +

+ ) : ( +
+ + + + + + + + + + + + {volume.per_inbox.map((v) => ( + + + + + + + + ))} + +
MailboxSentSizeBouncesComplaints
+
+ {v.email} + {isProblematic(v) && ( + + )} +
+
{v.send_count.toLocaleString()}{formatBytes(v.bytes_total)} 0 ? 'text-amber-700' : 'text-gray-400'}`}> + {v.bounce_count.toLocaleString()} + 0 ? 'text-red-700 font-semibold' : 'text-gray-400'}`}> + {v.complaint_count.toLocaleString()} +
+
+ )} +
+ ); +}; + +const Stat = ({ label, value, icon: Icon, color = '' }) => ( +
+
{label}
+
+ {Icon && } + {value} +
+
+); + export default BillingModal; diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index e67e011..940c77d 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -101,11 +101,15 @@ export const billingAPI = { const qs = params.toString(); return (await api.get(`/api/billing/events${qs ? '?' + qs : ''}`)).data; }, + volume: async ({ domain, ym }) => { + const params = new URLSearchParams(); + params.set('domain', domain); + if (ym) params.set('ym', ym); + return (await api.get(`/api/billing/volume?${params.toString()}`)).data; + }, }; export const healthAPI = { - // Read the last persisted status (cheap; used by the banner). - // Returns null if the domain has never been checked. getStatus: async (domain) => { try { return (await api.get(`/api/health/domains/${encodeURIComponent(domain)}`)).data; @@ -114,7 +118,6 @@ export const healthAPI = { throw err; } }, - // Run all checks now and return the full report. runCheck: async (domain) => (await api.post(`/api/health/domains/${encodeURIComponent(domain)}/check`)).data, };