Files
floci-ui/packages/api/src/adapter-aws/AwsSesAdapter.ts
T
Zakaria 91ce11274d
CI / checks (pull_request) Successful in 25m47s
chore: show TheCloud instead of Floci in console text and messages
Labels, empty states, error messages and defaults the console shows now name
TheCloud or its runtimes. Identifiers that must match the emulator (FLOCI_*
settings, the X-Floci-CSRF header, emulator-returned host names) are unchanged.
2026-10-24 14:30:44 -04:00

167 lines
6.0 KiB
TypeScript

import {httpStatusToCloudError, NotSupportedError, RuntimeError, RuntimeUnavailableError} from '../cloud-spi/errors'
import {awsSesEmailSchema} from '../cloud-spi/emailSchema'
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
interface FlociSesMessage {
Id?: string
Region?: string | null
Source?: string
Destination?: {
ToAddresses?: string[]
CcAddresses?: string[]
BccAddresses?: string[]
}
ReplyToAddresses?: string[]
Subject?: string
Body?: {
text_part?: string | null
html_part?: string | null
}
RawData?: string
Timestamp?: string
}
interface FlociSesInbox {
messages?: FlociSesMessage[]
}
type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
/**
* Adapts Floci's documented SES inspection surface to normalized email resources.
* It deliberately does not send email: applications under test keep using the
* AWS SES SDK, and this mailbox shows exactly what the runtime captured.
*/
export class AwsSesAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'email' as const
constructor(
private readonly endpoint = process.env.FLOCI_ENDPOINT ?? 'http://localhost:4566',
private readonly fetcher: FetchLike = globalThis.fetch,
) {}
schema(): ServiceSchema {
return awsSesEmailSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const messages = await this.fetchInbox()
const resources = messages.map(messageToResource).sort(newestFirst)
return filterBySearch(resources, query.search)
}
async get(id: string): Promise<CloudResource | null> {
const messages = await this.fetchInbox(id)
const message = messages.find((entry) => entry.Id === id)
return message ? messageToResource(message) : null
}
async create(_input: CreateResourceInput): Promise<CloudResource> {
throw new NotSupportedError('Sending email from the mailbox is not implemented yet')
}
async delete(_id: string): Promise<void> {
throw new NotSupportedError('Deleting individual captured emails is not supported by TheCloud SES')
}
async clearEmailInbox(): Promise<void> {
await this.request('/_aws/ses', {method: 'DELETE'})
}
private async fetchInbox(id?: string): Promise<FlociSesMessage[]> {
const search = id ? `?id=${encodeURIComponent(id)}` : ''
const response = await this.request(`/_aws/ses${search}`, {method: 'GET'})
let body: FlociSesInbox
try {
body = await response.json() as FlociSesInbox
} catch (error) {
throw new RuntimeError('TheCloud SES inspection returned invalid JSON', {cause: error})
}
if (!Array.isArray(body.messages)) {
throw new RuntimeError('TheCloud SES inspection response did not contain messages')
}
return body.messages
}
private async request(path: string, init: RequestInit): Promise<Response> {
let response: Response
try {
response = await this.fetcher(`${this.endpoint.replace(/\/$/, '')}${path}`, init)
} catch (error) {
throw new RuntimeUnavailableError(`Cannot reach TheCloud SES at ${this.endpoint}`, {cause: error})
}
if (response.ok) return response
const detail = await response.text().catch(() => '')
throw httpStatusToCloudError(
response.status,
detail || `TheCloud SES inspection returned HTTP ${response.status}`,
)
}
}
function messageToResource(message: FlociSesMessage): CloudResource {
const id = message.Id ?? ''
const raw = typeof message.RawData === 'string'
return {
id,
name: message.Subject?.trim() || '(no subject)',
cloud: 'aws',
service: 'email',
type: 'email',
region: message.Region ?? null,
createdAt: message.Timestamp ?? null,
status: 'captured',
metadata: {
provider: 'aws',
emailService: 'ses',
source: message.Source ?? null,
toAddresses: message.Destination?.ToAddresses ?? [],
ccAddresses: message.Destination?.CcAddresses ?? [],
bccAddresses: message.Destination?.BccAddresses ?? [],
replyToAddresses: message.ReplyToAddresses ?? [],
textBody: message.Body?.text_part ?? null,
htmlBody: message.Body?.html_part ?? null,
rawData: message.RawData ?? null,
messageType: raw ? 'raw' : 'simple',
code: findCode(message.Body?.text_part ?? message.Body?.html_part),
},
}
}
// Matches the wording of Cognito's default and most custom messages: "Your verification code is 123456."
const CODE_PATTERN = /\bcode\s+is:?\s*(?:<[^>]+>\s*)*(\d{4,8})\b/i
function findCode(body: string | null | undefined): string | null {
return body?.match(CODE_PATTERN)?.[1] ?? null
}
function newestFirst(a: CloudResource, b: CloudResource): number {
return (b.createdAt ?? '').localeCompare(a.createdAt ?? '')
}
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
const normalized = search?.trim().toLowerCase()
if (!normalized) return resources
return resources.filter((resource) => {
const metadata = resource.metadata
const searchable = [
resource.name,
stringValue(metadata.source),
...stringList(metadata.toAddresses),
...stringList(metadata.ccAddresses),
...stringList(metadata.bccAddresses),
].join(' ').toLowerCase()
return searchable.includes(normalized)
})
}
function stringValue(value: unknown): string {
return typeof value === 'string' ? value : ''
}
function stringList(value: unknown): string[] {
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
}