Skip to content

Commit 23f23af

Browse files
committed
chore: wip
1 parent 3ec7752 commit 23f23af

File tree

3 files changed

+496
-0
lines changed

3 files changed

+496
-0
lines changed

storage/framework/core/buddy/src/commands/deploy.ts

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)