import { CopyOutlined, DeleteOutlined, EditOutlined, KeyOutlined, PlusOutlined, ReloadOutlined, ThunderboltOutlined } from '@ant-design/icons'; import { Alert, App as AntApp, Button, Checkbox, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Skeleton, Space, Switch, Table, Tag, Typography } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { CodeBlock } from '../components/common/CodeBlock'; import { EmptyState } from '../components/common/EmptyState'; import { PageHeader } from '../components/common/PageHeader'; import { SectionCard } from '../components/common/SectionCard'; import { StatusPill } from '../components/common/StatusPill'; import type { StatusTone } from '../components/common/StatusPill'; import { useI18n } from '../frontend/i18n/react'; import { detailHistoryLocation, detailHistoryState } from '../frontend/navigation-state'; import { api } from '../frontend/services/api'; import type { Domain, InboundMailbox, Webhook, WebhookDelivery, WebhookDeliveryStatus, WebhookEvent, WebhookPayload } from '../frontend/types'; const DELIVERY_EVENTS: WebhookEvent[] = ['sent', 'bounced', 'failed', 'opened', 'clicked']; const ALL_EVENTS: WebhookEvent[] = [...DELIVERY_EVENTS, 'received']; const DELIVERY_STATUSES: WebhookDeliveryStatus[] = ['pending', 'processing', 'success', 'dead']; interface WebhooksProps { /** When set, list/create are scoped to this domain (no global page chrome). */ domainId?: number; /** When set, only receipt callbacks for this mailbox are shown. */ mailboxId?: number; domains?: Domain[]; mailboxes?: InboundMailbox[]; onCopy?: (value: string) => void; } interface WebhookFormValues { name: string; url: string; events: WebhookEvent[]; domainId?: number | null; enabled: boolean; } interface SecretReveal { webhook: Webhook; mode: 'created' | 'rotated'; } export default function Webhooks({ domainId: domainIdProp, mailboxId: mailboxIdProp, domains = [], mailboxes = [], onCopy }: WebhooksProps) { const { message } = AntApp.useApp(); const { t } = useI18n(); const location = useLocation(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const queryDomainId = Number(searchParams.get('domainId') || 0) || undefined; const queryMailboxId = Number(searchParams.get('mailboxId') || 0) || undefined; const requestedWebhookId = positiveInteger(searchParams.get('webhookId')); const deliveryStatus = deliveryStatusFromParam(searchParams.get('deliveryStatus')); const deliveryEvent = deliveryEventFromParam(searchParams.get('deliveryEvent')); const domainId = domainIdProp ?? queryDomainId; const mailboxId = mailboxIdProp ?? queryMailboxId; const embedded = domainIdProp != null || mailboxIdProp != null; const [webhooks, setWebhooks] = useState([]); const [recentDeliveries, setRecentDeliveries] = useState([]); const [detailDeliveries, setDetailDeliveries] = useState([]); const [detailLoading, setDetailLoading] = useState(false); const [detailError, setDetailError] = useState(''); const [availableDomains, setAvailableDomains] = useState(domains); const [availableMailboxes, setAvailableMailboxes] = useState(mailboxes); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(''); const [resourceError, setResourceError] = useState(''); const [recentDeliveriesError, setRecentDeliveriesError] = useState(''); const [actionKey, setActionKey] = useState(''); const [drawerOpen, setDrawerOpen] = useState(false); const [editing, setEditing] = useState(null); const [secretReveal, setSecretReveal] = useState(null); const [form] = Form.useForm(); const loadRequestId = useRef(0); const detailRequestId = useRef(0); const mailboxScoped = mailboxId != null; const scoped = domainId != null || mailboxScoped; const selectableEvents: WebhookEvent[] = mailboxScoped ? ['received'] : DELIVERY_EVENTS; const domainMap = useMemo(() => new Map(availableDomains.map((d) => [d.id, d.domain])), [availableDomains]); const mailboxMap = useMemo(() => new Map(availableMailboxes.map((m) => [m.id, m.address])), [availableMailboxes]); const selectedWebhook = useMemo( () => requestedWebhookId ? webhooks.find((webhook) => webhook.id === requestedWebhookId) || null : null, [requestedWebhookId, webhooks] ); const loadData = useCallback(async () => { const requestId = ++loadRequestId.current; setLoading(true); setLoadError(''); setResourceError(''); setRecentDeliveriesError(''); const shouldLoadResources = !domains.length || !mailboxes.length; const [webhooksResult, deliveriesResult, domainsResult, mailboxesResult] = await Promise.allSettled([ api.webhooks(mailboxScoped ? undefined : (domainId != null ? domainId : undefined), mailboxId), api.webhookDeliveries({ limit: 100 }), shouldLoadResources ? api.domains() : Promise.resolve(null), shouldLoadResources ? api.inboundMailboxes() : Promise.resolve(null) ]); if (requestId !== loadRequestId.current) return; let nextWebhooks: Webhook[] = []; if (webhooksResult.status === 'fulfilled') { nextWebhooks = (webhooksResult.value.webhooks || []).filter((webhook) => scoped || webhook.mailboxId == null); setWebhooks(nextWebhooks); } else { setLoadError(webhooksResult.reason instanceof Error ? webhooksResult.reason.message : t('common.error')); } if (deliveriesResult.status === 'fulfilled') { const webhookIds = new Set(nextWebhooks.map((w) => w.id)); const nextDeliveries = (deliveriesResult.value.deliveries || []).filter((d) => scoped ? webhookIds.has(d.webhookId) : true ); setRecentDeliveries(nextDeliveries); } else { setRecentDeliveriesError(deliveriesResult.reason instanceof Error ? deliveriesResult.reason.message : t('common.error')); } const resourceFailures: string[] = []; if (domainsResult.status === 'fulfilled') { if (domainsResult.value) setAvailableDomains(domainsResult.value.domains || []); } else { resourceFailures.push(domainsResult.reason instanceof Error ? domainsResult.reason.message : t('common.error')); } if (mailboxesResult.status === 'fulfilled') { if (mailboxesResult.value) setAvailableMailboxes(mailboxesResult.value.mailboxes || []); } else { resourceFailures.push(mailboxesResult.reason instanceof Error ? mailboxesResult.reason.message : t('common.error')); } setResourceError(resourceFailures.join(';')); setLoading(false); }, [domainId, domains.length, mailboxId, mailboxScoped, mailboxes.length, scoped, t]); useEffect(() => { if (domains.length) setAvailableDomains(domains); }, [domains]); useEffect(() => { if (mailboxes.length) setAvailableMailboxes(mailboxes); }, [mailboxes]); useEffect(() => { void loadData(); return () => { loadRequestId.current += 1; }; }, [loadData]); const lastDeliveryByWebhook = useMemo(() => { const map = new Map(); for (const delivery of recentDeliveries) { if (!map.has(delivery.webhookId)) map.set(delivery.webhookId, delivery); } return map; }, [recentDeliveries]); const loadDetailDeliveries = useCallback(async () => { const requestId = ++detailRequestId.current; if (!requestedWebhookId) { setDetailDeliveries([]); setDetailError(''); setDetailLoading(false); return; } setDetailDeliveries([]); setDetailError(''); setDetailLoading(true); try { const result = await api.webhookDeliveries({ webhookId: requestedWebhookId, status: deliveryStatus === 'all' ? undefined : deliveryStatus, eventType: deliveryEvent === 'all' ? undefined : deliveryEvent, limit: 200 }); if (requestId === detailRequestId.current) { setDetailDeliveries(result.deliveries || []); } } catch (error) { if (requestId === detailRequestId.current) { setDetailError(error instanceof Error ? error.message : t('common.error')); } } finally { if (requestId === detailRequestId.current) setDetailLoading(false); } }, [deliveryEvent, deliveryStatus, requestedWebhookId, t]); useEffect(() => { void loadDetailDeliveries(); }, [loadDetailDeliveries]); async function copyValue(value: string) { if (!value) return; if (onCopy) { onCopy(value); return; } await navigator.clipboard.writeText(value); message.success(t('common.copied')); } function openCreate() { setEditing(null); form.setFieldsValue({ name: '', url: '', events: [...selectableEvents], domainId: mailboxScoped ? undefined : (domainId != null ? domainId : undefined), enabled: true }); setDrawerOpen(true); } function openEdit(webhook: Webhook) { setEditing(webhook); form.setFieldsValue({ name: webhook.name, url: webhook.url, events: webhook.events?.length ? [...webhook.events] : [...selectableEvents], domainId: webhook.domainId ?? undefined, enabled: webhook.enabled }); setDrawerOpen(true); } function closeDrawer() { setDrawerOpen(false); setEditing(null); form.resetFields(); } async function submitForm() { const values = await form.validateFields(); const key = editing ? `save:${editing.id}` : 'create'; setActionKey(key); try { const payload: WebhookPayload = { name: values.name.trim(), url: values.url.trim(), events: values.events, domainId: mailboxScoped ? null : (domainId != null ? domainId : (values.domainId ?? null)), mailboxId: mailboxScoped ? mailboxId : null, enabled: values.enabled }; if (editing) { await api.updateWebhook(editing.id, payload); message.success(t('actions.webhookUpdated')); closeDrawer(); await loadData(); } else { const result = await api.createWebhook(payload); message.success(t('actions.webhookCreated')); closeDrawer(); if (result.webhook?.secret) { setSecretReveal({ webhook: result.webhook, mode: 'created' }); } await loadData(); } } catch (error) { message.error(error instanceof Error ? error.message : t('common.error')); } finally { setActionKey(''); } } async function toggleEnabled(webhook: Webhook, enabled: boolean) { setActionKey(`toggle:${webhook.id}`); try { await api.updateWebhook(webhook.id, { enabled }); setWebhooks((current) => current.map((item) => (item.id === webhook.id ? { ...item, enabled } : item)) ); } catch (error) { message.error(error instanceof Error ? error.message : t('common.error')); } finally { setActionKey(''); } } async function deleteWebhook(webhook: Webhook) { setActionKey(`delete:${webhook.id}`); try { await api.deleteWebhook(webhook.id); message.success(t('actions.webhookDeleted')); if (requestedWebhookId === webhook.id) closeDeliveries(); await loadData(); } catch (error) { message.error(error instanceof Error ? error.message : t('common.error')); } finally { setActionKey(''); } } async function rotateSecret(webhook: Webhook) { setActionKey(`rotate:${webhook.id}`); try { const result = await api.rotateWebhookSecret(webhook.id); message.success(t('actions.webhookSecretRotated')); if (result.webhook?.secret) { setSecretReveal({ webhook: result.webhook, mode: 'rotated' }); } await loadData(); } catch (error) { message.error(error instanceof Error ? error.message : t('common.error')); } finally { setActionKey(''); } } async function testWebhook(webhook: Webhook) { setActionKey(`test:${webhook.id}`); try { await api.testWebhook(webhook.id); message.success(t('actions.webhookTestQueued')); await loadData(); viewDeliveries(webhook); } catch (error) { message.error(error instanceof Error ? error.message : t('common.error')); } finally { setActionKey(''); } } async function replayDelivery(delivery: WebhookDelivery) { setActionKey(`replay:${delivery.id}`); try { await api.replayWebhookDelivery(delivery.id); message.success(t('actions.webhookDeliveryReplayed')); await Promise.all([loadData(), loadDetailDeliveries()]); } catch (error) { message.error(error instanceof Error ? error.message : t('common.error')); } finally { setActionKey(''); } } function viewDeliveries(webhook: Webhook) { const next = new URLSearchParams(searchParams); next.set('webhookId', String(webhook.id)); next.delete('deliveryStatus'); next.delete('deliveryEvent'); setSearchParams(next, { state: detailHistoryLocation(`${location.pathname}${location.search}`, 1) }); } function closeDeliveries() { const historyState = detailHistoryState(location.state); if (historyState) { navigate(-historyState.depth); return; } const next = new URLSearchParams(searchParams); next.delete('webhookId'); next.delete('deliveryStatus'); next.delete('deliveryEvent'); setSearchParams(next, { replace: true, state: null }); } function updateDeliveryFilter(key: 'deliveryStatus' | 'deliveryEvent', value: string) { const next = new URLSearchParams(searchParams); if (value === 'all') next.delete(key); else next.set(key, value); const historyState = detailHistoryState(location.state); if (!historyState) { setSearchParams(next, { replace: true }); return; } const search = next.toString(); navigate( { pathname: location.pathname, search: search ? `?${search}` : '' }, { state: detailHistoryLocation(historyState.listPath, historyState.depth + 1) } ); } function scopeLabel(webhook: Webhook) { if (webhook.mailboxId != null) { const address = mailboxMap.get(webhook.mailboxId); return address ? `${t('webhooks.scopeMailbox')} · ${address}` : t('webhooks.scopeMailbox'); } if (webhook.domainId == null) return t('webhooks.scopeAccount'); const name = domainMap.get(webhook.domainId); return name ? `${t('webhooks.scopeDomain')} · ${name}` : t('webhooks.scopeDomain'); } function eventLabel(event: string) { if (event === 'sent') return t('webhooks.eventSent'); if (event === 'bounced') return t('webhooks.eventBounced'); if (event === 'failed') return t('webhooks.eventFailed'); if (event === 'opened') return t('webhooks.eventOpened'); if (event === 'clicked') return t('webhooks.eventClicked'); if (event === 'received') return t('webhooks.eventReceived'); return event; } function deliveryStatusLabel(status: string) { if (status === 'pending') return t('webhooks.statusPending'); if (status === 'processing') return t('webhooks.statusProcessing'); if (status === 'success') return t('webhooks.statusSuccess'); if (status === 'dead') return t('webhooks.statusDead'); return status; } function deliveryStatusTone(status: string): StatusTone { if (status === 'success') return 'success'; if (status === 'pending') return 'info'; if (status === 'processing') return 'warning'; if (status === 'dead') return 'error'; return 'neutral'; } function lastDeliverySnippet(webhook: Webhook) { const delivery = lastDeliveryByWebhook.get(webhook.id); if (!delivery) return ; const detail = delivery.error || (delivery.responseStatus != null ? `HTTP ${delivery.responseStatus}` : '') || deliveryStatusLabel(String(delivery.status)); return ( {deliveryStatusLabel(String(delivery.status))} {eventLabel(String(delivery.eventType))} · {detail} ); } const endpointColumns: ColumnsType = [ { title: t('webhooks.name'), dataIndex: 'name', render: (value: string, webhook) => ( {value} {webhook.secretPrefix}… ) }, { title: t('webhooks.scope'), render: (_, webhook) => scopeLabel(webhook) }, { title: t('webhooks.url'), dataIndex: 'url', ellipsis: true, render: (value: string) => ( {truncateUrl(value)} ) }, { title: t('webhooks.events'), dataIndex: 'events', render: (events: WebhookEvent[]) => ( {(events || []).map((event) => ( {eventLabel(event)} ))} ) }, { title: t('webhooks.enabled'), dataIndex: 'enabled', width: 100, render: (enabled: boolean, webhook) => ( void toggleEnabled(webhook, checked)} checkedChildren={t('webhooks.enabled')} unCheckedChildren={t('webhooks.disabled')} /> ) }, { title: t('webhooks.lastAttemptAt'), render: (_, webhook) => lastDeliverySnippet(webhook) }, { title: t('webhooks.actions'), fixed: 'right', width: 280, render: (_, webhook) => ( ) } ]; const secret = secretReveal?.webhook.secret || ''; const sampleVerifier = buildSignatureSample(secret || 'whsec_your_secret'); return ( {!embedded ? ( } /> ) : ( )} {!embedded ? ( ) : null} {webhooks.length}} > {!loadError && resourceError ? : null} {!loadError && recentDeliveriesError && webhooks.length ? : null} {loadError ? ( } onClick={() => void loadData()}>{t('common.refresh')}} /> ) : loading && !webhooks.length ? ( ) : webhooks.length ? ( ) : ( )} } >
({ value: event, label: eventLabel(event) }))} disabled={mailboxScoped} /> {!scoped ? ( updateDeliveryFilter('deliveryStatus', value)} options={[{ value: 'all', label: t('webhooks.allStatuses') }, ...DELIVERY_STATUSES.map((status) => ({ value: status, label: deliveryStatusLabel(status) }))]} />
) : } ) : loadError && requestedWebhookId ? ( } onClick={() => void loadData()}>{t('common.refresh')}} /> ) : requestedWebhookId ? : null} } onClick={() => void copyValue(secret)} > {t('webhooks.copySecret')} , ]} >
{t('webhooks.secret')}
{t('webhooks.docsSignature')}
); } function truncateUrl(url: string, max = 48) { if (!url) return ''; if (url.length <= max) return url; return `${url.slice(0, max - 1)}…`; } function positiveInteger(value: string | null) { const parsed = Number(value); return Number.isInteger(parsed) && parsed > 0 ? parsed : null; } function deliveryStatusFromParam(value: string | null): WebhookDeliveryStatus | 'all' { return DELIVERY_STATUSES.includes(value as WebhookDeliveryStatus) ? value as WebhookDeliveryStatus : 'all'; } function deliveryEventFromParam(value: string | null): WebhookEvent | 'all' { return ALL_EVENTS.includes(value as WebhookEvent) ? value as WebhookEvent : 'all'; } function buildSignatureSample(secret: string) { return `// Verify X-MailHub-Signature (Node.js) const crypto = require('crypto'); function verify(rawBody, signatureHeader, secret = ${JSON.stringify(secret)}) { const parts = Object.fromEntries( signatureHeader.split(',').map((p) => p.trim().split('=')) ); const signed = \`\${parts.t}.\${rawBody}\`; const expected = crypto.createHmac('sha256', secret).update(signed).digest('hex'); return crypto.timingSafeEqual(Buffer.from(parts.v1, 'hex'), Buffer.from(expected, 'hex')); }`; }