ses sender

This commit is contained in:
2026-04-29 17:14:50 -05:00
parent 479df311ba
commit 3f0e770d21
4 changed files with 381 additions and 66 deletions

View File

@@ -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 (
<Modal
open={open}
onClose={onClose}
title="Inbox billing"
subtitle="$5 per active inbox per month"
subtitle="$5 per active inbox per month · SES outbound volume"
size="lg"
>
<div className="relative min-h-[400px]">
{/* Tabs */}
<div className="border-b border-gray-200 mb-4">
<div className="flex gap-1">
{TABS.map((t) => {
@@ -93,29 +132,49 @@ const BillingModal = ({ open, domains, onClose, onToast }) => {
</div>
</div>
{/* Domain filter */}
<div className="flex items-center gap-3 mb-4">
<label className="text-sm font-semibold text-gray-700">Domain:</label>
<select
value={domainFilter}
onChange={(e) => setDomainFilter(e.target.value)}
className="input-field max-w-xs py-2"
>
<option value="">All domains</option>
{sortedDomains.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
{/* Filters */}
<div className="flex items-center gap-3 mb-4 flex-wrap">
<div className="flex items-center gap-2">
<label className="text-sm font-semibold text-gray-700">Domain:</label>
<select
value={domainFilter}
onChange={(e) => setDomainFilter(e.target.value)}
className="input-field max-w-xs py-2"
>
<option value="">{activeTab === 'volume' ? 'Select a domain...' : 'All domains'}</option>
{sortedDomains.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
</div>
{activeTab === 'volume' && (
<div className="flex items-center gap-2">
<label className="text-sm font-semibold text-gray-700">Month:</label>
<select
value={ymFilter}
onChange={(e) => setYmFilter(e.target.value)}
className="input-field max-w-xs py-2"
>
{ymOptions.map((o) => (
<option key={o.ym} value={o.ym}>{o.label}</option>
))}
</select>
</div>
)}
</div>
{loading && <LoadingOverlay message="Loading..." />}
{!loading && activeTab === 'summary' && (
<SummaryView summary={summary} />
)}
{!loading && activeTab === 'events' && (
<EventsView events={events} />
{!loading && activeTab === 'summary' && <SummaryView summary={summary} />}
{!loading && activeTab === 'events' && <EventsView events={events} />}
{!loading && activeTab === 'volume' && (
<VolumeView
volume={volume}
domain={domainFilter}
ymOptions={ymOptions}
ymFilter={ymFilter}
/>
)}
</div>
</Modal>
@@ -131,21 +190,15 @@ const SummaryView = ({ summary }) => {
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>
);
return <p className="text-sm text-gray-500 text-center py-12">No billable activity yet.</p>;
}
// 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 (
<div className="space-y-6">
{/* Headline */}
{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">
@@ -170,7 +222,6 @@ const SummaryView = ({ summary }) => {
</div>
)}
{/* Per month / per domain */}
<div className="space-y-5">
{sortedYms.map((ym) => {
const rows = byYm.get(ym);
@@ -222,11 +273,7 @@ const SummaryView = ({ summary }) => {
// ============================================================
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 <p className="text-sm text-gray-500 text-center py-12">No events yet.</p>;
}
return (
@@ -271,4 +318,116 @@ const EventsView = ({ events }) => {
);
};
// ============================================================
// Sub: Volume (SES outbound stats per inbox)
// ============================================================
const VolumeView = ({ volume, domain, ymFilter }) => {
if (!domain) {
return (
<p className="text-sm text-gray-500 text-center py-12">
Pick a domain to see SES outbound volume.
</p>
);
}
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 (
<div className="space-y-5">
{/* Domain totals headline */}
<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}
</div>
<div className="mt-2 grid grid-cols-2 sm:grid-cols-4 gap-4">
<Stat label="Sent" value={volume.send_count.toLocaleString()} icon={FiSend} />
<Stat label="Total size" value={formatBytes(volume.bytes_total)} icon={null} />
<Stat
label="Bounces"
value={volume.bounce_count.toLocaleString()}
icon={FiAlertCircle}
color={volume.bounce_count > 0 ? 'text-amber-600' : ''}
/>
<Stat
label="Complaints"
value={volume.complaint_count.toLocaleString()}
icon={FiAlertTriangle}
color={volume.complaint_count > 0 ? 'text-red-600' : ''}
/>
</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}.
</p>
) : (
<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">
<th className="py-2 pr-4 font-semibold">Mailbox</th>
<th className="py-2 pr-4 font-semibold text-right">Sent</th>
<th className="py-2 pr-4 font-semibold text-right">Size</th>
<th className="py-2 pr-4 font-semibold text-right">Bounces</th>
<th className="py-2 pr-4 font-semibold text-right">Complaints</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{volume.per_inbox.map((v) => (
<tr key={v.email} className="hover:bg-gray-50">
<td className="py-2 pr-4">
<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"
/>
)}
</div>
</td>
<td className="py-2 pr-4 text-right font-mono">{v.send_count.toLocaleString()}</td>
<td className="py-2 pr-4 text-right font-mono text-gray-600">{formatBytes(v.bytes_total)}</td>
<td className={`py-2 pr-4 text-right font-mono ${v.bounce_count > 0 ? 'text-amber-700' : 'text-gray-400'}`}>
{v.bounce_count.toLocaleString()}
</td>
<td className={`py-2 pr-4 text-right font-mono ${v.complaint_count > 0 ? 'text-red-700 font-semibold' : 'text-gray-400'}`}>
{v.complaint_count.toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
};
const Stat = ({ label, value, icon: Icon, color = '' }) => (
<div>
<div className="text-xs uppercase text-gray-500 tracking-wide mb-1">{label}</div>
<div className={`text-2xl font-bold ${color || 'text-gray-900'} flex items-center gap-2`}>
{Icon && <Icon className={`w-5 h-5 ${color || 'text-gray-400'}`} />}
{value}
</div>
</div>
);
export default BillingModal;

View File

@@ -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,
};