@allma/core-cdk
Version:
Core AWS CDK constructs for deploying the Allma serverless AI orchestration platform.
168 lines • 8.93 kB
JavaScript
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