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

View 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')}`;
}