feat: Add AWS SES v2 email service
Complete transactional email layer for Elmeg: - 3 SES templates (verification, password reset, security alert) - TypeScript integration module with AWS SDK v3 - Template deployment script - Usage examples - Comprehensive README with compliance notes
This commit is contained in:
parent
da5b5e7c45
commit
530f217445
7 changed files with 1131 additions and 0 deletions
147
email/README.md
Normal file
147
email/README.md
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
# Elmeg Email Service
|
||||
|
||||
Transactional email layer for Elmeg using Amazon SES v2 with stored templates.
|
||||
|
||||
## Purpose
|
||||
|
||||
This module provides a production-ready email service for user-initiated transactional emails:
|
||||
|
||||
- **Email Verification** – Sent after user registration
|
||||
- **Password Reset** – Sent when user requests password recovery
|
||||
- **Security Alerts** – Sent for account security events (new logins, password changes)
|
||||
|
||||
> **Compliance Note:** This service is strictly for transactional, user-initiated emails. No newsletters, marketing emails, or cold outreach. No purchased or third-party email lists are used.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js >= 18.0.0
|
||||
- AWS account with SES verified domain
|
||||
- SES templates deployed (see below)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```bash
|
||||
# Required
|
||||
AWS_ACCESS_KEY_ID=AKIA... # IAM user with SES permissions
|
||||
AWS_SECRET_ACCESS_KEY=... # IAM user secret key
|
||||
AWS_SES_REGION=us-east-1 # SES region (domain must be verified here)
|
||||
EMAIL_FROM=noreply@elmeg.xyz # Verified sender address
|
||||
|
||||
# Optional
|
||||
FRONTEND_URL=https://elmeg.xyz # For generating email links
|
||||
SUPPORT_EMAIL=support@elmeg.xyz # Contact email in templates
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd email
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Deploy Templates to SES
|
||||
|
||||
Before sending emails, deploy the templates to AWS SES:
|
||||
|
||||
```bash
|
||||
npm run deploy-templates
|
||||
```
|
||||
|
||||
This creates/updates three templates in SES:
|
||||
|
||||
- `ELMEG_EMAIL_VERIFICATION`
|
||||
- `ELMEG_PASSWORD_RESET`
|
||||
- `ELMEG_SECURITY_ALERT`
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import {
|
||||
sendVerificationEmail,
|
||||
sendPasswordResetEmail,
|
||||
sendSecurityAlertEmail,
|
||||
generateVerificationLink,
|
||||
generateResetLink,
|
||||
} from "@elmeg/email-service";
|
||||
|
||||
// After user registration
|
||||
await sendVerificationEmail({
|
||||
to: "user@example.com",
|
||||
userName: "John",
|
||||
verificationLink: generateVerificationLink(token),
|
||||
});
|
||||
|
||||
// After password reset request
|
||||
await sendPasswordResetEmail({
|
||||
to: "user@example.com",
|
||||
userName: "John",
|
||||
resetLink: generateResetLink(token),
|
||||
});
|
||||
|
||||
// After suspicious login
|
||||
await sendSecurityAlertEmail({
|
||||
to: "user@example.com",
|
||||
userName: "John",
|
||||
securityEventDescription: "New sign-in from Chrome on Windows at 10:30 AM",
|
||||
});
|
||||
```
|
||||
|
||||
## Template Placeholders
|
||||
|
||||
| Placeholder | Description | Templates |
|
||||
|-------------|-------------|-----------|
|
||||
| `{{app_name}}` | "Elmeg" | All |
|
||||
| `{{user_name}}` | User's name or email prefix | All |
|
||||
| `{{support_email}}` | Support contact | All |
|
||||
| `{{verification_link}}` | Email verification URL | Verification |
|
||||
| `{{reset_link}}` | Password reset URL | Password Reset |
|
||||
| `{{security_event_description}}` | Event details | Security Alert |
|
||||
|
||||
## AWS SES Setup Checklist
|
||||
|
||||
1. **Verify Domain** – Add `elmeg.xyz` in SES console with DKIM records
|
||||
2. **Request Production Access** – Move out of sandbox to send to any address
|
||||
3. **Create IAM User** – With `ses:SendEmail` and `ses:SendTemplatedEmail` permissions
|
||||
4. **Deploy Templates** – Run `npm run deploy-templates`
|
||||
|
||||
## Compliance & Best Practices
|
||||
|
||||
| Requirement | Implementation |
|
||||
|-------------|----------------|
|
||||
| User-initiated only | All emails triggered by user actions |
|
||||
| No purchased lists | Only registered users receive emails |
|
||||
| Bounce handling | SES automatically suppresses bounced addresses |
|
||||
| Complaint handling | SES suppresses addresses that report spam |
|
||||
| Unsubscribe | N/A for transactional (required action emails) |
|
||||
|
||||
## Error Handling
|
||||
|
||||
All send functions return a structured result:
|
||||
|
||||
```typescript
|
||||
interface EmailResult {
|
||||
success: boolean;
|
||||
messageId?: string; // SES message ID on success
|
||||
error?: {
|
||||
code: string; // Error code from SES
|
||||
message: string; // Human-readable error message
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
email/
|
||||
├── src/
|
||||
│ ├── email-service.ts # Main service module
|
||||
│ └── examples.ts # Usage examples
|
||||
├── scripts/
|
||||
│ └── deploy-templates.ts # Template deployment script
|
||||
├── templates/
|
||||
│ └── README.md # Template documentation
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
└── README.md # This file
|
||||
```
|
||||
33
email/package.json
Normal file
33
email/package.json
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"name": "@elmeg/email-service",
|
||||
"version": "1.0.0",
|
||||
"description": "Transactional email service for Elmeg using AWS SES v2",
|
||||
"main": "dist/email-service.js",
|
||||
"types": "dist/email-service.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "jest",
|
||||
"deploy-templates": "ts-node scripts/deploy-templates.ts"
|
||||
},
|
||||
"keywords": [
|
||||
"email",
|
||||
"ses",
|
||||
"aws",
|
||||
"transactional"
|
||||
],
|
||||
"author": "Elmeg",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sesv2": "^3.478.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"typescript": "^5.3.0",
|
||||
"ts-node": "^10.9.0",
|
||||
"jest": "^29.7.0",
|
||||
"@types/jest": "^29.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
234
email/scripts/deploy-templates.ts
Normal file
234
email/scripts/deploy-templates.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
/**
|
||||
* Deploy SES Templates to AWS
|
||||
*
|
||||
* Run this script to create or update the email templates in AWS SES.
|
||||
* Usage: npx ts-node scripts/deploy-templates.ts
|
||||
*/
|
||||
|
||||
import { SESv2Client, CreateEmailTemplateCommand, UpdateEmailTemplateCommand } from "@aws-sdk/client-sesv2";
|
||||
|
||||
const sesClient = new SESv2Client({
|
||||
region: process.env.AWS_SES_REGION || "us-east-1",
|
||||
});
|
||||
|
||||
const APP_NAME = "Elmeg";
|
||||
const SUPPORT_EMAIL = "support@elmeg.xyz";
|
||||
|
||||
// Template definitions
|
||||
const templates = [
|
||||
{
|
||||
TemplateName: "ELMEG_EMAIL_VERIFICATION",
|
||||
TemplateContent: {
|
||||
Subject: "Verify your Elmeg account",
|
||||
Html: `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 600px; margin: 0 auto; background-color: #ffffff;">
|
||||
<tr>
|
||||
<td style="padding: 40px 30px; text-align: center; background-color: #1a1a2e;">
|
||||
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 700;">{{app_name}}</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 40px 30px;">
|
||||
<h2 style="margin: 0 0 20px; color: #1a1a2e; font-size: 22px; font-weight: 600;">Verify your email address</h2>
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">Hi {{user_name}},</p>
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">Thanks for signing up for {{app_name}}. Please verify your email address by clicking the button below.</p>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 30px 0;">
|
||||
<tr>
|
||||
<td style="background-color: #4f46e5; border-radius: 6px;">
|
||||
<a href="{{verification_link}}" style="display: inline-block; padding: 14px 32px; color: #ffffff; text-decoration: none; font-size: 16px; font-weight: 600;">Verify Email Address</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin: 0 0 20px; color: #666666; font-size: 14px; line-height: 1.5;">This link will expire after 24 hours for your security.</p>
|
||||
<p style="margin: 0 0 20px; color: #666666; font-size: 14px; line-height: 1.5;">If you did not create an account, you can safely ignore this email.</p>
|
||||
<hr style="border: none; border-top: 1px solid #eeeeee; margin: 30px 0;">
|
||||
<p style="margin: 0; color: #999999; font-size: 12px;">If the button above doesn't work, copy and paste this URL: {{verification_link}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 30px; background-color: #f9fafb; text-align: center;">
|
||||
<p style="margin: 0; color: #999999; font-size: 12px;">{{app_name}} • Contact: {{support_email}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`,
|
||||
Text: `Verify your Elmeg account
|
||||
|
||||
Hi {{user_name}},
|
||||
|
||||
Thanks for signing up for {{app_name}}. Please verify your email address by clicking the link below:
|
||||
|
||||
{{verification_link}}
|
||||
|
||||
This link will expire after 24 hours for your security.
|
||||
|
||||
If you did not create an account, you can safely ignore this email.
|
||||
|
||||
---
|
||||
{{app_name}} • Contact: {{support_email}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
TemplateName: "ELMEG_PASSWORD_RESET",
|
||||
TemplateContent: {
|
||||
Subject: "Reset your Elmeg password",
|
||||
Html: `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 600px; margin: 0 auto; background-color: #ffffff;">
|
||||
<tr>
|
||||
<td style="padding: 40px 30px; text-align: center; background-color: #1a1a2e;">
|
||||
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 700;">{{app_name}}</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 40px 30px;">
|
||||
<h2 style="margin: 0 0 20px; color: #1a1a2e; font-size: 22px; font-weight: 600;">Reset your password</h2>
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">Hi {{user_name}},</p>
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">We received a request to reset the password for your {{app_name}} account. Click the button below to choose a new password.</p>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 30px 0;">
|
||||
<tr>
|
||||
<td style="background-color: #4f46e5; border-radius: 6px;">
|
||||
<a href="{{reset_link}}" style="display: inline-block; padding: 14px 32px; color: #ffffff; text-decoration: none; font-size: 16px; font-weight: 600;">Reset Password</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin: 0 0 20px; color: #666666; font-size: 14px; line-height: 1.5;">This link will expire after 1 hour for your security.</p>
|
||||
<p style="margin: 0 0 20px; color: #666666; font-size: 14px; line-height: 1.5;">If you did not request a password reset, you can safely ignore this email.</p>
|
||||
<hr style="border: none; border-top: 1px solid #eeeeee; margin: 30px 0;">
|
||||
<p style="margin: 0; color: #999999; font-size: 12px;">If the button above doesn't work, copy and paste this URL: {{reset_link}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 30px; background-color: #f9fafb; text-align: center;">
|
||||
<p style="margin: 0; color: #999999; font-size: 12px;">{{app_name}} • Contact: {{support_email}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`,
|
||||
Text: `Reset your Elmeg password
|
||||
|
||||
Hi {{user_name}},
|
||||
|
||||
We received a request to reset the password for your {{app_name}} account.
|
||||
|
||||
Click the link below to choose a new password:
|
||||
|
||||
{{reset_link}}
|
||||
|
||||
This link will expire after 1 hour for your security.
|
||||
|
||||
If you did not request a password reset, you can safely ignore this email.
|
||||
|
||||
---
|
||||
{{app_name}} • Contact: {{support_email}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
TemplateName: "ELMEG_SECURITY_ALERT",
|
||||
TemplateContent: {
|
||||
Subject: "Security alert for your Elmeg account",
|
||||
Html: `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 600px; margin: 0 auto; background-color: #ffffff;">
|
||||
<tr>
|
||||
<td style="padding: 40px 30px; text-align: center; background-color: #1a1a2e;">
|
||||
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 700;">{{app_name}}</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 40px 30px;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" style="margin-bottom: 20px;">
|
||||
<tr>
|
||||
<td style="background-color: #fef3c7; border-radius: 6px; padding: 12px 16px;">
|
||||
<p style="margin: 0; color: #92400e; font-size: 14px; font-weight: 600;">⚠️ Security Notice</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<h2 style="margin: 0 0 20px; color: #1a1a2e; font-size: 22px; font-weight: 600;">Account activity detected</h2>
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">Hi {{user_name}},</p>
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">We detected the following activity on your {{app_name}} account:</p>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 20px 0; width: 100%;">
|
||||
<tr>
|
||||
<td style="background-color: #f3f4f6; border-radius: 6px; padding: 16px;">
|
||||
<p style="margin: 0; color: #374151; font-size: 15px; line-height: 1.5;">{{security_event_description}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">If this was you, no further action is needed.</p>
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">If you did not perform this action, please secure your account immediately.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 30px; background-color: #f9fafb; text-align: center;">
|
||||
<p style="margin: 0; color: #999999; font-size: 12px;">{{app_name}} • Contact: {{support_email}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`,
|
||||
Text: `Security alert for your Elmeg account
|
||||
|
||||
Hi {{user_name}},
|
||||
|
||||
We detected the following activity on your {{app_name}} account:
|
||||
|
||||
{{security_event_description}}
|
||||
|
||||
If this was you, no further action is needed.
|
||||
|
||||
If you did not perform this action, please secure your account immediately.
|
||||
|
||||
---
|
||||
{{app_name}} • Contact: {{support_email}}`,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
async function deployTemplates() {
|
||||
console.log("🚀 Deploying SES email templates...\n");
|
||||
|
||||
for (const template of templates) {
|
||||
try {
|
||||
// Try to create the template first
|
||||
const createCommand = new CreateEmailTemplateCommand(template);
|
||||
await sesClient.send(createCommand);
|
||||
console.log(`✅ Created template: ${template.TemplateName}`);
|
||||
} catch (error: unknown) {
|
||||
const err = error as { name?: string };
|
||||
if (err.name === "AlreadyExistsException") {
|
||||
// Template exists, update it
|
||||
try {
|
||||
const updateCommand = new UpdateEmailTemplateCommand(template);
|
||||
await sesClient.send(updateCommand);
|
||||
console.log(`🔄 Updated template: ${template.TemplateName}`);
|
||||
} catch (updateError) {
|
||||
console.error(`❌ Failed to update ${template.TemplateName}:`, updateError);
|
||||
}
|
||||
} else {
|
||||
console.error(`❌ Failed to create ${template.TemplateName}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n✅ Template deployment complete!");
|
||||
}
|
||||
|
||||
deployTemplates().catch(console.error);
|
||||
209
email/src/email-service.ts
Normal file
209
email/src/email-service.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
/**
|
||||
* Elmeg Email Service - AWS SES v2 Integration
|
||||
*
|
||||
* Transactional email layer for user-initiated emails only.
|
||||
* Uses AWS SES stored templates for consistent, reliable delivery.
|
||||
*/
|
||||
|
||||
import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2";
|
||||
|
||||
// Configuration from environment variables
|
||||
const config = {
|
||||
region: process.env.AWS_SES_REGION || "us-east-1",
|
||||
fromAddress: process.env.EMAIL_FROM || "noreply@elmeg.xyz",
|
||||
appName: "Elmeg",
|
||||
supportEmail: process.env.SUPPORT_EMAIL || "support@elmeg.xyz",
|
||||
frontendUrl: process.env.FRONTEND_URL || "https://elmeg.xyz",
|
||||
};
|
||||
|
||||
// SES Template Names
|
||||
export const SES_TEMPLATES = {
|
||||
EMAIL_VERIFICATION: "ELMEG_EMAIL_VERIFICATION",
|
||||
PASSWORD_RESET: "ELMEG_PASSWORD_RESET",
|
||||
SECURITY_ALERT: "ELMEG_SECURITY_ALERT",
|
||||
} as const;
|
||||
|
||||
// Initialize SES v2 client
|
||||
const sesClient = new SESv2Client({
|
||||
region: config.region,
|
||||
// Credentials are loaded automatically from env vars:
|
||||
// AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface SendVerificationEmailParams {
|
||||
to: string;
|
||||
userName: string;
|
||||
verificationLink: string;
|
||||
}
|
||||
|
||||
export interface SendPasswordResetEmailParams {
|
||||
to: string;
|
||||
userName: string;
|
||||
resetLink: string;
|
||||
}
|
||||
|
||||
export interface SendSecurityAlertEmailParams {
|
||||
to: string;
|
||||
userName: string;
|
||||
securityEventDescription: string;
|
||||
}
|
||||
|
||||
export interface EmailResult {
|
||||
success: boolean;
|
||||
messageId?: string;
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class EmailError extends Error {
|
||||
code: string;
|
||||
|
||||
constructor(code: string, message: string) {
|
||||
super(message);
|
||||
this.name = "EmailError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Email Sending Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Send email verification email to new users
|
||||
*/
|
||||
export async function sendVerificationEmail(
|
||||
params: SendVerificationEmailParams
|
||||
): Promise<EmailResult> {
|
||||
const templateData = {
|
||||
user_name: params.userName,
|
||||
verification_link: params.verificationLink,
|
||||
app_name: config.appName,
|
||||
support_email: config.supportEmail,
|
||||
};
|
||||
|
||||
return sendTemplatedEmail(
|
||||
params.to,
|
||||
SES_TEMPLATES.EMAIL_VERIFICATION,
|
||||
templateData
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send password reset email
|
||||
*/
|
||||
export async function sendPasswordResetEmail(
|
||||
params: SendPasswordResetEmailParams
|
||||
): Promise<EmailResult> {
|
||||
const templateData = {
|
||||
user_name: params.userName,
|
||||
reset_link: params.resetLink,
|
||||
app_name: config.appName,
|
||||
support_email: config.supportEmail,
|
||||
};
|
||||
|
||||
return sendTemplatedEmail(
|
||||
params.to,
|
||||
SES_TEMPLATES.PASSWORD_RESET,
|
||||
templateData
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send security alert email for account events
|
||||
*/
|
||||
export async function sendSecurityAlertEmail(
|
||||
params: SendSecurityAlertEmailParams
|
||||
): Promise<EmailResult> {
|
||||
const templateData = {
|
||||
user_name: params.userName,
|
||||
security_event_description: params.securityEventDescription,
|
||||
app_name: config.appName,
|
||||
support_email: config.supportEmail,
|
||||
};
|
||||
|
||||
return sendTemplatedEmail(
|
||||
params.to,
|
||||
SES_TEMPLATES.SECURITY_ALERT,
|
||||
templateData
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Core Email Function
|
||||
// =============================================================================
|
||||
|
||||
async function sendTemplatedEmail(
|
||||
to: string,
|
||||
templateName: string,
|
||||
templateData: Record<string, string>
|
||||
): Promise<EmailResult> {
|
||||
try {
|
||||
const command = new SendEmailCommand({
|
||||
FromEmailAddress: config.fromAddress,
|
||||
Destination: {
|
||||
ToAddresses: [to],
|
||||
},
|
||||
Content: {
|
||||
Template: {
|
||||
TemplateName: templateName,
|
||||
TemplateData: JSON.stringify(templateData),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await sesClient.send(command);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: response.MessageId,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const err = error as { name?: string; message?: string; Code?: string };
|
||||
|
||||
console.error(`[Email] Failed to send ${templateName} to ${to}:`, err.message);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: err.Code || err.name || "UNKNOWN_ERROR",
|
||||
message: err.message || "Failed to send email",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Utility Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Generate a verification link for a user
|
||||
*/
|
||||
export function generateVerificationLink(token: string): string {
|
||||
return `${config.frontendUrl}/verify-email?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a password reset link for a user
|
||||
*/
|
||||
export function generateResetLink(token: string): string {
|
||||
return `${config.frontendUrl}/reset-password?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the email service is properly configured
|
||||
*/
|
||||
export function isEmailConfigured(): boolean {
|
||||
return !!(
|
||||
process.env.AWS_ACCESS_KEY_ID &&
|
||||
process.env.AWS_SECRET_ACCESS_KEY &&
|
||||
process.env.AWS_SES_REGION
|
||||
);
|
||||
}
|
||||
176
email/src/examples.ts
Normal file
176
email/src/examples.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
/**
|
||||
* Elmeg Email Service - Usage Examples
|
||||
*
|
||||
* These examples show how to integrate the email service
|
||||
* into your application's user flows.
|
||||
*/
|
||||
|
||||
import {
|
||||
sendVerificationEmail,
|
||||
sendPasswordResetEmail,
|
||||
sendSecurityAlertEmail,
|
||||
generateVerificationLink,
|
||||
generateResetLink,
|
||||
isEmailConfigured,
|
||||
} from "./email-service";
|
||||
|
||||
// =============================================================================
|
||||
// Example 1: User Registration Flow
|
||||
// =============================================================================
|
||||
|
||||
async function handleUserRegistration(
|
||||
userEmail: string,
|
||||
userName: string,
|
||||
verificationToken: string
|
||||
) {
|
||||
// Check if email is configured
|
||||
if (!isEmailConfigured()) {
|
||||
console.warn("[Email] Email service not configured, skipping verification email");
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate the verification link
|
||||
const verificationLink = generateVerificationLink(verificationToken);
|
||||
|
||||
// Send the verification email
|
||||
const result = await sendVerificationEmail({
|
||||
to: userEmail,
|
||||
userName: userName || userEmail.split("@")[0],
|
||||
verificationLink,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.log(`[Email] Verification email sent to ${userEmail}, messageId: ${result.messageId}`);
|
||||
} else {
|
||||
console.error(`[Email] Failed to send verification email: ${result.error?.message}`);
|
||||
// Handle error - maybe retry or alert admin
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Example 2: Forgot Password Flow
|
||||
// =============================================================================
|
||||
|
||||
async function handleForgotPassword(
|
||||
userEmail: string,
|
||||
userName: string,
|
||||
resetToken: string
|
||||
) {
|
||||
if (!isEmailConfigured()) {
|
||||
console.warn("[Email] Email service not configured, skipping password reset email");
|
||||
return;
|
||||
}
|
||||
|
||||
const resetLink = generateResetLink(resetToken);
|
||||
|
||||
const result = await sendPasswordResetEmail({
|
||||
to: userEmail,
|
||||
userName: userName || userEmail.split("@")[0],
|
||||
resetLink,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.log(`[Email] Password reset email sent to ${userEmail}`);
|
||||
} else {
|
||||
console.error(`[Email] Failed to send password reset email: ${result.error?.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Example 3: Security Alert - New Login
|
||||
// =============================================================================
|
||||
|
||||
async function handleNewLogin(
|
||||
userEmail: string,
|
||||
userName: string,
|
||||
loginDetails: { ip: string; browser: string; location?: string; timestamp: Date }
|
||||
) {
|
||||
if (!isEmailConfigured()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventDescription = [
|
||||
`New sign-in to your account`,
|
||||
``,
|
||||
`Time: ${loginDetails.timestamp.toLocaleString()}`,
|
||||
`IP Address: ${loginDetails.ip}`,
|
||||
`Browser: ${loginDetails.browser}`,
|
||||
loginDetails.location ? `Location: ${loginDetails.location}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
const result = await sendSecurityAlertEmail({
|
||||
to: userEmail,
|
||||
userName: userName || userEmail.split("@")[0],
|
||||
securityEventDescription: eventDescription,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error(`[Email] Failed to send security alert: ${result.error?.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Example 4: Security Alert - Password Changed
|
||||
// =============================================================================
|
||||
|
||||
async function handlePasswordChanged(
|
||||
userEmail: string,
|
||||
userName: string,
|
||||
timestamp: Date
|
||||
) {
|
||||
if (!isEmailConfigured()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventDescription = `Your password was changed on ${timestamp.toLocaleString()}. If you did not make this change, please contact support immediately.`;
|
||||
|
||||
await sendSecurityAlertEmail({
|
||||
to: userEmail,
|
||||
userName: userName || userEmail.split("@")[0],
|
||||
securityEventDescription: eventDescription,
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Example 5: Express.js Route Handler Integration
|
||||
// =============================================================================
|
||||
|
||||
/*
|
||||
import express from "express";
|
||||
import { sendVerificationEmail, generateVerificationLink } from "./email-service";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post("/register", async (req, res) => {
|
||||
const { email, password, name } = req.body;
|
||||
|
||||
// ... create user in database ...
|
||||
const user = await createUser({ email, password, name });
|
||||
|
||||
// Generate verification token
|
||||
const verificationToken = generateSecureToken();
|
||||
await saveVerificationToken(user.id, verificationToken);
|
||||
|
||||
// Send verification email
|
||||
const verificationLink = generateVerificationLink(verificationToken);
|
||||
|
||||
const emailResult = await sendVerificationEmail({
|
||||
to: email,
|
||||
userName: name || email.split("@")[0],
|
||||
verificationLink,
|
||||
});
|
||||
|
||||
if (!emailResult.success) {
|
||||
console.error("Failed to send verification email:", emailResult.error);
|
||||
// Don't fail registration, just log the error
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
message: "Account created. Please check your email to verify your account."
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
*/
|
||||
306
email/templates/README.md
Normal file
306
email/templates/README.md
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
# AWS SES Email Templates for Elmeg
|
||||
|
||||
## Template 1: Email Verification
|
||||
|
||||
**Template Name:** `ELMEG_EMAIL_VERIFICATION`
|
||||
|
||||
### Subject
|
||||
|
||||
```
|
||||
Verify your Elmeg account
|
||||
```
|
||||
|
||||
### HTML Body
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Verify your email</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 600px; margin: 0 auto; background-color: #ffffff;">
|
||||
<tr>
|
||||
<td style="padding: 40px 30px; text-align: center; background-color: #1a1a2e;">
|
||||
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 700;">{{app_name}}</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 40px 30px;">
|
||||
<h2 style="margin: 0 0 20px; color: #1a1a2e; font-size: 22px; font-weight: 600;">Verify your email address</h2>
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">
|
||||
Hi {{user_name}},
|
||||
</p>
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">
|
||||
Thanks for signing up for {{app_name}}. Please verify your email address by clicking the button below.
|
||||
</p>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 30px 0;">
|
||||
<tr>
|
||||
<td style="background-color: #4f46e5; border-radius: 6px;">
|
||||
<a href="{{verification_link}}" style="display: inline-block; padding: 14px 32px; color: #ffffff; text-decoration: none; font-size: 16px; font-weight: 600;">Verify Email Address</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin: 0 0 20px; color: #666666; font-size: 14px; line-height: 1.5;">
|
||||
This link will expire after 24 hours for your security.
|
||||
</p>
|
||||
<p style="margin: 0 0 20px; color: #666666; font-size: 14px; line-height: 1.5;">
|
||||
If you did not create an account, you can safely ignore this email.
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #eeeeee; margin: 30px 0;">
|
||||
<p style="margin: 0; color: #999999; font-size: 12px; line-height: 1.5;">
|
||||
If the button above doesn't work, copy and paste this URL into your browser:
|
||||
</p>
|
||||
<p style="margin: 10px 0 0; color: #4f46e5; font-size: 12px; word-break: break-all;">
|
||||
{{verification_link}}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 30px; background-color: #f9fafb; text-align: center;">
|
||||
<p style="margin: 0 0 10px; color: #666666; font-size: 14px;">
|
||||
{{app_name}} – The Goose Community Archive
|
||||
</p>
|
||||
<p style="margin: 0; color: #999999; font-size: 12px;">
|
||||
Questions? Contact us at {{support_email}}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Plain Text Body
|
||||
|
||||
```
|
||||
Verify your Elmeg account
|
||||
|
||||
Hi {{user_name}},
|
||||
|
||||
Thanks for signing up for {{app_name}}. Please verify your email address by clicking the link below:
|
||||
|
||||
{{verification_link}}
|
||||
|
||||
This link will expire after 24 hours for your security.
|
||||
|
||||
If you did not create an account, you can safely ignore this email.
|
||||
|
||||
---
|
||||
{{app_name}} – The Goose Community Archive
|
||||
Questions? Contact us at {{support_email}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Template 2: Password Reset
|
||||
|
||||
**Template Name:** `ELMEG_PASSWORD_RESET`
|
||||
|
||||
### Subject
|
||||
|
||||
```
|
||||
Reset your Elmeg password
|
||||
```
|
||||
|
||||
### HTML Body
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Reset your password</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 600px; margin: 0 auto; background-color: #ffffff;">
|
||||
<tr>
|
||||
<td style="padding: 40px 30px; text-align: center; background-color: #1a1a2e;">
|
||||
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 700;">{{app_name}}</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 40px 30px;">
|
||||
<h2 style="margin: 0 0 20px; color: #1a1a2e; font-size: 22px; font-weight: 600;">Reset your password</h2>
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">
|
||||
Hi {{user_name}},
|
||||
</p>
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">
|
||||
We received a request to reset the password for your {{app_name}} account. Click the button below to choose a new password.
|
||||
</p>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 30px 0;">
|
||||
<tr>
|
||||
<td style="background-color: #4f46e5; border-radius: 6px;">
|
||||
<a href="{{reset_link}}" style="display: inline-block; padding: 14px 32px; color: #ffffff; text-decoration: none; font-size: 16px; font-weight: 600;">Reset Password</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin: 0 0 20px; color: #666666; font-size: 14px; line-height: 1.5;">
|
||||
This link will expire after 1 hour for your security.
|
||||
</p>
|
||||
<p style="margin: 0 0 20px; color: #666666; font-size: 14px; line-height: 1.5;">
|
||||
If you did not request a password reset, you can safely ignore this email. Your password will remain unchanged.
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #eeeeee; margin: 30px 0;">
|
||||
<p style="margin: 0; color: #999999; font-size: 12px; line-height: 1.5;">
|
||||
If the button above doesn't work, copy and paste this URL into your browser:
|
||||
</p>
|
||||
<p style="margin: 10px 0 0; color: #4f46e5; font-size: 12px; word-break: break-all;">
|
||||
{{reset_link}}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 30px; background-color: #f9fafb; text-align: center;">
|
||||
<p style="margin: 0 0 10px; color: #666666; font-size: 14px;">
|
||||
{{app_name}} – The Goose Community Archive
|
||||
</p>
|
||||
<p style="margin: 0; color: #999999; font-size: 12px;">
|
||||
Questions? Contact us at {{support_email}}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Plain Text Body
|
||||
|
||||
```
|
||||
Reset your Elmeg password
|
||||
|
||||
Hi {{user_name}},
|
||||
|
||||
We received a request to reset the password for your {{app_name}} account.
|
||||
|
||||
Click the link below to choose a new password:
|
||||
|
||||
{{reset_link}}
|
||||
|
||||
This link will expire after 1 hour for your security.
|
||||
|
||||
If you did not request a password reset, you can safely ignore this email. Your password will remain unchanged.
|
||||
|
||||
---
|
||||
{{app_name}} – The Goose Community Archive
|
||||
Questions? Contact us at {{support_email}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Template 3: Security Alert
|
||||
|
||||
**Template Name:** `ELMEG_SECURITY_ALERT`
|
||||
|
||||
### Subject
|
||||
|
||||
```
|
||||
Security alert for your Elmeg account
|
||||
```
|
||||
|
||||
### HTML Body
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Security Alert</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 600px; margin: 0 auto; background-color: #ffffff;">
|
||||
<tr>
|
||||
<td style="padding: 40px 30px; text-align: center; background-color: #1a1a2e;">
|
||||
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 700;">{{app_name}}</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 40px 30px;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" style="margin-bottom: 20px;">
|
||||
<tr>
|
||||
<td style="background-color: #fef3c7; border-radius: 6px; padding: 12px 16px;">
|
||||
<p style="margin: 0; color: #92400e; font-size: 14px; font-weight: 600;">⚠️ Security Notice</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<h2 style="margin: 0 0 20px; color: #1a1a2e; font-size: 22px; font-weight: 600;">Account activity detected</h2>
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">
|
||||
Hi {{user_name}},
|
||||
</p>
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">
|
||||
We detected the following activity on your {{app_name}} account:
|
||||
</p>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 20px 0; width: 100%;">
|
||||
<tr>
|
||||
<td style="background-color: #f3f4f6; border-radius: 6px; padding: 16px;">
|
||||
<p style="margin: 0; color: #374151; font-size: 15px; line-height: 1.5;">
|
||||
{{security_event_description}}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">
|
||||
If this was you, no further action is needed.
|
||||
</p>
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">
|
||||
If you did not perform this action, we recommend you secure your account immediately by changing your password.
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #eeeeee; margin: 30px 0;">
|
||||
<p style="margin: 0; color: #999999; font-size: 12px; line-height: 1.5;">
|
||||
This is an automated security notification. If you have concerns about your account security, please contact us at {{support_email}}.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 30px; background-color: #f9fafb; text-align: center;">
|
||||
<p style="margin: 0 0 10px; color: #666666; font-size: 14px;">
|
||||
{{app_name}} – The Goose Community Archive
|
||||
</p>
|
||||
<p style="margin: 0; color: #999999; font-size: 12px;">
|
||||
Questions? Contact us at {{support_email}}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Plain Text Body
|
||||
|
||||
```
|
||||
Security alert for your Elmeg account
|
||||
|
||||
Hi {{user_name}},
|
||||
|
||||
We detected the following activity on your {{app_name}} account:
|
||||
|
||||
{{security_event_description}}
|
||||
|
||||
If this was you, no further action is needed.
|
||||
|
||||
If you did not perform this action, we recommend you secure your account immediately by changing your password.
|
||||
|
||||
---
|
||||
This is an automated security notification. If you have concerns about your account security, please contact us at {{support_email}}.
|
||||
|
||||
{{app_name}} – The Goose Community Archive
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Template Placeholders Reference
|
||||
|
||||
| Placeholder | Description | Used In |
|
||||
|-------------|-------------|---------|
|
||||
| `{{app_name}}` | Application name ("Elmeg") | All templates |
|
||||
| `{{user_name}}` | User's display name or email | All templates |
|
||||
| `{{support_email}}` | Support contact email | All templates |
|
||||
| `{{verification_link}}` | Email verification URL | Email Verification |
|
||||
| `{{reset_link}}` | Password reset URL | Password Reset |
|
||||
| `{{security_event_description}}` | Description of the security event | Security Alert |
|
||||
26
email/tsconfig.json
Normal file
26
email/tsconfig.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": [
|
||||
"ES2022"
|
||||
],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue