86 KiB
SMTP Integration Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Implement SMTP email integration for welcome emails and password reset functionality with hybrid configuration (DB + .env fallback).
Architecture: Hybrid config approach with database-stored SMTP settings and .env fallback. Nodemailer for SMTP transport, React Email for templates. Encrypted password storage using AES-256-GCM. Admin panel UI for configuration and live email previews.
Tech Stack: Next.js 16, Nodemailer, React Email, better-sqlite3, crypto (Node.js built-in)
Phase 1: Foundation & Dependencies
Task 1: Install Dependencies
Files:
- Modify:
package.json
Step 1: Install required packages
Run:
npm install nodemailer react-email @react-email/components
npm install --save-dev @types/nodemailer
Expected: Packages installed successfully
Step 2: Verify installation
Run:
npm list nodemailer react-email
Expected: Shows installed versions
Step 3: Add email dev script
In package.json, add to scripts section:
"email:dev": "email dev"
Step 4: Commit
git add package.json package-lock.json
git commit -m "feat: add email dependencies (nodemailer, react-email)"
Task 2: Extend Database Schema
Files:
- Modify:
scripts/init-database.js:70-116
Step 1: Add settings table creation
After line 70 (after indexes creation), add:
// Create Settings table for app configuration
db.exec(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
`);
console.log('✓ Created settings table');
Step 2: Add password reset tokens table
After the settings table creation, add:
// Create password reset tokens table
db.exec(`
CREATE TABLE IF NOT EXISTS password_reset_tokens (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at TEXT NOT NULL,
used INTEGER DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE
);
`);
console.log('✓ Created password_reset_tokens table');
// Create index for performance
db.exec(`
CREATE INDEX IF NOT EXISTS idx_reset_tokens_user_id
ON password_reset_tokens(user_id);
`);
console.log('✓ Created password reset tokens index');
Step 3: Run database migration
npm run db:init:app
Expected: See "✓ Created settings table" and "✓ Created password_reset_tokens table"
Step 4: Verify tables exist
sqlite3 data/database.sqlite "SELECT name FROM sqlite_master WHERE type='table';"
Expected: Should include "settings" and "password_reset_tokens"
Step 5: Commit
git add scripts/init-database.js
git commit -m "feat: add settings and password_reset_tokens tables"
Task 3: Update .env.example
Files:
- Modify:
.env.example:14-18
Step 1: Update SMTP section
Replace the commented SMTP section (lines 15-18) with:
# SMTP Configuration (Fallback when DB config is empty)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
SMTP_FROM_EMAIL=noreply@example.com
SMTP_FROM_NAME=Location Tracker
# Encryption for SMTP passwords in database
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
ENCRYPTION_KEY=your-32-byte-hex-key-here
Step 2: Commit
git add .env.example
git commit -m "docs: update .env.example with SMTP and encryption settings"
Phase 2: Core Email Infrastructure
Task 4: Create Crypto Utilities
Files:
- Create:
lib/crypto-utils.ts
Step 1: Write crypto utilities
Create file with content:
/**
* Encryption utilities for sensitive data
* Uses AES-256-GCM for encryption
*/
import crypto from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
const AUTH_TAG_LENGTH = 16;
const SALT_LENGTH = 64;
/**
* Get encryption key from environment
*/
function getEncryptionKey(): Buffer {
const key = process.env.ENCRYPTION_KEY;
if (!key || key.length !== 64) {
throw new Error('ENCRYPTION_KEY must be a 32-byte hex string (64 characters)');
}
return Buffer.from(key, 'hex');
}
/**
* Encrypt text using AES-256-GCM
* Returns base64 encoded string with format: iv:authTag:encrypted
*/
export function encrypt(text: string): string {
try {
const key = getEncryptionKey();
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(text, 'utf8', 'base64');
encrypted += cipher.final('base64');
const authTag = cipher.getAuthTag();
// Combine iv, authTag, and encrypted data
return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`;
} catch (error) {
console.error('[Crypto] Encryption failed:', error);
throw new Error('Failed to encrypt data');
}
}
/**
* Decrypt text encrypted with encrypt()
* Expects base64 string with format: iv:authTag:encrypted
*/
export function decrypt(encryptedText: string): string {
try {
const key = getEncryptionKey();
const parts = encryptedText.split(':');
if (parts.length !== 3) {
throw new Error('Invalid encrypted text format');
}
const iv = Buffer.from(parts[0], 'base64');
const authTag = Buffer.from(parts[1], 'base64');
const encrypted = parts[2];
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'base64', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
console.error('[Crypto] Decryption failed:', error);
throw new Error('Failed to decrypt data');
}
}
/**
* Generate a random encryption key (32 bytes as hex string)
*/
export function generateEncryptionKey(): string {
return crypto.randomBytes(32).toString('hex');
}
Step 2: Test crypto functions
Create a test file temporarily at test-crypto.js:
const { encrypt, decrypt } = require('./lib/crypto-utils.ts');
// Set test key
process.env.ENCRYPTION_KEY = require('crypto').randomBytes(32).toString('hex');
const original = 'test-password-123';
const encrypted = encrypt(original);
const decrypted = decrypt(encrypted);
console.log('Original:', original);
console.log('Encrypted:', encrypted);
console.log('Decrypted:', decrypted);
console.log('Match:', original === decrypted);
Run: node test-crypto.js (optional - for manual verification)
Step 3: Commit
git add lib/crypto-utils.ts
git commit -m "feat: add AES-256-GCM encryption utilities for sensitive data"
Task 5: Create SMTP Configuration Types
Files:
- Create:
lib/types/smtp.ts
Step 1: Define SMTP types
/**
* SMTP Configuration types
*/
export interface SMTPConfig {
host: string;
port: number;
secure: boolean;
auth: {
user: string;
pass: string; // Encrypted in DB
};
from: {
email: string;
name: string;
};
replyTo?: string;
timeout?: number;
}
export interface SMTPConfigResponse {
config: SMTPConfig | null;
source: 'database' | 'env';
}
export interface SMTPTestRequest {
config: SMTPConfig;
testEmail: string;
}
export interface EmailTemplate {
name: string;
subject: string;
description: string;
}
export const EMAIL_TEMPLATES: EmailTemplate[] = [
{
name: 'welcome',
subject: 'Welcome to Location Tracker',
description: 'Sent when a new user is created',
},
{
name: 'password-reset',
subject: 'Password Reset Request',
description: 'Sent when user requests password reset',
},
];
Step 2: Commit
git add lib/types/smtp.ts
git commit -m "feat: add SMTP configuration types"
Task 6: Create Settings Database Operations
Files:
- Create:
lib/settings-db.ts
Step 1: Write settings DB helpers
/**
* Database operations for app settings
*/
import { getDb } from './db';
import { SMTPConfig } from './types/smtp';
import { encrypt, decrypt } from './crypto-utils';
export interface Setting {
key: string;
value: string;
updated_at: string;
}
export const settingsDb = {
/**
* Get a setting by key
*/
get: (key: string): Setting | null => {
const db = getDb();
const setting = db
.prepare('SELECT * FROM settings WHERE key = ?')
.get(key) as Setting | undefined;
db.close();
return setting || null;
},
/**
* Set a setting value
*/
set: (key: string, value: string): void => {
const db = getDb();
db.prepare(
`INSERT INTO settings (key, value, updated_at)
VALUES (?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = datetime('now')`
).run(key, value);
db.close();
},
/**
* Delete a setting
*/
delete: (key: string): boolean => {
const db = getDb();
const result = db.prepare('DELETE FROM settings WHERE key = ?').run(key);
db.close();
return result.changes > 0;
},
/**
* Get SMTP config from database (password decrypted)
*/
getSMTPConfig: (): SMTPConfig | null => {
const setting = settingsDb.get('smtp_config');
if (!setting) return null;
try {
const config = JSON.parse(setting.value) as SMTPConfig;
// Decrypt password if present
if (config.auth?.pass) {
config.auth.pass = decrypt(config.auth.pass);
}
return config;
} catch (error) {
console.error('[SettingsDB] Failed to parse SMTP config:', error);
return null;
}
},
/**
* Save SMTP config to database (password encrypted)
*/
setSMTPConfig: (config: SMTPConfig): void => {
// Encrypt password before saving
const configToSave = {
...config,
auth: {
...config.auth,
pass: encrypt(config.auth.pass),
},
};
settingsDb.set('smtp_config', JSON.stringify(configToSave));
},
};
Step 2: Commit
git add lib/settings-db.ts
git commit -m "feat: add settings database operations with SMTP config helpers"
Task 7: Create Password Reset Token Operations
Files:
- Create:
lib/password-reset-db.ts
Step 1: Write password reset DB helpers
/**
* Database operations for password reset tokens
*/
import { getDb } from './db';
import { randomUUID } from 'crypto';
export interface PasswordResetToken {
token: string;
user_id: string;
expires_at: string;
used: number;
created_at: string;
}
export const passwordResetDb = {
/**
* Create a new password reset token
* Returns token string
*/
create: (userId: string, expiresInHours: number = 1): string => {
const db = getDb();
const token = randomUUID();
const expiresAt = new Date(Date.now() + expiresInHours * 60 * 60 * 1000).toISOString();
db.prepare(`
INSERT INTO password_reset_tokens (token, user_id, expires_at)
VALUES (?, ?, ?)
`).run(token, userId, expiresAt);
db.close();
return token;
},
/**
* Get token by token string
*/
findByToken: (token: string): PasswordResetToken | null => {
const db = getDb();
const result = db
.prepare('SELECT * FROM password_reset_tokens WHERE token = ?')
.get(token) as PasswordResetToken | undefined;
db.close();
return result || null;
},
/**
* Validate token (exists, not used, not expired)
*/
isValid: (token: string): boolean => {
const resetToken = passwordResetDb.findByToken(token);
if (!resetToken) return false;
if (resetToken.used) return false;
const now = new Date();
const expiresAt = new Date(resetToken.expires_at);
if (now > expiresAt) return false;
return true;
},
/**
* Mark token as used
*/
markUsed: (token: string): boolean => {
const db = getDb();
const result = db
.prepare('UPDATE password_reset_tokens SET used = 1 WHERE token = ?')
.run(token);
db.close();
return result.changes > 0;
},
/**
* Delete expired tokens (cleanup)
*/
deleteExpired: (): number => {
const db = getDb();
const result = db
.prepare("DELETE FROM password_reset_tokens WHERE expires_at < datetime('now')")
.run();
db.close();
return result.changes;
},
/**
* Delete all tokens for a user
*/
deleteByUserId: (userId: string): number => {
const db = getDb();
const result = db
.prepare('DELETE FROM password_reset_tokens WHERE user_id = ?')
.run(userId);
db.close();
return result.changes;
},
};
Step 2: Commit
git add lib/password-reset-db.ts
git commit -m "feat: add password reset token database operations"
Phase 3: React Email Templates
Task 8: Create Email Base Layout
Files:
- Create:
emails/components/email-layout.tsx
Step 1: Create base email layout
import {
Body,
Container,
Head,
Html,
Preview,
Section,
} from '@react-email/components';
import * as React from 'react';
interface EmailLayoutProps {
preview: string;
children: React.ReactNode;
}
export const EmailLayout = ({ preview, children }: EmailLayoutProps) => {
return (
<Html>
<Head />
<Preview>{preview}</Preview>
<Body style={main}>
<Container style={container}>{children}</Container>
</Body>
</Html>
);
};
const main = {
backgroundColor: '#f6f9fc',
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
};
const container = {
backgroundColor: '#ffffff',
margin: '0 auto',
padding: '20px 0 48px',
marginBottom: '64px',
maxWidth: '600px',
};
Step 2: Create email header component
Create emails/components/email-header.tsx:
import { Heading, Section, Text } from '@react-email/components';
import * as React from 'react';
interface EmailHeaderProps {
title: string;
}
export const EmailHeader = ({ title }: EmailHeaderProps) => {
return (
<Section style={header}>
<Heading style={h1}>{title}</Heading>
<Text style={subtitle}>Location Tracker</Text>
</Section>
);
};
const header = {
padding: '20px 40px',
borderBottom: '1px solid #eaeaea',
};
const h1 = {
color: '#1f2937',
fontSize: '24px',
fontWeight: '600',
lineHeight: '1.3',
margin: '0 0 8px',
};
const subtitle = {
color: '#6b7280',
fontSize: '14px',
margin: '0',
};
Step 3: Create email footer component
Create emails/components/email-footer.tsx:
import { Hr, Link, Section, Text } from '@react-email/components';
import * as React from 'react';
export const EmailFooter = () => {
return (
<>
<Hr style={hr} />
<Section style={footer}>
<Text style={footerText}>
This email was sent from Location Tracker.
</Text>
<Text style={footerText}>
If you have questions, please contact your administrator.
</Text>
</Section>
</>
);
};
const hr = {
borderColor: '#eaeaea',
margin: '26px 0',
};
const footer = {
padding: '0 40px',
};
const footerText = {
color: '#6b7280',
fontSize: '12px',
lineHeight: '1.5',
margin: '0 0 8px',
};
Step 4: Commit
git add emails/components/
git commit -m "feat: add React Email base components (layout, header, footer)"
Task 9: Create Welcome Email Template
Files:
- Create:
emails/welcome.tsx
Step 1: Write welcome email template
import { Button, Link, Section, Text } from '@react-email/components';
import * as React from 'react';
import { EmailLayout } from './components/email-layout';
import { EmailHeader } from './components/email-header';
import { EmailFooter } from './components/email-footer';
interface WelcomeEmailProps {
username: string;
loginUrl: string;
temporaryPassword?: string;
}
export const WelcomeEmail = ({
username = 'user',
loginUrl = 'http://localhost:3000/login',
temporaryPassword,
}: WelcomeEmailProps) => {
return (
<EmailLayout preview="Welcome to Location Tracker">
<EmailHeader title="Welcome!" />
<Section style={content}>
<Text style={paragraph}>Hi {username},</Text>
<Text style={paragraph}>
Welcome to Location Tracker! Your account has been created and you can now access the system.
</Text>
{temporaryPassword && (
<>
<Text style={paragraph}>
Your temporary password is: <strong style={code}>{temporaryPassword}</strong>
</Text>
<Text style={paragraph}>
Please change this password after your first login for security.
</Text>
</>
)}
<Button style={button} href={loginUrl}>
Login to Location Tracker
</Button>
<Text style={paragraph}>
Or copy and paste this URL into your browser:{' '}
<Link href={loginUrl} style={link}>
{loginUrl}
</Link>
</Text>
<Text style={paragraph}>
If you have any questions, please contact your administrator.
</Text>
<Text style={paragraph}>
Best regards,
<br />
Location Tracker Team
</Text>
</Section>
<EmailFooter />
</EmailLayout>
);
};
export default WelcomeEmail;
const content = {
padding: '20px 40px',
};
const paragraph = {
color: '#374151',
fontSize: '16px',
lineHeight: '1.6',
margin: '0 0 16px',
};
const button = {
backgroundColor: '#2563eb',
borderRadius: '6px',
color: '#fff',
display: 'inline-block',
fontSize: '16px',
fontWeight: '600',
lineHeight: '1',
padding: '12px 24px',
textDecoration: 'none',
textAlign: 'center' as const,
margin: '20px 0',
};
const link = {
color: '#2563eb',
textDecoration: 'underline',
};
const code = {
backgroundColor: '#f3f4f6',
borderRadius: '4px',
color: '#1f2937',
fontFamily: 'monospace',
fontSize: '14px',
padding: '2px 6px',
};
Step 2: Commit
git add emails/welcome.tsx
git commit -m "feat: add welcome email template"
Task 10: Create Password Reset Email Template
Files:
- Create:
emails/password-reset.tsx
Step 1: Write password reset email template
import { Button, Link, Section, Text } from '@react-email/components';
import * as React from 'react';
import { EmailLayout } from './components/email-layout';
import { EmailHeader } from './components/email-header';
import { EmailFooter } from './components/email-footer';
interface PasswordResetEmailProps {
username: string;
resetUrl: string;
expiresIn?: string;
}
export const PasswordResetEmail = ({
username = 'user',
resetUrl = 'http://localhost:3000/reset-password?token=xxx',
expiresIn = '1 hour',
}: PasswordResetEmailProps) => {
return (
<EmailLayout preview="Password Reset Request">
<EmailHeader title="Password Reset" />
<Section style={content}>
<Text style={paragraph}>Hi {username},</Text>
<Text style={paragraph}>
We received a request to reset your password for your Location Tracker account.
</Text>
<Text style={paragraph}>
Click the button below to reset your password:
</Text>
<Button style={button} href={resetUrl}>
Reset Password
</Button>
<Text style={paragraph}>
Or copy and paste this URL into your browser:{' '}
<Link href={resetUrl} style={link}>
{resetUrl}
</Link>
</Text>
<Text style={warningText}>
⚠️ This link will expire in {expiresIn}. If you didn't request this password reset, please ignore this email or contact your administrator if you have concerns.
</Text>
<Text style={paragraph}>
For security reasons, this password reset link can only be used once.
</Text>
<Text style={paragraph}>
Best regards,
<br />
Location Tracker Team
</Text>
</Section>
<EmailFooter />
</EmailLayout>
);
};
export default PasswordResetEmail;
const content = {
padding: '20px 40px',
};
const paragraph = {
color: '#374151',
fontSize: '16px',
lineHeight: '1.6',
margin: '0 0 16px',
};
const button = {
backgroundColor: '#dc2626',
borderRadius: '6px',
color: '#fff',
display: 'inline-block',
fontSize: '16px',
fontWeight: '600',
lineHeight: '1',
padding: '12px 24px',
textDecoration: 'none',
textAlign: 'center' as const,
margin: '20px 0',
};
const link = {
color: '#2563eb',
textDecoration: 'underline',
};
const warningText = {
backgroundColor: '#fef3c7',
border: '1px solid #fbbf24',
borderRadius: '6px',
color: '#92400e',
fontSize: '14px',
lineHeight: '1.6',
margin: '20px 0',
padding: '12px 16px',
};
Step 2: Commit
git add emails/password-reset.tsx
git commit -m "feat: add password reset email template"
Phase 4: Email Service
Task 11: Create Email Renderer
Files:
- Create:
lib/email-renderer.ts
Step 1: Write email renderer
/**
* Renders React Email templates to HTML
*/
import { render } from '@react-email/components';
import WelcomeEmail from '@/emails/welcome';
import PasswordResetEmail from '@/emails/password-reset';
export interface WelcomeEmailData {
username: string;
loginUrl: string;
temporaryPassword?: string;
}
export interface PasswordResetEmailData {
username: string;
resetUrl: string;
expiresIn?: string;
}
export async function renderWelcomeEmail(data: WelcomeEmailData): Promise<string> {
return render(WelcomeEmail(data));
}
export async function renderPasswordResetEmail(data: PasswordResetEmailData): Promise<string> {
return render(PasswordResetEmail(data));
}
export async function renderEmailTemplate(
template: string,
data: any
): Promise<string> {
switch (template) {
case 'welcome':
return renderWelcomeEmail(data);
case 'password-reset':
return renderPasswordResetEmail(data);
default:
throw new Error(`Unknown email template: ${template}`);
}
}
Step 2: Commit
git add lib/email-renderer.ts
git commit -m "feat: add email renderer for React Email templates"
Task 12: Create Email Service
Files:
- Create:
lib/email-service.ts
Step 1: Write email service (part 1 - config)
/**
* Email service for sending emails via SMTP
* Supports hybrid configuration (DB + .env fallback)
*/
import nodemailer, { Transporter } from 'nodemailer';
import { SMTPConfig } from './types/smtp';
import { settingsDb } from './settings-db';
import {
renderWelcomeEmail,
renderPasswordResetEmail,
WelcomeEmailData,
PasswordResetEmailData,
} from './email-renderer';
export class EmailService {
private transporter: Transporter | null = null;
/**
* Get SMTP configuration (DB first, then .env fallback)
*/
private async getConfig(): Promise<SMTPConfig> {
// Try database first
const dbConfig = settingsDb.getSMTPConfig();
if (dbConfig) {
console.log('[EmailService] Using SMTP config from database');
return dbConfig;
}
// Fallback to environment variables
console.log('[EmailService] Using SMTP config from environment');
const envConfig: SMTPConfig = {
host: process.env.SMTP_HOST || '',
port: parseInt(process.env.SMTP_PORT || '587', 10),
secure: process.env.SMTP_SECURE === 'true',
auth: {
user: process.env.SMTP_USER || '',
pass: process.env.SMTP_PASS || '',
},
from: {
email: process.env.SMTP_FROM_EMAIL || '',
name: process.env.SMTP_FROM_NAME || 'Location Tracker',
},
timeout: 10000,
};
// Validate env config
if (!envConfig.host || !envConfig.auth.user || !envConfig.auth.pass) {
throw new Error('SMTP configuration is incomplete. Please configure SMTP settings in admin panel or .env file.');
}
return envConfig;
}
/**
* Create and configure nodemailer transporter
*/
private async getTransporter(): Promise<Transporter> {
if (this.transporter) {
return this.transporter;
}
const config = await this.getConfig();
this.transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: {
user: config.auth.user,
pass: config.auth.pass,
},
connectionTimeout: config.timeout || 10000,
});
return this.transporter;
}
Step 2: Write email service (part 2 - send methods)
Continue in same file:
/**
* Send an email
*/
private async sendEmail(
to: string,
subject: string,
html: string
): Promise<void> {
try {
const config = await this.getConfig();
const transporter = await this.getTransporter();
const info = await transporter.sendMail({
from: `"${config.from.name}" <${config.from.email}>`,
to,
subject,
html,
replyTo: config.replyTo,
});
console.log('[EmailService] Email sent:', {
messageId: info.messageId,
to,
subject,
});
} catch (error) {
console.error('[EmailService] Failed to send email:', error);
throw new Error(`Failed to send email: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Send welcome email to new user
*/
async sendWelcomeEmail(data: WelcomeEmailData & { email: string }): Promise<void> {
const html = await renderWelcomeEmail({
username: data.username,
loginUrl: data.loginUrl,
temporaryPassword: data.temporaryPassword,
});
await this.sendEmail(
data.email,
'Welcome to Location Tracker',
html
);
}
/**
* Send password reset email
*/
async sendPasswordResetEmail(data: PasswordResetEmailData & { email: string }): Promise<void> {
const html = await renderPasswordResetEmail({
username: data.username,
resetUrl: data.resetUrl,
expiresIn: data.expiresIn || '1 hour',
});
await this.sendEmail(
data.email,
'Password Reset Request - Location Tracker',
html
);
}
/**
* Test SMTP connection
*/
async testConnection(config?: SMTPConfig): Promise<boolean> {
try {
let transporter: Transporter;
if (config) {
// Test provided config
transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.auth,
connectionTimeout: config.timeout || 10000,
});
} else {
// Test current config
transporter = await this.getTransporter();
}
await transporter.verify();
console.log('[EmailService] SMTP connection test successful');
return true;
} catch (error) {
console.error('[EmailService] SMTP connection test failed:', error);
return false;
}
}
}
// Export singleton instance
export const emailService = new EmailService();
Step 3: Commit
git add lib/email-service.ts
git commit -m "feat: add email service with SMTP support and hybrid config"
Phase 5: Admin Panel - SMTP Settings
Task 13: Create SMTP Settings API
Files:
- Create:
app/api/admin/settings/smtp/route.ts
Step 1: Write GET and POST handlers
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { settingsDb } from '@/lib/settings-db';
import { SMTPConfig, SMTPConfigResponse } from '@/lib/types/smtp';
/**
* GET /api/admin/settings/smtp
* Returns current SMTP configuration (password masked)
*/
export async function GET() {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const dbConfig = settingsDb.getSMTPConfig();
let response: SMTPConfigResponse;
if (dbConfig) {
// Mask password
const maskedConfig = {
...dbConfig,
auth: {
...dbConfig.auth,
pass: '***',
},
};
response = { config: maskedConfig, source: 'database' };
} else {
// Check if env config exists
const hasEnvConfig =
process.env.SMTP_HOST &&
process.env.SMTP_USER &&
process.env.SMTP_PASS;
if (hasEnvConfig) {
const envConfig: SMTPConfig = {
host: process.env.SMTP_HOST!,
port: parseInt(process.env.SMTP_PORT || '587', 10),
secure: process.env.SMTP_SECURE === 'true',
auth: {
user: process.env.SMTP_USER!,
pass: '***',
},
from: {
email: process.env.SMTP_FROM_EMAIL || '',
name: process.env.SMTP_FROM_NAME || 'Location Tracker',
},
replyTo: process.env.SMTP_REPLY_TO,
timeout: 10000,
};
response = { config: envConfig, source: 'env' };
} else {
response = { config: null, source: 'env' };
}
}
return NextResponse.json(response);
} catch (error) {
console.error('[API] Failed to get SMTP config:', error);
return NextResponse.json(
{ error: 'Failed to get SMTP configuration' },
{ status: 500 }
);
}
}
/**
* POST /api/admin/settings/smtp
* Save SMTP configuration to database
*/
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const config = body.config as SMTPConfig;
// Validation
if (!config.host || !config.port || !config.auth?.user || !config.auth?.pass) {
return NextResponse.json(
{ error: 'Missing required SMTP configuration fields' },
{ status: 400 }
);
}
if (config.port < 1 || config.port > 65535) {
return NextResponse.json(
{ error: 'Port must be between 1 and 65535' },
{ status: 400 }
);
}
// Save to database (password will be encrypted)
settingsDb.setSMTPConfig(config);
return NextResponse.json({ success: true });
} catch (error) {
console.error('[API] Failed to save SMTP config:', error);
return NextResponse.json(
{ error: 'Failed to save SMTP configuration' },
{ status: 500 }
);
}
}
/**
* DELETE /api/admin/settings/smtp
* Reset to environment config
*/
export async function DELETE() {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
settingsDb.delete('smtp_config');
return NextResponse.json({ success: true });
} catch (error) {
console.error('[API] Failed to delete SMTP config:', error);
return NextResponse.json(
{ error: 'Failed to reset SMTP configuration' },
{ status: 500 }
);
}
}
Step 2: Commit
git add app/api/admin/settings/smtp/route.ts
git commit -m "feat: add SMTP settings API endpoints"
Task 14: Create SMTP Test API
Files:
- Create:
app/api/admin/settings/smtp/test/route.ts
Step 1: Write test endpoint
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { emailService } from '@/lib/email-service';
import { SMTPConfig } from '@/lib/types/smtp';
/**
* POST /api/admin/settings/smtp/test
* Test SMTP configuration by sending a test email
*/
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { config, testEmail } = body as { config?: SMTPConfig; testEmail: string };
if (!testEmail) {
return NextResponse.json(
{ error: 'Test email address is required' },
{ status: 400 }
);
}
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(testEmail)) {
return NextResponse.json(
{ error: 'Invalid email address' },
{ status: 400 }
);
}
// Test connection
const connectionOk = await emailService.testConnection(config);
if (!connectionOk) {
return NextResponse.json(
{ error: 'SMTP connection failed. Please check your settings.' },
{ status: 500 }
);
}
// Send test email
try {
await emailService.sendWelcomeEmail({
email: testEmail,
username: 'Test User',
loginUrl: `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/login`,
temporaryPassword: undefined,
});
return NextResponse.json({
success: true,
message: `Test email sent successfully to ${testEmail}`,
});
} catch (sendError) {
console.error('[API] Test email send failed:', sendError);
return NextResponse.json(
{
error: `Email send failed: ${sendError instanceof Error ? sendError.message : 'Unknown error'}`,
},
{ status: 500 }
);
}
} catch (error) {
console.error('[API] SMTP test failed:', error);
return NextResponse.json(
{ error: 'SMTP test failed' },
{ status: 500 }
);
}
}
Step 2: Commit
git add app/api/admin/settings/smtp/test/route.ts
git commit -m "feat: add SMTP test API endpoint"
Phase 6: Admin Panel - SMTP Settings UI
Task 15: Create SMTP Settings Page
Files:
- Create:
app/admin/settings/page.tsx
Step 1: Write settings page (part 1 - state and fetch)
"use client";
import { useEffect, useState } from "react";
import { SMTPConfig, SMTPConfigResponse } from "@/lib/types/smtp";
export default function SettingsPage() {
const [activeTab, setActiveTab] = useState<'smtp'>('smtp');
const [config, setConfig] = useState<SMTPConfig>({
host: '',
port: 587,
secure: false,
auth: { user: '', pass: '' },
from: { email: '', name: 'Location Tracker' },
replyTo: '',
timeout: 10000,
});
const [source, setSource] = useState<'database' | 'env'>('env');
const [hasPassword, setHasPassword] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [testEmail, setTestEmail] = useState('');
const [showTestModal, setShowTestModal] = useState(false);
// Fetch current config
useEffect(() => {
fetchConfig();
}, []);
const fetchConfig = async () => {
try {
const response = await fetch('/api/admin/settings/smtp');
if (!response.ok) throw new Error('Failed to fetch config');
const data: SMTPConfigResponse = await response.json();
if (data.config) {
setConfig(data.config);
setHasPassword(data.config.auth.pass === '***');
}
setSource(data.source);
} catch (error) {
console.error('Failed to fetch SMTP config:', error);
setMessage({ type: 'error', text: 'Failed to load SMTP configuration' });
} finally {
setLoading(false);
}
};
Step 2: Write settings page (part 2 - handlers)
Continue in same file:
// Save config
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setMessage(null);
try {
const response = await fetch('/api/admin/settings/smtp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to save');
}
setMessage({ type: 'success', text: 'SMTP settings saved successfully' });
setHasPassword(true);
setSource('database');
// Clear password field for security
setConfig({ ...config, auth: { ...config.auth, pass: '' } });
} catch (error: any) {
setMessage({ type: 'error', text: error.message || 'Failed to save settings' });
} finally {
setSaving(false);
}
};
// Reset to defaults
const handleReset = async () => {
if (!confirm('Reset to environment defaults? This will delete database configuration.')) {
return;
}
try {
const response = await fetch('/api/admin/settings/smtp', {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to reset');
setMessage({ type: 'success', text: 'Reset to environment defaults' });
await fetchConfig();
} catch (error) {
setMessage({ type: 'error', text: 'Failed to reset settings' });
}
};
// Test connection
const handleTest = async () => {
if (!testEmail) {
alert('Please enter a test email address');
return;
}
setTesting(true);
setMessage(null);
try {
const response = await fetch('/api/admin/settings/smtp/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
config: hasPassword ? undefined : config,
testEmail,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Test failed');
}
setMessage({ type: 'success', text: data.message });
setShowTestModal(false);
setTestEmail('');
} catch (error: any) {
setMessage({ type: 'error', text: error.message || 'Connection test failed' });
} finally {
setTesting(false);
}
};
Step 3: Write settings page (part 3 - render)
Continue in same file:
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<p className="text-gray-600">Loading settings...</p>
</div>
);
}
return (
<div>
<h2 className="text-3xl font-bold text-gray-900 mb-6">Settings</h2>
{/* Tab Navigation */}
<div className="border-b border-gray-200 mb-6">
<nav className="flex gap-4">
<button
onClick={() => setActiveTab('smtp')}
className={`px-4 py-2 border-b-2 font-medium ${
activeTab === 'smtp'
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-600 hover:text-gray-900'
}`}
>
SMTP Settings
</button>
</nav>
</div>
{/* Status Message */}
{message && (
<div
className={`mb-6 p-4 rounded ${
message.type === 'success'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{message.text}
</div>
)}
{/* Config Source Info */}
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded">
<p className="text-sm text-blue-900">
<strong>Current source:</strong> {source === 'database' ? 'Database (Custom)' : 'Environment (.env)'}
</p>
</div>
{/* SMTP Form */}
<form onSubmit={handleSave} className="bg-white rounded-lg shadow p-6">
<div className="space-y-4">
{/* Host */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
SMTP Host *
</label>
<input
type="text"
required
value={config.host}
onChange={(e) => setConfig({ ...config, host: e.target.value })}
placeholder="smtp.gmail.com"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Port and Secure */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Port *
</label>
<input
type="number"
required
min="1"
max="65535"
value={config.port}
onChange={(e) => setConfig({ ...config, port: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={config.secure}
onChange={(e) => setConfig({ ...config, secure: e.target.checked })}
className="w-4 h-4 text-blue-600"
/>
<span className="text-sm text-gray-700">Use TLS/SSL</span>
</label>
</div>
</div>
{/* Username */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Username *
</label>
<input
type="text"
required
value={config.auth.user}
onChange={(e) => setConfig({ ...config, auth: { ...config.auth, user: e.target.value } })}
placeholder="your-email@example.com"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Password */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Password {hasPassword && '(leave empty to keep current)'}
</label>
<input
type="password"
required={!hasPassword}
value={config.auth.pass}
onChange={(e) => setConfig({ ...config, auth: { ...config.auth, pass: e.target.value } })}
placeholder={hasPassword ? '••••••••' : 'your-password'}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* From Email */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
From Email *
</label>
<input
type="email"
required
value={config.from.email}
onChange={(e) => setConfig({ ...config, from: { ...config.from, email: e.target.value } })}
placeholder="noreply@example.com"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* From Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
From Name *
</label>
<input
type="text"
required
value={config.from.name}
onChange={(e) => setConfig({ ...config, from: { ...config.from, name: e.target.value } })}
placeholder="Location Tracker"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Reply-To */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Reply-To (optional)
</label>
<input
type="email"
value={config.replyTo || ''}
onChange={(e) => setConfig({ ...config, replyTo: e.target.value })}
placeholder="support@example.com"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Timeout */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Timeout (ms)
</label>
<input
type="number"
min="1000"
value={config.timeout}
onChange={(e) => setConfig({ ...config, timeout: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
{/* Buttons */}
<div className="flex gap-3 mt-6">
<button
type="submit"
disabled={saving}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400"
>
{saving ? 'Saving...' : 'Save Settings'}
</button>
<button
type="button"
onClick={() => setShowTestModal(true)}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700"
>
Test Connection
</button>
{source === 'database' && (
<button
type="button"
onClick={handleReset}
className="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-100"
>
Reset to Defaults
</button>
)}
</div>
</form>
{/* Test Email Modal */}
{showTestModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h3 className="text-xl font-bold mb-4">Test SMTP Connection</h3>
<p className="text-sm text-gray-600 mb-4">
Enter your email address to receive a test email.
</p>
<input
type="email"
value={testEmail}
onChange={(e) => setTestEmail(e.target.value)}
placeholder="your-email@example.com"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 mb-4"
/>
<div className="flex gap-3">
<button
onClick={() => {
setShowTestModal(false);
setTestEmail('');
}}
className="flex-1 px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-100"
>
Cancel
</button>
<button
onClick={handleTest}
disabled={testing || !testEmail}
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:bg-gray-400"
>
{testing ? 'Sending...' : 'Send Test Email'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
Step 4: Commit
git add app/admin/settings/page.tsx
git commit -m "feat: add SMTP settings UI page"
Task 16: Update Admin Navigation
Files:
- Modify:
app/admin/layout.tsx:14-18
Step 1: Add Settings link to navigation
In the navigation array, add:
const navigation = [
{ name: "Dashboard", href: "/admin" },
{ name: "Devices", href: "/admin/devices" },
{ name: "Users", href: "/admin/users" },
{ name: "Settings", href: "/admin/settings" },
];
Step 2: Commit
git add app/admin/layout.tsx
git commit -m "feat: add Settings to admin navigation"
Phase 7: Email Preview & Testing
Task 17: Create Email Preview API
Files:
- Create:
app/api/admin/emails/preview/route.ts
Step 1: Write preview endpoint
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { renderEmailTemplate } from '@/lib/email-renderer';
/**
* GET /api/admin/emails/preview?template=welcome
* Render email template with sample data for preview
*/
export async function GET(request: Request) {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const template = searchParams.get('template');
if (!template) {
return NextResponse.json(
{ error: 'Template parameter is required' },
{ status: 400 }
);
}
// Sample data for each template
const sampleData: Record<string, any> = {
welcome: {
username: 'John Doe',
loginUrl: `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/login`,
temporaryPassword: 'TempPass123!',
},
'password-reset': {
username: 'John Doe',
resetUrl: `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/reset-password?token=sample-token-123`,
expiresIn: '1 hour',
},
};
if (!sampleData[template]) {
return NextResponse.json(
{ error: `Unknown template: ${template}` },
{ status: 400 }
);
}
const html = await renderEmailTemplate(template, sampleData[template]);
return new NextResponse(html, {
headers: { 'Content-Type': 'text/html' },
});
} catch (error) {
console.error('[API] Email preview failed:', error);
return NextResponse.json(
{ error: 'Failed to render email template' },
{ status: 500 }
);
}
}
Step 2: Commit
git add app/api/admin/emails/preview/route.ts
git commit -m "feat: add email preview API endpoint"
Task 18: Create Send Test Email API
Files:
- Create:
app/api/admin/emails/send-test/route.ts
Step 1: Write send test endpoint
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { emailService } from '@/lib/email-service';
// Simple rate limiting (in-memory)
const rateLimitMap = new Map<string, number[]>();
const RATE_LIMIT = 5; // max requests
const RATE_WINDOW = 60 * 1000; // per minute
function checkRateLimit(ip: string): boolean {
const now = Date.now();
const requests = rateLimitMap.get(ip) || [];
// Filter out old requests
const recentRequests = requests.filter(time => now - time < RATE_WINDOW);
if (recentRequests.length >= RATE_LIMIT) {
return false;
}
recentRequests.push(now);
rateLimitMap.set(ip, recentRequests);
return true;
}
/**
* POST /api/admin/emails/send-test
* Send test email with specific template
*/
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Rate limiting
const ip = request.headers.get('x-forwarded-for') || 'unknown';
if (!checkRateLimit(ip)) {
return NextResponse.json(
{ error: 'Too many requests. Please wait a minute.' },
{ status: 429 }
);
}
const body = await request.json();
const { template, email } = body;
if (!template || !email) {
return NextResponse.json(
{ error: 'Template and email are required' },
{ status: 400 }
);
}
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json(
{ error: 'Invalid email address' },
{ status: 400 }
);
}
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
// Send appropriate template
switch (template) {
case 'welcome':
await emailService.sendWelcomeEmail({
email,
username: 'Test User',
loginUrl: `${baseUrl}/login`,
temporaryPassword: 'TempPass123!',
});
break;
case 'password-reset':
await emailService.sendPasswordResetEmail({
email,
username: 'Test User',
resetUrl: `${baseUrl}/reset-password?token=sample-token-123`,
expiresIn: '1 hour',
});
break;
default:
return NextResponse.json(
{ error: `Unknown template: ${template}` },
{ status: 400 }
);
}
return NextResponse.json({
success: true,
message: `Test email sent to ${email}`,
});
} catch (error) {
console.error('[API] Send test email failed:', error);
return NextResponse.json(
{ error: `Failed to send email: ${error instanceof Error ? error.message : 'Unknown error'}` },
{ status: 500 }
);
}
}
Step 2: Commit
git add app/api/admin/emails/send-test/route.ts
git commit -m "feat: add send test email API with rate limiting"
Task 19: Create Email Preview Page
Files:
- Create:
app/admin/emails/page.tsx
Step 1: Write emails preview page
"use client";
import { useState } from "react";
import { EMAIL_TEMPLATES, EmailTemplate } from "@/lib/types/smtp";
export default function EmailsPage() {
const [selectedTemplate, setSelectedTemplate] = useState<string>('welcome');
const [testEmail, setTestEmail] = useState('');
const [sending, setSending] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [showSendModal, setShowSendModal] = useState(false);
const handleSendTest = async () => {
if (!testEmail) {
alert('Please enter a test email address');
return;
}
setSending(true);
setMessage(null);
try {
const response = await fetch('/api/admin/emails/send-test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
template: selectedTemplate,
email: testEmail,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to send');
}
setMessage({ type: 'success', text: data.message });
setShowSendModal(false);
setTestEmail('');
} catch (error: any) {
setMessage({ type: 'error', text: error.message || 'Failed to send test email' });
} finally {
setSending(false);
}
};
const previewUrl = `/api/admin/emails/preview?template=${selectedTemplate}`;
return (
<div>
<h2 className="text-3xl font-bold text-gray-900 mb-6">Email Templates</h2>
{/* Status Message */}
{message && (
<div
className={`mb-6 p-4 rounded ${
message.type === 'success'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{message.text}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Template List */}
<div className="lg:col-span-1">
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">Templates</h3>
</div>
<div className="p-4">
<div className="space-y-2">
{EMAIL_TEMPLATES.map((template) => (
<button
key={template.name}
onClick={() => setSelectedTemplate(template.name)}
className={`w-full text-left px-4 py-3 rounded-md transition-colors ${
selectedTemplate === template.name
? 'bg-blue-600 text-white'
: 'bg-gray-50 hover:bg-gray-100 text-gray-900'
}`}
>
<p className="font-medium">{template.subject}</p>
<p className={`text-sm mt-1 ${
selectedTemplate === template.name
? 'text-blue-100'
: 'text-gray-600'
}`}>
{template.description}
</p>
</button>
))}
</div>
</div>
</div>
{/* Send Test Button */}
<button
onClick={() => setShowSendModal(true)}
className="w-full mt-4 px-4 py-3 bg-green-600 text-white rounded-md hover:bg-green-700 font-medium"
>
Send Test Email
</button>
</div>
{/* Preview */}
<div className="lg:col-span-2">
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<h3 className="text-lg font-semibold text-gray-900">Preview</h3>
<span className="text-sm text-gray-600">
{EMAIL_TEMPLATES.find(t => t.name === selectedTemplate)?.subject}
</span>
</div>
<div className="p-4">
<iframe
src={previewUrl}
className="w-full border border-gray-300 rounded"
style={{ height: '600px' }}
title="Email Preview"
/>
</div>
</div>
</div>
</div>
{/* Send Test Email Modal */}
{showSendModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h3 className="text-xl font-bold mb-4">Send Test Email</h3>
<p className="text-sm text-gray-600 mb-2">
Template: <strong>{EMAIL_TEMPLATES.find(t => t.name === selectedTemplate)?.subject}</strong>
</p>
<p className="text-sm text-gray-600 mb-4">
Enter your email address to receive a test email.
</p>
<input
type="email"
value={testEmail}
onChange={(e) => setTestEmail(e.target.value)}
placeholder="your-email@example.com"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 mb-4"
/>
<div className="flex gap-3">
<button
onClick={() => {
setShowSendModal(false);
setTestEmail('');
}}
className="flex-1 px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-100"
>
Cancel
</button>
<button
onClick={handleSendTest}
disabled={sending || !testEmail}
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:bg-gray-400"
>
{sending ? 'Sending...' : 'Send Test'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
Step 2: Add Emails link to navigation
In app/admin/layout.tsx, update navigation:
const navigation = [
{ name: "Dashboard", href: "/admin" },
{ name: "Devices", href: "/admin/devices" },
{ name: "Users", href: "/admin/users" },
{ name: "Settings", href: "/admin/settings" },
{ name: "Emails", href: "/admin/emails" },
];
Step 3: Commit
git add app/admin/emails/page.tsx app/admin/layout.tsx
git commit -m "feat: add email preview page with send test functionality"
Phase 8: Password Reset Flow
Task 20: Create Forgot Password API
Files:
- Create:
app/api/auth/forgot-password/route.ts
Step 1: Write forgot password endpoint
import { NextResponse } from 'next/server';
import { userDb } from '@/lib/db';
import { passwordResetDb } from '@/lib/password-reset-db';
import { emailService } from '@/lib/email-service';
/**
* POST /api/auth/forgot-password
* Request password reset email
*/
export async function POST(request: Request) {
try {
const body = await request.json();
const { email } = body;
if (!email) {
return NextResponse.json(
{ error: 'Email is required' },
{ status: 400 }
);
}
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json(
{ error: 'Invalid email address' },
{ status: 400 }
);
}
// Find user by email
const users = userDb.findAll();
const user = users.find(u => u.email?.toLowerCase() === email.toLowerCase());
// SECURITY: Always return success to prevent user enumeration
// Even if user doesn't exist, return success but don't send email
if (!user) {
console.log('[ForgotPassword] Email not found, but returning success (security)');
return NextResponse.json({
success: true,
message: 'If an account exists with this email, a password reset link has been sent.',
});
}
// Create password reset token
const token = passwordResetDb.create(user.id, 1); // 1 hour expiry
// Send password reset email
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
const resetUrl = `${baseUrl}/reset-password?token=${token}`;
try {
await emailService.sendPasswordResetEmail({
email: user.email!,
username: user.username,
resetUrl,
expiresIn: '1 hour',
});
console.log('[ForgotPassword] Password reset email sent to:', user.email);
} catch (emailError) {
console.error('[ForgotPassword] Failed to send email:', emailError);
// Don't fail the request if email fails - log and continue
}
return NextResponse.json({
success: true,
message: 'If an account exists with this email, a password reset link has been sent.',
});
} catch (error) {
console.error('[ForgotPassword] Error:', error);
return NextResponse.json(
{ error: 'An error occurred. Please try again later.' },
{ status: 500 }
);
}
}
Step 2: Commit
git add app/api/auth/forgot-password/route.ts
git commit -m "feat: add forgot password API endpoint"
Task 21: Create Reset Password API
Files:
- Create:
app/api/auth/reset-password/route.ts
Step 1: Write reset password endpoint
import { NextResponse } from 'next/server';
import { userDb } from '@/lib/db';
import { passwordResetDb } from '@/lib/password-reset-db';
import bcrypt from 'bcryptjs';
/**
* POST /api/auth/reset-password
* Reset password with token
*/
export async function POST(request: Request) {
try {
const body = await request.json();
const { token, newPassword } = body;
if (!token || !newPassword) {
return NextResponse.json(
{ error: 'Token and new password are required' },
{ status: 400 }
);
}
// Password validation
if (newPassword.length < 6) {
return NextResponse.json(
{ error: 'Password must be at least 6 characters' },
{ status: 400 }
);
}
// Validate token
if (!passwordResetDb.isValid(token)) {
return NextResponse.json(
{ error: 'Invalid or expired reset token' },
{ status: 400 }
);
}
// Get token details
const resetToken = passwordResetDb.findByToken(token);
if (!resetToken) {
return NextResponse.json(
{ error: 'Invalid reset token' },
{ status: 400 }
);
}
// Get user
const user = userDb.findById(resetToken.user_id);
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
// Hash new password
const passwordHash = await bcrypt.hash(newPassword, 10);
// Update user password
userDb.update(user.id, { passwordHash });
// Mark token as used
passwordResetDb.markUsed(token);
console.log('[ResetPassword] Password reset successful for user:', user.username);
return NextResponse.json({
success: true,
message: 'Password has been reset successfully',
});
} catch (error) {
console.error('[ResetPassword] Error:', error);
return NextResponse.json(
{ error: 'Failed to reset password' },
{ status: 500 }
);
}
}
/**
* GET /api/auth/reset-password?token=xxx
* Validate reset token (for checking if link is still valid)
*/
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const token = searchParams.get('token');
if (!token) {
return NextResponse.json(
{ error: 'Token is required' },
{ status: 400 }
);
}
const isValid = passwordResetDb.isValid(token);
return NextResponse.json({ valid: isValid });
} catch (error) {
console.error('[ResetPassword] Validation error:', error);
return NextResponse.json(
{ error: 'Failed to validate token' },
{ status: 500 }
);
}
}
Step 2: Commit
git add app/api/auth/reset-password/route.ts
git commit -m "feat: add reset password API endpoint"
Task 22: Create Forgot Password Page
Files:
- Create:
app/forgot-password/page.tsx
Step 1: Write forgot password page
"use client";
import { useState } from "react";
import Link from "next/link";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const response = await fetch('/api/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to send reset email');
}
setSubmitted(true);
} catch (err: any) {
setError(err.message || 'An error occurred');
} finally {
setLoading(false);
}
};
if (submitted) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 px-4">
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
<div className="text-center">
<div className="text-5xl mb-4">✅</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Check Your Email
</h2>
<p className="text-gray-600 mb-6">
If an account exists with the email <strong>{email}</strong>, you will receive a password reset link shortly.
</p>
<Link
href="/login"
className="text-blue-600 hover:text-blue-700 font-medium"
>
← Back to Login
</Link>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 px-4">
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Forgot Password
</h2>
<p className="text-gray-600 mb-6">
Enter your email address and we'll send you a link to reset your password.
</p>
{error && (
<div className="mb-4 p-3 bg-red-100 text-red-800 rounded">
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Email Address
</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="your-email@example.com"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 font-medium"
>
{loading ? 'Sending...' : 'Send Reset Link'}
</button>
<div className="mt-4 text-center">
<Link
href="/login"
className="text-blue-600 hover:text-blue-700 text-sm"
>
← Back to Login
</Link>
</div>
</form>
</div>
</div>
);
}
Step 2: Commit
git add app/forgot-password/page.tsx
git commit -m "feat: add forgot password page"
Task 23: Create Reset Password Page
Files:
- Create:
app/reset-password/page.tsx
Step 1: Write reset password page
"use client";
import { useEffect, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import Link from "next/link";
export default function ResetPasswordPage() {
const searchParams = useSearchParams();
const router = useRouter();
const token = searchParams.get('token');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [validating, setValidating] = useState(true);
const [tokenValid, setTokenValid] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
// Validate token on mount
useEffect(() => {
if (!token) {
setError('Invalid reset link');
setValidating(false);
return;
}
const validateToken = async () => {
try {
const response = await fetch(`/api/auth/reset-password?token=${token}`);
const data = await response.json();
if (data.valid) {
setTokenValid(true);
} else {
setError('This reset link is invalid or has expired');
}
} catch (err) {
setError('Failed to validate reset link');
} finally {
setValidating(false);
}
};
validateToken();
}, [token]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (newPassword !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (newPassword.length < 6) {
setError('Password must be at least 6 characters');
return;
}
setLoading(true);
try {
const response = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, newPassword }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to reset password');
}
setSuccess(true);
// Redirect to login after 3 seconds
setTimeout(() => {
router.push('/login');
}, 3000);
} catch (err: any) {
setError(err.message || 'An error occurred');
} finally {
setLoading(false);
}
};
if (validating) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<p className="text-gray-600">Validating reset link...</p>
</div>
);
}
if (!tokenValid) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 px-4">
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
<div className="text-center">
<div className="text-5xl mb-4">❌</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Invalid Reset Link
</h2>
<p className="text-gray-600 mb-6">
{error || 'This password reset link is invalid or has expired.'}
</p>
<Link
href="/forgot-password"
className="text-blue-600 hover:text-blue-700 font-medium"
>
Request New Reset Link →
</Link>
</div>
</div>
</div>
);
}
if (success) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 px-4">
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
<div className="text-center">
<div className="text-5xl mb-4">✅</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Password Reset Successful
</h2>
<p className="text-gray-600 mb-6">
Your password has been reset successfully. Redirecting to login...
</p>
<Link
href="/login"
className="text-blue-600 hover:text-blue-700 font-medium"
>
Go to Login →
</Link>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 px-4">
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Reset Password
</h2>
<p className="text-gray-600 mb-6">
Enter your new password below.
</p>
{error && (
<div className="mb-4 p-3 bg-red-100 text-red-800 rounded">
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
New Password
</label>
<input
type="password"
required
minLength={6}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="At least 6 characters"
/>
</div>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Confirm New Password
</label>
<input
type="password"
required
minLength={6}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Re-enter password"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 font-medium"
>
{loading ? 'Resetting...' : 'Reset Password'}
</button>
</form>
</div>
</div>
);
}
Step 2: Commit
git add app/reset-password/page.tsx
git commit -m "feat: add reset password page"
Task 24: Update Login Page
Files:
- Modify:
app/login/page.tsx
Step 1: Add forgot password link
Find the login form and add a "Forgot Password?" link below the password field. Look for the password input section and add after it:
{/* After password input field, before submit button */}
<div className="text-right mb-4">
<Link
href="/forgot-password"
className="text-sm text-blue-600 hover:text-blue-700"
>
Forgot Password?
</Link>
</div>
Don't forget to add the import at the top:
import Link from "next/link";
Step 2: Commit
git add app/login/page.tsx
git commit -m "feat: add forgot password link to login page"
Phase 9: Integration with User Management
Task 25: Integrate Welcome Email in User Creation
Files:
- Modify:
app/api/users/route.ts:78-92
Step 1: Import email service
At the top of the file, add import:
import { emailService } from '@/lib/email-service';
Step 2: Send welcome email after user creation
After the user is created (around line 87-92), add email sending:
// Create user
const user = userDb.create({
id: randomUUID(),
username,
email: email || null,
passwordHash,
role: role || 'VIEWER',
});
// Send welcome email (don't fail if email fails)
if (email) {
try {
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
await emailService.sendWelcomeEmail({
email,
username,
loginUrl: `${baseUrl}/login`,
temporaryPassword: password, // Send the original password
});
console.log('[UserCreate] Welcome email sent to:', email);
} catch (emailError) {
console.error('[UserCreate] Failed to send welcome email:', emailError);
// Don't fail user creation if email fails
}
}
// Remove password hash from response
const { passwordHash: _, ...safeUser } = user;
Step 3: Commit
git add app/api/users/route.ts
git commit -m "feat: integrate welcome email in user creation"
Task 26: Add Manual Email Actions to Users Page
Files:
- Modify:
app/admin/users/page.tsx
Step 1: Add resend welcome email function
After the handleDelete function (around line 129), add:
// Resend welcome email
const handleResendWelcome = async (user: User) => {
if (!user.email) {
alert('This user has no email address');
return;
}
if (!confirm(`Send welcome email to ${user.email}?`)) {
return;
}
try {
const response = await fetch('/api/admin/emails/send-test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
template: 'welcome',
email: user.email,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to send email');
}
alert('Welcome email sent successfully');
} catch (err: any) {
alert(err.message || 'Failed to send welcome email');
}
};
// Send password reset
const handleSendPasswordReset = async (user: User) => {
if (!user.email) {
alert('This user has no email address');
return;
}
if (!confirm(`Send password reset email to ${user.email}?`)) {
return;
}
try {
const response = await fetch('/api/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: user.email }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to send email');
}
alert('Password reset email sent successfully');
} catch (err: any) {
alert(err.message || 'Failed to send password reset email');
}
};
Step 2: Add email action buttons to user cards
Find the buttons section in the user card (around line 222-235) and update:
<div className="flex gap-2">
<button
onClick={() => openEditModal(user)}
className="flex-1 px-3 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
>
Edit
</button>
<button
onClick={() => openDeleteModal(user)}
className="flex-1 px-3 py-2 bg-red-600 text-white text-sm rounded-md hover:bg-red-700"
>
Delete
</button>
</div>
{/* Email Actions */}
{user.email && (
<div className="flex gap-2 mt-2">
<button
onClick={() => handleResendWelcome(user)}
className="flex-1 px-3 py-2 bg-green-600 text-white text-xs rounded-md hover:bg-green-700"
>
📧 Resend Welcome
</button>
<button
onClick={() => handleSendPasswordReset(user)}
className="flex-1 px-3 py-2 bg-orange-600 text-white text-xs rounded-md hover:bg-orange-700"
>
🔑 Reset Password
</button>
</div>
)}
Step 3: Commit
git add app/admin/users/page.tsx
git commit -m "feat: add email action buttons to user management"
Phase 10: Testing & Documentation
Task 27: Create Test Script
Files:
- Create:
scripts/test-smtp.js
Step 1: Write test script
#!/usr/bin/env node
/**
* Test SMTP configuration and email sending
* Usage: node scripts/test-smtp.js your-email@example.com
*/
require('dotenv').config({ path: '.env.local' });
const { emailService } = require('../lib/email-service.ts');
const testEmail = process.argv[2];
if (!testEmail) {
console.error('Usage: node scripts/test-smtp.js your-email@example.com');
process.exit(1);
}
async function testSMTP() {
console.log('Testing SMTP configuration...\n');
try {
// Test connection
console.log('1. Testing SMTP connection...');
const connected = await emailService.testConnection();
if (connected) {
console.log('✓ SMTP connection successful\n');
} else {
console.error('✗ SMTP connection failed\n');
process.exit(1);
}
// Test welcome email
console.log('2. Sending test welcome email...');
await emailService.sendWelcomeEmail({
email: testEmail,
username: 'Test User',
loginUrl: 'http://localhost:3000/login',
temporaryPassword: 'TempPass123!',
});
console.log('✓ Welcome email sent\n');
// Test password reset email
console.log('3. Sending test password reset email...');
await emailService.sendPasswordResetEmail({
email: testEmail,
username: 'Test User',
resetUrl: 'http://localhost:3000/reset-password?token=test-token-123',
expiresIn: '1 hour',
});
console.log('✓ Password reset email sent\n');
console.log('All tests passed! Check your inbox at:', testEmail);
} catch (error) {
console.error('Test failed:', error.message);
process.exit(1);
}
}
testSMTP();
Step 2: Make script executable
chmod +x scripts/test-smtp.js
Step 3: Commit
git add scripts/test-smtp.js
git commit -m "feat: add SMTP test script"
Task 28: Update Documentation
Files:
- Create:
docs/SMTP-SETUP.md
Step 1: Write SMTP setup guide
# SMTP Setup Guide
## Overview
This guide explains how to configure SMTP for email functionality in the Location Tracker app.
## Prerequisites
- SMTP server credentials (Gmail, SendGrid, Mailgun, etc.)
- For Gmail: App Password (not regular password)
## Configuration Methods
### Method 1: Environment Variables (Fallback)
1. Copy `.env.example` to `.env.local`:
```bash
cp .env.example .env.local
-
Generate encryption key:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" -
Update SMTP settings in
.env.local:SMTP_HOST=smtp.gmail.com SMTP_PORT=587 SMTP_SECURE=false SMTP_USER=your-email@gmail.com SMTP_PASS=your-app-password SMTP_FROM_EMAIL=noreply@example.com SMTP_FROM_NAME=Location Tracker ENCRYPTION_KEY=<generated-key-from-step-2>
Method 2: Admin Panel (Recommended)
- Log in as admin
- Navigate to Settings → SMTP Settings
- Fill in SMTP configuration
- Click Test Connection to verify
- Click Save Settings
Provider-Specific Setup
Gmail
- Enable 2-Factor Authentication
- Generate App Password:
- Go to Google Account → Security → 2-Step Verification → App Passwords
- Select "Mail" and generate password
- Use generated password in SMTP_PASS
Settings:
- Host:
smtp.gmail.com - Port:
587 - Secure:
false(uses STARTTLS)
SendGrid
Settings:
- Host:
smtp.sendgrid.net - Port:
587 - Secure:
false - User:
apikey - Pass: Your SendGrid API key
Mailgun
Settings:
- Host:
smtp.mailgun.org - Port:
587 - Secure:
false - User: Your Mailgun SMTP username
- Pass: Your Mailgun SMTP password
Testing
Via Script
node scripts/test-smtp.js your-email@example.com
Via Admin Panel
- Go to Emails page
- Select a template
- Click Send Test Email
- Enter your email and send
Troubleshooting
Connection Timeout
- Check firewall settings
- Verify port is correct (587 for STARTTLS, 465 for SSL)
- Try toggling "Use TLS/SSL" setting
Authentication Failed
- Verify username and password
- For Gmail: Use App Password, not account password
- Check if SMTP is enabled for your account
Emails Not Received
- Check spam/junk folder
- Verify "From Email" is valid
- Check provider sending limits
Email Templates
Available templates:
- Welcome Email: Sent when new user is created
- Password Reset: Sent when user requests password reset
Templates can be previewed in Admin → Emails.
Security Notes
- Passwords stored in database are encrypted using AES-256-GCM
- ENCRYPTION_KEY must be kept secret
- Never commit
.env.localto git - Use environment-specific SMTP credentials
**Step 2: Commit**
```bash
git add docs/SMTP-SETUP.md
git commit -m "docs: add SMTP setup guide"
Task 29: Final Integration Test
Files:
- N/A (Manual testing)
Step 1: Generate encryption key
Run:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
Copy output to .env.local as ENCRYPTION_KEY
Step 2: Configure SMTP in admin panel
- Start dev server:
npm run dev - Login as admin
- Go to
/admin/settings - Enter SMTP settings
- Click "Test Connection"
- If successful, click "Save Settings"
Step 3: Test welcome email
- Go to
/admin/users - Create new user with email address
- Check email inbox for welcome message
Step 4: Test password reset
- Logout
- Go to
/forgot-password - Enter email address
- Check inbox for reset email
- Click reset link
- Enter new password
- Login with new password
Step 5: Test email templates
- Go to
/admin/emails - Preview each template
- Send test email for each template
- Verify emails received
Step 6: Final commit
git add -A
git commit -m "feat: SMTP integration complete and tested"
Completion Checklist
- All dependencies installed
- Database schema extended
- Crypto utilities working
- Email templates render correctly
- SMTP settings configurable via admin panel
- SMTP test connection works
- Welcome emails sent on user creation
- Password reset flow complete
- Email preview page functional
- Forgot password page works
- Reset password page works
- Login page has forgot password link
- User management has email actions
- Documentation complete
- All tests pass
Notes
- ENCRYPTION_KEY: Must be 32-byte hex string (64 characters)
- Password Storage: Encrypted in DB, never logged
- Email Failures: Don't fail user creation if email fails
- Rate Limiting: 5 test emails per minute
- Token Expiry: Password reset tokens expire after 1 hour
- Security: Always return success on forgot password (prevent user enumeration)
Future Enhancements
- Email queue for bulk sending
- Email templates editor in admin panel
- Email delivery tracking
- Multiple SMTP providers with failover
- Scheduled email reports
- Email preferences per user