This commit is contained in:
whilb 2025-09-01 22:04:26 -07:00
parent f537273fd8
commit 19711f2153
10 changed files with 2602 additions and 2 deletions

4
.gitignore vendored
View file

@ -5,3 +5,7 @@ tests/__pycache__
infra/.terraform infra/.terraform
infra/.terraform.lock.hcl infra/.terraform.lock.hcl
venv venv
infra/email/.terraform
infra/email/.terraform.lock.hcl
infra/email/node_modules
infra/email/email_processor.zip

View file

@ -0,0 +1,180 @@
const { SESClient, SendEmailCommand } = require('@aws-sdk/client-ses');
const { S3Client, GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const { simpleParser } = require('mailparser');
// Initialize clients
const sesClient = new SESClient({ region: process.env.AWS_REGION || 'us-west-2' });
const s3Client = new S3Client({ region: process.env.AWS_REGION || 'us-west-2' });
exports.handler = async (event) => {
try {
// Extract S3 event information
if (!event.Records || !event.Records[0] || !event.Records[0].s3) {
throw new Error('Invalid S3 event structure');
}
const s3Record = event.Records[0].s3;
const bucketName = s3Record.bucket.name;
const objectKey = s3Record.object.key;
// Filter out attachment files to prevent infinite loops
if (objectKey.startsWith('attachments/')) {
console.log(`Skipping attachment file: ${objectKey}`);
return {
statusCode: 200,
body: JSON.stringify({
message: 'Skipped attachment file',
reason: 'Object is in attachments/ prefix',
objectKey: objectKey
})
};
}
console.log(`Processing email: ${objectKey}`);
// Get the email content from S3
const getObjectParams = {
Bucket: bucketName,
Key: objectKey
};
const s3Response = await s3Client.send(new GetObjectCommand(getObjectParams));
const emailContent = await s3Response.Body.transformToString();
// Extract recipient from the object key path
const pathParts = objectKey.split('/');
const recipient = pathParts[0]; // e.g., "calculator"
const forwardEmail = process.env.FORWARD_EMAIL;
const domainName = process.env.DOMAIN_NAME || '127local.net';
const environment = process.env.ENVIRONMENT || 'production';
// Parse the email using mailparser
const parsedEmail = await simpleParser(emailContent);
// Extract email details
const subject = parsedEmail.subject || 'No Subject';
const from = parsedEmail.from?.text || 'unknown@example.com';
const emailBody = parsedEmail.text || parsedEmail.html || 'No body content';
// Create a clean, properly formatted forwarded email
const forwardSubject = `[${environment.toUpperCase()}] FWD: ${subject}`;
// Build the email body with proper formatting
let forwardBody = `Email forwarded from ${from} to ${recipient}@${domainName} (${environment.toUpperCase()})\n`;
forwardBody += `Forwarded at: ${new Date().toISOString()}\n`;
forwardBody += `\n--- Original Email ---\n\n`;
forwardBody += `Subject: ${subject}\n`;
forwardBody += `From: ${from}\n`;
forwardBody += `\n${emailBody}`;
// Process attachments and generate download links
let attachmentLinks = [];
if (parsedEmail.attachments && parsedEmail.attachments.length > 0) {
console.log(`Processing ${parsedEmail.attachments.length} attachments...`);
for (const attachment of parsedEmail.attachments) {
try {
// Create unique filename for S3
const timestamp = Date.now();
const safeFilename = attachment.filename.replace(/[^a-zA-Z0-9.-]/g, '_');
const s3Key = `attachments/${recipient}/${timestamp}-${safeFilename}`;
// Upload attachment to S3
const uploadParams = {
Bucket: bucketName,
Key: s3Key,
Body: attachment.content,
ContentType: attachment.contentType,
Metadata: {
originalEmail: objectKey,
recipient: recipient,
sender: from,
originalFilename: attachment.filename
}
};
await s3Client.send(new PutObjectCommand(uploadParams));
console.log(`Uploaded: ${attachment.filename}`);
// Generate pre-signed download URL (expires in 7 days)
const downloadUrl = await getSignedUrl(
s3Client,
new GetObjectCommand({
Bucket: bucketName,
Key: s3Key
}),
{ expiresIn: 604800 } // 7 days
);
attachmentLinks.push({
filename: attachment.filename,
type: attachment.contentType,
size: attachment.size,
downloadUrl: downloadUrl
});
} catch (error) {
console.error(`Failed to process attachment ${attachment.filename}:`, error);
// Continue with other attachments
}
}
}
// Add attachment information with download links
if (attachmentLinks.length > 0) {
forwardBody += `\n\n--- Attachments (${attachmentLinks.length}) ---\n`;
attachmentLinks.forEach((attachment) => {
forwardBody += `${attachment.filename} (${attachment.type}, ${attachment.size} bytes)\n`;
forwardBody += ` Download: ${attachment.downloadUrl}\n`;
forwardBody += ` Note: Download link expires in 7 days\n\n`;
});
}
// Create the forwarded email using SendEmail
const forwardParams = {
Source: `noreply@${domainName}`,
Destination: {
ToAddresses: [forwardEmail]
},
Message: {
Subject: {
Data: forwardSubject
},
Body: {
Text: {
Data: forwardBody
}
}
}
};
// Send the properly formatted email
const result = await sesClient.send(new SendEmailCommand(forwardParams));
console.log(`Email forwarded successfully: ${result.MessageId}`);
return {
statusCode: 200,
body: JSON.stringify({
message: 'Email forwarded successfully',
messageId: result.MessageId,
originalRecipient: recipient,
forwardedTo: forwardEmail,
s3Location: `${bucketName}/${objectKey}`
})
};
} catch (error) {
console.error('Error processing email:', error);
return {
statusCode: 500,
body: JSON.stringify({
error: 'Failed to process email',
message: error.message,
stack: error.stack
})
};
}
};

265
infra/email/main.tf Normal file
View file

@ -0,0 +1,265 @@
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.0"
}
}
backend "s3" {
bucket = "calculator-127local-net-terraform-state"
key = "email/infrastructure/terraform.tfstate"
region = "us-west-2"
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = local.common_tags
}
}
# Common tags for all resources
locals {
common_tags = {
Project = "calculator-127local-net"
Environment = "production"
ManagedBy = "terraform"
Owner = "will@aerenserve.net"
Purpose = "email-forwarding"
CostCenter = "calculator-services"
}
}
# S3 bucket for email storage
resource "aws_s3_bucket" "email_storage" {
bucket = "calculator-127local-net-emails-${random_string.bucket_suffix.result}"
tags = merge(local.common_tags, {
Name = "Calculator Email Storage"
})
}
resource "random_string" "bucket_suffix" {
length = 8
special = false
upper = false
}
# S3 bucket versioning
resource "aws_s3_bucket_versioning" "email_storage" {
bucket = aws_s3_bucket.email_storage.id
versioning_configuration {
status = "Enabled"
}
}
# S3 bucket server-side encryption
resource "aws_s3_bucket_server_side_encryption_configuration" "email_storage" {
bucket = aws_s3_bucket.email_storage.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
# S3 bucket public access block
resource "aws_s3_bucket_public_access_block" "email_storage" {
bucket = aws_s3_bucket.email_storage.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# SES Domain identity for 127local.net
resource "aws_ses_domain_identity" "calculator" {
domain = var.domain_name
}
# SES Domain DKIM for 127local.net
resource "aws_ses_domain_dkim" "calculator" {
domain = aws_ses_domain_identity.calculator.domain
}
# Route53 records for SES DKIM validation
resource "aws_route53_record" "ses_dkim" {
count = 3
zone_id = var.route53_zone_id
name = "${element(aws_ses_domain_dkim.calculator.dkim_tokens, count.index)}._domainkey.${var.domain_name}"
type = "CNAME"
ttl = "600"
records = ["${element(aws_ses_domain_dkim.calculator.dkim_tokens, count.index)}.dkim.amazonses.com"]
}
# SES Email receiving rule set
resource "aws_ses_receipt_rule_set" "calculator" {
rule_set_name = "calculator-main-rule-set"
}
# SES Receipt rule for calculator@127local.net
resource "aws_ses_receipt_rule" "calculator_forward" {
name = "calculator-forward"
rule_set_name = aws_ses_receipt_rule_set.calculator.rule_set_name
recipients = ["calculator@${var.domain_name}"]
enabled = true
scan_enabled = true
tls_policy = "Optional"
add_header_action {
header_name = "X-Forwarded-To"
header_value = var.forward_email
position = 1
}
s3_action {
bucket_name = aws_s3_bucket.email_storage.bucket
object_key_prefix = "calculator/"
position = 2
}
}
# Set this rule set as active
resource "aws_ses_active_receipt_rule_set" "calculator" {
rule_set_name = aws_ses_receipt_rule_set.calculator.rule_set_name
}
# Lambda function for email processing
resource "aws_lambda_function" "email_processor" {
filename = "email_processor.zip"
function_name = "calculator-email-processor"
role = aws_iam_role.lambda_role.arn
handler = "email_processor.handler"
source_code_hash = data.archive_file.email_processor_zip.output_base64sha256
runtime = "nodejs18.x"
timeout = 30
environment {
variables = {
FORWARD_EMAIL = var.forward_email
DOMAIN_NAME = var.domain_name
ENVIRONMENT = "production"
}
}
tags = merge(local.common_tags, {
Name = "Calculator Email Processor"
})
}
# Create zip file for Lambda function with dependencies
data "archive_file" "email_processor_zip" {
type = "zip"
source_dir = "."
output_path = "email_processor.zip"
excludes = ["email_processor.zip", "*.tf", "*.tfvars", "README.md", ".terraform", ".terraform.lock.hcl"]
}
# IAM role for Lambda function
resource "aws_iam_role" "lambda_role" {
name = "calculator-email-processor-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "lambda.amazonaws.com"
}
}
]
})
tags = local.common_tags
}
# IAM policy for Lambda function
resource "aws_iam_role_policy" "lambda_policy" {
name = "calculator-email-processor-policy"
role = aws_iam_role.lambda_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
]
Resource = "arn:aws:logs:*:*:*"
},
{
Effect = "Allow"
Action = [
"s3:GetObject",
"s3:PutObject"
]
Resource = "${aws_s3_bucket.email_storage.arn}/*"
},
{
Effect = "Allow"
Action = [
"ses:SendEmail",
"ses:SendRawEmail"
]
Resource = "*"
}
]
})
}
# S3 bucket notification to trigger Lambda
resource "aws_s3_bucket_notification" "email_processor" {
bucket = aws_s3_bucket.email_storage.id
lambda_function {
lambda_function_arn = aws_lambda_function.email_processor.arn
events = ["s3:ObjectCreated:*"]
filter_prefix = "calculator/"
filter_suffix = ""
}
}
# Lambda permission for S3 to invoke the function
resource "aws_lambda_permission" "allow_s3" {
statement_id = "AllowExecutionFromS3Bucket"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.email_processor.function_name
principal = "s3.amazonaws.com"
source_arn = aws_s3_bucket.email_storage.arn
}
# Outputs
output "email_storage_bucket_name" {
value = aws_s3_bucket.email_storage.bucket
}
output "ses_domain_identity" {
value = aws_ses_domain_identity.calculator.domain
}
output "ses_receipt_rule_set_name" {
value = aws_ses_receipt_rule_set.calculator.rule_set_name
}
output "lambda_function_name" {
value = aws_lambda_function.email_processor.function_name
}
output "dkim_tokens" {
value = aws_ses_domain_dkim.calculator.dkim_tokens
}

2045
infra/email/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

15
infra/email/package.json Normal file
View file

@ -0,0 +1,15 @@
{
"name": "calculator-email-processor",
"version": "1.0.0",
"description": "Lambda function to process and forward emails for calculator.127local.net",
"main": "email_processor.js",
"dependencies": {
"@aws-sdk/client-ses": "^3.0.0",
"@aws-sdk/client-s3": "^3.0.0",
"@aws-sdk/s3-request-presigner": "^3.0.0",
"mailparser": "^3.6.5"
},
"engines": {
"node": ">=18.0.0"
}
}

58
infra/email/setup.sh Normal file
View file

@ -0,0 +1,58 @@
#!/bin/bash
# Calculator Email Infrastructure Setup Script
set -e
echo "🚀 Setting up Calculator Email Infrastructure..."
# Check if we're in the right directory
if [ ! -f "main.tf" ]; then
echo "❌ Error: main.tf not found. Please run this script from infra/email directory"
exit 1
fi
# Install npm dependencies for Lambda function
echo "📦 Installing Lambda dependencies..."
if [ ! -d "node_modules" ]; then
npm install
else
echo "✅ Dependencies already installed"
fi
# Initialize Terraform if needed
if [ ! -d ".terraform" ]; then
echo "🔧 Initializing Terraform..."
terraform init
else
echo "✅ Terraform already initialized"
fi
# Plan the infrastructure setup
echo "📋 Planning infrastructure setup..."
terraform plan
# Ask for confirmation
echo ""
read -p "🤔 Do you want to create this infrastructure? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "🚀 Creating infrastructure..."
terraform apply -auto-approve
echo ""
echo "✅ Infrastructure setup complete!"
echo ""
echo "📋 Next steps:"
echo "1. Go to AWS SES Console and verify the 127local.net domain identity"
echo "2. Wait for DKIM verification (should happen automatically)"
echo "3. Test by sending an email to calculator@127local.net"
echo ""
echo "🔍 Useful commands:"
echo " terraform output # Show all outputs"
echo " terraform output dkim_tokens # Show DKIM tokens"
echo " terraform output lambda_function_name # Show Lambda function name"
else
echo "❌ Infrastructure setup cancelled"
exit 1
fi

View file

@ -0,0 +1,3 @@
aws_region = "us-west-2"
domain_name = "127local.net"
route53_zone_id = "Z001158010D1XENOLOOMC"

20
infra/email/variables.tf Normal file
View file

@ -0,0 +1,20 @@
variable "aws_region" {
description = "AWS region for resources"
type = string
default = "us-west-2"
}
variable "domain_name" {
description = "Domain name for email forwarding"
type = string
}
variable "forward_email" {
description = "Email address to forward incoming emails to"
type = string
}
variable "route53_zone_id" {
description = "Route53 hosted zone ID for the domain"
type = string
}

View file

@ -276,14 +276,21 @@ input,select,textarea{width:100%;background:transparent;color:var(--text);border
align-items: center; align-items: center;
} }
.footer-links {
display: flex;
gap: 16px;
align-items: center;
}
.contact-link,
.source-link { .source-link {
color: var(--accent); color: var(--accent);
text-decoration: none; text-decoration: none;
font-weight: 500; font-weight: 500;
transition: color 0.2s ease; transition: color 0.2s ease;
margin-left: auto;
} }
.contact-link:hover,
.source-link:hover { .source-link:hover {
color: var(--accent2); color: var(--accent2);
text-decoration: underline; text-decoration: underline;

View file

@ -33,8 +33,11 @@
<footer class="wrap foot"> <footer class="wrap foot">
<div class="footer-content"> <div class="footer-content">
<span>No tracking. No server. Everything runs in your browser.</span> <span>No tracking. No server. Everything runs in your browser.</span>
<div class="footer-links">
<a href="mailto:calculator@127local.net" class="contact-link">calculator@127local.net</a>
<a href="https://code.disobey.net/whilb/calculator.127local.net" target="_blank" rel="noopener noreferrer" class="source-link">Source Code</a> <a href="https://code.disobey.net/whilb/calculator.127local.net" target="_blank" rel="noopener noreferrer" class="source-link">Source Code</a>
</div> </div>
</div>
</footer> </footer>
<script type="module" src="/js/app.js"></script> <script type="module" src="/js/app.js"></script>
</body> </body>