Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,14 @@ API_KEY=
NEXT_PUBLIC_API_KEY=

# ===========================================
# AWS SES Email Configuration (optional)
# Email (@bitbaum/mail-kit — Resend, optional)
# ===========================================
NEXT_AWS_ACCESS_KEY_ID=
NEXT_AWS_SECRET_ACCESS_KEY=
NEXT_AWS_REGION=
RESEND_API_KEY=
RESEND_FROM=

# ===========================================
# Email Addresses
# ===========================================
FROM_EMAIL=
ADMIN_EMAIL=

# ===========================================
Expand Down
19 changes: 3 additions & 16 deletions lib/config/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,13 @@ const getEnvWithDefault = (key: string, fallback: string): string => {
return process.env[key] || fallback;
};

/**
* AWS SES Configuration
*/
export const AWS_CONFIG = {
region: process.env.NEXT_AWS_REGION || 'eu-central-1', // Frankfurt for Swiss compliance
credentials: {
accessKeyId: process.env.NEXT_AWS_ACCESS_KEY_ID || '',
secretAccessKey: process.env.NEXT_AWS_SECRET_ACCESS_KEY || '',
},
} as const;

/**
* Email Addresses
*
* The SENDER is not configured here: @bitbaum/mail-kit reads RESEND_FROM
* (fleet env SSOT) with the conventional fleet sender as fallback.
*/
export const EMAIL_ADDRESSES = {
/** Email address used as the sender for outgoing emails */
from: getEnvWithDefault('FROM_EMAIL', 'noreply@botsmann.com'),

/** Email address for admin notifications */
admin: getEnvWithDefault('ADMIN_EMAIL', 'admin@botsmann.com'),

Expand Down Expand Up @@ -63,14 +52,12 @@ export const EMAIL_URLS = {
} as const;

export type EmailConfig = {
aws: typeof AWS_CONFIG;
addresses: typeof EMAIL_ADDRESSES;
subjects: typeof EMAIL_SUBJECTS;
urls: typeof EMAIL_URLS;
};

export const emailConfig: EmailConfig = {
aws: AWS_CONFIG,
addresses: EMAIL_ADDRESSES,
subjects: EMAIL_SUBJECTS,
urls: EMAIL_URLS,
Expand Down
11 changes: 2 additions & 9 deletions lib/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,8 @@ const serverSchema = z.object({
OLLAMA_MODEL: z.string().default('llama3.2:latest'),
OLLAMA_URL: z.string().url().default('http://localhost:11434'),

// Email (AWS SES)
NEXT_AWS_REGION: z.string().default('eu-central-1'),
NEXT_AWS_ACCESS_KEY_ID: z.string().default(''),
NEXT_AWS_SECRET_ACCESS_KEY: z.string().default(''),
FROM_EMAIL: z.string().email().default('noreply@botsmann.com'),
// Email — transport env (RESEND_API_KEY / RESEND_FROM) is read by
// @bitbaum/mail-kit directly; only recipient addresses live here.
ADMIN_EMAIL: z.string().email().default('admin@botsmann.com'),

// API key for middleware
Expand Down Expand Up @@ -88,10 +85,6 @@ export function getServerEnv(): ServerEnv {
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
OLLAMA_MODEL: process.env.OLLAMA_MODEL,
OLLAMA_URL: process.env.OLLAMA_URL,
NEXT_AWS_REGION: process.env.NEXT_AWS_REGION,
NEXT_AWS_ACCESS_KEY_ID: process.env.NEXT_AWS_ACCESS_KEY_ID,
NEXT_AWS_SECRET_ACCESS_KEY: process.env.NEXT_AWS_SECRET_ACCESS_KEY,
FROM_EMAIL: process.env.FROM_EMAIL,
ADMIN_EMAIL: process.env.ADMIN_EMAIL,
API_KEY: process.env.API_KEY,
});
Expand Down
76 changes: 24 additions & 52 deletions lib/email/service.ts
Original file line number Diff line number Diff line change
@@ -1,73 +1,45 @@
import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses';
import { sendMail, fromAddress, conventionalFrom } from '@bitbaum/mail-kit';
import type { Customer } from '@/lib/schemas/customer';
import { logger } from '../logger';

export class EmailService {
private ses: SESClient;
private fromEmail: string;
private adminEmail: string;

constructor() {
this.ses = new SESClient({
region: process.env.NEXT_AWS_REGION || 'eu-central-1', // Frankfurt region for Swiss compliance
credentials: {
accessKeyId: process.env.NEXT_AWS_ACCESS_KEY_ID || '',
secretAccessKey: process.env.NEXT_AWS_SECRET_ACCESS_KEY || '',
},
});
this.fromEmail = process.env.FROM_EMAIL || 'noreply@botsmann.com';
// RESEND_FROM is the fleet-wide sender SSOT (read by mail-kit); the
// conventional fallback sends as botsmann@fleetcrown.orangecat.ch.
this.fromEmail = fromAddress() ?? conventionalFrom('Botsmann');
this.adminEmail = process.env.ADMIN_EMAIL || 'REDACTED_EMAIL';
}

async sendWelcomeEmail(customer: Customer): Promise<void> {
const params = {
Source: this.fromEmail,
Destination: {
ToAddresses: [customer.email],
},
Message: {
Subject: {
Data: 'Welcome to Botsmann!',
},
Body: {
Text: {
Data: `Hello ${customer.name},\n\nThank you for your interest in Botsmann! We've received your message and will get back to you soon.\n\nBest regards,\nThe Botsmann Team`,
},
},
},
};
const result = await sendMail({
from: this.fromEmail,
to: customer.email,
subject: 'Welcome to Botsmann!',
text: `Hello ${customer.name},\n\nThank you for your interest in Botsmann! We've received your message and will get back to you soon.\n\nBest regards,\nThe Botsmann Team`,
});

try {
await this.ses.send(new SendEmailCommand(params));
} catch (error) {
logger.error('Failed to send welcome email:', error);
throw error;
if (!result.sent) {
// Preserve the old throw-on-failure contract — the caller catches.
logger.error('Failed to send welcome email:', result.error);
throw new Error(`Failed to send welcome email: ${result.error}`);
}
}

async sendAdminNotification(customer: Customer): Promise<void> {
const params = {
Source: this.fromEmail,
Destination: {
ToAddresses: [this.adminEmail],
},
Message: {
Subject: {
Data: 'New Customer Registration',
},
Body: {
Text: {
Data: `New customer registration:\n\nName: ${customer.name}\nEmail: ${customer.email}\nMessage: ${customer.message}\n\nPreferences:\n- Newsletter: ${customer.preferences.newsletter ? 'Yes' : 'No'}\n- Product Updates: ${customer.preferences.productUpdates ? 'Yes' : 'No'}`,
},
},
},
};
const result = await sendMail({
from: this.fromEmail,
to: this.adminEmail,
subject: 'New Customer Registration',
text: `New customer registration:\n\nName: ${customer.name}\nEmail: ${customer.email}\nMessage: ${customer.message}\n\nPreferences:\n- Newsletter: ${customer.preferences.newsletter ? 'Yes' : 'No'}\n- Product Updates: ${customer.preferences.productUpdates ? 'Yes' : 'No'}`,
});

try {
await this.ses.send(new SendEmailCommand(params));
} catch (error) {
logger.error('Failed to send admin notification:', error);
throw error;
if (!result.sent) {
// Preserve the old throw-on-failure contract — the caller catches.
logger.error('Failed to send admin notification:', result.error);
throw new Error(`Failed to send admin notification: ${result.error}`);
}
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
"lint-staged": "lint-staged"
},
"dependencies": {
"@aws-sdk/client-ses": "^3.980.0",
"@bitbaum/ai-kit": "^0.6.2",
"@bitbaum/mail-kit": "^0.1.0",
"@giscus/react": "^3.1.0",
"@headlessui/react": "^2.2.0",
"@supabase/ssr": "^0.8.0",
Expand Down
Loading