UNPKG

@allma/core-cdk

Version:

Core AWS CDK constructs for deploying the Allma serverless AI orchestration platform.

168 lines 8.93 kB
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2'; import { TransientStepError, EmailSendStepPayloadSchema, RenderedEmailParamsSchema, } from '@allma/core-types'; import { log_error, log_info, log_debug } from '@allma/core-sdk'; import { renderNestedTemplates } from '../../../allma-core/utils/template-renderer.js'; const sesClient = new SESv2Client({}); /** * Extracts the email address from a string that might be in the "Name <email@example.com>" format. * @param input The string to parse. * @returns The extracted email address. */ function extractEmail(input) { if (typeof input !== 'string') return ''; const match = input.match(/<([^>]+)>/); return match ? match[1] : input.trim(); } /** * A standard StepHandler for sending an email via AWS SES. * It expects a pre-rendered configuration object. */ export const executeSendEmail = async (stepDefinition, stepInput, runtimeState) => { const correlationId = runtimeState.flowExecutionId; // The stepDefinition object itself contains the templates (e.g., subject: "Re: {{subject}}"). // This will be the object we render. const templateObject = stepDefinition; // The context for rendering needs to include the general flow context // AND the specific inputs for this step from inputMappings. const templateContext = { ...runtimeState.currentContextData, ...runtimeState, ...stepInput }; // Now, render the templates within the stepDefinition using the full context. // Await the async renderer to ensure JSONPaths are resolved correctly. const renderedInput = await renderNestedTemplates(templateObject, templateContext, correlationId); // First, parse against the relaxed schema to get the structure and values. const structuralValidation = EmailSendStepPayloadSchema.safeParse(renderedInput); if (!structuralValidation.success) { log_error("Invalid structural input for system/email-send module after rendering.", { errors: structuralValidation.error.flatten(), receivedStepInput: stepInput, templateObjectBeforeRender: templateObject, finalInputAfterRender: renderedInput, }, correlationId); throw new Error(`Invalid input structure for email-send: ${structuralValidation.error.message}`); } /** * Processes a dynamic address field that could be a stringified JSON array, * a comma-separated string, a single email string, or a native array. * This uses a heuristic to handle names containing commas (e.g., "Last, First <email@domain.com>"). * @param fieldValue The value to process. * @returns An array of strings, a single string, or undefined. */ const processAddressField = (fieldValue) => { if (typeof fieldValue !== 'string') { return fieldValue; // It's already an array, or undefined/null. } const trimmed = fieldValue.trim(); // Case 1: Attempt to parse as a stringified JSON array. if (trimmed.startsWith('[') && trimmed.endsWith(']')) { try { const parsed = JSON.parse(trimmed); if (Array.isArray(parsed)) { log_debug('Successfully parsed stringified JSON array in address field.', { originalValue: fieldValue }, correlationId); return parsed; } } catch (e) { log_debug('An address field appeared to be a JSON array but failed to parse. Falling back to intelligent comma-splitting.', { value: trimmed }, correlationId); } } // Case 2: Intelligent comma splitting. // Standard splitting by comma fails on "Last, First <email>". // We accumulate parts until we have a segment that looks like a complete address (has '@' and balanced quotes/brackets). const rawParts = trimmed.split(','); const finalAddresses = []; let currentBuffer = ""; for (let i = 0; i < rawParts.length; i++) { const part = rawParts[i]; if (currentBuffer === "") { currentBuffer = part; } else { currentBuffer += "," + part; } // Heuristic checks const hasAt = currentBuffer.includes('@'); const openAngles = (currentBuffer.match(/</g) || []).length; const closeAngles = (currentBuffer.match(/>/g) || []).length; const quotes = (currentBuffer.match(/"/g) || []).length; // We consider the buffer "balanced" if angle brackets match and quotes are even. const isBalanced = (openAngles === closeAngles) && (quotes % 2 === 0); // We commit the address if: // 1. It looks valid (has @ and is balanced) // 2. OR we are at the very end of the string (flush whatever garbage is left) if ((hasAt && isBalanced) || i === rawParts.length - 1) { const cleanAddress = currentBuffer.trim(); if (cleanAddress.length > 0) { finalAddresses.push(cleanAddress); } currentBuffer = ""; } } // Return single string if one item, array otherwise. return finalAddresses.length === 1 ? finalAddresses[0] : finalAddresses; }; const { from, to: rawTo, replyTo: rawReplyTo, subject, body } = structuralValidation.data; const to = processAddressField(rawTo); const replyTo = processAddressField(rawReplyTo); // Handle case where required fields resolve to undefined/null after templating if (!from) throw new Error("The 'from' field is missing or resolved to an empty value after template rendering."); if (!to || (Array.isArray(to) && to.length === 0)) throw new Error("The 'to' field is missing or resolved to an empty value after template rendering."); if (subject === undefined || subject === null) throw new Error("The 'subject' field is missing or resolved to an empty value after template rendering."); if (body === undefined || body === null) throw new Error("The 'body' field is missing or resolved to an empty value after template rendering."); const cleanedParams = { from: extractEmail(from), // Use the processed 'to' and 'replyTo' values to: Array.isArray(to) ? to.map(extractEmail) : extractEmail(to), replyTo: replyTo ? (Array.isArray(replyTo) ? replyTo.map(extractEmail) : extractEmail(replyTo)) : undefined, subject, body, }; // Now, perform a strict validation on the cleaned, rendered values. const runtimeValidation = RenderedEmailParamsSchema.safeParse(cleanedParams); if (!runtimeValidation.success) { log_error("Rendered email parameters are invalid. Check that your templates resolve to valid email addresses.", { errors: runtimeValidation.error.flatten(), // Log the state of data at each step for debugging renderedValues: structuralValidation.data, processedValues: { to, replyTo }, cleanedValues: cleanedParams }, correlationId); throw new Error(`Invalid rendered parameters for email-send: ${runtimeValidation.error.message}`); } const { from: validFrom, to: validTo, replyTo: validReplyTo, subject: validSubject, body: validBody } = runtimeValidation.data; const toAddresses = Array.isArray(validTo) ? validTo : [validTo]; const replyToAddresses = validReplyTo ? (Array.isArray(validReplyTo) ? validReplyTo : [validReplyTo]) : undefined; log_info(`Sending email via SES`, { from: validFrom, to: toAddresses, replyTo: replyToAddresses, subject: validSubject }, correlationId); const command = new SendEmailCommand({ FromEmailAddress: validFrom, Destination: { ToAddresses: toAddresses }, ReplyToAddresses: replyToAddresses, Content: { Simple: { Subject: { Data: validSubject }, Body: { Html: { Data: validBody } }, }, }, }); try { const result = await sesClient.send(command); log_info(`Successfully sent email via SES.`, { messageId: result.MessageId }, correlationId); return { outputData: { sesMessageId: result.MessageId, _meta: { status: 'SUCCESS' }, }, }; } catch (error) { log_error(`Failed to send email via SES: ${error.message}`, { from: validFrom, to: toAddresses, error: error.message }, correlationId); if (['ServiceUnavailable', 'ThrottlingException', 'InternalFailure'].includes(error.name)) { throw new TransientStepError(`SES send failed due to a transient error: ${error.message}`); } throw error; } }; //# sourceMappingURL=send-email-handler.js.map