ses sender
This commit is contained in:
@@ -1,23 +1,15 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { requireAuth, requireSuperAdmin } from '../middleware/auth.js';
|
import { requireAuth, requireSuperAdmin } from '../middleware/auth.js';
|
||||||
import { computeMonthlyBilling, listBillingEvents, PRICE_PER_INBOX } from '../services/billing.js';
|
import { computeMonthlyBilling, listBillingEvents, PRICE_PER_INBOX } from '../services/billing.js';
|
||||||
|
import { getDomainVolumeForMonth, currentYm } from '../services/ses-events.js';
|
||||||
|
|
||||||
export const billingRouter = Router();
|
export const billingRouter = Router();
|
||||||
billingRouter.use(requireAuth);
|
billingRouter.use(requireAuth);
|
||||||
billingRouter.use(requireSuperAdmin);
|
billingRouter.use(requireSuperAdmin);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/billing/summary
|
* GET /api/billing/summary?domain=foo.com
|
||||||
* Optional query params:
|
* Inbox-count based monthly summary ($5 per inbox per month).
|
||||||
* ?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) => {
|
billingRouter.get('/summary', async (req, res) => {
|
||||||
const domain = req.query.domain ? String(req.query.domain).toLowerCase() : undefined;
|
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
|
* GET /api/billing/events?domain=foo.com
|
||||||
* Raw event log. Useful for "Show me the create/delete history".
|
* Raw mailbox lifecycle event log (created/deleted).
|
||||||
*
|
|
||||||
* 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) => {
|
billingRouter.get('/events', async (req, res) => {
|
||||||
const events = await listBillingEvents({
|
const events = await listBillingEvents({
|
||||||
@@ -47,3 +33,34 @@ billingRouter.get('/events', async (req, res) => {
|
|||||||
});
|
});
|
||||||
res.json(events);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
136
backend/src/services/ses-events.ts
Normal file
136
backend/src/services/ses-events.ts
Normal file
@@ -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<InboxVolume> {
|
||||||
|
const pk = `${domain}#${email}#${ym}`;
|
||||||
|
|
||||||
|
let lastEvaluatedKey: Record<string, unknown> | 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<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));
|
||||||
|
|
||||||
|
// 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')}`;
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
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 Modal from './Modal';
|
||||||
import LoadingOverlay from './LoadingOverlay';
|
import LoadingOverlay from './LoadingOverlay';
|
||||||
import { billingAPI } from '../services/api';
|
import { billingAPI } from '../services/api';
|
||||||
@@ -11,14 +14,31 @@ const MONTH_NAMES = [
|
|||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'summary', label: 'Monthly summary', icon: FiDollarSign },
|
{ id: 'summary', label: 'Monthly summary', icon: FiDollarSign },
|
||||||
|
{ id: 'volume', label: 'SES volume', icon: FiSend },
|
||||||
{ id: 'events', label: 'Event log', icon: FiList },
|
{ 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 BillingModal = ({ open, domains, onClose, onToast }) => {
|
||||||
const [activeTab, setActiveTab] = useState('summary');
|
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 [summary, setSummary] = useState(null);
|
||||||
const [events, setEvents] = useState([]);
|
const [events, setEvents] = useState([]);
|
||||||
|
const [volume, setVolume] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const sortedDomains = useMemo(
|
const sortedDomains = useMemo(
|
||||||
@@ -26,20 +46,38 @@ const BillingModal = ({ open, domains, onClose, onToast }) => {
|
|||||||
[domains]
|
[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 () => {
|
const reload = async () => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const filter = domainFilter || undefined;
|
const filter = domainFilter || undefined;
|
||||||
|
|
||||||
if (activeTab === 'summary') {
|
if (activeTab === 'summary') {
|
||||||
const data = await billingAPI.summary(filter);
|
setSummary(await billingAPI.summary(filter));
|
||||||
setSummary(data);
|
} 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 {
|
} else {
|
||||||
const data = await billingAPI.events({ domain: filter, limit: 500 });
|
setVolume(await billingAPI.volume({ domain: filter, ym: ymFilter }));
|
||||||
setEvents(data);
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
onToast?.(`Failed to load billing data: ${err.message}`, 'error');
|
onToast?.(`Failed to load: ${err.message}`, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -49,8 +87,10 @@ const BillingModal = ({ open, domains, onClose, onToast }) => {
|
|||||||
if (open) {
|
if (open) {
|
||||||
setActiveTab('summary');
|
setActiveTab('summary');
|
||||||
setDomainFilter('');
|
setDomainFilter('');
|
||||||
|
setYmFilter(currentYm());
|
||||||
setSummary(null);
|
setSummary(null);
|
||||||
setEvents([]);
|
setEvents([]);
|
||||||
|
setVolume(null);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open]);
|
}, [open]);
|
||||||
@@ -58,18 +98,17 @@ const BillingModal = ({ open, domains, onClose, onToast }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reload();
|
reload();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open, activeTab, domainFilter]);
|
}, [open, activeTab, domainFilter, ymFilter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title="Inbox billing"
|
title="Inbox billing"
|
||||||
subtitle="$5 per active inbox per month"
|
subtitle="$5 per active inbox per month · SES outbound volume"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
<div className="relative min-h-[400px]">
|
<div className="relative min-h-[400px]">
|
||||||
{/* Tabs */}
|
|
||||||
<div className="border-b border-gray-200 mb-4">
|
<div className="border-b border-gray-200 mb-4">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{TABS.map((t) => {
|
{TABS.map((t) => {
|
||||||
@@ -93,29 +132,49 @@ const BillingModal = ({ open, domains, onClose, onToast }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Domain filter */}
|
{/* Filters */}
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<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>
|
<label className="text-sm font-semibold text-gray-700">Domain:</label>
|
||||||
<select
|
<select
|
||||||
value={domainFilter}
|
value={domainFilter}
|
||||||
onChange={(e) => setDomainFilter(e.target.value)}
|
onChange={(e) => setDomainFilter(e.target.value)}
|
||||||
className="input-field max-w-xs py-2"
|
className="input-field max-w-xs py-2"
|
||||||
>
|
>
|
||||||
<option value="">All domains</option>
|
<option value="">{activeTab === 'volume' ? 'Select a domain...' : 'All domains'}</option>
|
||||||
{sortedDomains.map((d) => (
|
{sortedDomains.map((d) => (
|
||||||
<option key={d} value={d}>{d}</option>
|
<option key={d} value={d}>{d}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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 && <LoadingOverlay message="Loading..." />}
|
||||||
|
|
||||||
{!loading && activeTab === 'summary' && (
|
{!loading && activeTab === 'summary' && <SummaryView summary={summary} />}
|
||||||
<SummaryView summary={summary} />
|
{!loading && activeTab === 'events' && <EventsView events={events} />}
|
||||||
)}
|
{!loading && activeTab === 'volume' && (
|
||||||
|
<VolumeView
|
||||||
{!loading && activeTab === 'events' && (
|
volume={volume}
|
||||||
<EventsView events={events} />
|
domain={domainFilter}
|
||||||
|
ymOptions={ymOptions}
|
||||||
|
ymFilter={ymFilter}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -131,21 +190,15 @@ const SummaryView = ({ summary }) => {
|
|||||||
const { months, price_per_inbox } = summary;
|
const { months, price_per_inbox } = summary;
|
||||||
|
|
||||||
if (!months || months.length === 0) {
|
if (!months || months.length === 0) {
|
||||||
return (
|
return <p className="text-sm text-gray-500 text-center py-12">No billable activity yet.</p>;
|
||||||
<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();
|
const byYm = new Map();
|
||||||
for (const row of months) {
|
for (const row of months) {
|
||||||
if (!byYm.has(row.ym)) byYm.set(row.ym, []);
|
if (!byYm.has(row.ym)) byYm.set(row.ym, []);
|
||||||
byYm.get(row.ym).push(row);
|
byYm.get(row.ym).push(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Total across the most recent month for the headline number.
|
|
||||||
const sortedYms = [...byYm.keys()].sort().reverse();
|
const sortedYms = [...byYm.keys()].sort().reverse();
|
||||||
const latestYm = sortedYms[0];
|
const latestYm = sortedYms[0];
|
||||||
const latestRows = byYm.get(latestYm) || [];
|
const latestRows = byYm.get(latestYm) || [];
|
||||||
@@ -155,7 +208,6 @@ const SummaryView = ({ summary }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Headline */}
|
|
||||||
{latestYm && (
|
{latestYm && (
|
||||||
<div className="bg-primary-50 border border-primary-200 rounded-lg p-4">
|
<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">
|
<div className="text-xs uppercase tracking-wide text-primary-700 font-semibold">
|
||||||
@@ -170,7 +222,6 @@ const SummaryView = ({ summary }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Per month / per domain */}
|
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{sortedYms.map((ym) => {
|
{sortedYms.map((ym) => {
|
||||||
const rows = byYm.get(ym);
|
const rows = byYm.get(ym);
|
||||||
@@ -222,11 +273,7 @@ const SummaryView = ({ summary }) => {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
const EventsView = ({ events }) => {
|
const EventsView = ({ events }) => {
|
||||||
if (!events || events.length === 0) {
|
if (!events || events.length === 0) {
|
||||||
return (
|
return <p className="text-sm text-gray-500 text-center py-12">No events yet.</p>;
|
||||||
<p className="text-sm text-gray-500 text-center py-12">
|
|
||||||
No events yet.
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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;
|
export default BillingModal;
|
||||||
|
|||||||
@@ -101,11 +101,15 @@ export const billingAPI = {
|
|||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return (await api.get(`/api/billing/events${qs ? '?' + qs : ''}`)).data;
|
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 = {
|
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) => {
|
getStatus: async (domain) => {
|
||||||
try {
|
try {
|
||||||
return (await api.get(`/api/health/domains/${encodeURIComponent(domain)}`)).data;
|
return (await api.get(`/api/health/domains/${encodeURIComponent(domain)}`)).data;
|
||||||
@@ -114,7 +118,6 @@ export const healthAPI = {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Run all checks now and return the full report.
|
|
||||||
runCheck: async (domain) =>
|
runCheck: async (domain) =>
|
||||||
(await api.post(`/api/health/domains/${encodeURIComponent(domain)}/check`)).data,
|
(await api.post(`/api/health/domains/${encodeURIComponent(domain)}/check`)).data,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user