feat: MSK, MSK CONNECT and SES

This commit is contained in:
Zakaria
2026-10-05 11:06:57 -04:00
parent 2d8ff50a99
commit c0d3e5f402
84 changed files with 3513 additions and 195 deletions
@@ -0,0 +1,71 @@
import {useEffect, useState} from 'react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {Loader2, Mail, Plus, RefreshCw, Send, Trash2} from 'lucide-react'
import {createSesConfigurationSet, createSesEventDestination, createSesIdentity, deleteSesConfigurationSet, deleteSesEventDestination, deleteSesIdentity, deleteSesSuppression, deleteSesTemplate, getSesTemplate, listSesConfigurationSets, listSesEventBuses, listSesEventDestinations, listSesIdentities, listSesSuppressions, listSesTemplates, listSesTopics, putSesSuppression, renderSesTemplate, saveSesTemplate, sendSesRaw, sendSesSimple, sendSesTemplate} from '@/api/sesClient'
const SIMULATORS = ['success@simulator.amazonses.com', 'bounce@simulator.amazonses.com', 'complaint@simulator.amazonses.com']
const EVENT_TYPES = ['SEND', 'DELIVERY', 'BOUNCE', 'COMPLAINT', 'REJECT']
export function SesConsolePanel({runtimeReachable}: {runtimeReachable: boolean}) {
const qc = useQueryClient()
const [identity, setIdentity] = useState('')
const [mode, setMode] = useState<'simple' | 'raw' | 'template'>('simple')
const [from, setFrom] = useState('')
const [to, setTo] = useState(SIMULATORS[0])
const [subject, setSubject] = useState('Local SES test')
const [text, setText] = useState('Sent from the Floci SES console.')
const [html, setHtml] = useState('')
const [raw, setRaw] = useState('From: sender@example.test\r\nTo: success@simulator.amazonses.com\r\nSubject: Raw SES test\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\nHello from a raw MIME message.')
const [configurationSet, setConfigurationSet] = useState('')
const [templateName, setTemplateName] = useState('')
const [templateData, setTemplateData] = useState('{"name":"Floci"}')
const [editingTemplate, setEditingTemplate] = useState('')
const [templateSubject, setTemplateSubject] = useState('Hello {{name}}')
const [templateText, setTemplateText] = useState('Hello {{name}}')
const [templateHtml, setTemplateHtml] = useState('')
const [rendered, setRendered] = useState('')
const [suppressionAddress, setSuppressionAddress] = useState('')
const [suppressionReason, setSuppressionReason] = useState<'BOUNCE' | 'COMPLAINT'>('BOUNCE')
const [configurationSetName, setConfigurationSetName] = useState('')
const [selectedConfigurationSet, setSelectedConfigurationSet] = useState('')
const [destinationName, setDestinationName] = useState('')
const [destinationKind, setDestinationKind] = useState<'sns' | 'eventbridge' | 'cloudwatch'>('sns')
const [destinationTarget, setDestinationTarget] = useState('')
const [events, setEvents] = useState<string[]>(['SEND', 'BOUNCE', 'DELIVERY'])
const [dimensionName, setDimensionName] = useState('ses:configuration-set')
const identities = useQuery({queryKey: ['ses-identities'], queryFn: listSesIdentities, enabled: runtimeReachable})
const templates = useQuery({queryKey: ['ses-templates'], queryFn: listSesTemplates, enabled: runtimeReachable})
const suppressions = useQuery({queryKey: ['ses-suppressions'], queryFn: listSesSuppressions, enabled: runtimeReachable})
const configurationSets = useQuery({queryKey: ['ses-configuration-sets'], queryFn: listSesConfigurationSets, enabled: runtimeReachable})
const topics = useQuery({queryKey: ['ses-topics'], queryFn: listSesTopics, enabled: runtimeReachable})
const buses = useQuery({queryKey: ['ses-event-buses'], queryFn: listSesEventBuses, enabled: runtimeReachable})
const destinations = useQuery({queryKey: ['ses-event-destinations', selectedConfigurationSet], queryFn: () => listSesEventDestinations(selectedConfigurationSet), enabled: runtimeReachable && Boolean(selectedConfigurationSet)})
const refreshMailbox = () => qc.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'email']})
const send = useMutation({mutationFn: async () => {
const destination = {ToAddresses: to.split(',').map((value) => value.trim()).filter(Boolean)}
const options = configurationSet ? {ConfigurationSetName: configurationSet} : {}
if (mode === 'raw') return sendSesRaw({...options, FromEmailAddress: from || undefined, Destination: destination, Content: {Raw: {Data: btoa(unescape(encodeURIComponent(raw)))}}})
if (mode === 'template') return sendSesTemplate({...options, FromEmailAddress: from, Destination: destination, Content: {Template: {TemplateName: templateName, TemplateData: templateData}}})
return sendSesSimple({...options, FromEmailAddress: from, Destination: destination, Content: {Simple: {Subject: {Data: subject}, Body: {...(text ? {Text: {Data: text}} : {}), ...(html ? {Html: {Data: html}} : {})}}}})
}, onSuccess: () => { refreshMailbox(); setTimeout(refreshMailbox, 300) }})
const createIdentity = useMutation({mutationFn: () => createSesIdentity(identity.trim()), onSuccess: () => {setIdentity(''); void qc.invalidateQueries({queryKey: ['ses-identities']})}})
const saveTemplate = useMutation({mutationFn: () => saveSesTemplate(editingTemplate.trim(), {Subject: templateSubject, ...(templateText ? {Text: templateText} : {}), ...(templateHtml ? {Html: templateHtml} : {})}, templates.data?.some((item) => item.TemplateName === editingTemplate) ?? false), onSuccess: () => void qc.invalidateQueries({queryKey: ['ses-templates']})})
const renderTemplate = useMutation({mutationFn: () => renderSesTemplate(editingTemplate || templateName, templateData), onSuccess: setRendered})
const addSuppression = useMutation({mutationFn: () => putSesSuppression(suppressionAddress.trim(), suppressionReason), onSuccess: () => {setSuppressionAddress(''); void qc.invalidateQueries({queryKey: ['ses-suppressions']})}})
const createConfigurationSet = useMutation({mutationFn: () => createSesConfigurationSet(configurationSetName.trim()), onSuccess: () => {setConfigurationSetName(''); void qc.invalidateQueries({queryKey: ['ses-configuration-sets']})}})
const addDestination = useMutation({mutationFn: () => createSesEventDestination(selectedConfigurationSet, {EventDestinationName: destinationName.trim(), EventDestination: {Enabled: true, MatchingEventTypes: events, ...(destinationKind === 'sns' ? {SnsDestination: {TopicArn: destinationTarget}} : destinationKind === 'eventbridge' ? {EventBridgeDestination: {EventBusArn: destinationTarget}} : {CloudWatchDestination: {DimensionConfigurations: [{DimensionName: dimensionName, DimensionValueSource: 'MESSAGE_TAG', DefaultDimensionValue: 'local'}]}})}}), onSuccess: () => {setDestinationName(''); void qc.invalidateQueries({queryKey: ['ses-event-destinations', selectedConfigurationSet]})}})
const error = send.error ?? createIdentity.error ?? saveTemplate.error ?? renderTemplate.error ?? addSuppression.error ?? createConfigurationSet.error ?? addDestination.error
useEffect(() => { if (!selectedConfigurationSet && configurationSets.data?.[0]?.ConfigurationSetName) setSelectedConfigurationSet(configurationSets.data[0].ConfigurationSetName) }, [configurationSets.data, selectedConfigurationSet])
async function loadTemplate(name: string) { setEditingTemplate(name); const detail = await getSesTemplate(name); setTemplateSubject(detail.TemplateContent?.Subject ?? ''); setTemplateText(detail.TemplateContent?.Text ?? ''); setTemplateHtml(detail.TemplateContent?.Html ?? '') }
const pending = send.isPending || createIdentity.isPending || saveTemplate.isPending || addSuppression.isPending || createConfigurationSet.isPending || addDestination.isPending
return <div className="ses-console">
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">SES identities</p><h3>Verify senders</h3><p className="muted compact-text">Floci verifies identities immediately for AWS-compatible workflows. Local sending is not sandbox-gated.</p></div><button className="button compact" type="button" onClick={() => identities.refetch()}><RefreshCw size={13}/>Refresh</button></div><form className="input-row" onSubmit={(event) => {event.preventDefault(); if (identity.trim()) createIdentity.mutate()}}><input className="input" value={identity} onChange={(event) => setIdentity(event.target.value)} placeholder="sender@example.test or example.test" required/><button className="button primary" disabled={!runtimeReachable || createIdentity.isPending}><Plus size={13}/>Verify identity</button></form>{identities.data?.map((item) => <div className="apigateway-route-item" key={item.IdentityName}><div><code>{item.IdentityName}</code><small>{item.IdentityType} · {item.VerifiedForSendingStatus ? 'verified for sending' : 'not verified'}</small></div><button className="icon-btn danger" onClick={() => deleteSesIdentity(item.IdentityName).then(() => qc.invalidateQueries({queryKey: ['ses-identities']}))}><Trash2 size={13}/></button></div>)}</section>
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Compose</p><h3><Mail size={15}/> Send through SES</h3><p className="muted compact-text">Simulator recipients produce documented delivery, bounce, and complaint events locally.</p></div></div><div className="button-row">{SIMULATORS.map((address) => <button className="button compact" type="button" key={address} onClick={() => setTo(address)}>{address.split('@')[0]}</button>)}</div><form className="dynamic-form" onSubmit={(event) => {event.preventDefault(); send.mutate()}}><label className="dynamic-field"><span>Format</span><select className="input" value={mode} onChange={(event) => setMode(event.target.value as typeof mode)}><option value="simple">Simple</option><option value="raw">Raw MIME</option><option value="template">Template</option></select></label><label className="dynamic-field"><span>From</span><input className="input" value={from} onChange={(event) => setFrom(event.target.value)} placeholder="Verified identity" required={mode !== 'raw'}/></label><label className="dynamic-field"><span>To</span><input className="input" value={to} onChange={(event) => setTo(event.target.value)} required/></label><label className="dynamic-field"><span>Configuration set</span><select className="input" value={configurationSet} onChange={(event) => setConfigurationSet(event.target.value)}><option value="">None</option>{configurationSets.data?.map((item) => <option key={item.ConfigurationSetName} value={item.ConfigurationSetName}>{item.ConfigurationSetName}</option>)}</select></label>{mode === 'simple' && <><label className="dynamic-field dynamic-field--span"><span>Subject</span><input className="input" value={subject} onChange={(event) => setSubject(event.target.value)} required/></label><label className="dynamic-field"><span>Text body</span><textarea className="textarea code-textarea small" value={text} onChange={(event) => setText(event.target.value)}/></label><label className="dynamic-field"><span>HTML body</span><textarea className="textarea code-textarea small" value={html} onChange={(event) => setHtml(event.target.value)}/></label></>}{mode === 'raw' && <label className="dynamic-field dynamic-field--span"><span>Raw MIME</span><textarea className="textarea code-textarea" value={raw} onChange={(event) => setRaw(event.target.value)} required/></label>}{mode === 'template' && <><label className="dynamic-field"><span>Template</span><select className="input" value={templateName} onChange={(event) => setTemplateName(event.target.value)} required><option value="">Select template</option>{templates.data?.map((item) => <option key={item.TemplateName} value={item.TemplateName}>{item.TemplateName}</option>)}</select></label><label className="dynamic-field"><span>Template data (JSON)</span><textarea className="textarea code-textarea small" value={templateData} onChange={(event) => setTemplateData(event.target.value)} required/></label></>}<button className="button primary" disabled={!runtimeReachable || pending}>{send.isPending ? <Loader2 className="spin" size={13}/> : <Send size={13}/>}{send.isPending ? 'Sending' : 'Send email'}</button></form>{send.isSuccess && <p className="success-text compact-text">Accepted by SES. Refresh the mailbox below to inspect the captured message.</p>}</section>
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Templates</p><h3>Author and render</h3></div></div><form className="dynamic-form" onSubmit={(event) => {event.preventDefault(); saveTemplate.mutate()}}><label className="dynamic-field"><span>Template name</span><input className="input" value={editingTemplate} onChange={(event) => setEditingTemplate(event.target.value)} required/></label><label className="dynamic-field"><span>Subject</span><input className="input" value={templateSubject} onChange={(event) => setTemplateSubject(event.target.value)} required/></label><label className="dynamic-field"><span>Text</span><textarea className="textarea code-textarea small" value={templateText} onChange={(event) => setTemplateText(event.target.value)}/></label><label className="dynamic-field"><span>HTML</span><textarea className="textarea code-textarea small" value={templateHtml} onChange={(event) => setTemplateHtml(event.target.value)}/></label><div className="button-row"><button className="button primary" disabled={!runtimeReachable || !editingTemplate.trim() || saveTemplate.isPending}>Save template</button><button className="button" type="button" disabled={!editingTemplate.trim() || renderTemplate.isPending} onClick={() => renderTemplate.mutate()}>Test render</button></div></form>{rendered && <pre className="metadata-block">{rendered}</pre>}{templates.data?.map((item) => <div className="apigateway-route-item" key={item.TemplateName}><code>{item.TemplateName}</code><div><button className="button compact" onClick={() => void loadTemplate(item.TemplateName)}>Edit</button><button className="icon-btn danger" onClick={() => deleteSesTemplate(item.TemplateName).then(() => qc.invalidateQueries({queryKey: ['ses-templates']}))}><Trash2 size={13}/></button></div></div>)}</section>
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Suppression list</p><h3>Accepted but suppressed</h3><p className="muted compact-text">Suppressed mail is still accepted and captured, but it is not relayed.</p></div></div><form className="input-row" onSubmit={(event) => {event.preventDefault(); addSuppression.mutate()}}><input className="input" type="email" value={suppressionAddress} onChange={(event) => setSuppressionAddress(event.target.value)} placeholder="recipient@example.test" required/><select className="input" value={suppressionReason} onChange={(event) => setSuppressionReason(event.target.value as typeof suppressionReason)}><option value="BOUNCE">Bounce</option><option value="COMPLAINT">Complaint</option></select><button className="button primary" disabled={addSuppression.isPending}>Add</button></form>{suppressions.data?.map((item) => <div className="apigateway-route-item" key={item.EmailAddress}><div><code>{item.EmailAddress}</code><small>{item.Reason}</small></div><button className="icon-btn danger" onClick={() => deleteSesSuppression(item.EmailAddress ?? '').then(() => qc.invalidateQueries({queryKey: ['ses-suppressions']}))}><Trash2 size={13}/></button></div>)}</section>
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Configuration sets</p><h3>Publish SES events</h3><p className="muted compact-text">Send, delivery, bounce, and complaint events use real SNS, EventBridge, and CloudWatch destinations.</p></div></div><form className="input-row" onSubmit={(event) => {event.preventDefault(); createConfigurationSet.mutate()}}><input className="input" value={configurationSetName} onChange={(event) => setConfigurationSetName(event.target.value)} placeholder="Configuration set name" required/><button className="button primary" disabled={createConfigurationSet.isPending}>Create</button></form><label className="dynamic-field"><span>Active configuration set</span><select className="input" value={selectedConfigurationSet} onChange={(event) => setSelectedConfigurationSet(event.target.value)}><option value="">Select configuration set</option>{configurationSets.data?.map((item) => <option key={item.ConfigurationSetName} value={item.ConfigurationSetName}>{item.ConfigurationSetName}</option>)}</select></label>{selectedConfigurationSet && <><form className="dynamic-form" onSubmit={(event) => {event.preventDefault(); addDestination.mutate()}}><label className="dynamic-field"><span>Destination name</span><input className="input" value={destinationName} onChange={(event) => setDestinationName(event.target.value)} required/></label><label className="dynamic-field"><span>Target type</span><select className="input" value={destinationKind} onChange={(event) => {setDestinationKind(event.target.value as typeof destinationKind); setDestinationTarget('')}}><option value="sns">SNS topic</option><option value="eventbridge">EventBridge bus</option><option value="cloudwatch">CloudWatch metric</option></select></label>{destinationKind !== 'cloudwatch' ? <label className="dynamic-field dynamic-field--span"><span>{destinationKind === 'sns' ? 'SNS topic' : 'Event bus'}</span><select className="input" value={destinationTarget} onChange={(event) => setDestinationTarget(event.target.value)} required><option value="">Select target</option>{(destinationKind === 'sns' ? topics.data?.map((item) => ({value: item.TopicArn, label: item.TopicArn})) : buses.data?.map((item) => ({value: item.Arn, label: item.Name ?? item.Arn})))?.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label> : <label className="dynamic-field dynamic-field--span"><span>CloudWatch dimension</span><input className="input" value={dimensionName} onChange={(event) => setDimensionName(event.target.value)} required/><small>Events publish counts to AWS/SES using this message-tag dimension.</small></label>}<fieldset className="dynamic-field dynamic-field--span"><span>Events</span><div className="button-row">{EVENT_TYPES.map((type) => <label key={type}><input type="checkbox" checked={events.includes(type)} onChange={() => setEvents((current) => current.includes(type) ? current.filter((item) => item !== type) : [...current, type])}/>{type}</label>)}</div></fieldset><button className="button primary" disabled={!destinationName.trim() || events.length === 0 || (destinationKind !== 'cloudwatch' && !destinationTarget) || addDestination.isPending}>Add destination</button></form>{destinations.data?.map((item) => <div className="apigateway-route-item" key={item.EventDestinationName}><div><code>{item.EventDestinationName}</code><small>{item.MatchingEventTypes?.join(', ')} · {item.SnsDestination?.TopicArn ?? item.EventBridgeDestination?.EventBusArn ?? 'CloudWatch'}</small></div><button className="icon-btn danger" onClick={() => deleteSesEventDestination(selectedConfigurationSet, item.EventDestinationName ?? '').then(() => qc.invalidateQueries({queryKey: ['ses-event-destinations', selectedConfigurationSet]}))}><Trash2 size={13}/></button></div>)}</>} {configurationSets.data?.map((item) => <button className="button danger compact" key={`delete-${item.ConfigurationSetName}`} onClick={() => deleteSesConfigurationSet(item.ConfigurationSetName).then(() => qc.invalidateQueries({queryKey: ['ses-configuration-sets']}))}>Delete {item.ConfigurationSetName}</button>)}</section>
{error instanceof Error && <p className="error-text compact-text">{error.message}</p>}
</div>
}