2026-01-25 02:19:49 +01:00
|
|
|
import nodemailer from 'nodemailer';
|
|
|
|
|
import type { Transporter } from 'nodemailer';
|
|
|
|
|
import { supabaseAdmin } from './supabase';
|
|
|
|
|
|
|
|
|
|
export interface SmtpConfig {
|
|
|
|
|
host: string;
|
|
|
|
|
port: number;
|
|
|
|
|
secure: boolean;
|
|
|
|
|
username: string;
|
|
|
|
|
password: string;
|
|
|
|
|
from_address: string;
|
|
|
|
|
from_name: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface SendEmailOptions {
|
|
|
|
|
to: string;
|
|
|
|
|
subject: string;
|
|
|
|
|
html: string;
|
|
|
|
|
text?: string;
|
|
|
|
|
recipientId?: string;
|
|
|
|
|
recipientName?: string;
|
|
|
|
|
templateKey?: string;
|
|
|
|
|
emailType?: string;
|
|
|
|
|
sentBy?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-26 17:19:06 +01:00
|
|
|
* Get SMTP configuration from app_settings table, with fallback to environment variables
|
|
|
|
|
* This allows welcome emails to work using the same SMTP as GoTrue when app_settings isn't configured
|
2026-01-25 02:19:49 +01:00
|
|
|
*/
|
|
|
|
|
export async function getSmtpConfig(): Promise<SmtpConfig | null> {
|
2026-01-26 17:19:06 +01:00
|
|
|
// First try to get from app_settings
|
2026-01-25 02:19:49 +01:00
|
|
|
const { data: settings } = await supabaseAdmin
|
|
|
|
|
.from('app_settings')
|
|
|
|
|
.select('setting_key, setting_value')
|
|
|
|
|
.eq('category', 'email');
|
|
|
|
|
|
|
|
|
|
const config: Record<string, string> = {};
|
2026-01-26 17:19:06 +01:00
|
|
|
if (settings && settings.length > 0) {
|
|
|
|
|
for (const s of settings) {
|
|
|
|
|
// Parse the value - it might be JSON stringified or plain
|
|
|
|
|
let value = s.setting_value;
|
|
|
|
|
if (typeof value === 'string') {
|
|
|
|
|
// Remove surrounding quotes if present
|
|
|
|
|
value = value.replace(/^"|"$/g, '');
|
|
|
|
|
}
|
|
|
|
|
config[s.setting_key] = value as string;
|
2026-01-25 02:19:49 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-26 17:19:06 +01:00
|
|
|
// Check if app_settings has valid SMTP config
|
|
|
|
|
if (config.smtp_host && config.smtp_username && config.smtp_password) {
|
|
|
|
|
return {
|
|
|
|
|
host: config.smtp_host,
|
|
|
|
|
port: parseInt(config.smtp_port || '587'),
|
|
|
|
|
secure: config.smtp_secure === 'true' || parseInt(config.smtp_port || '587') === 465,
|
|
|
|
|
username: config.smtp_username,
|
|
|
|
|
password: config.smtp_password,
|
|
|
|
|
from_address: config.smtp_from_address || 'noreply@monacousa.org',
|
|
|
|
|
from_name: config.smtp_from_name || 'Monaco USA'
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fall back to environment variables (same as GoTrue SMTP settings)
|
|
|
|
|
const envHost = process.env.SMTP_HOST || process.env.GOTRUE_SMTP_HOST;
|
|
|
|
|
const envUser = process.env.SMTP_USER || process.env.GOTRUE_SMTP_USER;
|
|
|
|
|
const envPass = process.env.SMTP_PASS || process.env.GOTRUE_SMTP_PASS;
|
|
|
|
|
|
|
|
|
|
if (envHost && envUser && envPass) {
|
|
|
|
|
const envPort = process.env.SMTP_PORT || process.env.GOTRUE_SMTP_PORT || '587';
|
|
|
|
|
return {
|
|
|
|
|
host: envHost,
|
|
|
|
|
port: parseInt(envPort),
|
|
|
|
|
secure: parseInt(envPort) === 465,
|
|
|
|
|
username: envUser,
|
|
|
|
|
password: envPass,
|
|
|
|
|
from_address: process.env.SMTP_ADMIN_EMAIL || process.env.GOTRUE_SMTP_ADMIN_EMAIL || 'noreply@monacousa.org',
|
|
|
|
|
from_name: process.env.SMTP_SENDER_NAME || process.env.GOTRUE_SMTP_SENDER_NAME || 'Monaco USA'
|
|
|
|
|
};
|
2026-01-25 02:19:49 +01:00
|
|
|
}
|
|
|
|
|
|
2026-01-26 17:19:06 +01:00
|
|
|
return null;
|
2026-01-25 02:19:49 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create a nodemailer transporter with the configured SMTP settings
|
|
|
|
|
*/
|
|
|
|
|
export async function createTransporter(): Promise<Transporter | null> {
|
|
|
|
|
const config = await getSmtpConfig();
|
|
|
|
|
if (!config) {
|
|
|
|
|
console.error('SMTP configuration not found or incomplete');
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nodemailer.createTransport({
|
|
|
|
|
host: config.host,
|
|
|
|
|
port: config.port,
|
|
|
|
|
secure: config.secure,
|
|
|
|
|
auth: {
|
|
|
|
|
user: config.username,
|
|
|
|
|
pass: config.password
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Send an email using the configured SMTP settings
|
|
|
|
|
*/
|
|
|
|
|
export async function sendEmail(options: SendEmailOptions): Promise<{ success: boolean; error?: string; messageId?: string }> {
|
|
|
|
|
const config = await getSmtpConfig();
|
|
|
|
|
if (!config) {
|
|
|
|
|
return { success: false, error: 'SMTP not configured. Please configure email settings first.' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const transporter = await createTransporter();
|
|
|
|
|
if (!transporter) {
|
|
|
|
|
return { success: false, error: 'Failed to create email transporter' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const result = await transporter.sendMail({
|
|
|
|
|
from: `"${config.from_name}" <${config.from_address}>`,
|
|
|
|
|
to: options.to,
|
|
|
|
|
subject: options.subject,
|
|
|
|
|
html: options.html,
|
|
|
|
|
text: options.text || stripHtml(options.html)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Log to email_logs table
|
|
|
|
|
await supabaseAdmin.from('email_logs').insert({
|
|
|
|
|
recipient_id: options.recipientId || null,
|
|
|
|
|
recipient_email: options.to,
|
|
|
|
|
recipient_name: options.recipientName || null,
|
|
|
|
|
template_key: options.templateKey || null,
|
|
|
|
|
subject: options.subject,
|
|
|
|
|
email_type: options.emailType || 'manual',
|
|
|
|
|
status: 'sent',
|
|
|
|
|
provider: 'smtp',
|
|
|
|
|
provider_message_id: result.messageId,
|
|
|
|
|
sent_by: options.sentBy || null,
|
|
|
|
|
sent_at: new Date().toISOString()
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return { success: true, messageId: result.messageId };
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
|
|
|
console.error('Email send error:', error);
|
|
|
|
|
|
|
|
|
|
// Log failed attempt
|
|
|
|
|
await supabaseAdmin.from('email_logs').insert({
|
|
|
|
|
recipient_id: options.recipientId || null,
|
|
|
|
|
recipient_email: options.to,
|
|
|
|
|
recipient_name: options.recipientName || null,
|
|
|
|
|
template_key: options.templateKey || null,
|
|
|
|
|
subject: options.subject,
|
|
|
|
|
email_type: options.emailType || 'manual',
|
|
|
|
|
status: 'failed',
|
|
|
|
|
provider: 'smtp',
|
|
|
|
|
error_message: errorMessage,
|
|
|
|
|
sent_by: options.sentBy || null
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return { success: false, error: errorMessage };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Send a templated email with variable substitution
|
|
|
|
|
* Templates should contain content only (no full HTML wrapper) - will be wrapped automatically
|
|
|
|
|
*/
|
|
|
|
|
export async function sendTemplatedEmail(
|
|
|
|
|
templateKey: string,
|
|
|
|
|
to: string,
|
|
|
|
|
variables: Record<string, string>,
|
|
|
|
|
options?: {
|
|
|
|
|
recipientId?: string;
|
|
|
|
|
recipientName?: string;
|
|
|
|
|
sentBy?: string;
|
|
|
|
|
baseUrl?: string;
|
|
|
|
|
}
|
|
|
|
|
): Promise<{ success: boolean; error?: string; messageId?: string }> {
|
|
|
|
|
// Fetch template from database
|
|
|
|
|
const { data: template, error: templateError } = await supabaseAdmin
|
|
|
|
|
.from('email_templates')
|
|
|
|
|
.select('*')
|
|
|
|
|
.eq('template_key', templateKey)
|
|
|
|
|
.eq('is_active', true)
|
|
|
|
|
.single();
|
|
|
|
|
|
|
|
|
|
if (templateError || !template) {
|
|
|
|
|
return { success: false, error: `Email template "${templateKey}" not found or inactive` };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get site URL for logo
|
|
|
|
|
const baseUrl = options?.baseUrl || process.env.SITE_URL || 'https://monacousa.org';
|
|
|
|
|
const logoUrl = `${baseUrl}/MONACOUSA-Flags_376x376.png`;
|
|
|
|
|
|
|
|
|
|
// Add default variables
|
|
|
|
|
const allVariables: Record<string, string> = {
|
|
|
|
|
logo_url: logoUrl,
|
|
|
|
|
site_url: baseUrl,
|
|
|
|
|
...variables
|
|
|
|
|
};
|
|
|
|
|
|
Phase 1: Full implementation — security, bugs, utilities, UI/UX, consolidation
28 items across 7 batches. 36 files changed (9 new, 27 modified).
1061 insertions, 406 deletions.
== Batch 1: Critical Security Fixes ==
1.1 — Fix open redirect in /auth/callback
- src/routes/auth/callback/+server.ts: url.searchParams.get('next')
was used directly in redirect(303, next). Attacker could set
next=https://evil.com. Now wrapped through sanitizeRedirectUrl()
which rejects protocol/host, //, javascript: prefixes; falls back
to /dashboard.
1.2 — Fix open redirect in /login
- src/routes/(auth)/login/+page.server.ts: redirectTo param used
without validation in both load() and form action. Applied
sanitizeRedirectUrl() to both locations.
1.3 — Fix RLS self-role-escalation
- supabase/migrations/017_fix_rls_role_escalation.sql (NEW):
"Users can update own profile" policy had USING(auth.uid()=id)
but no WITH CHECK clause — users could SET role='admin' on their
own row. Added WITH CHECK constraining role to current value.
- deploy/init.sql: updated to match migration 017.
1.4 — Remove hardcoded secrets from docker-compose.yml
- docker-compose.yml: removed hardcoded SECRET_KEY_BASE fallback.
== Batch 2: Critical & High Bugs ==
2.1 — Fix deleteAvatar wrong argument type
- src/routes/(app)/settings/+page.server.ts: was passing supabase
client object as second arg to deleteAvatar(memberId, avatarPath).
Changed to pass member.avatar_url instead.
2.2 — Fix event.start_time typo -> event.start_datetime
- src/routes/(app)/board/events/[id]/attendees/+page.server.ts:
referenced event.start_time (doesn't exist on type). Caused
"Invalid Date" in invitation/roll-call emails. Replaced both
occurrences with event.start_datetime.
2.3 — Fix landing page CTA buttons missing href
- src/routes/+page.svelte: Sign In and Join Us buttons had no href
attribute — completely non-functional for visitors. Added
href="/login" and href="/join" respectively.
2.4 — Fix auth pages logo inconsistency
- src/routes/auth/reset-password/+page.svelte: hardcoded "M" letter
in colored box replaced with actual Monaco USA logo image
(MONACOUSA-Flags_376x376.png) matching login/layout.
2.5 — Fix currency USD -> EUR everywhere
- src/routes/(app)/board/reports/+page.svelte: USD -> EUR, locale
to fr-MC.
- src/routes/public/events/[id]/+page.svelte: USD -> EUR, locale
to fr-MC.
- src/routes/(app)/admin/dashboard/+page.svelte: USD -> EUR, locale
to fr-MC.
== Batch 3: High Security Fixes ==
3.1 — Sanitize HTML in email template rendering
- src/lib/server/email.ts: added escapeHtml() utility that escapes
&, <, >, ", '. Applied to all template variable values in
sendTemplatedEmail() before substitution. URL-type keys
(logo_url, site_url) exempted. Prevents XSS in emails.
3.2 — Add file upload MIME type validation
- src/lib/server/storage.ts: added MAGIC_BYTES constant and
validateFileMagicBytes() function checking PNG (89504E47),
JPEG (FFD8FF), PDF (25504446), WebP (52494646), GIF (47494638)
magic bytes against declared MIME. Applied in uploadAvatar and
uploadDocument before storing.
3.3 — Docker container hardening
- docker-compose.yml portal service: added security_opt
[no-new-privileges:true], read_only: true with tmpfs for /tmp,
deploy.resources.limits (memory: 512M, cpus: 1.0). Dockerfile
already had USER sveltekit (non-root).
3.4 — Restrict board endpoints data exposure
- src/routes/(app)/board/members/+page.server.ts: replaced
.select('*') with explicit column list returning only fields
the board UI actually displays. Removed sensitive columns.
== Batch 4: Shared Utilities ==
4.1 — Extract getVisibleLevels to shared utility
- src/lib/server/visibility.ts (NEW): exports getVisibleLevels(role)
returning appropriate visibility levels per role.
- Replaced 4 duplicate definitions in:
src/routes/(app)/dashboard/+page.server.ts
src/routes/(app)/documents/+page.server.ts
src/routes/(app)/events/+page.server.ts
src/routes/(app)/events/[id]/+page.server.ts
4.3 — Fix N+1 query in getReminderEffectiveness
- src/lib/server/dues.ts: rewrote loop executing individual DB
queries per reminder into single batch query with IN filter.
Maps results in JS instead of N+1 round-trips.
== Batch 5: Shared UI Components ==
5.1 — Create reusable EmptyState component
- src/lib/components/ui/empty-state.svelte (NEW): accepts icon,
title, description props and optional children snippet. Consistent
muted-text centered layout matching design system.
- Applied in DocumentPreviewModal and NotificationCenter.
5.2 — Move LoadingSpinner to shared ui/
- src/lib/components/ui/LoadingSpinner.svelte (NEW): copied from
auth/ to ui/ for general use. Original kept for compatibility.
- src/lib/components/ui/index.ts: added barrel exports for
EmptyState and LoadingSpinner.
== Batch 6: UX Standardization ==
6.4 — Add skip-to-content link
- src/routes/(app)/+layout.svelte: added visually-hidden-until-
focused skip link as first focusable element:
<a href="#main-content" class="sr-only focus:not-sr-only ...">
Added id="main-content" to <main> element.
6.5 — Add navigation loading indicator
- src/routes/(app)/+layout.svelte: imported SvelteKit $navigating
store. Shows thin animated progress bar at page top during
transitions. CSS-only animation, no external dependencies.
== Batch 7: Code Consolidation ==
7.1 — Consolidate profile/settings pages
- src/lib/server/member-profile.ts (NEW, 283 lines): shared helpers
handleAvatarUpload(), handleAvatarRemoval(), handleProfileUpdate().
Supports admin mode (supabaseAdmin) and user mode (scoped client).
- src/routes/(app)/profile/+page.server.ts: simplified from ~167
to ~88 lines using shared helpers.
- src/routes/(app)/settings/+page.server.ts: simplified from ~219
to ~106 lines using shared helpers.
7.2 — Consolidate registration flows
- src/lib/server/registration.ts (NEW, 201 lines): shared helpers
createMemberRecord(), cleanupAuthUser(), sendWelcomeEmail().
- src/routes/(auth)/signup/+page.server.ts: simplified from ~167
to ~85 lines using shared helpers.
- src/routes/join/+page.server.ts: simplified from ~209 to ~117
lines using shared helpers.
7.3 — Create status badge utility
- src/lib/utils/status-badges.ts (NEW, 55 lines): centralized
STATUS_MAP for all status types (membership, dues, payment,
RSVP, event, roles). Exports getStatusConfig(),
getStatusBadgeClasses(), getStatusLabel().
7.4 — Create rate limiting utility
- src/lib/server/rate-limit.ts (NEW, 73 lines): in-memory
Map-based rate limiter with TTL cleanup. Exports
checkRateLimit(key, maxAttempts, windowMs) and resetRateLimit().
- Applied to login: 5 attempts per 15 min by email.
- Applied to forgot-password: 3 attempts per 15 min by email.
- src/routes/(auth)/login/+page.server.ts: added rate limit check
before signInWithPassword, reset on success.
- src/routes/(auth)/forgot-password/+page.server.ts: added rate
limit check before resetPasswordForEmail.
== New Files (9) ==
src/lib/server/auth-utils.ts
src/lib/server/visibility.ts
src/lib/server/member-profile.ts
src/lib/server/registration.ts
src/lib/server/rate-limit.ts
src/lib/server/email.ts (escapeHtml addition)
src/lib/server/storage.ts (validateFileMagicBytes addition)
src/lib/utils/status-badges.ts
src/lib/components/ui/empty-state.svelte
src/lib/components/ui/LoadingSpinner.svelte
supabase/migrations/017_fix_rls_role_escalation.sql
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 07:54:10 +01:00
|
|
|
// Keys that contain URLs should not be escaped
|
|
|
|
|
const urlKeys = new Set(['logo_url', 'site_url']);
|
|
|
|
|
|
|
|
|
|
// Escape all non-URL variable values to prevent XSS
|
|
|
|
|
for (const [key, value] of Object.entries(allVariables)) {
|
|
|
|
|
if (!urlKeys.has(key)) {
|
|
|
|
|
allVariables[key] = escapeHtml(value);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-25 02:19:49 +01:00
|
|
|
// Replace variables in subject and body
|
|
|
|
|
let subject = template.subject;
|
|
|
|
|
let bodyContent = template.body_html;
|
|
|
|
|
let text = template.body_text || '';
|
|
|
|
|
|
|
|
|
|
for (const [key, value] of Object.entries(allVariables)) {
|
|
|
|
|
const regex = new RegExp(`{{${key}}}`, 'g');
|
|
|
|
|
subject = subject.replace(regex, value);
|
|
|
|
|
bodyContent = bodyContent.replace(regex, value);
|
|
|
|
|
text = text.replace(regex, value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Extract title from template or use subject
|
|
|
|
|
// Look for title in template metadata or first h2 tag
|
|
|
|
|
let emailTitle = template.email_title || subject;
|
|
|
|
|
// Try to extract from first h2 in content
|
|
|
|
|
const h2Match = bodyContent.match(/<h2[^>]*>([^<]+)<\/h2>/i);
|
|
|
|
|
if (h2Match) {
|
|
|
|
|
emailTitle = h2Match[1].replace(/{{[^}]+}}/g, '').trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if template already has full HTML wrapper (legacy templates)
|
|
|
|
|
const hasFullWrapper = bodyContent.includes('<!DOCTYPE') || bodyContent.includes('<html');
|
|
|
|
|
|
|
|
|
|
let html: string;
|
|
|
|
|
if (hasFullWrapper) {
|
|
|
|
|
// Legacy template with full HTML - use as-is but inject background image
|
|
|
|
|
// Replace old gradient-only background with new background image pattern
|
|
|
|
|
html = bodyContent.replace(
|
|
|
|
|
/<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background: linear-gradient\([^)]+\); background-color: #0f172a;">/g,
|
|
|
|
|
`<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-image: url('${EMAIL_BACKGROUND_IMAGE_URL}'); background-size: cover; background-position: center; background-color: #0f172a;">`
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
// Content-only template - wrap with Monaco template
|
|
|
|
|
html = wrapInMonacoTemplate({
|
|
|
|
|
title: emailTitle,
|
|
|
|
|
content: bodyContent,
|
|
|
|
|
logoUrl
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return sendEmail({
|
|
|
|
|
to,
|
|
|
|
|
subject,
|
|
|
|
|
html,
|
|
|
|
|
text: text || undefined,
|
|
|
|
|
recipientId: options?.recipientId,
|
|
|
|
|
recipientName: options?.recipientName,
|
|
|
|
|
templateKey,
|
|
|
|
|
emailType: template.category,
|
|
|
|
|
sentBy: options?.sentBy
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Test SMTP connection and optionally send a test email
|
|
|
|
|
*/
|
|
|
|
|
export async function testSmtpConnection(
|
|
|
|
|
sendTo?: string,
|
|
|
|
|
sentBy?: string
|
|
|
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
|
|
|
const config = await getSmtpConfig();
|
|
|
|
|
if (!config) {
|
|
|
|
|
return { success: false, error: 'SMTP not configured. Please configure and save email settings first.' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const transporter = await createTransporter();
|
|
|
|
|
if (!transporter) {
|
|
|
|
|
return { success: false, error: 'Failed to create email transporter' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Verify connection
|
|
|
|
|
await transporter.verify();
|
|
|
|
|
|
|
|
|
|
// If a recipient is provided, send a test email
|
|
|
|
|
if (sendTo) {
|
|
|
|
|
const testContent = `
|
|
|
|
|
<p style="margin: 0 0 16px 0; color: #334155; line-height: 1.6;">This is a test email from your Monaco USA Portal.</p>
|
|
|
|
|
<div style="background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 12px; padding: 20px; margin: 0 0 20px 0;">
|
|
|
|
|
<p style="margin: 0 0 8px 0; color: #166534; font-size: 14px; font-weight: 600;">✓ Configuration Verified</p>
|
|
|
|
|
<p style="margin: 0; color: #334155; font-size: 14px;">Your SMTP settings are working correctly!</p>
|
|
|
|
|
</div>
|
|
|
|
|
<p style="margin: 0; color: #64748b; font-size: 12px;">Sent at ${new Date().toLocaleString()}</p>`;
|
|
|
|
|
|
|
|
|
|
const result = await sendEmail({
|
|
|
|
|
to: sendTo,
|
|
|
|
|
subject: 'Monaco USA Portal - SMTP Test Email',
|
|
|
|
|
html: wrapInMonacoTemplate({
|
|
|
|
|
title: 'SMTP Test Successful!',
|
|
|
|
|
content: testContent
|
|
|
|
|
}),
|
|
|
|
|
emailType: 'test',
|
|
|
|
|
sentBy
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!result.success) {
|
|
|
|
|
return { success: false, error: result.error };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { success: true };
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
|
|
|
console.error('SMTP test error:', error);
|
|
|
|
|
return { success: false, error: `SMTP connection failed: ${errorMessage}` };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// S3-hosted background image URL matching login screen
|
|
|
|
|
const EMAIL_BACKGROUND_IMAGE_URL = 'https://s3.monacousa.org/public/monaco_high_res.jpg';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Wrap email content in Monaco-branded template
|
|
|
|
|
* This creates a consistent look matching the login page styling with background image
|
|
|
|
|
*/
|
|
|
|
|
export function wrapInMonacoTemplate(options: {
|
|
|
|
|
title: string;
|
|
|
|
|
content: string;
|
|
|
|
|
logoUrl?: string;
|
|
|
|
|
backgroundImageUrl?: string;
|
|
|
|
|
}): string {
|
|
|
|
|
const baseUrl = process.env.SITE_URL || 'http://localhost:7453';
|
|
|
|
|
const logoUrl = options.logoUrl || `${baseUrl}/MONACOUSA-Flags_376x376.png`;
|
|
|
|
|
const bgImageUrl = options.backgroundImageUrl || EMAIL_BACKGROUND_IMAGE_URL;
|
|
|
|
|
|
|
|
|
|
return `<!DOCTYPE html>
|
|
|
|
|
<html>
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
<!--[if mso]>
|
|
|
|
|
<style type="text/css">
|
|
|
|
|
body, table, td { font-family: Arial, sans-serif !important; }
|
|
|
|
|
</style>
|
|
|
|
|
<![endif]-->
|
|
|
|
|
</head>
|
|
|
|
|
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #0f172a;">
|
|
|
|
|
<!--[if gte mso 9]>
|
|
|
|
|
<v:background xmlns:v="urn:schemas-microsoft-com:vml" fill="t">
|
|
|
|
|
<v:fill type="tile" src="${bgImageUrl}" color="#0f172a"/>
|
|
|
|
|
</v:background>
|
|
|
|
|
<![endif]-->
|
|
|
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-image: url('${bgImageUrl}'); background-size: cover; background-position: center; background-color: #0f172a;">
|
|
|
|
|
<tr>
|
|
|
|
|
<td>
|
|
|
|
|
<!-- Gradient overlay matching login screen: from-slate-900/80 via-slate-900/60 to-monaco-900/70 -->
|
|
|
|
|
<div style="background: linear-gradient(135deg, rgba(15, 23, 42, 0.8) 0%, rgba(15, 23, 42, 0.6) 50%, rgba(127, 29, 29, 0.7) 100%);">
|
|
|
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
|
|
|
|
|
<tr>
|
|
|
|
|
<td align="center" style="padding: 40px 20px;">
|
|
|
|
|
<!-- Logo Section -->
|
|
|
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 480px;">
|
|
|
|
|
<tr>
|
|
|
|
|
<td align="center" style="padding-bottom: 30px;">
|
|
|
|
|
<div style="background: rgba(255, 255, 255, 0.95); border-radius: 16px; padding: 12px; display: inline-block; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);">
|
|
|
|
|
<img src="${logoUrl}" alt="Monaco USA" width="80" height="80" style="display: block; border-radius: 8px;">
|
|
|
|
|
</div>
|
|
|
|
|
<h1 style="margin: 16px 0 4px 0; font-size: 24px; font-weight: bold; color: #ffffff; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);">Monaco <span style="color: #fca5a5;">USA</span></h1>
|
|
|
|
|
<p style="margin: 0; font-size: 14px; color: rgba(255, 255, 255, 0.8);">Americans in Monaco</p>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</table>
|
|
|
|
|
|
|
|
|
|
<!-- Main Content Card -->
|
|
|
|
|
<table role="presentation" cellspacing="0" cellpadding="0" style="max-width: 480px; width: 100%; background: #ffffff; border-radius: 16px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);">
|
|
|
|
|
<tr>
|
|
|
|
|
<td style="padding: 40px;">
|
|
|
|
|
<h2 style="margin: 0 0 20px 0; color: #CE1126; font-size: 24px; text-align: center;">${options.title}</h2>
|
|
|
|
|
<div style="text-align: left;">${options.content}</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</table>
|
|
|
|
|
|
|
|
|
|
<!-- Footer -->
|
|
|
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 480px;">
|
|
|
|
|
<tr>
|
|
|
|
|
<td align="center" style="padding-top: 24px;">
|
|
|
|
|
<p style="margin: 0; font-size: 12px; color: rgba(255, 255, 255, 0.5);">© 2026 Monaco USA. All rights reserved.</p>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</table>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</table>
|
|
|
|
|
</body>
|
|
|
|
|
</html>`;
|
|
|
|
|
}
|
|
|
|
|
|
Phase 1: Full implementation — security, bugs, utilities, UI/UX, consolidation
28 items across 7 batches. 36 files changed (9 new, 27 modified).
1061 insertions, 406 deletions.
== Batch 1: Critical Security Fixes ==
1.1 — Fix open redirect in /auth/callback
- src/routes/auth/callback/+server.ts: url.searchParams.get('next')
was used directly in redirect(303, next). Attacker could set
next=https://evil.com. Now wrapped through sanitizeRedirectUrl()
which rejects protocol/host, //, javascript: prefixes; falls back
to /dashboard.
1.2 — Fix open redirect in /login
- src/routes/(auth)/login/+page.server.ts: redirectTo param used
without validation in both load() and form action. Applied
sanitizeRedirectUrl() to both locations.
1.3 — Fix RLS self-role-escalation
- supabase/migrations/017_fix_rls_role_escalation.sql (NEW):
"Users can update own profile" policy had USING(auth.uid()=id)
but no WITH CHECK clause — users could SET role='admin' on their
own row. Added WITH CHECK constraining role to current value.
- deploy/init.sql: updated to match migration 017.
1.4 — Remove hardcoded secrets from docker-compose.yml
- docker-compose.yml: removed hardcoded SECRET_KEY_BASE fallback.
== Batch 2: Critical & High Bugs ==
2.1 — Fix deleteAvatar wrong argument type
- src/routes/(app)/settings/+page.server.ts: was passing supabase
client object as second arg to deleteAvatar(memberId, avatarPath).
Changed to pass member.avatar_url instead.
2.2 — Fix event.start_time typo -> event.start_datetime
- src/routes/(app)/board/events/[id]/attendees/+page.server.ts:
referenced event.start_time (doesn't exist on type). Caused
"Invalid Date" in invitation/roll-call emails. Replaced both
occurrences with event.start_datetime.
2.3 — Fix landing page CTA buttons missing href
- src/routes/+page.svelte: Sign In and Join Us buttons had no href
attribute — completely non-functional for visitors. Added
href="/login" and href="/join" respectively.
2.4 — Fix auth pages logo inconsistency
- src/routes/auth/reset-password/+page.svelte: hardcoded "M" letter
in colored box replaced with actual Monaco USA logo image
(MONACOUSA-Flags_376x376.png) matching login/layout.
2.5 — Fix currency USD -> EUR everywhere
- src/routes/(app)/board/reports/+page.svelte: USD -> EUR, locale
to fr-MC.
- src/routes/public/events/[id]/+page.svelte: USD -> EUR, locale
to fr-MC.
- src/routes/(app)/admin/dashboard/+page.svelte: USD -> EUR, locale
to fr-MC.
== Batch 3: High Security Fixes ==
3.1 — Sanitize HTML in email template rendering
- src/lib/server/email.ts: added escapeHtml() utility that escapes
&, <, >, ", '. Applied to all template variable values in
sendTemplatedEmail() before substitution. URL-type keys
(logo_url, site_url) exempted. Prevents XSS in emails.
3.2 — Add file upload MIME type validation
- src/lib/server/storage.ts: added MAGIC_BYTES constant and
validateFileMagicBytes() function checking PNG (89504E47),
JPEG (FFD8FF), PDF (25504446), WebP (52494646), GIF (47494638)
magic bytes against declared MIME. Applied in uploadAvatar and
uploadDocument before storing.
3.3 — Docker container hardening
- docker-compose.yml portal service: added security_opt
[no-new-privileges:true], read_only: true with tmpfs for /tmp,
deploy.resources.limits (memory: 512M, cpus: 1.0). Dockerfile
already had USER sveltekit (non-root).
3.4 — Restrict board endpoints data exposure
- src/routes/(app)/board/members/+page.server.ts: replaced
.select('*') with explicit column list returning only fields
the board UI actually displays. Removed sensitive columns.
== Batch 4: Shared Utilities ==
4.1 — Extract getVisibleLevels to shared utility
- src/lib/server/visibility.ts (NEW): exports getVisibleLevels(role)
returning appropriate visibility levels per role.
- Replaced 4 duplicate definitions in:
src/routes/(app)/dashboard/+page.server.ts
src/routes/(app)/documents/+page.server.ts
src/routes/(app)/events/+page.server.ts
src/routes/(app)/events/[id]/+page.server.ts
4.3 — Fix N+1 query in getReminderEffectiveness
- src/lib/server/dues.ts: rewrote loop executing individual DB
queries per reminder into single batch query with IN filter.
Maps results in JS instead of N+1 round-trips.
== Batch 5: Shared UI Components ==
5.1 — Create reusable EmptyState component
- src/lib/components/ui/empty-state.svelte (NEW): accepts icon,
title, description props and optional children snippet. Consistent
muted-text centered layout matching design system.
- Applied in DocumentPreviewModal and NotificationCenter.
5.2 — Move LoadingSpinner to shared ui/
- src/lib/components/ui/LoadingSpinner.svelte (NEW): copied from
auth/ to ui/ for general use. Original kept for compatibility.
- src/lib/components/ui/index.ts: added barrel exports for
EmptyState and LoadingSpinner.
== Batch 6: UX Standardization ==
6.4 — Add skip-to-content link
- src/routes/(app)/+layout.svelte: added visually-hidden-until-
focused skip link as first focusable element:
<a href="#main-content" class="sr-only focus:not-sr-only ...">
Added id="main-content" to <main> element.
6.5 — Add navigation loading indicator
- src/routes/(app)/+layout.svelte: imported SvelteKit $navigating
store. Shows thin animated progress bar at page top during
transitions. CSS-only animation, no external dependencies.
== Batch 7: Code Consolidation ==
7.1 — Consolidate profile/settings pages
- src/lib/server/member-profile.ts (NEW, 283 lines): shared helpers
handleAvatarUpload(), handleAvatarRemoval(), handleProfileUpdate().
Supports admin mode (supabaseAdmin) and user mode (scoped client).
- src/routes/(app)/profile/+page.server.ts: simplified from ~167
to ~88 lines using shared helpers.
- src/routes/(app)/settings/+page.server.ts: simplified from ~219
to ~106 lines using shared helpers.
7.2 — Consolidate registration flows
- src/lib/server/registration.ts (NEW, 201 lines): shared helpers
createMemberRecord(), cleanupAuthUser(), sendWelcomeEmail().
- src/routes/(auth)/signup/+page.server.ts: simplified from ~167
to ~85 lines using shared helpers.
- src/routes/join/+page.server.ts: simplified from ~209 to ~117
lines using shared helpers.
7.3 — Create status badge utility
- src/lib/utils/status-badges.ts (NEW, 55 lines): centralized
STATUS_MAP for all status types (membership, dues, payment,
RSVP, event, roles). Exports getStatusConfig(),
getStatusBadgeClasses(), getStatusLabel().
7.4 — Create rate limiting utility
- src/lib/server/rate-limit.ts (NEW, 73 lines): in-memory
Map-based rate limiter with TTL cleanup. Exports
checkRateLimit(key, maxAttempts, windowMs) and resetRateLimit().
- Applied to login: 5 attempts per 15 min by email.
- Applied to forgot-password: 3 attempts per 15 min by email.
- src/routes/(auth)/login/+page.server.ts: added rate limit check
before signInWithPassword, reset on success.
- src/routes/(auth)/forgot-password/+page.server.ts: added rate
limit check before resetPasswordForEmail.
== New Files (9) ==
src/lib/server/auth-utils.ts
src/lib/server/visibility.ts
src/lib/server/member-profile.ts
src/lib/server/registration.ts
src/lib/server/rate-limit.ts
src/lib/server/email.ts (escapeHtml addition)
src/lib/server/storage.ts (validateFileMagicBytes addition)
src/lib/utils/status-badges.ts
src/lib/components/ui/empty-state.svelte
src/lib/components/ui/LoadingSpinner.svelte
supabase/migrations/017_fix_rls_role_escalation.sql
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 07:54:10 +01:00
|
|
|
/**
|
|
|
|
|
* Escape HTML special characters to prevent XSS in email templates.
|
|
|
|
|
* Used to sanitize user-provided content before template substitution.
|
|
|
|
|
*/
|
|
|
|
|
export function escapeHtml(str: string): string {
|
|
|
|
|
return str
|
|
|
|
|
.replace(/&/g, '&')
|
|
|
|
|
.replace(/</g, '<')
|
|
|
|
|
.replace(/>/g, '>')
|
|
|
|
|
.replace(/"/g, '"')
|
|
|
|
|
.replace(/'/g, ''');
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-25 02:19:49 +01:00
|
|
|
/**
|
|
|
|
|
* Strip HTML tags from a string to create plain text version
|
|
|
|
|
*/
|
|
|
|
|
function stripHtml(html: string): string {
|
|
|
|
|
return html
|
|
|
|
|
.replace(/<[^>]*>/g, '')
|
|
|
|
|
.replace(/ /g, ' ')
|
|
|
|
|
.replace(/&/g, '&')
|
|
|
|
|
.replace(/</g, '<')
|
|
|
|
|
.replace(/>/g, '>')
|
|
|
|
|
.replace(/"/g, '"')
|
|
|
|
|
.replace(/'/g, "'")
|
|
|
|
|
.replace(/\s+/g, ' ')
|
|
|
|
|
.trim();
|
|
|
|
|
}
|