@@ -501,6 +501,15 @@ async function checkIfAwsIsBootstrapped(options?: DeployOptions) {
501501 const hasEmailBucket = resources . StackResourceSummaries ?. some (
502502 ( r : any ) => r . LogicalResourceId === 'EmailBucket'
503503 )
504+ const hasOutboundLambda = resources . StackResourceSummaries ?. some (
505+ ( r : any ) => r . LogicalResourceId === 'OutboundEmailLambda'
506+ )
507+ const hasConversionLambda = resources . StackResourceSummaries ?. some (
508+ ( r : any ) => r . LogicalResourceId === 'EmailConversionLambda'
509+ )
510+ const hasNotificationTopic = resources . StackResourceSummaries ?. some (
511+ ( r : any ) => r . LogicalResourceId === 'EmailNotificationTopic'
512+ )
504513
505514 // Get current email domain from stack outputs to check if it needs updating
506515 const currentEmailDomain = result . Stacks [ 0 ] ?. Outputs ?. find (
@@ -517,6 +526,10 @@ async function checkIfAwsIsBootstrapped(options?: DeployOptions) {
517526 log . info ( `Email domain changed: ${ currentEmailDomain } -> ${ configuredDomain } , will update...` )
518527 needsEmailUpdate = true
519528 }
529+ else if ( hasEmailBucket && ( ! hasOutboundLambda || ! hasConversionLambda || ! hasNotificationTopic ) ) {
530+ log . info ( 'Email infrastructure incomplete, will update...' )
531+ needsEmailUpdate = true
532+ }
520533
521534 if ( ! needsEmailUpdate ) {
522535 return true
@@ -833,6 +846,200 @@ exports.handler = async (event) => {
833846 } ,
834847 }
835848
849+ // Outbound Email Lambda Function
850+ template . Resources . OutboundEmailLambda = {
851+ Type : 'AWS::Lambda::Function' ,
852+ DependsOn : [ 'EmailLambdaRole' ] ,
853+ Properties : {
854+ FunctionName : `${ appName } -outbound-email` ,
855+ Runtime : 'nodejs20.x' ,
856+ Handler : 'index.handler' ,
857+ Role : { 'Fn::GetAtt' : [ 'EmailLambdaRole' , 'Arn' ] } ,
858+ Timeout : 30 ,
859+ MemorySize : 256 ,
860+ Environment : {
861+ Variables : {
862+ DOMAIN : emailDomain ,
863+ CONFIGURATION_SET : `${ appName } -email-config` ,
864+ } ,
865+ } ,
866+ Code : {
867+ ZipFile : `
868+ const { SESClient, SendRawEmailCommand } = require('@aws-sdk/client-ses');
869+ const ses = new SESClient({});
870+
871+ exports.handler = async (event) => {
872+ console.log('Processing outbound email:', JSON.stringify(event));
873+ const { to, from, subject, html, text, cc, bcc, replyTo, attachments = [] } = event;
874+ const domain = process.env.DOMAIN;
875+ const configSet = process.env.CONFIGURATION_SET;
876+
877+ const boundary = 'NextPart_' + Date.now().toString(16);
878+ const fromAddress = from || 'noreply@' + domain;
879+
880+ let rawEmail = '';
881+ rawEmail += 'From: ' + fromAddress + '\\r\\n';
882+ rawEmail += 'To: ' + (Array.isArray(to) ? to.join(', ') : to) + '\\r\\n';
883+ if (cc) rawEmail += 'Cc: ' + (Array.isArray(cc) ? cc.join(', ') : cc) + '\\r\\n';
884+ if (bcc) rawEmail += 'Bcc: ' + (Array.isArray(bcc) ? bcc.join(', ') : bcc) + '\\r\\n';
885+ if (replyTo) rawEmail += 'Reply-To: ' + replyTo + '\\r\\n';
886+ rawEmail += 'Subject: ' + subject + '\\r\\n';
887+ rawEmail += 'MIME-Version: 1.0\\r\\n';
888+ rawEmail += 'Content-Type: multipart/mixed; boundary="' + boundary + '"\\r\\n\\r\\n';
889+
890+ rawEmail += '--' + boundary + '\\r\\n';
891+ rawEmail += 'Content-Type: multipart/alternative; boundary="alt_boundary"\\r\\n\\r\\n';
892+
893+ if (text) {
894+ rawEmail += '--alt_boundary\\r\\n';
895+ rawEmail += 'Content-Type: text/plain; charset=UTF-8\\r\\n\\r\\n';
896+ rawEmail += text + '\\r\\n\\r\\n';
897+ }
898+ if (html) {
899+ rawEmail += '--alt_boundary\\r\\n';
900+ rawEmail += 'Content-Type: text/html; charset=UTF-8\\r\\n\\r\\n';
901+ rawEmail += html + '\\r\\n\\r\\n';
902+ }
903+ rawEmail += '--alt_boundary--\\r\\n';
904+
905+ for (const att of attachments) {
906+ rawEmail += '--' + boundary + '\\r\\n';
907+ rawEmail += 'Content-Type: ' + (att.contentType || 'application/octet-stream') + '; name="' + att.filename + '"\\r\\n';
908+ rawEmail += 'Content-Transfer-Encoding: base64\\r\\n';
909+ rawEmail += 'Content-Disposition: attachment; filename="' + att.filename + '"\\r\\n\\r\\n';
910+ rawEmail += att.content + '\\r\\n';
911+ }
912+ rawEmail += '--' + boundary + '--\\r\\n';
913+
914+ const params = {
915+ RawMessage: { Data: Buffer.from(rawEmail) },
916+ Source: fromAddress,
917+ Destinations: [...(Array.isArray(to) ? to : [to]), ...(cc ? (Array.isArray(cc) ? cc : [cc]) : []), ...(bcc ? (Array.isArray(bcc) ? bcc : [bcc]) : [])]
918+ };
919+ if (configSet) params.ConfigurationSetName = configSet;
920+
921+ const result = await ses.send(new SendRawEmailCommand(params));
922+ return { statusCode: 200, body: JSON.stringify({ messageId: result.MessageId }) };
923+ };
924+ ` ,
925+ } ,
926+ Tags : [
927+ { Key : 'Purpose' , Value : 'OutboundEmail' } ,
928+ { Key : 'ManagedBy' , Value : 'Stacks' } ,
929+ ] ,
930+ } ,
931+ }
932+
933+ // Email Conversion Lambda Function
934+ template . Resources . EmailConversionLambda = {
935+ Type : 'AWS::Lambda::Function' ,
936+ DependsOn : [ 'EmailLambdaRole' ] ,
937+ Properties : {
938+ FunctionName : `${ appName } -email-conversion` ,
939+ Runtime : 'nodejs20.x' ,
940+ Handler : 'index.handler' ,
941+ Role : { 'Fn::GetAtt' : [ 'EmailLambdaRole' , 'Arn' ] } ,
942+ Timeout : 60 ,
943+ MemorySize : 512 ,
944+ Environment : {
945+ Variables : {
946+ S3_BUCKET : emailBucketName ,
947+ CONVERTED_PREFIX : 'converted/' ,
948+ } ,
949+ } ,
950+ Code : {
951+ ZipFile : `
952+ const { S3Client, GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3');
953+ const s3 = new S3Client({});
954+
955+ exports.handler = async (event) => {
956+ console.log('Converting email:', JSON.stringify(event));
957+ const bucket = process.env.S3_BUCKET;
958+ const convertedPrefix = process.env.CONVERTED_PREFIX || 'converted/';
959+
960+ for (const record of event.Records || []) {
961+ const key = decodeURIComponent(record.s3.object.key.replace(/\\+/g, ' '));
962+ if (!key.startsWith('inbound/')) continue;
963+
964+ const getCmd = new GetObjectCommand({ Bucket: bucket, Key: key });
965+ const response = await s3.send(getCmd);
966+ const rawEmail = await response.Body.transformToString();
967+
968+ // Simple email parsing (headers + body)
969+ const [headerSection, ...bodyParts] = rawEmail.split('\\r\\n\\r\\n');
970+ const body = bodyParts.join('\\r\\n\\r\\n');
971+ const headers = {};
972+ for (const line of headerSection.split('\\r\\n')) {
973+ const colonIdx = line.indexOf(':');
974+ if (colonIdx > 0) {
975+ const name = line.slice(0, colonIdx).toLowerCase();
976+ headers[name] = line.slice(colonIdx + 1).trim();
977+ }
978+ }
979+
980+ const metadata = {
981+ from: headers.from || '',
982+ to: headers.to || '',
983+ subject: headers.subject || '',
984+ date: headers.date || new Date().toISOString(),
985+ contentType: headers['content-type'] || 'text/plain',
986+ };
987+
988+ const baseName = key.replace('inbound/', '').replace(/\\.[^.]+$/, '');
989+ await s3.send(new PutObjectCommand({
990+ Bucket: bucket,
991+ Key: convertedPrefix + baseName + '.json',
992+ Body: JSON.stringify(metadata, null, 2),
993+ ContentType: 'application/json'
994+ }));
995+
996+ if (body) {
997+ const isHtml = metadata.contentType.includes('html');
998+ await s3.send(new PutObjectCommand({
999+ Bucket: bucket,
1000+ Key: convertedPrefix + baseName + (isHtml ? '.html' : '.txt'),
1001+ Body: body,
1002+ ContentType: isHtml ? 'text/html' : 'text/plain'
1003+ }));
1004+ }
1005+ console.log('Converted email:', key);
1006+ }
1007+ return { statusCode: 200, body: 'Emails converted' };
1008+ };
1009+ ` ,
1010+ } ,
1011+ Tags : [
1012+ { Key : 'Purpose' , Value : 'EmailConversion' } ,
1013+ { Key : 'ManagedBy' , Value : 'Stacks' } ,
1014+ ] ,
1015+ } ,
1016+ }
1017+
1018+ // S3 trigger for email conversion Lambda
1019+ template . Resources . EmailConversionLambdaPermission = {
1020+ Type : 'AWS::Lambda::Permission' ,
1021+ Properties : {
1022+ FunctionName : { Ref : 'EmailConversionLambda' } ,
1023+ Action : 'lambda:InvokeFunction' ,
1024+ Principal : 's3.amazonaws.com' ,
1025+ SourceArn : { 'Fn::GetAtt' : [ 'EmailBucket' , 'Arn' ] } ,
1026+ SourceAccount : { Ref : 'AWS::AccountId' } ,
1027+ } ,
1028+ }
1029+
1030+ // SNS Topic for email notifications
1031+ template . Resources . EmailNotificationTopic = {
1032+ Type : 'AWS::SNS::Topic' ,
1033+ Properties : {
1034+ TopicName : `${ appName } -email-notifications` ,
1035+ DisplayName : `${ appName } Email Notifications` ,
1036+ Tags : [
1037+ { Key : 'Purpose' , Value : 'EmailNotifications' } ,
1038+ { Key : 'ManagedBy' , Value : 'Stacks' } ,
1039+ ] ,
1040+ } ,
1041+ }
1042+
8361043 // Add email outputs
8371044 template . Outputs . EmailBucketName = {
8381045 Description : 'Name of the email storage bucket' ,
@@ -846,6 +1053,14 @@ exports.handler = async (event) => {
8461053 Description : 'SES Receipt Rule Set name' ,
8471054 Value : { Ref : 'EmailReceiptRuleSet' } ,
8481055 }
1056+ template . Outputs . OutboundEmailLambdaArn = {
1057+ Description : 'Outbound email Lambda ARN' ,
1058+ Value : { 'Fn::GetAtt' : [ 'OutboundEmailLambda' , 'Arn' ] } ,
1059+ }
1060+ template . Outputs . EmailNotificationTopicArn = {
1061+ Description : 'Email notification SNS topic ARN' ,
1062+ Value : { Ref : 'EmailNotificationTopic' } ,
1063+ }
8491064
8501065 log . success ( 'Email infrastructure added to template' )
8511066 }
0 commit comments