page.tsx326 lines · main
1import { MailIcon } from '@/components/ui/mail';
2import { ZapIcon } from '@/components/ui/zap';
3
4import { apiJson } from '@/lib/api';
5import { toValidDate } from '@/lib/utils';
6
7import { EmptyState } from '../_components/empty-state';
8import { Section } from '../_components/section';
9import { EventChips, type EventChip } from './event-chips';
10
11interface EmailEvent {
12 id: string;
13 eventType: string;
14 messageId: string | null;
15 recipientRedacted: string | null;
16 bounceCode: string | null;
17 bounceMessage: string | null;
18 complaintReason: string | null;
19 deliveredAt: string | null;
20 createdAt: string | Date;
21}
22
23interface EmailTemplateStat {
24 template: string;
25 sends: number;
26 delivered: number;
27 bounced: number;
28 complained: number;
29}
30
31interface EmailOverview {
32 sender: {
33 fromAddress: string;
34 mitteraConfigured: boolean;
35 mitteraEndpoint: string | null;
36 smtpFallbackConfigured: boolean;
37 activeTransport: 'mittera' | 'smtp' | 'dev-stdout';
38 recentTransport: { mittera: number; smtp: number };
39 providerNote: string;
40 };
41 templates: EmailTemplateStat[];
42}
43
44const TRANSPORT_LABEL: Record<EmailOverview['sender']['activeTransport'], string> = {
45 mittera: 'mittera.eu',
46 smtp: 'SMTP fallback',
47 'dev-stdout': 'dev — stdout only',
48};
49
50export const dynamic = 'force-dynamic';
51export const metadata = { title: 'admin · email events' };
52
53const SEVERITY: Record<string, 'ok' | 'warn' | 'fail'> = {
54 delivered: 'ok',
55 opened: 'ok',
56 clicked: 'ok',
57 sent: 'ok',
58 'magic_link.sent': 'ok',
59 'invitation.sent': 'ok',
60 'email_verification.sent': 'ok',
61 queued: 'ok',
62 bounced: 'fail',
63 complained: 'fail',
64 rejected: 'fail',
65 failed: 'fail',
66 delayed: 'warn',
67 deferred: 'warn',
68};
69
70function severityClass(t: string): string {
71 const sev = SEVERITY[t] ?? 'warn';
72 if (sev === 'ok')
73 return 'inline-flex rounded-full bg-[var(--color-primary-subtle)] px-2.5 py-0.5 text-[var(--color-primary)]';
74 if (sev === 'fail') return 'inline-flex rounded-full bg-red-500/10 px-2.5 py-0.5 text-red-400';
75 return 'inline-flex rounded-full bg-yellow-500/10 px-2.5 py-0.5 text-yellow-300';
76}
77
78function formatTs(t: string | Date): string {
79 const d = toValidDate(t);
80 return d ? d.toISOString().replace('T', ' ').slice(0, 19) + 'Z' : '—';
81}
82
83export default async function EmailEventsAdminPage() {
84 const [{ events }, overview] = await Promise.all([
85 apiJson<{ events: EmailEvent[] }>('/v1/admin/email-events').catch(() => ({
86 events: [] as EmailEvent[],
87 })),
88 apiJson<EmailOverview>('/v1/admin/email-overview').catch(() => null),
89 ]);
90
91 // Group counts for the summary chips — real events only.
92 const counts = events.reduce<Record<string, number>>((acc, e) => {
93 acc[e.eventType] = (acc[e.eventType] ?? 0) + 1;
94 return acc;
95 }, {});
96 const chips: EventChip[] = Object.entries(counts)
97 .sort((a, b) => b[1] - a[1])
98 .map(([type, count]) => ({ type, count, severity: SEVERITY[type] ?? 'warn' }));
99
100 return (
101 <div className="flex flex-col gap-10">
102 <header className="flex flex-col gap-2">
103 <div className="flex items-center gap-2">
104 <span className="text-[var(--color-primary)]">
105 <MailIcon size={20} />
106 </span>
107 <h1 className="font-mono text-xl tracking-tight">email events</h1>
108 </div>
109 <p className="max-w-prose font-mono text-sm text-[var(--color-text-muted)]">
110 last 200 webhook events from mittera.eu, newest first. recipient addresses are
111 intentionally not stored — only the messageId, which mittera correlates back. when
112 investigating a delivery, check the matching messageId on mittera&apos;s side.
113 </p>
114 </header>
115
116 {overview ? (
117 <>
118 <Section title="active sender" icon={<ZapIcon size={16} />}>
119 <SenderStatus sender={overview.sender} />
120 </Section>
121 <TemplateStats templates={overview.templates} />
122 </>
123 ) : null}
124
125 {events.length === 0 ? (
126 <Section title="events" icon={<MailIcon size={16} />}>
127 <EmptyState
128 icon={<MailIcon size={28} />}
129 title="no events yet"
130 message="mittera will start sending webhook events to https://api.briven.tech/mittera-webhook as soon as the first transactional mail goes out (magic link, invitation, verification)."
131 />
132 </Section>
133 ) : (
134 <Section
135 title="events · last 200"
136 icon={<MailIcon size={16} />}
137 right={
138 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
139 newest first
140 </span>
141 }
142 >
143 <div className="flex flex-col gap-6">
144 <EventChips chips={chips} />
145
146 <div className="overflow-x-auto rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)]">
147 <table className="w-full font-mono text-xs">
148 <thead className="text-left text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
149 <tr>
150 <th className="px-6 py-4 font-medium">when</th>
151 <th className="px-6 py-4 font-medium">event</th>
152 <th className="px-6 py-4 font-medium">messageId</th>
153 <th className="px-6 py-4 font-medium">detail</th>
154 </tr>
155 </thead>
156 <tbody>
157 {events.map((e) => (
158 <tr
159 key={e.id}
160 className="border-t border-[var(--color-border-subtle)] align-top"
161 >
162 <td className="whitespace-nowrap px-6 py-4 text-[var(--color-text-subtle)]">
163 {formatTs(e.createdAt)}
164 </td>
165 <td className="px-6 py-4">
166 <span className={severityClass(e.eventType)}>{e.eventType}</span>
167 </td>
168 <td className="px-6 py-4 text-[var(--color-text-muted)]">
169 {e.messageId ?? '—'}
170 </td>
171 <td className="px-6 py-4 text-[var(--color-text-muted)]">
172 {e.bounceCode ? (
173 <span>
174 {e.bounceCode}
175 {e.bounceMessage ? ` · ${e.bounceMessage}` : ''}
176 </span>
177 ) : e.complaintReason ? (
178 <span>{e.complaintReason}</span>
179 ) : e.deliveredAt ? (
180 <span>delivered {formatTs(e.deliveredAt)}</span>
181 ) : e.recipientRedacted ? (
182 <span>to {e.recipientRedacted}</span>
183 ) : (
184 '—'
185 )}
186 </td>
187 </tr>
188 ))}
189 </tbody>
190 </table>
191 </div>
192 </div>
193 </Section>
194 )}
195 </div>
196 );
197}
198
199/**
200 * Live sender / transport status (Phase 8 §1). Shows the From: every send
201 * uses + which leg Briven actually drives. Provider is deliberately absent —
202 * mittera abstracts it; the note spells out why.
203 */
204function SenderStatus({ sender }: { sender: EmailOverview['sender'] }) {
205 const transportTint =
206 sender.activeTransport === 'mittera'
207 ? 'bg-[var(--color-primary-subtle)] text-[var(--color-primary)]'
208 : sender.activeTransport === 'smtp'
209 ? 'bg-yellow-500/10 text-yellow-300'
210 : 'bg-[var(--color-surface-raised)] text-[var(--color-text-muted)]';
211 return (
212 <div className="rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6 font-mono text-xs">
213 <dl className="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2">
214 <div className="flex flex-col gap-1">
215 <dt className="text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
216 from address
217 </dt>
218 <dd className="text-sm text-[var(--color-text)]">{sender.fromAddress}</dd>
219 </div>
220 <div className="flex flex-col gap-1">
221 <dt className="text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
222 active transport
223 </dt>
224 <dd>
225 <span className={`inline-flex rounded-full px-2.5 py-0.5 ${transportTint}`}>
226 {TRANSPORT_LABEL[sender.activeTransport]}
227 </span>
228 </dd>
229 </div>
230 <div className="flex flex-col gap-1">
231 <dt className="text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
232 mittera endpoint
233 </dt>
234 <dd className="text-[var(--color-text-muted)]">
235 {sender.mitteraEndpoint ?? <span className="text-yellow-300">not configured</span>}
236 </dd>
237 </div>
238 <div className="flex flex-col gap-1">
239 <dt className="text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
240 smtp fallback
241 </dt>
242 <dd className="text-[var(--color-text-muted)]">
243 {sender.smtpFallbackConfigured ? (
244 'configured'
245 ) : (
246 <span className="text-[var(--color-text-subtle)]">off</span>
247 )}
248 </dd>
249 </div>
250 <div className="flex flex-col gap-1">
251 <dt className="text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
252 recent sends by transport
253 </dt>
254 <dd className="text-[var(--color-text)]">
255 mittera {sender.recentTransport.mittera} · smtp {sender.recentTransport.smtp}
256 </dd>
257 </div>
258 </dl>
259 <p className="mt-6 border-t border-[var(--color-border-subtle)] pt-4 text-[10px] leading-relaxed text-[var(--color-text-subtle)]">
260 {sender.providerNote}
261 </p>
262 </div>
263 );
264}
265
266/**
267 * Per-template stats (Phase 8 §3) — sends · delivered · bounced · complained
268 * grouped by email-type. Delivery outcomes are correlated to the template via
269 * the messageId captured at send time; a send whose outcomes haven't arrived
270 * (or arrived outside the audit window) simply shows 0 in those columns.
271 */
272function TemplateStats({ templates }: { templates: EmailTemplateStat[] }) {
273 if (templates.length === 0) return null;
274 return (
275 <Section
276 title="per-template stats"
277 icon={<MailIcon size={16} />}
278 right={
279 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
280 recent audit window
281 </span>
282 }
283 >
284 <div className="flex flex-col gap-3">
285 <p className="font-mono text-[10px] text-[var(--color-text-subtle)]">
286 deliveries/bounces/complaints are matched to a template by the send-time messageId, so
287 very recent sends may show outcomes still catching up.
288 </p>
289 <div className="overflow-x-auto rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)]">
290 <table className="w-full font-mono text-xs">
291 <thead className="text-left text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
292 <tr>
293 <th className="px-6 py-4 font-medium">template</th>
294 <th className="px-6 py-4 text-right font-medium">sends</th>
295 <th className="px-6 py-4 text-right font-medium">delivered</th>
296 <th className="px-6 py-4 text-right font-medium">bounced</th>
297 <th className="px-6 py-4 text-right font-medium">complained</th>
298 </tr>
299 </thead>
300 <tbody>
301 {templates.map((t) => (
302 <tr key={t.template} className="border-t border-[var(--color-border-subtle)]">
303 <td className="px-6 py-4 text-[var(--color-text)]">{t.template}</td>
304 <td className="px-6 py-4 text-right text-[var(--color-text)]">{t.sends}</td>
305 <td className="px-6 py-4 text-right text-[var(--color-primary)]">
306 {t.delivered}
307 </td>
308 <td
309 className={`px-6 py-4 text-right ${t.bounced > 0 ? 'text-red-400' : 'text-[var(--color-text-subtle)]'}`}
310 >
311 {t.bounced}
312 </td>
313 <td
314 className={`px-6 py-4 text-right ${t.complained > 0 ? 'text-red-400' : 'text-[var(--color-text-subtle)]'}`}
315 >
316 {t.complained}
317 </td>
318 </tr>
319 ))}
320 </tbody>
321 </table>
322 </div>
323 </div>
324 </Section>
325 );
326}