diff --git a/.env.example b/.env.example index ef5255e..e96a884 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,16 @@ -# Appwrite Configuration +# Appwrite Configuration (Express / node-appwrite) APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1 APPWRITE_PROJECT_ID=your_project_id_here + +# React (Vite): separate file client/.env — see client/.env.example (VITE_APPWRITE_*). +# APPWRITE_ENDPOINT + APPWRITE_PROJECT_ID here must match the real Appwrite project (same IDs as in client/.env). +# The Express server calls Appwrite directly, not through the Vite dev proxy (localhost:5173). APPWRITE_API_KEY=your_api_key_here APPWRITE_DATABASE_ID=your_database_id_here +# Optional: JWT for automated API tests against protected routes (create via Appwrite client: account.createJWT) +# APPWRITE_TEST_JWT= + # Database Configuration (for bootstrap script) DB_ID=your_database_id_here DB_NAME=EmailSorter @@ -23,6 +30,9 @@ PRODUCT_CURRENCY=eur # Stripe Configuration STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key_here STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here +# STRIPE_PRICE_BASIC=price_xxx +# STRIPE_PRICE_PRO=price_xxx +# STRIPE_PRICE_BUSINESS=price_xxx # Gitea Webhook (Deployment) # Secret für X-Gitea-Signature und optional Bearer-Header (gleicher Wert möglich) @@ -33,3 +43,37 @@ GITEA_WEBHOOK_SECRET=your_webhook_secret_here # Server Configuration PORT=3000 BASE_URL=http://localhost:3000 +FRONTEND_URL=http://localhost:5173 +# CORS_ORIGIN=http://localhost:5173 + +# OAuth (optional; Gmail / Outlook) +# GOOGLE_CLIENT_ID= +# GOOGLE_CLIENT_SECRET= +# GOOGLE_REDIRECT_URI=http://localhost:3000/api/oauth/gmail/callback +# MICROSOFT_CLIENT_ID= +# MICROSOFT_CLIENT_SECRET= +# MICROSOFT_REDIRECT_URI=http://localhost:3000/api/oauth/outlook/callback + +# HMAC secret for OAuth state (recommended in production). If unset, state is unsigned JSON (dev only). +# OAUTH_STATE_SECRET= + +# Mistral AI (email categorization) +# MISTRAL_API_KEY= + +# IMAP credential encryption (64 hex chars = 32-byte AES key). If unset, IMAP passwords are stored plaintext. +# Generate: openssl rand -hex 32 +# ENCRYPTION_KEY= + +# SMTP (Stripe lifecycle & system emails via nodemailer). If unset, emails are skipped (logged). +# SMTP_HOST=smtp.example.com +# SMTP_PORT=587 +# SMTP_SECURE=false +# SMTP_USER= +# SMTP_PASS= +# SMTP_FROM=noreply@example.com + +# Admin emails (comma-separated): name-labels, /api/me isAdmin, unlimited sort, effective subscription = top tier (see TOP_SUBSCRIPTION_PLAN) +# ADMIN_EMAILS=support@webklar.com + +# Highest tier id (must match a key in server stripe PLANS: basic | pro | business). Admin comped plan uses this. +# TOP_SUBSCRIPTION_PLAN=business diff --git a/.gitignore b/.gitignore index d73e19f..e7c4383 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Environment variables .env server/.env +client/.env # Node modules node_modules/ diff --git a/client/.env.example b/client/.env.example new file mode 100644 index 0000000..417d9c3 --- /dev/null +++ b/client/.env.example @@ -0,0 +1,25 @@ +# Kopiere nach .env — Vite neu starten nach Änderungen. + +VITE_APPWRITE_PROJECT_ID= + +# Wähle EINE Variante: + +# B) Self-hosted / nur Produktions-Domain in Appwrite erlaubt → Vite leitet /v1 weiter (kein CORS-Problem) +# APPWRITE_DEV_ORIGIN=https://dein-appwrite-host.tld +# VITE_APPWRITE_ENDPOINT=http://localhost:5173/v1 + +# A) Appwrite-Webplattform enthält „localhost“ (oder 127.0.0.1) → direkte URL, APPWRITE_DEV_ORIGIN leer lassen +# APPWRITE_DEV_ORIGIN= +# VITE_APPWRITE_ENDPOINT=https://appwrite.webklar.com/v1 + +# Cloud-Beispiel (ohne Proxy): +# APPWRITE_DEV_ORIGIN= +# VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1 + +# Backend: leer = Browser ruft /api auf (Vite-Proxy → 127.0.0.1:3000/api/…). +# Nicht VITE_APPWRITE_ENDPOINT (/v1) als VITE_API_URL verwenden — sonst 404. +# Wenn /api 404: nur EIN Vite auf 5173 (strictPort) — anderen Prozess auf 5173 beenden oder FRONTEND_URL anpassen. +# Wenn /api im Browser 404 liefert: PORT prüfen und ggf. Proxy-Ziel setzen: +# VITE_DEV_API_ORIGIN=http://127.0.0.1:3000 +# Optional direkt (ohne Proxy): http://localhost:3000 — fehlendes /api wird ergänzt. +# VITE_API_URL= diff --git a/client/package-lock.json b/client/package-lock.json index 148a508..900cfbe 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,11 +1,11 @@ { - "name": "emailsorter-client", + "name": "mailflow-client", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "emailsorter-client", + "name": "mailflow-client", "version": "0.0.0", "dependencies": { "@radix-ui/react-accordion": "^1.2.12", diff --git a/client/src/context/AuthContext.tsx b/client/src/context/AuthContext.tsx index 0bd707b..178cd50 100644 --- a/client/src/context/AuthContext.tsx +++ b/client/src/context/AuthContext.tsx @@ -1,8 +1,31 @@ /* eslint-disable react-refresh/only-export-components */ import React, { createContext, useContext, useEffect, useState } from 'react' +import { AppwriteException } from 'appwrite' import { auth } from '@/lib/appwrite' import type { Models } from 'appwrite' +function mapLoginError(err: unknown): Error { + if (err instanceof AppwriteException) { + const type = (err.type || '').toLowerCase() + const msg = (err.message || '').toLowerCase() + if (type.includes('user_blocked') || msg.includes('blocked')) { + return new Error('Dieser Account ist gesperrt. Bitte Support kontaktieren.') + } + if (err.code === 401) { + return new Error( + 'Anmeldung fehlgeschlagen. Häufige Ursachen: falsches Passwort; User wurde nur in der Console angelegt ohne Passwort; E-Mail/Passwort-Login ist im Appwrite-Projekt deaktiviert; oder die VITE_APPWRITE_PROJECT_ID passt nicht zu dem Projekt, in dem der User liegt.' + ) + } + if (err.message) { + return new Error(err.message) + } + } + if (err instanceof Error) { + return err + } + return new Error('Login fehlgeschlagen.') +} + interface AuthContextType { user: Models.User | null loading: boolean @@ -36,13 +59,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }, []) const login = async (email: string, password: string) => { - await auth.login(email, password) - await refreshUser() + try { + await auth.login(email, password) + await refreshUser() + } catch (e) { + throw mapLoginError(e) + } } const register = async (email: string, password: string, name?: string) => { - await auth.register(email, password, name) - await refreshUser() + try { + await auth.register(email, password, name) + await refreshUser() + } catch (e) { + throw mapLoginError(e) + } } const logout = async () => { diff --git a/client/src/lib/analytics.ts b/client/src/lib/analytics.ts index b2a8efb..f7c5bf8 100644 --- a/client/src/lib/analytics.ts +++ b/client/src/lib/analytics.ts @@ -1,3 +1,5 @@ +import { getApiJwt } from './appwrite' + /** * Analytics & Tracking Utility * Handles UTM parameter tracking and event analytics @@ -162,17 +164,17 @@ export async function trackEvent( } try { - // Send to your analytics endpoint + const jwt = await getApiJwt() + if (!jwt) return + await fetch('/api/analytics/track', { method: 'POST', headers: { 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, }, body: JSON.stringify(payload), - }).catch(() => { - // Silently fail if analytics endpoint doesn't exist yet - // This allows graceful degradation - }) + }).catch(() => {}) // Also log to console in development if (import.meta.env.DEV) { diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 762e028..78dd317 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -1,4 +1,62 @@ -const API_BASE = import.meta.env.VITE_API_URL || '/api' +import { getApiJwt } from './appwrite' + +/** + * Endpoints in this file are paths like `/subscription/status`. + * Express mounts the API under `/api`, so the base must end with `/api`. + * If VITE_API_URL is `http://localhost:3000` (missing /api), requests would 404. + */ +function resolveApiBase(): string { + const raw = (import.meta.env.VITE_API_URL as string | undefined)?.trim() + if (!raw) return '/api' + + if (raw.startsWith('/')) { + const p = raw.replace(/\/+$/, '') || '/api' + return p + } + + if (!/^https?:\/\//i.test(raw)) { + const p = raw.replace(/^\/+/, '').replace(/\/+$/, '') || 'api' + return p.startsWith('api') ? `/${p}` : `/api` + } + + const normalized = raw.replace(/\/+$/, '') + try { + const u = new URL(normalized) + const path = u.pathname.replace(/\/$/, '') || '/' + // Same host as Vite + /v1 = Appwrite-Proxy; never append /api (would hit /v1/api/… → 404) + const localVite = + /^(localhost|127\.0\.0\.1)$/i.test(u.hostname) && + (u.port === '5173' || (u.port === '' && u.hostname === 'localhost')) + if (path === '/v1' && localVite) { + return '/api' + } + if (path === '/' || path === '') { + return `${normalized}/api` + } + if (path.endsWith('/api')) { + return normalized + } + return `${normalized}/api` + } catch { + return '/api' + } +} + +const API_BASE = resolveApiBase() + +/** Root-relative or absolute API URL; avoids `api/foo` (relative to current route). */ +function joinApiUrl(base: string, endpoint: string): string { + const ep = endpoint.startsWith('/') ? endpoint : `/${endpoint}` + if (/^https?:\/\//i.test(base)) { + return `${base.replace(/\/+$/, '')}${ep}` + } + let b = base.trim() + if (!b.startsWith('/')) { + b = `/${b.replace(/^\/+/, '')}` + } + b = b.replace(/\/+$/, '') || '/api' + return `${b}${ep}` +} interface ApiResponse { success?: boolean @@ -17,32 +75,58 @@ async function fetchApi( options?: RequestInit ): Promise> { try { - const response = await fetch(`${API_BASE}${endpoint}`, { + const headers: Record = { + ...(options?.headers as Record), + } + if (!headers['Content-Type']) { + headers['Content-Type'] = 'application/json' + } + const jwt = await getApiJwt() + if (jwt) { + headers['Authorization'] = `Bearer ${jwt}` + } + + const urlJoined = joinApiUrl(API_BASE, endpoint) + + const response = await fetch(urlJoined, { ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers, - }, + headers, }) - const data = await response.json() + const ct = response.headers.get('content-type') || '' + const isJson = ct.includes('application/json') + const data = isJson + ? await response.json() + : { success: false as const, error: undefined } - if (!response.ok || data.success === false) { - return { - error: data.error || { - code: 'UNKNOWN', - message: `HTTP ${response.status}` - } + if (!isJson) { + return { + error: { + code: response.status === 404 ? 'NOT_FOUND' : 'INVALID_RESPONSE', + message: + response.status === 404 + ? 'API 404: backend unreachable or wrong port — check server is running and VITE_DEV_API_ORIGIN matches PORT.' + : `Expected JSON, got ${ct || 'unknown'} (HTTP ${response.status})`, + }, } } - return { success: true, data: data.data || data } + if (!response.ok || data.success === false) { + return { + error: data.error || { + code: 'UNKNOWN', + message: `HTTP ${response.status}`, + }, + } + } + + return { success: true, data: data.data ?? data } } catch (error) { - return { - error: { - code: 'NETWORK_ERROR', - message: error instanceof Error ? error.message : 'Network error' - } + return { + error: { + code: 'NETWORK_ERROR', + message: error instanceof Error ? error.message : 'Network error', + }, } } } @@ -51,32 +135,34 @@ export const api = { // ═══════════════════════════════════════════════════════════════════════════ // EMAIL ACCOUNTS // ═══════════════════════════════════════════════════════════════════════════ - - async getEmailAccounts(userId: string) { + + async getEmailAccounts() { return fetchApi>(`/email/accounts?userId=${userId}`) + }>>('/email/accounts') }, - async connectEmailAccount(userId: string, provider: 'gmail' | 'outlook', email: string, accessToken: string, refreshToken?: string) { + async connectEmailAccount(provider: 'gmail' | 'outlook', email: string, accessToken: string, refreshToken?: string) { return fetchApi<{ accountId: string }>('/email/connect', { method: 'POST', - body: JSON.stringify({ userId, provider, email, accessToken, refreshToken }), + body: JSON.stringify({ provider, email, accessToken, refreshToken }), }) }, - async connectImapAccount( - userId: string, - params: { email: string; password: string; imapHost?: string; imapPort?: number; imapSecure?: boolean } - ) { + async connectImapAccount(params: { + email: string + password: string + imapHost?: string + imapPort?: number + imapSecure?: boolean + }) { return fetchApi<{ accountId: string }>('/email/connect', { method: 'POST', body: JSON.stringify({ - userId, provider: 'imap', email: params.email, accessToken: params.password, @@ -87,8 +173,8 @@ export const api = { }) }, - async disconnectEmailAccount(accountId: string, userId: string) { - return fetchApi<{ success: boolean }>(`/email/accounts/${accountId}?userId=${userId}`, { + async disconnectEmailAccount(accountId: string) { + return fetchApi<{ success: boolean }>(`/email/accounts/${accountId}`, { method: 'DELETE', }) }, @@ -97,17 +183,17 @@ export const api = { // EMAIL STATS & SORTING // ═══════════════════════════════════════════════════════════════════════════ - async getEmailStats(userId: string) { + async getEmailStats() { return fetchApi<{ totalSorted: number todaySorted: number weekSorted: number categories: Record timeSaved: number - }>(`/email/stats?userId=${userId}`) + }>('/email/stats') }, - async sortEmails(userId: string, accountId: string, maxEmails?: number, processAll?: boolean) { + async sortEmails(accountId: string, maxEmails?: number, processAll?: boolean) { return fetchApi<{ sorted: number inboxCleared: number @@ -127,11 +213,10 @@ export const api = { }> }>('/email/sort', { method: 'POST', - body: JSON.stringify({ userId, accountId, maxEmails, processAll }), + body: JSON.stringify({ accountId, maxEmails, processAll }), }) }, - // Demo sorting without account (for quick tests) async sortDemo(count: number = 10) { return fetchApi<{ sorted: number @@ -152,8 +237,7 @@ export const api = { }) }, - // Connect demo account - async connectDemoAccount(userId: string) { + async connectDemoAccount() { return fetchApi<{ accountId: string email: string @@ -161,11 +245,10 @@ export const api = { message?: string }>('/email/connect-demo', { method: 'POST', - body: JSON.stringify({ userId }), + body: JSON.stringify({}), }) }, - // Get categories async getCategories() { return fetchApi>('/email/categories') }, - // Get today's digest - async getDigest(userId: string) { + async getDigest() { return fetchApi<{ date: string totalSorted: number @@ -188,11 +270,10 @@ export const api = { highlights: Array<{ type: string; count: number; message: string }> suggestions: Array<{ type: string; message: string }> hasData: boolean - }>(`/email/digest?userId=${userId}`) + }>('/email/digest') }, - // Get digest history - async getDigestHistory(userId: string, days: number = 7) { + async getDigestHistory(days: number = 7) { return fetchApi<{ days: number digests: Array<{ @@ -207,15 +288,15 @@ export const api = { inboxCleared: number timeSavedMinutes: number } - }>(`/email/digest/history?userId=${userId}&days=${days}`) + }>(`/email/digest/history?days=${days}`) }, // ═══════════════════════════════════════════════════════════════════════════ // OAUTH // ═══════════════════════════════════════════════════════════════════════════ - async getOAuthUrl(provider: 'gmail' | 'outlook', userId: string) { - return fetchApi<{ url: string }>(`/oauth/${provider}/connect?userId=${userId}`) + async getOAuthUrl(provider: 'gmail' | 'outlook') { + return fetchApi<{ url: string }>(`/oauth/${provider}/connect`) }, async getOAuthStatus() { @@ -229,10 +310,11 @@ export const api = { // SUBSCRIPTION // ═══════════════════════════════════════════════════════════════════════════ - async getSubscriptionStatus(userId: string) { + async getSubscriptionStatus() { return fetchApi<{ status: string plan: string + planDisplayName?: string isFreeTier: boolean emailsUsedThisMonth?: number emailsLimit?: number @@ -245,34 +327,34 @@ export const api = { } currentPeriodEnd?: string cancelAtPeriodEnd?: boolean - }>(`/subscription/status?userId=${userId}`) + }>('/subscription/status') }, - async createSubscriptionCheckout(plan: string, userId: string, email?: string) { + async createSubscriptionCheckout(plan: string, email?: string) { return fetchApi<{ url: string; sessionId: string }>('/subscription/checkout', { method: 'POST', - body: JSON.stringify({ userId, plan, email }), + body: JSON.stringify({ plan, email }), }) }, - async createPortalSession(userId: string) { + async createPortalSession() { return fetchApi<{ url: string }>('/subscription/portal', { method: 'POST', - body: JSON.stringify({ userId }), + body: JSON.stringify({}), }) }, - async cancelSubscription(userId: string) { + async cancelSubscription() { return fetchApi<{ success: boolean }>('/subscription/cancel', { method: 'POST', - body: JSON.stringify({ userId }), + body: JSON.stringify({}), }) }, - async reactivateSubscription(userId: string) { + async reactivateSubscription() { return fetchApi<{ success: boolean }>('/subscription/reactivate', { method: 'POST', - body: JSON.stringify({ userId }), + body: JSON.stringify({}), }) }, @@ -280,16 +362,21 @@ export const api = { // USER PREFERENCES // ═══════════════════════════════════════════════════════════════════════════ - async getUserPreferences(userId: string) { + async getUserPreferences() { return fetchApi<{ vipSenders: Array<{ email: string; name?: string }> blockedSenders: string[] customRules: Array<{ condition: string; category: string }> priorityTopics: string[] - }>(`/preferences?userId=${userId}`) + profile?: { + displayName?: string + timezone?: string + notificationPrefs?: Record + } + }>('/preferences') }, - async saveUserPreferences(userId: string, preferences: { + async saveUserPreferences(preferences: { vipSenders?: Array<{ email: string; name?: string }> blockedSenders?: string[] customRules?: Array<{ condition: string; category: string }> @@ -298,7 +385,7 @@ export const api = { }) { return fetchApi<{ success: boolean }>('/preferences', { method: 'POST', - body: JSON.stringify({ userId, ...preferences }), + body: JSON.stringify(preferences), }) }, @@ -306,7 +393,7 @@ export const api = { // AI CONTROL // ═══════════════════════════════════════════════════════════════════════════ - async getAIControlSettings(userId: string) { + async getAIControlSettings() { return fetchApi<{ enabledCategories: string[] categoryActions: Record @@ -314,10 +401,10 @@ export const api = { cleanup?: unknown categoryAdvanced?: Record version?: number - }>(`/preferences/ai-control?userId=${userId}`) + }>('/preferences/ai-control') }, - async saveAIControlSettings(userId: string, settings: { + async saveAIControlSettings(settings: { enabledCategories?: string[] categoryActions?: Record autoDetectCompanies?: boolean @@ -327,33 +414,24 @@ export const api = { }) { return fetchApi<{ success: boolean }>('/preferences/ai-control', { method: 'POST', - body: JSON.stringify({ userId, ...settings }), + body: JSON.stringify(settings), }) }, - // Cleanup Preview - shows what would be cleaned up without actually doing it - // TODO: Backend endpoint needs to be implemented - // GET /api/preferences/ai-control/cleanup/preview?userId=xxx - // Response: { preview: Array<{id, subject, from, date, reason}> } - async getCleanupPreview(userId: string) { - // TODO: Implement backend endpoint + async getCleanupPreview(accountId: string) { return fetchApi<{ - preview: Array<{ + messages: Array<{ id: string subject: string from: string date: string reason: 'read' | 'promotion' }> - }>(`/preferences/ai-control/cleanup/preview?userId=${userId}`) + count: number + }>(`/email/${accountId}/cleanup/preview`) }, - // Run cleanup now - executes cleanup for the user - // POST /api/preferences/ai-control/cleanup/run - // Body: { userId: string } - // Response: { success: boolean, data: { readItems: number, promotions: number } } - async runCleanup(userId: string) { - // Uses existing /api/email/cleanup endpoint + async runCleanup() { return fetchApi<{ usersProcessed: number emailsProcessed: { @@ -363,40 +441,47 @@ export const api = { errors: Array<{ userId: string; error: string }> }>('/email/cleanup', { method: 'POST', - body: JSON.stringify({ userId }), + body: JSON.stringify({}), }) }, - // Get cleanup status - last run info and counts - // TODO: Backend endpoint needs to be implemented - // GET /api/preferences/ai-control/cleanup/status?userId=xxx - // Response: { lastRun?: string, lastRunCounts?: { readItems: number, promotions: number } } - async getCleanupStatus(userId: string) { - // TODO: Implement backend endpoint + async getCleanupStatus(accountId: string) { return fetchApi<{ lastRun?: string lastRunCounts?: { readItems: number promotions: number } - }>(`/preferences/ai-control/cleanup/status?userId=${userId}`) + lastErrors?: string[] + }>(`/email/${accountId}/cleanup/status`) + }, + + async updateProfile(payload: { + displayName?: string + timezone?: string + notificationPrefs?: Record + }) { + return fetchApi<{ success: boolean }>('/preferences/profile', { + method: 'PATCH', + body: JSON.stringify(payload), + }) }, // ═══════════════════════════════════════════════════════════════════════════ // COMPANY LABELS // ═══════════════════════════════════════════════════════════════════════════ - async getCompanyLabels(userId: string) { + async getCompanyLabels() { return fetchApi>(`/preferences/company-labels?userId=${userId}`) + }>>('/preferences/company-labels') }, - async saveCompanyLabel(userId: string, companyLabel: { + async saveCompanyLabel(companyLabel: { id?: string name: string condition: string @@ -411,12 +496,12 @@ export const api = { category?: string }>('/preferences/company-labels', { method: 'POST', - body: JSON.stringify({ userId, companyLabel }), + body: JSON.stringify({ companyLabel }), }) }, - async deleteCompanyLabel(userId: string, labelId: string) { - return fetchApi<{ success: boolean }>(`/preferences/company-labels/${labelId}?userId=${userId}`, { + async deleteCompanyLabel(labelId: string) { + return fetchApi<{ success: boolean }>(`/preferences/company-labels/${labelId}`, { method: 'DELETE', }) }, @@ -425,43 +510,36 @@ export const api = { // ME / ADMIN // ═══════════════════════════════════════════════════════════════════════════ - async getMe(email: string) { - return fetchApi<{ isAdmin: boolean }>(`/me?email=${encodeURIComponent(email)}`) + async getMe() { + return fetchApi<{ isAdmin: boolean }>('/me') }, // ═══════════════════════════════════════════════════════════════════════════ // NAME LABELS (Workers – Admin only) // ═══════════════════════════════════════════════════════════════════════════ - async getNameLabels(userId: string, email: string) { + async getNameLabels() { return fetchApi>(`/preferences/name-labels?userId=${userId}&email=${encodeURIComponent(email)}`) + }>>('/preferences/name-labels') }, - async saveNameLabel( - userId: string, - userEmail: string, - nameLabel: { id?: string; name: string; email?: string; keywords?: string[]; enabled: boolean } - ) { + async saveNameLabel(nameLabel: { id?: string; name: string; email?: string; keywords?: string[]; enabled: boolean }) { return fetchApi<{ id?: string; name: string; email?: string; keywords?: string[]; enabled: boolean }>( '/preferences/name-labels', { method: 'POST', - body: JSON.stringify({ userId, email: userEmail, nameLabel }), + body: JSON.stringify({ nameLabel }), } ) }, - async deleteNameLabel(userId: string, userEmail: string, labelId: string) { - return fetchApi<{ success: boolean }>( - `/preferences/name-labels/${labelId}?userId=${userId}&email=${encodeURIComponent(userEmail)}`, - { method: 'DELETE' } - ) + async deleteNameLabel(labelId: string) { + return fetchApi<{ success: boolean }>(`/preferences/name-labels/${labelId}`, { method: 'DELETE' }) }, // ═══════════════════════════════════════════════════════════════════════════ @@ -523,36 +601,36 @@ export const api = { // ONBOARDING // ═══════════════════════════════════════════════════════════════════════════ - async getOnboardingStatus(userId: string) { + async getOnboardingStatus() { return fetchApi<{ onboarding_step: string completedSteps: string[] first_value_seen_at?: string skipped_at?: string - }>(`/onboarding/status?userId=${userId}`) + }>('/onboarding/status') }, - async updateOnboardingStep(userId: string, step: string, completedSteps: string[] = []) { + async updateOnboardingStep(step: string, completedSteps: string[] = []) { return fetchApi<{ step: string; completedSteps: string[] }>('/onboarding/step', { method: 'POST', - body: JSON.stringify({ userId, step, completedSteps }), + body: JSON.stringify({ step, completedSteps }), }) }, - async skipOnboarding(userId: string) { + async skipOnboarding() { return fetchApi<{ skipped: boolean }>('/onboarding/skip', { method: 'POST', - body: JSON.stringify({ userId }), + body: JSON.stringify({}), }) }, - async resumeOnboarding(userId: string) { + async resumeOnboarding() { return fetchApi<{ onboarding_step: string completedSteps: string[] }>('/onboarding/resume', { method: 'POST', - body: JSON.stringify({ userId }), + body: JSON.stringify({}), }) }, @@ -560,10 +638,10 @@ export const api = { // ACCOUNT MANAGEMENT // ═══════════════════════════════════════════════════════════════════════════ - async deleteAccount(userId: string) { + async deleteAccount() { return fetchApi<{ success: boolean }>('/account/delete', { method: 'DELETE', - body: JSON.stringify({ userId }), + body: JSON.stringify({}), }) }, @@ -571,17 +649,17 @@ export const api = { // REFERRALS // ═══════════════════════════════════════════════════════════════════════════ - async getReferralCode(userId: string) { + async getReferralCode() { return fetchApi<{ referralCode: string referralCount: number - }>(`/referrals/code?userId=${userId}`) + }>('/referrals/code') }, - async trackReferral(userId: string, referralCode: string) { + async trackReferral(referralCode: string) { return fetchApi<{ success: boolean }>('/referrals/track', { method: 'POST', - body: JSON.stringify({ userId, referralCode }), + body: JSON.stringify({ referralCode }), }) }, } diff --git a/client/src/lib/appwrite.ts b/client/src/lib/appwrite.ts index 53dae5d..b6f21da 100644 --- a/client/src/lib/appwrite.ts +++ b/client/src/lib/appwrite.ts @@ -4,20 +4,89 @@ const client = new Client() // Configure these in your .env file const APPWRITE_ENDPOINT = import.meta.env.VITE_APPWRITE_ENDPOINT || 'https://cloud.appwrite.io/v1' -const APPWRITE_PROJECT_ID = import.meta.env.VITE_APPWRITE_PROJECT_ID || '' +const APPWRITE_PROJECT_ID = (import.meta.env.VITE_APPWRITE_PROJECT_ID || '').trim() + +/** Ohne Project ID keine gültigen Appwrite-Requests — sonst nur 401-Spam im Netzwerk-Tab. */ +export function isAppwriteClientConfigured(): boolean { + return APPWRITE_PROJECT_ID.length > 0 +} + +function assertAppwriteConfigured(): void { + if (!isAppwriteClientConfigured()) { + throw new Error( + 'Appwrite ist nicht konfiguriert: Lege client/.env an und setze VITE_APPWRITE_PROJECT_ID (siehe client/.env.example). Danach Vite neu starten.' + ) + } +} client .setEndpoint(APPWRITE_ENDPOINT) .setProject(APPWRITE_PROJECT_ID) +if (import.meta.env.DEV && !isAppwriteClientConfigured()) { + console.warn( + '[MailFlow] VITE_APPWRITE_PROJECT_ID fehlt → keine Appwrite-Aufrufe bis client/.env gesetzt ist. Kopiere client/.env.example nach client/.env.' + ) +} + +/** Vite-Dev mit Proxy: Endpoint zeigt auf localhost → Session liegt in cookieFallback (localStorage). */ +function isLocalViteAppwriteProxy(): boolean { + if (typeof window === 'undefined') return false + return /localhost|127\.0\.0\.1/.test(APPWRITE_ENDPOINT) +} + +function hasCookieFallbackSession(): boolean { + if (typeof window === 'undefined' || !APPWRITE_PROJECT_ID) return false + try { + const raw = window.localStorage.getItem('cookieFallback') + if (!raw) return false + const o = JSON.parse(raw) as Record + return Boolean(o[`a_session_${APPWRITE_PROJECT_ID}`]) + } catch { + return false + } +} + export const account = new Account(client) export const databases = new Databases(client) export { ID } +const JWT_BUFFER_MS = 30_000 +let jwtCache: { token: string; expMs: number } | null = null + +export function clearApiJwtCache() { + jwtCache = null +} + +/** Short-lived JWT for MailFlow API (Bearer). Cached until near expiry. */ +export async function getApiJwt(): Promise { + if (!isAppwriteClientConfigured()) { + return null + } + if (isLocalViteAppwriteProxy() && !hasCookieFallbackSession()) { + return null + } + try { + const now = Date.now() + if (jwtCache && jwtCache.expMs > now + JWT_BUFFER_MS) { + return jwtCache.token + } + const res = await account.createJWT() + const token = res.jwt + const expireSec = (res as { expire?: number }).expire + const expMs = expireSec != null ? expireSec * 1000 : now + 14 * 60 * 1000 + jwtCache = { token, expMs } + return token + } catch { + return null + } +} + // Auth helper functions export const auth = { // Create a new account async register(email: string, password: string, name?: string) { + assertAppwriteConfigured() const user = await account.create(ID.unique(), email, password, name) await this.login(email, password) return user @@ -25,16 +94,27 @@ export const auth = { // Login with email and password async login(email: string, password: string) { + assertAppwriteConfigured() return await account.createEmailPasswordSession(email, password) }, // Logout current session async logout() { + clearApiJwtCache() + if (!isAppwriteClientConfigured()) { + return + } return await account.deleteSession('current') }, // Get current logged in user async getCurrentUser() { + if (!isAppwriteClientConfigured()) { + return null + } + if (isLocalViteAppwriteProxy() && !hasCookieFallbackSession()) { + return null + } try { return await account.get() } catch { @@ -44,6 +124,12 @@ export const auth = { // Check if user is logged in async isLoggedIn() { + if (!isAppwriteClientConfigured()) { + return false + } + if (isLocalViteAppwriteProxy() && !hasCookieFallbackSession()) { + return false + } try { await account.get() return true @@ -54,6 +140,7 @@ export const auth = { // Send password recovery email async forgotPassword(email: string) { + assertAppwriteConfigured() return await account.createRecovery( email, `${window.location.origin}/reset-password` @@ -62,11 +149,13 @@ export const auth = { // Complete password recovery async resetPassword(userId: string, secret: string, newPassword: string) { + assertAppwriteConfigured() return await account.updateRecovery(userId, secret, newPassword) }, // Send verification email async sendVerification() { + assertAppwriteConfigured() return await account.createVerification( `${window.location.origin}/verify` ) @@ -74,6 +163,7 @@ export const auth = { // Complete email verification async verifyEmail(userId: string, secret: string) { + assertAppwriteConfigured() return await account.updateVerification(userId, secret) }, } diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx index a934af6..467d730 100644 --- a/client/src/pages/Dashboard.tsx +++ b/client/src/pages/Dashboard.tsx @@ -86,6 +86,7 @@ export function Dashboard() { const [digest, setDigest] = useState(null) const [subscription, setSubscription] = useState<{ plan: string + planDisplayName?: string isFreeTier: boolean emailsUsedThisMonth?: number emailsLimit?: number @@ -115,11 +116,11 @@ export function Dashboard() { try { const [statsRes, accountsRes, digestRes, subscriptionRes, referralRes] = await Promise.all([ - api.getEmailStats(user.$id), - api.getEmailAccounts(user.$id), - api.getDigest(user.$id), - api.getSubscriptionStatus(user.$id), - api.getReferralCode(user.$id).catch(() => ({ data: null })), + api.getEmailStats(), + api.getEmailAccounts(), + api.getDigest(), + api.getSubscriptionStatus(), + api.getReferralCode().catch(() => ({ data: null })), ]) if (statsRes.data) setStats(statsRes.data) @@ -146,7 +147,7 @@ export function Dashboard() { setError(null) try { - const result = await api.sortEmails(user.$id, accounts[0].id) + const result = await api.sortEmails(accounts[0].id) if (result.data) { setSortResult(result.data) @@ -155,9 +156,9 @@ export function Dashboard() { // Refresh stats, digest, and subscription const [statsRes, digestRes, subscriptionRes] = await Promise.all([ - api.getEmailStats(user.$id), - api.getDigest(user.$id), - api.getSubscriptionStatus(user.$id), + api.getEmailStats(), + api.getDigest(), + api.getSubscriptionStatus(), ]) if (statsRes.data) setStats(statsRes.data) if (digestRes.data) setDigest(digestRes.data) @@ -168,7 +169,7 @@ export function Dashboard() { setError(result.error.message || 'Monthly limit reached') trackLimitReached(user.$id, result.error.limit || 500, result.error.used || 500) // Refresh subscription to show updated usage - const subscriptionRes = await api.getSubscriptionStatus(user.$id) + const subscriptionRes = await api.getSubscriptionStatus() if (subscriptionRes.data) setSubscription(subscriptionRes.data) } else { setError(result.error.message || 'Email sorting failed. Please try again or reconnect your account.') @@ -504,7 +505,7 @@ export function Dashboard() { } if (Object.keys(updates).length > 0) { - await api.saveUserPreferences(user.$id, updates) + await api.saveUserPreferences(updates) trackRulesApplied(user.$id, sortResult.suggestedRules.length) showMessage('success', `${sortResult.suggestedRules.length} rules applied. Your inbox will stay organized.`) setSortResult({ ...sortResult, suggestedRules: [] }) @@ -834,7 +835,12 @@ export function Dashboard() {

Subscription

- {subscription?.plan === 'free' ? 'Free Tier' : subscription?.plan || 'Free Tier'} + {subscription?.isFreeTier + ? 'Free Tier' + : subscription?.planDisplayName || + (subscription?.plan + ? subscription.plan.charAt(0).toUpperCase() + subscription.plan.slice(1) + : 'Business')}
{subscription?.isFreeTier && subscription.emailsLimit && ( diff --git a/client/src/pages/Register.tsx b/client/src/pages/Register.tsx index ccfc884..ccecb95 100644 --- a/client/src/pages/Register.tsx +++ b/client/src/pages/Register.tsx @@ -34,7 +34,7 @@ export function Register() { useEffect(() => { if (user?.$id && referralCode) { // Track referral if code exists - api.trackReferral(user.$id, referralCode).catch((err) => { + api.trackReferral(referralCode).catch((err) => { console.error('Failed to track referral:', err) }) } diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx index f3aec50..969e3dc 100644 --- a/client/src/pages/Settings.tsx +++ b/client/src/pages/Settings.tsx @@ -60,12 +60,74 @@ import { PrivacySecurity } from '@/components/PrivacySecurity' type TabType = 'profile' | 'accounts' | 'vip' | 'ai-control' | 'name-labels' | 'subscription' | 'privacy' | 'referrals' +const HEX_COLOR = /^#([0-9A-Fa-f]{6})$/ + +function validateLabelImport( + imported: unknown, + existing: CompanyLabel[] +): { labels: CompanyLabel[]; errors: string[] } { + const errors: string[] = [] + if (!Array.isArray(imported)) { + return { labels: [], errors: ['File must contain a JSON array'] } + } + const seen = new Set() + const labels: CompanyLabel[] = [] + imported.forEach((row, i) => { + const rowNum = i + 1 + if (!row || typeof row !== 'object') { + errors.push(`Row ${rowNum}: invalid object`) + return + } + const r = row as Record + const name = typeof r.name === 'string' ? r.name.trim() : '' + if (!name) { + errors.push(`Row ${rowNum}: name is required`) + return + } + if (name.length > 50) { + errors.push(`Row ${rowNum}: name must be at most 50 characters`) + return + } + if (r.color != null && r.color !== '') { + if (typeof r.color !== 'string' || !HEX_COLOR.test(r.color)) { + errors.push(`Row ${rowNum}: color must be a valid #RRGGBB hex`) + return + } + } + const key = name.toLowerCase() + if (seen.has(key)) { + errors.push(`Row ${rowNum}: duplicate name "${name}" in import`) + return + } + seen.add(key) + if (existing.some((e) => e.name.trim().toLowerCase() === key)) { + errors.push(`Row ${rowNum}: name "${name}" already exists`) + return + } + labels.push({ + id: typeof r.id === 'string' && r.id ? r.id : `label_import_${Date.now()}_${i}`, + name, + condition: typeof r.condition === 'string' ? r.condition : '', + enabled: r.enabled !== false, + category: typeof r.category === 'string' ? r.category : 'promotions', + }) + }) + if (existing.length + labels.length > 100) { + return { + labels: [], + errors: [`Cannot exceed 100 labels total (have ${existing.length}, importing ${labels.length})`], + } + } + return { labels, errors } +} + interface EmailAccount { id: string email: string - provider: 'gmail' | 'outlook' | 'imap' + provider: 'gmail' | 'outlook' | 'imap' | 'demo' connected: boolean lastSync?: string + isDemo?: boolean } interface VIPSender { @@ -76,10 +138,35 @@ interface VIPSender { interface Subscription { status: string plan: string + planDisplayName?: string + isFreeTier?: boolean currentPeriodEnd?: string cancelAtPeriodEnd?: boolean } +function subscriptionTitle(sub: Subscription | null): string { + if (!sub) return '' + if (sub.planDisplayName) return sub.planDisplayName + if (sub.plan === 'free' || sub.isFreeTier) return 'Free plan' + if (sub.plan) return sub.plan.charAt(0).toUpperCase() + sub.plan.slice(1) + return 'Subscription' +} + +function subscriptionBadge(sub: Subscription | null): { + label: string + variant: 'success' | 'warning' | 'secondary' +} { + if (!sub) return { label: '', variant: 'secondary' } + if (sub.isFreeTier) return { label: 'Free plan', variant: 'secondary' } + if (sub.status === 'active') return { label: 'Active', variant: 'success' } + const s = (sub.status || '').toLowerCase() + if (s === 'trialing' || s === 'trial') return { label: 'Trial', variant: 'warning' } + return { + label: sub.status ? sub.status.charAt(0).toUpperCase() + sub.status.slice(1) : 'Inactive', + variant: 'warning', + } +} + export function Settings() { const { user } = useAuth() const navigate = useNavigate() @@ -130,6 +217,7 @@ export function Settings() { }) const [categories, setCategories] = useState([]) const [companyLabels, setCompanyLabels] = useState([]) + const [labelImportErrors, setLabelImportErrors] = useState([]) const [isAdmin, setIsAdmin] = useState(false) const [nameLabels, setNameLabels] = useState([]) const [editingNameLabel, setEditingNameLabel] = useState(null) @@ -174,11 +262,24 @@ export function Settings() { } }, [user]) + // Refetch subscription when opening this tab (fixes JWT timing vs initial loadData) + useEffect(() => { + if (activeTab !== 'subscription' || !user?.$id) return + let cancelled = false + ;(async () => { + const res = await api.getSubscriptionStatus() + if (!cancelled && res.data) setSubscription(res.data) + })() + return () => { + cancelled = true + } + }, [activeTab, user?.$id]) + const loadReferralData = async () => { if (!user?.$id) return setLoadingReferral(true) try { - const res = await api.getReferralCode(user.$id) + const res = await api.getReferralCode() if (res.data) setReferralData(res.data) } catch (err) { console.error('Failed to load referral data:', err) @@ -194,24 +295,33 @@ export function Settings() { try { const [accountsRes, subsRes, prefsRes, aiControlRes, companyLabelsRes, meRes] = await Promise.all([ - api.getEmailAccounts(user.$id), - api.getSubscriptionStatus(user.$id), - api.getUserPreferences(user.$id), - api.getAIControlSettings(user.$id), - api.getCompanyLabels(user.$id), - user?.email ? api.getMe(user.email) : Promise.resolve({ data: { isAdmin: false } }), + api.getEmailAccounts(), + api.getSubscriptionStatus(), + api.getUserPreferences(), + api.getAIControlSettings(), + api.getCompanyLabels(), + user?.$id ? api.getMe() : Promise.resolve({ data: { isAdmin: false } }), ]) if (accountsRes.data) setAccounts(accountsRes.data) if (subsRes.data) setSubscription(subsRes.data) if (meRes.data?.isAdmin) { setIsAdmin(true) - const nameLabelsRes = await api.getNameLabels(user.$id, user.email) + const nameLabelsRes = await api.getNameLabels() if (nameLabelsRes.data) setNameLabels(nameLabelsRes.data) } else { setIsAdmin(false) } if (prefsRes.data?.vipSenders) setVipSenders(prefsRes.data.vipSenders) + const pdata = prefsRes.data as { + profile?: { displayName?: string; timezone?: string; notificationPrefs?: { language?: string } } + } | undefined + if (pdata?.profile) { + const prof = pdata.profile + if (prof.displayName != null && prof.displayName !== '') setName(prof.displayName) + if (prof.timezone) setTimezone(prof.timezone) + if (prof.notificationPrefs?.language) setLanguage(String(prof.notificationPrefs.language)) + } if (aiControlRes.data) { // Merge cleanup defaults if not present const raw = aiControlRes.data @@ -297,7 +407,7 @@ export function Settings() { if (!user?.$id) return setSaving(true) try { - await api.saveAIControlSettings(user.$id, { + await api.saveAIControlSettings({ enabledCategories: aiControlSettings.enabledCategories, categoryActions: aiControlSettings.categoryActions, autoDetectCompanies: aiControlSettings.autoDetectCompanies, @@ -326,24 +436,25 @@ export function Settings() { // Load cleanup status const loadCleanupStatus = async () => { - if (!user?.$id) return + const aid = accounts.find((a) => a.provider !== 'demo')?.id + if (!aid) return try { - const res = await api.getCleanupStatus(user.$id) + const res = await api.getCleanupStatus(aid) if (res.data) setCleanupStatus(res.data) } catch { - // Silently fail if endpoint doesn't exist yet console.debug('Cleanup status endpoint not available') } } // Load cleanup preview const loadCleanupPreview = async () => { - if (!user?.$id || !aiControlSettings.cleanup?.enabled) return + if (!aiControlSettings.cleanup?.enabled) return + const aid = accounts.find((a) => a.provider !== 'demo')?.id + if (!aid) return try { - const res = await api.getCleanupPreview(user.$id) - if (res.data?.preview) setCleanupPreview(res.data.preview) + const res = await api.getCleanupPreview(aid) + if (res.data?.messages) setCleanupPreview(res.data.messages) } catch { - // Silently fail if endpoint doesn't exist yet console.debug('Cleanup preview endpoint not available') } } @@ -356,14 +467,14 @@ export function Settings() { loadCleanupPreview() } } - }, [activeTab, controlPanelTab, aiControlSettings.cleanup?.enabled, aiControlSettings.cleanup?.safety.dryRun]) + }, [activeTab, controlPanelTab, aiControlSettings.cleanup?.enabled, aiControlSettings.cleanup?.safety.dryRun, accounts]) // Run cleanup now const handleRunCleanup = async () => { if (!user?.$id) return setRunningCleanup(true) try { - const res = await api.runCleanup(user.$id) + const res = await api.runCleanup() if (res.data) { showMessage('success', `Cleanup completed: ${res.data.emailsProcessed.readItems + res.data.emailsProcessed.promotions} emails processed`) await loadCleanupStatus() @@ -432,8 +543,15 @@ export function Settings() { if (!user?.$id) return setSaving(true) try { - // TODO: Save profile data to backend - // await api.updateUserProfile(user.$id, { name, language, timezone }) + const res = await api.updateProfile({ + displayName: name, + timezone, + notificationPrefs: { language }, + }) + if (res.error) { + showMessage('error', res.error.message || 'Failed to save profile') + return + } savedProfileRef.current = { name, language, timezone } setHasProfileChanges(false) showMessage('success', 'Profile saved successfully!') @@ -472,7 +590,7 @@ export function Settings() { setConnectingProvider(provider) try { - const res = await api.getOAuthUrl(provider, user.$id) + const res = await api.getOAuthUrl(provider) if (res.data?.url) { window.location.href = res.data.url } @@ -486,7 +604,7 @@ export function Settings() { if (!user?.$id) return try { - await api.disconnectEmailAccount(accountId, user.$id) + await api.disconnectEmailAccount(accountId) setAccounts(accounts.filter(a => a.id !== accountId)) showMessage('success', 'Account disconnected') } catch { @@ -498,7 +616,7 @@ export function Settings() { e.preventDefault() if (!user?.$id || !imapForm.email.trim() || !imapForm.password) return setImapConnecting(true) - const res = await api.connectImapAccount(user.$id, { + const res = await api.connectImapAccount({ email: imapForm.email.trim(), password: imapForm.password, imapHost: imapForm.imapHost || undefined, @@ -511,7 +629,7 @@ export function Settings() { setImapConnecting(false) return } - const list = await api.getEmailAccounts(user.$id) + const list = await api.getEmailAccounts() setAccounts(list.data ?? []) setShowImapForm(false) setImapForm({ email: '', password: '', imapHost: 'imap.porkbun.com', imapPort: 993, imapSecure: true }) @@ -541,7 +659,7 @@ export function Settings() { setSaving(true) try { - await api.saveUserPreferences(user.$id, { vipSenders }) + await api.saveUserPreferences({ vipSenders }) showMessage('success', 'VIP list saved!') } catch { showMessage('error', 'Failed to save') @@ -554,7 +672,7 @@ export function Settings() { if (!user?.$id) return try { - const res = await api.createPortalSession(user.$id) + const res = await api.createPortalSession() if (res.data?.url) { window.location.href = res.data.url } @@ -567,7 +685,7 @@ export function Settings() { if (!user?.$id) return try { - const res = await api.createSubscriptionCheckout(plan, user.$id, user.email) + const res = await api.createSubscriptionCheckout(plan, user.email) if (res.data?.url) { window.location.href = res.data.url } @@ -1721,6 +1839,7 @@ export function Settings() { variant="secondary" size="sm" onClick={() => { + setLabelImportErrors([]) const input = document.createElement('input') input.type = 'file' input.accept = 'application/json' @@ -1730,12 +1849,18 @@ export function Settings() { try { const text = await file.text() const imported = JSON.parse(text) - if (Array.isArray(imported)) { - // TODO: Validate and import labels - showMessage('success', `Imported ${imported.length} labels`) + const { labels, errors } = validateLabelImport(imported, companyLabels) + if (errors.length > 0) { + setLabelImportErrors(errors) + showMessage('error', 'Fix import errors before saving') + return } + setLabelImportErrors([]) + setCompanyLabels([...companyLabels, ...labels]) + showMessage('success', `Imported ${labels.length} labels`) } catch { showMessage('error', 'Invalid JSON file') + setLabelImportErrors([]) } } input.click() @@ -1755,6 +1880,16 @@ export function Settings() { Add Label + {labelImportErrors.length > 0 && ( +
+

Import issues

+
    + {labelImportErrors.map((err, i) => ( +
  • {err}
  • + ))} +
+
+ )} {/* Auto-Detection Toggle */} @@ -1853,7 +1988,7 @@ export function Settings() { onClick={async () => { if (!user?.$id || !label.id) return try { - await api.saveCompanyLabel(user.$id, { ...label, enabled: !label.enabled }) + await api.saveCompanyLabel({ ...label, enabled: !label.enabled }) setCompanyLabels(companyLabels.map(l => l.id === label.id ? { ...l, enabled: !l.enabled } : l)) showMessage('success', 'Label updated!') } catch { @@ -1874,7 +2009,7 @@ export function Settings() { if (!user?.$id || !label.id) return if (!confirm('Are you sure you want to delete this label?')) return try { - await api.deleteCompanyLabel(user.$id, label.id) + await api.deleteCompanyLabel(label.id) setCompanyLabels(companyLabels.filter(l => l.id !== label.id)) showMessage('success', 'Label deleted!') } catch { @@ -2163,7 +2298,7 @@ export function Settings() { return } try { - const saved = await api.saveCompanyLabel(user.$id, editingLabel) + const saved = await api.saveCompanyLabel(editingLabel) if (saved.data) { if (editingLabel.id) { setCompanyLabels(companyLabels.map(l => l.id === editingLabel.id ? (saved.data || l) : l)) @@ -2235,7 +2370,7 @@ export function Settings() { onClick={async () => { if (!user?.$id || !label.id) return try { - await api.saveNameLabel(user.$id, user.email, { ...label, enabled: !label.enabled }) + await api.saveNameLabel({ ...label, enabled: !label.enabled }) setNameLabels(nameLabels.map(l => l.id === label.id ? { ...l, enabled: !l.enabled } : l)) showMessage('success', 'Label updated!') } catch { @@ -2256,7 +2391,7 @@ export function Settings() { if (!user?.$id || !label.id) return if (!confirm('Delete this name label?')) return try { - await api.deleteNameLabel(user.$id, user.email, label.id) + await api.deleteNameLabel(label.id) setNameLabels(nameLabels.filter(l => l.id !== label.id)) showMessage('success', 'Label deleted!') } catch { @@ -2383,7 +2518,7 @@ export function Settings() { return } try { - const saved = await api.saveNameLabel(user.$id, user.email, editingNameLabel) + const saved = await api.saveNameLabel(editingNameLabel) if (saved.data) { if (editingNameLabel.id) { setNameLabels(nameLabels.map(l => l.id === editingNameLabel.id ? (saved.data || l) : l)) @@ -2466,7 +2601,7 @@ export function Settings() { onDisconnect={async (accountId) => { if (!user?.$id) return try { - const result = await api.disconnectEmailAccount(accountId, user.$id) + const result = await api.disconnectEmailAccount(accountId) if (result.data) { setAccounts(accounts.filter(a => a.id !== accountId)) showMessage('success', 'Account disconnected') @@ -2479,7 +2614,7 @@ export function Settings() { if (!user?.$id) return if (!confirm('Are you absolutely sure? This cannot be undone.')) return try { - const result = await api.deleteAccount(user.$id) + const result = await api.deleteAccount() if (result.data) { showMessage('success', 'Account deleted. Redirecting...') setTimeout(() => { @@ -2503,30 +2638,63 @@ export function Settings() { Manage your MailFlow subscription -
-
-
- -
-
-
-

{subscription?.plan || 'Trial'}

- - {subscription?.status === 'active' ? 'Active' : 'Trial'} - -
- {subscription?.currentPeriodEnd && ( -

- Next billing: {new Date(subscription.currentPeriodEnd).toLocaleDateString('en-US')} -

- )} -
+ {loading ? ( +

+ Loading subscription… +

+ ) : !subscription ? ( +
+

+ Subscription status could not be loaded. Make sure you are signed in and the API is running. +

+
- -
+ ) : ( +
+
+
+ +
+
+
+

+ {subscriptionTitle(subscription)} +

+ {(() => { + const b = subscriptionBadge(subscription) + return b.label ? ( + {b.label} + ) : null + })()} +
+ {subscription.currentPeriodEnd && ( +

+ Next billing:{' '} + {new Date(subscription.currentPeriodEnd).toLocaleDateString('en-US')} +

+ )} +
+
+ +
+ )} @@ -2556,8 +2724,8 @@ export function Settings() { /month
    - {plan.features.map((feature) => ( -
  • + {plan.features.map((feature, fi) => ( +
  • {feature}
  • diff --git a/client/src/pages/Setup.tsx b/client/src/pages/Setup.tsx index 5b1429e..76a595e 100644 --- a/client/src/pages/Setup.tsx +++ b/client/src/pages/Setup.tsx @@ -56,7 +56,7 @@ export function Setup() { if (user?.$id) { const loadOnboarding = async () => { try { - const stateRes = await api.getOnboardingStatus(user.$id) + const stateRes = await api.getOnboardingStatus() if (stateRes.data) { setOnboardingState(stateRes.data) @@ -89,7 +89,7 @@ export function Setup() { if (isFromCheckout && user?.$id) { const checkAccounts = async () => { try { - const accountsRes = await api.getEmailAccounts(user.$id) + const accountsRes = await api.getEmailAccounts() if (accountsRes.data && accountsRes.data.length > 0) { // User already has accounts connected - redirect to dashboard navigate('/dashboard?subscription=success&ready=true') @@ -118,16 +118,16 @@ export function Setup() { setError(null) try { - const response = await api.getOAuthUrl('gmail', user.$id) + const response = await api.getOAuthUrl('gmail') if (response.data?.url) { // Track onboarding step before redirect - await api.updateOnboardingStep(user.$id, 'connect', ['connect']) + await api.updateOnboardingStep('connect', ['connect']) window.location.href = response.data.url } else { setConnectedProvider('gmail') setConnectedEmail(user.email) setCurrentStep('complete') - await api.updateOnboardingStep(user.$id, 'see_results', ['connect']) + await api.updateOnboardingStep('see_results', ['connect']) trackOnboardingStep(user.$id, 'first_rule') trackProviderConnected(user.$id, 'gmail') } @@ -144,16 +144,16 @@ export function Setup() { setError(null) try { - const response = await api.getOAuthUrl('outlook', user.$id) + const response = await api.getOAuthUrl('outlook') if (response.data?.url) { // Track onboarding step before redirect - await api.updateOnboardingStep(user.$id, 'connect', ['connect']) + await api.updateOnboardingStep('connect', ['connect']) window.location.href = response.data.url } else { setConnectedProvider('outlook') setConnectedEmail(user.email) setCurrentStep('complete') - await api.updateOnboardingStep(user.$id, 'see_results', ['connect']) + await api.updateOnboardingStep('see_results', ['connect']) } } catch { setError('Outlook connection failed. Please try again.') @@ -168,12 +168,12 @@ export function Setup() { setError(null) try { - const response = await api.connectDemoAccount(user.$id) + const response = await api.connectDemoAccount() if (response.data) { setConnectedProvider('demo') setConnectedEmail(response.data.email) setCurrentStep('complete') - await api.updateOnboardingStep(user.$id, 'see_results', ['connect']) + await api.updateOnboardingStep('see_results', ['connect']) trackOnboardingStep(user.$id, 'first_rule') trackDemoUsed(user.$id) } @@ -202,7 +202,7 @@ export function Setup() { const completedSteps = onboardingState?.completedSteps || [] if (onboardingStep && !completedSteps.includes(stepMap[currentStep])) { const newCompleted = [...completedSteps, stepMap[currentStep]] - await api.updateOnboardingStep(user.$id, onboardingStep, newCompleted) + await api.updateOnboardingStep(onboardingStep, newCompleted) setOnboardingState({ onboarding_step: onboardingStep, completedSteps: newCompleted, @@ -227,7 +227,7 @@ export function Setup() { setSaving(true) try { - await api.saveUserPreferences(user.$id, { + await api.saveUserPreferences({ vipSenders: [], blockedSenders: [], customRules: [], @@ -235,7 +235,7 @@ export function Setup() { }) // Mark onboarding as completed - await api.updateOnboardingStep(user.$id, 'completed', ['connect', 'see_results']) + await api.updateOnboardingStep('completed', ['connect', 'see_results']) } catch (err) { console.error('Failed to save preferences:', err) } finally { @@ -248,7 +248,7 @@ export function Setup() { if (!user?.$id) return try { - await api.skipOnboarding(user.$id) + await api.skipOnboarding() navigate('/dashboard') } catch (err) { console.error('Failed to skip onboarding:', err) diff --git a/client/vite.config.ts b/client/vite.config.ts index 8046901..45112c0 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -1,27 +1,53 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' import path from 'path' // https://vite.dev/config/ -export default defineConfig({ - plugins: [react(), tailwindcss()], - resolve: { - alias: { - '@': path.resolve(__dirname, './src'), +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, __dirname, '') + const appwriteDevOrigin = (env.APPWRITE_DEV_ORIGIN || '').replace(/\/$/, '') + // 127.0.0.1 avoids Windows localhost → IPv6 (::1) vs backend listening on IPv4-only + const apiDevTarget = (env.VITE_DEV_API_ORIGIN || 'http://127.0.0.1:3000').replace( + /\/$/, + '' + ) + + const proxy: Record< + string, + { target: string; changeOrigin: boolean; secure?: boolean } + > = { + '/api': { + target: apiDevTarget, + changeOrigin: true, }, - }, - server: { - port: 5173, - proxy: { - '/api': { - target: 'http://localhost:3000', - changeOrigin: true, - }, - '/stripe': { - target: 'http://localhost:3000', - changeOrigin: true, + '/stripe': { + target: apiDevTarget, + changeOrigin: true, + }, + } + + // Dev: Browser → localhost:5173/v1/* → Appwrite (umgeht CORS, wenn die Console nur z. B. webklar.com erlaubt) + if (mode === 'development' && appwriteDevOrigin) { + proxy['/v1'] = { + target: appwriteDevOrigin, + changeOrigin: true, + secure: true, + } + } + + return { + plugins: [react(), tailwindcss()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), }, }, - }, + server: { + port: 5173, + // Wenn 5173 schon belegt ist, nicht still auf einen anderen Port wechseln — sonst öffnet man oft noch die alte URL und bekommt für /api 404. + strictPort: true, + proxy, + }, + } }) diff --git a/server/config/index.mjs b/server/config/index.mjs index 010c7f5..61039d9 100644 --- a/server/config/index.mjs +++ b/server/config/index.mjs @@ -76,6 +76,9 @@ export const config = { autoSchedule: false, // manual only }, + /** Highest product tier (admin comped plan, PLANS key in stripe.mjs). Optional env: TOP_SUBSCRIPTION_PLAN */ + topSubscriptionPlan: (process.env.TOP_SUBSCRIPTION_PLAN || 'business').trim().toLowerCase(), + // Admin: comma-separated list of emails with admin rights (e.g. support) adminEmails: (process.env.ADMIN_EMAILS || '') .split(',') @@ -87,6 +90,9 @@ export const config = { webhookSecret: process.env.GITEA_WEBHOOK_SECRET || '', webhookAuthToken: process.env.GITEA_WEBHOOK_AUTH_TOKEN || process.env.GITEA_WEBHOOK_SECRET || '', }, + + /** HMAC secret for Gmail/Outlook OAuth state (recommended in production) */ + oauthStateSecret: process.env.OAUTH_STATE_SECRET || '', } /** diff --git a/server/emails/payment-failed.txt b/server/emails/payment-failed.txt new file mode 100644 index 0000000..615ec50 --- /dev/null +++ b/server/emails/payment-failed.txt @@ -0,0 +1,7 @@ +Hello, + +We could not process your latest MailFlow payment (invoice {{invoiceId}}). + +Please update your payment method in the billing portal to keep your subscription active. + +— MailFlow diff --git a/server/emails/subscription-ended.txt b/server/emails/subscription-ended.txt new file mode 100644 index 0000000..d04a45e --- /dev/null +++ b/server/emails/subscription-ended.txt @@ -0,0 +1,7 @@ +Hello, + +Your MailFlow subscription has ended on {{endedDate}}. + +You can resubscribe anytime from your account settings. + +— MailFlow diff --git a/server/emails/subscription-updated.txt b/server/emails/subscription-updated.txt new file mode 100644 index 0000000..40e5076 --- /dev/null +++ b/server/emails/subscription-updated.txt @@ -0,0 +1,9 @@ +Hello, + +Your MailFlow subscription was updated. + +Plan: {{plan}} +Status: {{status}} +{{periodEndLine}} + +— MailFlow diff --git a/server/index.mjs b/server/index.mjs index 2daa30a..c654d1f 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -11,10 +11,11 @@ import { dirname, join } from 'path' // Config & Middleware import { config, validateConfig } from './config/index.mjs' -import { errorHandler, asyncHandler, NotFoundError, ValidationError, AuthorizationError } from './middleware/errorHandler.mjs' +import { errorHandler, asyncHandler, AppError, NotFoundError, ValidationError, AuthorizationError } from './middleware/errorHandler.mjs' import { respond } from './utils/response.mjs' import { logger, log } from './middleware/logger.mjs' import { limiters } from './middleware/rateLimit.mjs' +import { requireAuth } from './middleware/auth.mjs' // Routes import oauthRoutes from './routes/oauth.mjs' @@ -23,6 +24,7 @@ import stripeRoutes from './routes/stripe.mjs' import apiRoutes from './routes/api.mjs' import analyticsRoutes from './routes/analytics.mjs' import webhookRoutes from './routes/webhook.mjs' +import { startCounterJobs } from './jobs/reset-counters.mjs' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) @@ -93,21 +95,16 @@ import { userPreferences } from './services/database.mjs' import { isAdmin } from './config/index.mjs' /** - * GET /api/me?email=xxx - * Returns current user context (e.g. isAdmin) for the given email. + * GET /api/me + * Returns current user context (JWT). isAdmin from verified email. */ -app.get('/api/me', asyncHandler(async (req, res) => { - const { email } = req.query - if (!email || typeof email !== 'string') { - throw new ValidationError('email is required') - } - respond.success(res, { isAdmin: isAdmin(email) }) +app.get('/api/me', requireAuth, asyncHandler(async (req, res) => { + respond.success(res, { isAdmin: isAdmin(req.appwriteUser.email) }) })) -app.get('/api/preferences', asyncHandler(async (req, res) => { - const { userId } = req.query - if (!userId) throw new ValidationError('userId ist erforderlich') - +app.get('/api/preferences', requireAuth, asyncHandler(async (req, res) => { + const userId = req.appwriteUser.id + const prefs = await userPreferences.getByUser(userId) respond.success(res, prefs?.preferences || { vipSenders: [], @@ -117,22 +114,40 @@ app.get('/api/preferences', asyncHandler(async (req, res) => { }) })) -app.post('/api/preferences', asyncHandler(async (req, res) => { - const { userId, ...preferences } = req.body - if (!userId) throw new ValidationError('userId ist erforderlich') - +app.post('/api/preferences', requireAuth, asyncHandler(async (req, res) => { + const userId = req.appwriteUser.id + const { ...preferences } = req.body + await userPreferences.upsert(userId, preferences) respond.success(res, null, 'Einstellungen gespeichert') })) +/** + * PATCH /api/preferences/profile + * { displayName?, timezone?, notificationPrefs? } + */ +app.patch('/api/preferences/profile', requireAuth, asyncHandler(async (req, res) => { + const userId = req.appwriteUser.id + const { displayName, timezone, notificationPrefs } = req.body + const prefs = await userPreferences.getByUser(userId) + const current = prefs?.preferences?.profile || userPreferences.getDefaults().profile + const profile = { + ...current, + ...(displayName !== undefined && { displayName }), + ...(timezone !== undefined && { timezone }), + ...(notificationPrefs !== undefined && { notificationPrefs }), + } + await userPreferences.upsert(userId, { profile }) + respond.success(res, { profile }, 'Profile saved') +})) + /** * GET /api/preferences/ai-control * Get AI Control settings */ -app.get('/api/preferences/ai-control', asyncHandler(async (req, res) => { - const { userId } = req.query - if (!userId) throw new ValidationError('userId is required') - +app.get('/api/preferences/ai-control', requireAuth, asyncHandler(async (req, res) => { + const userId = req.appwriteUser.id + const prefs = await userPreferences.getByUser(userId) const preferences = prefs?.preferences || userPreferences.getDefaults() @@ -148,10 +163,10 @@ app.get('/api/preferences/ai-control', asyncHandler(async (req, res) => { * POST /api/preferences/ai-control * Save AI Control settings */ -app.post('/api/preferences/ai-control', asyncHandler(async (req, res) => { - const { userId, enabledCategories, categoryActions, autoDetectCompanies, cleanup } = req.body - if (!userId) throw new ValidationError('userId is required') - +app.post('/api/preferences/ai-control', requireAuth, asyncHandler(async (req, res) => { + const userId = req.appwriteUser.id + const { enabledCategories, categoryActions, autoDetectCompanies, cleanup } = req.body + const updates = {} if (enabledCategories !== undefined) updates.enabledCategories = enabledCategories if (categoryActions !== undefined) updates.categoryActions = categoryActions @@ -166,10 +181,9 @@ app.post('/api/preferences/ai-control', asyncHandler(async (req, res) => { * GET /api/preferences/company-labels * Get company labels */ -app.get('/api/preferences/company-labels', asyncHandler(async (req, res) => { - const { userId } = req.query - if (!userId) throw new ValidationError('userId is required') - +app.get('/api/preferences/company-labels', requireAuth, asyncHandler(async (req, res) => { + const userId = req.appwriteUser.id + const prefs = await userPreferences.getByUser(userId) const preferences = prefs?.preferences || userPreferences.getDefaults() @@ -180,9 +194,9 @@ app.get('/api/preferences/company-labels', asyncHandler(async (req, res) => { * POST /api/preferences/company-labels * Save/Update company label */ -app.post('/api/preferences/company-labels', asyncHandler(async (req, res) => { - const { userId, companyLabel } = req.body - if (!userId) throw new ValidationError('userId is required') +app.post('/api/preferences/company-labels', requireAuth, asyncHandler(async (req, res) => { + const userId = req.appwriteUser.id + const { companyLabel } = req.body if (!companyLabel) throw new ValidationError('companyLabel is required') const prefs = await userPreferences.getByUser(userId) @@ -211,10 +225,9 @@ app.post('/api/preferences/company-labels', asyncHandler(async (req, res) => { * DELETE /api/preferences/company-labels/:id * Delete company label */ -app.delete('/api/preferences/company-labels/:id', asyncHandler(async (req, res) => { - const { userId } = req.query +app.delete('/api/preferences/company-labels/:id', requireAuth, asyncHandler(async (req, res) => { + const userId = req.appwriteUser.id const { id } = req.params - if (!userId) throw new ValidationError('userId is required') if (!id) throw new ValidationError('label id is required') const prefs = await userPreferences.getByUser(userId) @@ -230,12 +243,10 @@ app.delete('/api/preferences/company-labels/:id', asyncHandler(async (req, res) * GET /api/preferences/name-labels * Get name labels (worker labels). Admin only. */ -app.get('/api/preferences/name-labels', asyncHandler(async (req, res) => { - const { userId, email } = req.query - if (!userId) throw new ValidationError('userId is required') - if (!email || typeof email !== 'string') throw new ValidationError('email is required') - if (!isAdmin(email)) throw new AuthorizationError('Admin access required for name labels') +app.get('/api/preferences/name-labels', requireAuth, asyncHandler(async (req, res) => { + if (!isAdmin(req.appwriteUser.email)) throw new AuthorizationError('Admin access required for name labels') + const userId = req.appwriteUser.id const prefs = await userPreferences.getByUser(userId) const preferences = prefs?.preferences || userPreferences.getDefaults() respond.success(res, preferences.nameLabels || []) @@ -245,11 +256,11 @@ app.get('/api/preferences/name-labels', asyncHandler(async (req, res) => { * POST /api/preferences/name-labels * Save/Update name label (worker). Admin only. */ -app.post('/api/preferences/name-labels', asyncHandler(async (req, res) => { - const { userId, email, nameLabel } = req.body - if (!userId) throw new ValidationError('userId is required') - if (!email || typeof email !== 'string') throw new ValidationError('email is required') - if (!isAdmin(email)) throw new AuthorizationError('Admin access required for name labels') +app.post('/api/preferences/name-labels', requireAuth, asyncHandler(async (req, res) => { + if (!isAdmin(req.appwriteUser.email)) throw new AuthorizationError('Admin access required for name labels') + + const userId = req.appwriteUser.id + const { nameLabel } = req.body if (!nameLabel) throw new ValidationError('nameLabel is required') const prefs = await userPreferences.getByUser(userId) @@ -274,12 +285,11 @@ app.post('/api/preferences/name-labels', asyncHandler(async (req, res) => { * DELETE /api/preferences/name-labels/:id * Delete name label. Admin only. */ -app.delete('/api/preferences/name-labels/:id', asyncHandler(async (req, res) => { - const { userId, email } = req.query +app.delete('/api/preferences/name-labels/:id', requireAuth, asyncHandler(async (req, res) => { + if (!isAdmin(req.appwriteUser.email)) throw new AuthorizationError('Admin access required for name labels') + + const userId = req.appwriteUser.id const { id } = req.params - if (!userId) throw new ValidationError('userId is required') - if (!email || typeof email !== 'string') throw new ValidationError('email is required') - if (!isAdmin(email)) throw new AuthorizationError('Admin access required for name labels') if (!id) throw new ValidationError('label id is required') const prefs = await userPreferences.getByUser(userId) @@ -292,14 +302,33 @@ app.delete('/api/preferences/name-labels/:id', asyncHandler(async (req, res) => // Legacy Stripe webhook endpoint app.use('/stripe', stripeRoutes) -// 404 handler for API routes -app.use('/api/*', (req, res, next) => { +// Unmatched /api → JSON 404 (Express 4 treats '/api/*' as a literal path, not a wildcard) +app.use((req, res, next) => { + const pathOnly = req.originalUrl.split('?')[0] + if (!pathOnly.startsWith('/api')) { + return next() + } next(new NotFoundError('Endpoint')) }) -// SPA fallback for non-API routes -app.get('*', (req, res) => { - res.sendFile(join(__dirname, '..', 'public', 'index.html')) +// SPA fallback: never send index.html for /api (avoids 404/HTML when public/index.html is missing) +app.get('*', (req, res, next) => { + const pathOnly = req.originalUrl.split('?')[0] + if (pathOnly.startsWith('/api')) { + return next(new NotFoundError('Endpoint')) + } + const indexPath = join(__dirname, '..', 'public', 'index.html') + res.sendFile(indexPath, (err) => { + if (err) { + next( + new AppError( + 'public/index.html fehlt. In Entwicklung: Frontend über Vite (z. B. http://localhost:5173) starten; für Produktion: Client-Build nach public/ legen.', + 404, + 'NOT_FOUND' + ) + ) + } + }) }) // Global error handler (must be last) @@ -346,6 +375,7 @@ server = app.listen(config.port, () => { console.log(` 🌐 API: http://localhost:${config.port}/api`) console.log(` 💚 Health: http://localhost:${config.port}/api/health`) console.log('') + startCounterJobs() }) export default app diff --git a/server/jobs/reset-counters.mjs b/server/jobs/reset-counters.mjs new file mode 100644 index 0000000..cb3bc5d --- /dev/null +++ b/server/jobs/reset-counters.mjs @@ -0,0 +1,39 @@ +/** + * Scheduled counter resets (UTC). + */ + +import cron from 'node-cron' +import { emailStats } from '../services/database.mjs' +import { log } from '../middleware/logger.mjs' + +export function startCounterJobs() { + cron.schedule( + '0 0 * * *', + async () => { + const t = new Date().toISOString() + try { + const n = await emailStats.resetDaily() + log.info(`[cron] resetDaily at ${t} — updated ${n} email_stats documents`) + } catch (e) { + log.error('[cron] resetDaily failed', { error: e.message }) + } + }, + { timezone: 'UTC' } + ) + + cron.schedule( + '0 0 1 * *', + async () => { + const t = new Date().toISOString() + try { + const n = await emailStats.resetWeekly() + log.info(`[cron] resetWeekly at ${t} — updated ${n} email_stats documents`) + } catch (e) { + log.error('[cron] resetWeekly failed', { error: e.message }) + } + }, + { timezone: 'UTC' } + ) + + log.success('Counter cron jobs scheduled (daily 00:00 UTC, monthly week reset 1st 00:00 UTC)') +} diff --git a/server/middleware/auth.mjs b/server/middleware/auth.mjs new file mode 100644 index 0000000..ad13e09 --- /dev/null +++ b/server/middleware/auth.mjs @@ -0,0 +1,58 @@ +/** + * Appwrite JWT verification for user-scoped API routes. + */ + +import { Client, Account } from 'node-appwrite' +import { config } from '../config/index.mjs' +import { AuthenticationError } from './errorHandler.mjs' + +/** + * Verify Authorization: Bearer and attach Appwrite user to req.appwriteUser + */ +export function requireAuth(req, res, next) { + ;(async () => { + try { + const header = req.headers.authorization || '' + const m = /^Bearer\s+(.+)$/i.exec(header) + if (!m?.[1]) { + throw new AuthenticationError('Authorization Bearer token required') + } + const jwt = m[1].trim() + const client = new Client() + .setEndpoint(config.appwrite.endpoint) + .setProject(config.appwrite.projectId) + .setJWT(jwt) + + const account = new Account(client) + const user = await account.get() + + if (!user || !user.$id) { + throw new AuthenticationError('Ungültige Appwrite-Sitzung') + } + + req.appwriteUser = { + id: user.$id, + email: user.email || '', + name: user.name || '', + } + next() + } catch (err) { + if (err instanceof AuthenticationError) { + next(err) + return + } + next(new AuthenticationError(err.message || 'Invalid or expired session')) + } + })() +} + +/** + * Skip auth for email provider inbound webhooks only. + */ +export function requireAuthUnlessEmailWebhook(req, res, next) { + const p = req.path || '' + if (p === '/webhook/gmail' || p === '/webhook/outlook') { + return next() + } + return requireAuth(req, res, next) +} diff --git a/server/middleware/errorHandler.mjs b/server/middleware/errorHandler.mjs index 40af5a1..210102c 100644 --- a/server/middleware/errorHandler.mjs +++ b/server/middleware/errorHandler.mjs @@ -3,6 +3,8 @@ * Catches all errors and returns consistent JSON responses */ +import { AppwriteException } from 'node-appwrite' + export class AppError extends Error { constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') { super(message) @@ -56,11 +58,28 @@ export function errorHandler(err, req, res, next) { stack: process.env.NODE_ENV === 'development' ? err.stack : undefined, }) - // Default error values - let statusCode = err.statusCode || 500 - let code = err.code || 'INTERNAL_ERROR' + // Default error values (AppwriteException uses numeric err.code — do not reuse as JSON "code" string) + let statusCode = + typeof err.statusCode === 'number' ? err.statusCode : undefined + let code = typeof err.code === 'string' && err.code ? err.code : 'INTERNAL_ERROR' let message = err.message || 'Ein Fehler ist aufgetreten' + if ( + err instanceof AppwriteException && + typeof err.code === 'number' && + err.code >= 400 && + err.code < 600 + ) { + statusCode = err.code + code = err.type || 'APPWRITE_ERROR' + message = err.message || message + err.isOperational = true + } + + if (statusCode === undefined) { + statusCode = 500 + } + // Handle specific error types if (err.name === 'ValidationError') { statusCode = 400 diff --git a/server/middleware/rateLimit.mjs b/server/middleware/rateLimit.mjs index 723b9b4..68cc55e 100644 --- a/server/middleware/rateLimit.mjs +++ b/server/middleware/rateLimit.mjs @@ -4,6 +4,7 @@ */ import { RateLimitError } from './errorHandler.mjs' +import { isAdmin } from '../config/index.mjs' // In-memory store for rate limiting (use Redis in production) const requestCounts = new Map() @@ -25,6 +26,7 @@ setInterval(() => { * @param {number} options.max - Max requests per window * @param {string} options.message - Error message * @param {Function} options.keyGenerator - Function to generate unique key + * @param {Function} options.skip - If (req) => true, do not count this request */ export function rateLimit(options = {}) { const { @@ -32,9 +34,14 @@ export function rateLimit(options = {}) { max = 100, message = 'Zu viele Anfragen. Bitte versuche es später erneut.', keyGenerator = (req) => req.ip, + skip = () => false, } = options return (req, res, next) => { + if (skip(req)) { + return next() + } + const key = keyGenerator(req) const now = Date.now() @@ -80,11 +87,12 @@ export const limiters = { message: 'Zu viele Anmeldeversuche. Bitte warte 15 Minuten.', }), - // Limit for email sorting (expensive operation) + // Limit for email sorting (expensive operation); ADMIN_EMAILS (isAdmin) bypass emailSort: rateLimit({ windowMs: 60000, max: 30, // Erhöht für Entwicklung message: 'E-Mail-Sortierung ist limitiert. Bitte warte eine Minute.', + skip: (req) => isAdmin(req.appwriteUser?.email), }), // Limit for AI operations diff --git a/server/package-lock.json b/server/package-lock.json index bf49185..659f7cc 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,11 +1,11 @@ { - "name": "email-sorter-server", + "name": "mailflow-server", "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "email-sorter-server", + "name": "mailflow-server", "version": "2.0.0", "license": "MIT", "dependencies": { @@ -18,7 +18,9 @@ "googleapis": "^144.0.0", "imapflow": "^1.2.8", "node-appwrite": "^14.1.0", - "stripe": "^17.4.0" + "node-cron": "^4.2.1", + "nodemailer": "^8.0.4", + "stripe": "^17.7.0" }, "devDependencies": { "jsdom": "^27.4.0" @@ -1119,6 +1121,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/imapflow/node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -1448,6 +1459,15 @@ "node-fetch-native-with-agent": "1.7.2" } }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -1497,9 +1517,9 @@ } }, "node_modules/nodemailer": { - "version": "7.0.13", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", - "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz", + "integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==", "license": "MIT-0", "engines": { "node": ">=6.0.0" diff --git a/server/package.json b/server/package.json index d1087fe..b19ef44 100644 --- a/server/package.json +++ b/server/package.json @@ -41,7 +41,9 @@ "googleapis": "^144.0.0", "imapflow": "^1.2.8", "node-appwrite": "^14.1.0", - "stripe": "^17.4.0" + "node-cron": "^4.2.1", + "nodemailer": "^8.0.4", + "stripe": "^17.7.0" }, "devDependencies": { "jsdom": "^27.4.0" diff --git a/server/routes/analytics.mjs b/server/routes/analytics.mjs index 669c75f..0990ebd 100644 --- a/server/routes/analytics.mjs +++ b/server/routes/analytics.mjs @@ -8,9 +8,12 @@ import { asyncHandler, ValidationError } from '../middleware/errorHandler.mjs' import { respond } from '../utils/response.mjs' import { db, Collections } from '../services/database.mjs' import { log } from '../middleware/logger.mjs' +import { requireAuth } from '../middleware/auth.mjs' const router = express.Router() +router.use(requireAuth) + // Whitelist of allowed event types const ALLOWED_EVENT_TYPES = [ 'page_view', @@ -79,7 +82,6 @@ function stripPII(metadata) { router.post('/track', asyncHandler(async (req, res) => { const { type, - userId, tracking, metadata, timestamp, @@ -88,6 +90,8 @@ router.post('/track', asyncHandler(async (req, res) => { sessionId, } = req.body + const userId = req.appwriteUser.id + // Validate event type if (!type || !ALLOWED_EVENT_TYPES.includes(type)) { throw new ValidationError(`Invalid event type. Allowed: ${ALLOWED_EVENT_TYPES.join(', ')}`) diff --git a/server/routes/api.mjs b/server/routes/api.mjs index bf8864b..98de311 100644 --- a/server/routes/api.mjs +++ b/server/routes/api.mjs @@ -11,6 +11,7 @@ import { products, questions, submissions, orders, onboardingState, emailAccount import Stripe from 'stripe' import { config } from '../config/index.mjs' import { log } from '../middleware/logger.mjs' +import { requireAuth } from '../middleware/auth.mjs' const router = express.Router() const stripe = new Stripe(config.stripe.secretKey) @@ -177,13 +178,9 @@ router.get('/config', (req, res) => { * Get current onboarding state */ router.get('/onboarding/status', - validate({ - query: { - userId: [rules.required('userId')], - }, - }), + requireAuth, asyncHandler(async (req, res) => { - const { userId } = req.query + const userId = req.appwriteUser.id const state = await onboardingState.getByUser(userId) respond.success(res, state) }) @@ -194,15 +191,16 @@ router.get('/onboarding/status', * Update onboarding step progress */ router.post('/onboarding/step', + requireAuth, validate({ body: { - userId: [rules.required('userId')], step: [rules.required('step')], completedSteps: [rules.isArray('completedSteps')], }, }), asyncHandler(async (req, res) => { - const { userId, step, completedSteps = [] } = req.body + const userId = req.appwriteUser.id + const { step, completedSteps = [] } = req.body await onboardingState.updateStep(userId, step, completedSteps) respond.success(res, { step, completedSteps }) }) @@ -213,13 +211,9 @@ router.post('/onboarding/step', * Skip onboarding */ router.post('/onboarding/skip', - validate({ - body: { - userId: [rules.required('userId')], - }, - }), + requireAuth, asyncHandler(async (req, res) => { - const { userId } = req.body + const userId = req.appwriteUser.id await onboardingState.skip(userId) respond.success(res, { skipped: true }) }) @@ -230,13 +224,9 @@ router.post('/onboarding/skip', * Resume onboarding */ router.post('/onboarding/resume', - validate({ - body: { - userId: [rules.required('userId')], - }, - }), + requireAuth, asyncHandler(async (req, res) => { - const { userId } = req.body + const userId = req.appwriteUser.id await onboardingState.resume(userId) const state = await onboardingState.getByUser(userId) respond.success(res, state) @@ -248,13 +238,9 @@ router.post('/onboarding/resume', * Delete all user data and account */ router.delete('/account/delete', - validate({ - body: { - userId: [rules.required('userId')], - }, - }), + requireAuth, asyncHandler(async (req, res) => { - const { userId } = req.body + const userId = req.appwriteUser.id log.info(`Account deletion requested for user ${userId}`) @@ -301,7 +287,7 @@ router.delete('/account/delete', } // Delete subscription - const subscription = await subscriptions.getByUser(userId) + const subscription = await subscriptions.getByUser(userId, req.appwriteUser?.email) if (subscription && subscription.$id) { try { await db.delete(Collections.SUBSCRIPTIONS, subscription.$id) @@ -344,13 +330,9 @@ router.delete('/account/delete', * Get or create referral code for user */ router.get('/referrals/code', - validate({ - query: { - userId: [rules.required('userId')], - }, - }), + requireAuth, asyncHandler(async (req, res) => { - const { userId } = req.query + const userId = req.appwriteUser.id const referral = await referrals.getOrCreateCode(userId) respond.success(res, { referralCode: referral.referralCode, @@ -364,14 +346,15 @@ router.get('/referrals/code', * Track a referral (when new user signs up with referral code) */ router.post('/referrals/track', + requireAuth, validate({ body: { - userId: [rules.required('userId')], referralCode: [rules.required('referralCode')], }, }), asyncHandler(async (req, res) => { - const { userId, referralCode } = req.body + const userId = req.appwriteUser.id + const { referralCode } = req.body // Find referrer by code const referrer = await referrals.getByCode(referralCode) diff --git a/server/routes/email.mjs b/server/routes/email.mjs index e3d3ff3..3d8a7ab 100644 --- a/server/routes/email.mjs +++ b/server/routes/email.mjs @@ -9,11 +9,15 @@ import { validate, rules } from '../middleware/validate.mjs' import { limiters } from '../middleware/rateLimit.mjs' import { respond } from '../utils/response.mjs' import { emailAccounts, emailStats, emailDigests, userPreferences, emailUsage, subscriptions } from '../services/database.mjs' -import { config, features } from '../config/index.mjs' +import { config, features, isAdmin } from '../config/index.mjs' import { log } from '../middleware/logger.mjs' +import { requireAuthUnlessEmailWebhook } from '../middleware/auth.mjs' +import { encryptImapSecret, decryptImapSecret } from '../utils/crypto.mjs' const router = express.Router() +router.use(requireAuthUnlessEmailWebhook) + // Lazy load heavy services let gmailServiceClass = null let outlookServiceClass = null @@ -77,13 +81,13 @@ const DEMO_EMAILS = [ router.post('/connect', validate({ body: { - userId: [rules.required('userId')], provider: [rules.required('provider'), rules.isIn('provider', ['gmail', 'outlook', 'demo', 'imap'])], email: [rules.required('email'), rules.email()], }, }), asyncHandler(async (req, res) => { - const { userId, provider, email, accessToken, refreshToken, expiresAt, password, imapHost, imapPort, imapSecure } = req.body + const userId = req.appwriteUser.id + const { provider, email, accessToken, refreshToken, expiresAt, password, imapHost, imapPort, imapSecure } = req.body // IMAP: require password (or accessToken as password) if (provider === 'imap') { @@ -125,11 +129,12 @@ router.post('/connect', } // Create account + const rawImapSecret = provider === 'imap' ? (password || accessToken) : '' const accountData = { userId, provider, email, - accessToken: provider === 'imap' ? (password || accessToken) : (accessToken || ''), + accessToken: provider === 'imap' ? encryptImapSecret(rawImapSecret) : (accessToken || ''), refreshToken: provider === 'imap' ? '' : (refreshToken || ''), expiresAt: provider === 'imap' ? 0 : (expiresAt || 0), isActive: true, @@ -157,13 +162,8 @@ router.post('/connect', * Connect a demo email account for testing */ router.post('/connect-demo', - validate({ - body: { - userId: [rules.required('userId')], - }, - }), asyncHandler(async (req, res) => { - const { userId } = req.body + const userId = req.appwriteUser.id const demoEmail = `demo-${userId.slice(0, 8)}@mailflow.demo` // Check if demo account already exists @@ -207,11 +207,7 @@ router.post('/connect-demo', * Get user's connected email accounts */ router.get('/accounts', asyncHandler(async (req, res) => { - const { userId } = req.query - - if (!userId) { - throw new ValidationError('userId is required') - } + const userId = req.appwriteUser.id const accounts = await emailAccounts.getByUser(userId) @@ -234,11 +230,7 @@ router.get('/accounts', asyncHandler(async (req, res) => { */ router.delete('/accounts/:accountId', asyncHandler(async (req, res) => { const { accountId } = req.params - const { userId } = req.query - - if (!userId) { - throw new ValidationError('userId is required') - } + const userId = req.appwriteUser.id // Verify ownership const account = await emailAccounts.get(accountId) @@ -259,11 +251,7 @@ router.delete('/accounts/:accountId', asyncHandler(async (req, res) => { * Get email sorting statistics */ router.get('/stats', asyncHandler(async (req, res) => { - const { userId } = req.query - - if (!userId) { - throw new ValidationError('userId is required') - } + const userId = req.appwriteUser.id const stats = await emailStats.getByUser(userId) @@ -299,19 +287,20 @@ router.post('/sort', limiters.emailSort, validate({ body: { - userId: [rules.required('userId')], accountId: [rules.required('accountId')], }, }), asyncHandler(async (req, res) => { - const { userId, accountId, maxEmails = 500, processAll = true } = req.body + const userId = req.appwriteUser.id + const { accountId, maxEmails = 500, processAll = true } = req.body // Check subscription status and free tier limits - const subscription = await subscriptions.getByUser(userId) + const subscription = await subscriptions.getByUser(userId, req.appwriteUser?.email) const isFreeTier = subscription?.isFreeTier || false - - // Check free tier limit - if (isFreeTier) { + const adminUser = isAdmin(req.appwriteUser?.email) + + // Check free tier limit (admins: unlimited) + if (isFreeTier && !adminUser) { const usage = await emailUsage.getUsage(userId) const limit = subscription?.emailsLimit || config.freeTier.emailsPerMonth @@ -875,7 +864,7 @@ router.post('/sort', port: account.imapPort != null ? account.imapPort : 993, secure: account.imapSecure !== false, user: account.email, - password: account.accessToken, + password: decryptImapSecret(account.accessToken), }) try { @@ -1013,8 +1002,8 @@ router.post('/sort', // Update last sync await emailAccounts.updateLastSync(accountId) - // Update email usage (for free tier tracking) - if (isFreeTier) { + // Update email usage (for free tier tracking; admins are "business", skip counter) + if (isFreeTier && !adminUser) { await emailUsage.increment(userId, sortedCount) } @@ -1202,18 +1191,18 @@ router.post('/sort-demo', asyncHandler(async (req, res) => { })) /** - * POST /api/email/cleanup - * Cleanup old MailFlow labels from Gmail + * POST /api/email/cleanup/mailflow-labels + * Cleanup old MailFlow labels from Gmail (legacy label names) */ -router.post('/cleanup', +router.post('/cleanup/mailflow-labels', validate({ body: { - userId: [rules.required('userId')], accountId: [rules.required('accountId')], }, }), asyncHandler(async (req, res) => { - const { userId, accountId } = req.body + const userId = req.appwriteUser.id + const { accountId } = req.body const account = await emailAccounts.get(accountId) @@ -1246,11 +1235,7 @@ router.post('/cleanup', * Get today's sorting digest summary */ router.get('/digest', asyncHandler(async (req, res) => { - const { userId } = req.query - - if (!userId) { - throw new ValidationError('userId is required') - } + const userId = req.appwriteUser.id const digest = await emailDigests.getByUserToday(userId) @@ -1285,13 +1270,10 @@ router.get('/digest', asyncHandler(async (req, res) => { * Get digest history for the last N days */ router.get('/digest/history', asyncHandler(async (req, res) => { - const { userId, days = 7 } = req.query + const userId = req.appwriteUser.id + const days = req.query.days ?? 7 - if (!userId) { - throw new ValidationError('userId is required') - } - - const digests = await emailDigests.getByUserRecent(userId, parseInt(days)) + const digests = await emailDigests.getByUserRecent(userId, parseInt(String(days), 10)) // Calculate totals const totals = { @@ -1333,6 +1315,77 @@ router.get('/categories', asyncHandler(async (req, res) => { respond.success(res, formattedCategories) })) +/** + * GET /api/email/:accountId/cleanup/preview + * Dry-run: messages that would be affected by cleanup settings (no mutations). + * + * curl examples: + * curl -s -H "Authorization: Bearer YOUR_JWT" "http://localhost:3000/api/email/ACCOUNT_DOC_ID/cleanup/preview" + */ +router.get('/:accountId/cleanup/preview', asyncHandler(async (req, res) => { + const userId = req.appwriteUser.id + const { accountId } = req.params + + const account = await emailAccounts.get(accountId) + if (account.userId !== userId) { + throw new AuthorizationError('No permission for this account') + } + + const prefs = await userPreferences.getByUser(userId) + const cleanup = prefs?.preferences?.cleanup || userPreferences.getDefaults().cleanup + const maxList = cleanup.safety?.maxDeletesPerRun ?? 100 + + const messages = [] + + if (cleanup.readItems?.enabled) { + const readList = await listReadCleanupPreviewMessages(account, cleanup.readItems.gracePeriodDays, maxList) + for (const m of readList) { + if (messages.length >= maxList) break + messages.push({ ...m, reason: 'read' }) + } + } + + if (cleanup.promotions?.enabled && messages.length < maxList) { + const promoList = await listPromotionCleanupPreviewMessages( + account, + cleanup.promotions.deleteAfterDays, + cleanup.promotions.matchCategoriesOrLabels || [], + maxList - messages.length + ) + for (const m of promoList) { + if (messages.length >= maxList) break + messages.push({ ...m, reason: 'promotion' }) + } + } + + respond.success(res, { messages, count: messages.length }) +})) + +/** + * GET /api/email/:accountId/cleanup/status + * + * curl examples: + * curl -s -H "Authorization: Bearer YOUR_JWT" "http://localhost:3000/api/email/ACCOUNT_DOC_ID/cleanup/status" + */ +router.get('/:accountId/cleanup/status', asyncHandler(async (req, res) => { + const userId = req.appwriteUser.id + const { accountId } = req.params + + const account = await emailAccounts.get(accountId) + if (account.userId !== userId) { + throw new AuthorizationError('No permission for this account') + } + + const prefs = await userPreferences.getByUser(userId) + const meta = prefs?.preferences?.cleanupMeta || {} + + respond.success(res, { + lastRun: meta.lastRun, + lastRunCounts: meta.lastRunCounts, + lastErrors: meta.lastErrors, + }) +})) + /** * POST /api/email/webhook/gmail * Gmail push notification webhook @@ -1380,10 +1433,10 @@ router.post('/webhook/outlook', asyncHandler(async (req, res) => { * Can be called manually or by cron job */ router.post('/cleanup', asyncHandler(async (req, res) => { - const { userId } = req.body // Optional: only process this user, otherwise all users - - log.info('Cleanup job started', { userId: userId || 'all' }) - + const userId = req.appwriteUser.id + + log.info('Cleanup job started', { userId }) + const results = { usersProcessed: 0, emailsProcessed: { @@ -1394,72 +1447,60 @@ router.post('/cleanup', asyncHandler(async (req, res) => { } try { - // Get all users with cleanup enabled - let usersToProcess = [] - - if (userId) { - // Single user mode - const prefs = await userPreferences.getByUser(userId) - if (prefs?.preferences?.cleanup?.enabled) { - usersToProcess = [{ userId, preferences: prefs.preferences }] - } - } else { - // All users mode - get all user preferences - // Note: This is a simplified approach. In production, you might want to add an index - // or query optimization for users with cleanup.enabled = true - const allPrefs = await emailAccounts.getByUser('*') // This won't work, need different approach - // For now, we'll process users individually when they have accounts - // TODO: Add efficient query for users with cleanup enabled - log.warn('Processing all users not yet implemented efficiently. Use userId parameter for single user cleanup.') + const prefs = await userPreferences.getByUser(userId) + if (!prefs?.preferences?.cleanup?.enabled) { + return respond.success(res, { ...results, message: 'Cleanup not enabled for this user' }) } - // If userId provided, process that user - if (userId) { - const prefs = await userPreferences.getByUser(userId) - if (!prefs?.preferences?.cleanup?.enabled) { - return respond.success(res, { ...results, message: 'Cleanup not enabled for this user' }) - } + const accounts = await emailAccounts.getByUser(userId) + if (!accounts || accounts.length === 0) { + return respond.success(res, { ...results, message: 'No email accounts found' }) + } - const accounts = await emailAccounts.getByUser(userId) - if (!accounts || accounts.length === 0) { - return respond.success(res, { ...results, message: 'No email accounts found' }) - } + for (const account of accounts) { + if (!account.isActive || !account.accessToken) continue - for (const account of accounts) { - if (!account.isActive || !account.accessToken) continue + try { + const cleanup = prefs.preferences.cleanup - try { - const cleanup = prefs.preferences.cleanup - - // Read Items Cleanup - if (cleanup.readItems?.enabled) { - const readItemsCount = await processReadItemsCleanup( - account, - cleanup.readItems.action, - cleanup.readItems.gracePeriodDays - ) - results.emailsProcessed.readItems += readItemsCount - } - - // Promotion Cleanup - if (cleanup.promotions?.enabled) { - const promotionsCount = await processPromotionsCleanup( - account, - cleanup.promotions.action, - cleanup.promotions.deleteAfterDays, - cleanup.promotions.matchCategoriesOrLabels || [] - ) - results.emailsProcessed.promotions += promotionsCount - } - - results.usersProcessed = 1 - } catch (error) { - log.error(`Cleanup failed for account ${account.email}`, { error: error.message }) - results.errors.push({ userId, accountId: account.id, error: error.message }) + if (cleanup.readItems?.enabled) { + const readItemsCount = await processReadItemsCleanup( + account, + cleanup.readItems.action, + cleanup.readItems.gracePeriodDays + ) + results.emailsProcessed.readItems += readItemsCount } + + if (cleanup.promotions?.enabled) { + const promotionsCount = await processPromotionsCleanup( + account, + cleanup.promotions.action, + cleanup.promotions.deleteAfterDays, + cleanup.promotions.matchCategoriesOrLabels || [] + ) + results.emailsProcessed.promotions += promotionsCount + } + + results.usersProcessed = 1 + } catch (error) { + log.error(`Cleanup failed for account ${account.email}`, { error: error.message }) + results.errors.push({ userId, accountId: account.$id, error: error.message }) } } + const lastRun = new Date().toISOString() + await userPreferences.upsert(userId, { + cleanupMeta: { + lastRun, + lastRunCounts: { + readItems: results.emailsProcessed.readItems, + promotions: results.emailsProcessed.promotions, + }, + lastErrors: results.errors.map((e) => e.error), + }, + }) + log.success('Cleanup job completed', results) respond.success(res, results, 'Cleanup completed') } catch (error) { @@ -1607,4 +1648,98 @@ async function processPromotionsCleanup(account, action, deleteAfterDays, matchC return processedCount } +async function listReadCleanupPreviewMessages(account, gracePeriodDays, cap) { + const out = [] + const cutoffDate = new Date() + cutoffDate.setDate(cutoffDate.getDate() - gracePeriodDays) + const before = `${cutoffDate.getFullYear()}/${String(cutoffDate.getMonth() + 1).padStart(2, '0')}/${String(cutoffDate.getDate()).padStart(2, '0')}` + + try { + if (account.provider === 'gmail') { + const gmail = await getGmailService(account.accessToken, account.refreshToken) + const query = `-is:unread before:${before}` + const response = await gmail.gmail.users.messages.list({ + userId: 'me', + q: query, + maxResults: Math.min(cap, 500), + }) + const ids = (response.data.messages || []).map((m) => m.id).slice(0, cap) + const emails = await gmail.batchGetEmails(ids) + for (const email of emails) { + out.push({ + id: email.id, + subject: email.headers?.subject || '', + from: email.headers?.from || '', + date: email.headers?.date || email.internalDate || '', + }) + } + } else if (account.provider === 'outlook') { + const outlook = await getOutlookService(account.accessToken) + const filter = `isRead eq true and receivedDateTime lt ${cutoffDate.toISOString()}` + const data = await outlook._request(`/me/messages?$filter=${encodeURIComponent(filter)}&$top=${Math.min(cap, 500)}`) + for (const message of data.value || []) { + out.push({ + id: message.id, + subject: message.subject || '', + from: message.from?.emailAddress?.address || '', + date: message.receivedDateTime || '', + }) + if (out.length >= cap) break + } + } + } catch (err) { + log.warn('listReadCleanupPreviewMessages failed', { error: err.message }) + } + return out.slice(0, cap) +} + +async function listPromotionCleanupPreviewMessages(account, deleteAfterDays, matchCategories, cap) { + const out = [] + const cutoffDate = new Date() + cutoffDate.setDate(cutoffDate.getDate() - deleteAfterDays) + const before = `${cutoffDate.getFullYear()}/${String(cutoffDate.getMonth() + 1).padStart(2, '0')}/${String(cutoffDate.getDate()).padStart(2, '0')}` + + try { + if (account.provider === 'gmail' && matchCategories.length > 0) { + const gmail = await getGmailService(account.accessToken, account.refreshToken) + const labelQueries = matchCategories.map((cat) => `label:MailFlow/${cat}`).join(' OR ') + const query = `(${labelQueries}) before:${before}` + const response = await gmail.gmail.users.messages.list({ + userId: 'me', + q: query, + maxResults: Math.min(cap, 500), + }) + const ids = (response.data.messages || []).map((m) => m.id).slice(0, cap) + const emails = await gmail.batchGetEmails(ids) + for (const email of emails) { + out.push({ + id: email.id, + subject: email.headers?.subject || '', + from: email.headers?.from || '', + date: email.headers?.date || email.internalDate || '', + }) + } + } else if (account.provider === 'outlook' && cap > 0) { + const outlook = await getOutlookService(account.accessToken) + const filter = `receivedDateTime lt ${cutoffDate.toISOString()}` + const data = await outlook._request(`/me/messages?$filter=${encodeURIComponent(filter)}&$top=${Math.min(cap, 500)}`) + for (const message of data.value || []) { + const cats = message.categories || [] + const match = matchCategories.length === 0 || cats.some((c) => matchCategories.includes(c)) + if (!match) continue + out.push({ + id: message.id, + subject: message.subject || '', + from: message.from?.emailAddress?.address || '', + date: message.receivedDateTime || '', + }) + if (out.length >= cap) break + } + } + } catch (err) { + log.warn('listPromotionCleanupPreviewMessages failed', { error: err.message }) + } + return out.slice(0, cap) +} + export default router diff --git a/server/routes/oauth.mjs b/server/routes/oauth.mjs index 493b693..b464dfd 100644 --- a/server/routes/oauth.mjs +++ b/server/routes/oauth.mjs @@ -6,14 +6,26 @@ import express from 'express' import { OAuth2Client } from 'google-auth-library' import { ConfidentialClientApplication } from '@azure/msal-node' -import { asyncHandler, ValidationError, AppError } from '../middleware/errorHandler.mjs' +import { asyncHandler, ValidationError, AppError, AuthorizationError } from '../middleware/errorHandler.mjs' import { respond } from '../utils/response.mjs' import { emailAccounts } from '../services/database.mjs' import { config, features } from '../config/index.mjs' import { log } from '../middleware/logger.mjs' +import { requireAuth } from '../middleware/auth.mjs' +import { buildOAuthState, parseOAuthState } from '../utils/oauth-state.mjs' const router = express.Router() +function requireAuthUnlessOAuthPublic(req, res, next) { + const p = req.path || '' + if (['/gmail/callback', '/outlook/callback', '/status'].includes(p)) { + return next() + } + return requireAuth(req, res, next) +} + +router.use(requireAuthUnlessOAuthPublic) + // Google OAuth client (lazy initialization) let googleClient = null @@ -71,12 +83,6 @@ const OUTLOOK_SCOPES = [ * Initiate Gmail OAuth flow */ router.get('/gmail/connect', asyncHandler(async (req, res) => { - const { userId } = req.query - - if (!userId) { - throw new ValidationError('userId ist erforderlich') - } - if (!features.gmail()) { throw new AppError('Gmail OAuth ist nicht konfiguriert', 503, 'FEATURE_DISABLED') } @@ -86,7 +92,7 @@ router.get('/gmail/connect', asyncHandler(async (req, res) => { access_type: 'offline', scope: GMAIL_SCOPES, prompt: 'consent', - state: JSON.stringify({ userId }), + state: buildOAuthState(req.appwriteUser.id), include_granted_scopes: true, }) @@ -118,10 +124,10 @@ router.get('/gmail/callback', asyncHandler(async (req, res) => { let userId try { - const stateData = JSON.parse(state) + const stateData = parseOAuthState(state) userId = stateData.userId } catch (e) { - log.error('Gmail OAuth: State konnte nicht geparst werden', { state }) + log.error('Gmail OAuth: State konnte nicht geparst werden', { state, error: e.message }) return res.redirect(`${config.frontendUrl}/settings?error=invalid_state`) } @@ -214,6 +220,10 @@ router.post('/gmail/refresh', asyncHandler(async (req, res) => { const account = await emailAccounts.get(accountId) + if (account.userId !== req.appwriteUser.id) { + throw new AuthorizationError('No permission for this account') + } + if (account.provider !== 'gmail') { throw new ValidationError('Kein Gmail-Konto') } @@ -249,12 +259,6 @@ router.post('/gmail/refresh', asyncHandler(async (req, res) => { * Initiate Outlook OAuth flow */ router.get('/outlook/connect', asyncHandler(async (req, res) => { - const { userId } = req.query - - if (!userId) { - throw new ValidationError('userId ist erforderlich') - } - if (!features.outlook()) { throw new AppError('Outlook OAuth ist nicht konfiguriert', 503, 'FEATURE_DISABLED') } @@ -263,7 +267,7 @@ router.get('/outlook/connect', asyncHandler(async (req, res) => { const authUrl = await client.getAuthCodeUrl({ scopes: OUTLOOK_SCOPES, redirectUri: config.microsoft.redirectUri, - state: JSON.stringify({ userId }), + state: buildOAuthState(req.appwriteUser.id), prompt: 'select_account', }) @@ -286,7 +290,14 @@ router.get('/outlook/callback', asyncHandler(async (req, res) => { throw new ValidationError('Code und State sind erforderlich') } - const { userId } = JSON.parse(state) + let userId + try { + userId = parseOAuthState(state).userId + } catch (e) { + log.error('Outlook OAuth: invalid state', { error: e.message }) + return respond.redirect(res, `${config.frontendUrl}/settings?error=invalid_state`) + } + const client = getMsalClient() // Exchange code for tokens @@ -334,6 +345,10 @@ router.post('/outlook/refresh', asyncHandler(async (req, res) => { const account = await emailAccounts.get(accountId) + if (account.userId !== req.appwriteUser.id) { + throw new AuthorizationError('No permission for this account') + } + if (account.provider !== 'outlook') { throw new ValidationError('Kein Outlook-Konto') } diff --git a/server/routes/stripe.mjs b/server/routes/stripe.mjs index eb6e8ca..bf8a114 100644 --- a/server/routes/stripe.mjs +++ b/server/routes/stripe.mjs @@ -5,6 +5,7 @@ import express from 'express' import Stripe from 'stripe' +import { Client, Users } from 'node-appwrite' import { asyncHandler, ValidationError, NotFoundError } from '../middleware/errorHandler.mjs' import { validate, rules } from '../middleware/validate.mjs' import { limiters } from '../middleware/rateLimit.mjs' @@ -12,13 +13,55 @@ import { respond } from '../utils/response.mjs' import { subscriptions, submissions } from '../services/database.mjs' import { config } from '../config/index.mjs' import { log } from '../middleware/logger.mjs' +import { requireAuth } from '../middleware/auth.mjs' +import { loadEmailTemplate, renderTemplate, sendPlainEmail } from '../utils/mailer.mjs' const router = express.Router() + +async function resolveUserEmail(userId, stripeCustomerId) { + if (userId) { + try { + const c = new Client() + .setEndpoint(config.appwrite.endpoint) + .setProject(config.appwrite.projectId) + .setKey(config.appwrite.apiKey) + const u = await new Users(c).get(userId) + if (u.email) return u.email + } catch (e) { + log.warn('Appwrite Users.get failed', { userId, error: e.message }) + } + } + if (stripeCustomerId) { + try { + const cust = await stripe.customers.retrieve(String(stripeCustomerId)) + if (cust && !cust.deleted && cust.email) return cust.email + } catch (e) { + log.warn('Stripe customer retrieve failed', { error: e.message }) + } + } + return null +} const stripe = new Stripe(config.stripe.secretKey) +function requireAuthUnlessStripeWebhook(req, res, next) { + if (req.path === '/webhook' && req.method === 'POST') { + return next() + } + return requireAuth(req, res, next) +} + +router.use(requireAuthUnlessStripeWebhook) + /** * Plan configuration */ +const PLAN_DISPLAY_NAMES = { + basic: 'Basic', + pro: 'Pro', + business: 'Business', + free: 'Free', +} + const PLANS = { basic: { name: 'Basic', @@ -63,12 +106,12 @@ router.post('/checkout', limiters.auth, validate({ body: { - userId: [rules.required('userId')], plan: [rules.required('plan'), rules.isIn('plan', ['basic', 'pro', 'business'])], }, }), asyncHandler(async (req, res) => { - const { userId, plan, email } = req.body + const userId = req.appwriteUser.id + const { plan, email } = req.body const planConfig = PLANS[plan] if (!planConfig) { @@ -76,7 +119,7 @@ router.post('/checkout', } // Check for existing subscription - const existing = await subscriptions.getByUser(userId) + const existing = await subscriptions.getByUser(userId, req.appwriteUser?.email) let customerId = existing?.stripeCustomerId // Create checkout session @@ -124,31 +167,26 @@ router.post('/checkout', * Get user's subscription status */ router.get('/status', asyncHandler(async (req, res) => { - const { userId } = req.query + const userId = req.appwriteUser.id - if (!userId) { - throw new ValidationError('userId ist erforderlich') - } - - const sub = await subscriptions.getByUser(userId) - - if (!sub) { - // No subscription - return trial info - return respond.success(res, { - status: 'trial', - plan: 'pro', - features: PLANS.pro.features, - trialEndsAt: null, // Would calculate from user creation date - cancelAtPeriodEnd: false, - }) - } + const sub = await subscriptions.getByUser(userId, req.appwriteUser?.email) + const topKey = config.topSubscriptionPlan + const plan = sub.plan || topKey + const features = + PLANS[plan]?.features || + PLANS[topKey]?.features || + PLANS.business.features respond.success(res, { - status: sub.status, - plan: sub.plan, - features: PLANS[sub.plan]?.features || PLANS.basic.features, + status: sub.status || 'active', + plan, + planDisplayName: PLAN_DISPLAY_NAMES[plan] || PLAN_DISPLAY_NAMES[topKey] || 'Business', + isFreeTier: Boolean(sub.isFreeTier), + emailsUsedThisMonth: sub.emailsUsedThisMonth ?? 0, + emailsLimit: sub.emailsLimit ?? -1, + features, currentPeriodEnd: sub.currentPeriodEnd, - cancelAtPeriodEnd: sub.cancelAtPeriodEnd || false, + cancelAtPeriodEnd: Boolean(sub.cancelAtPeriodEnd), }) })) @@ -157,15 +195,10 @@ router.get('/status', asyncHandler(async (req, res) => { * Create Stripe Customer Portal session */ router.post('/portal', - validate({ - body: { - userId: [rules.required('userId')], - }, - }), asyncHandler(async (req, res) => { - const { userId } = req.body + const userId = req.appwriteUser.id - const sub = await subscriptions.getByUser(userId) + const sub = await subscriptions.getByUser(userId, req.appwriteUser?.email) if (!sub?.stripeCustomerId) { throw new NotFoundError('Subscription') @@ -185,15 +218,10 @@ router.post('/portal', * Cancel subscription at period end */ router.post('/cancel', - validate({ - body: { - userId: [rules.required('userId')], - }, - }), asyncHandler(async (req, res) => { - const { userId } = req.body + const userId = req.appwriteUser.id - const sub = await subscriptions.getByUser(userId) + const sub = await subscriptions.getByUser(userId, req.appwriteUser?.email) if (!sub?.stripeSubscriptionId) { throw new NotFoundError('Subscription') @@ -216,15 +244,10 @@ router.post('/cancel', * Reactivate cancelled subscription */ router.post('/reactivate', - validate({ - body: { - userId: [rules.required('userId')], - }, - }), asyncHandler(async (req, res) => { - const { userId } = req.body + const userId = req.appwriteUser.id - const sub = await subscriptions.getByUser(userId) + const sub = await subscriptions.getByUser(userId, req.appwriteUser?.email) if (!sub?.stripeSubscriptionId) { throw new NotFoundError('Subscription') @@ -304,6 +327,29 @@ router.post('/webhook', express.raw({ type: 'application/json' }), asyncHandler( }) log.info(`Subscription aktualisiert: ${subscription.id}`) + + try { + const to = await resolveUserEmail(sub.userId, subscription.customer) + if (to) { + const plan = subscription.metadata?.plan || sub.plan || 'current' + const periodEnd = subscription.current_period_end + ? new Date(subscription.current_period_end * 1000).toISOString() + : '' + const tpl = loadEmailTemplate('subscription-updated') + const text = renderTemplate(tpl, { + plan: String(plan), + status: String(subscription.status || ''), + periodEndLine: periodEnd ? `Current period ends: ${periodEnd}` : '', + }) + await sendPlainEmail({ + to, + subject: 'MailFlow — Subscription updated', + text, + }) + } + } catch (e) { + log.warn('subscription.updated email skipped', { error: e.message }) + } } break } @@ -318,6 +364,23 @@ router.post('/webhook', express.raw({ type: 'application/json' }), asyncHandler( }) log.info(`Subscription gelöscht: ${subscription.id}`) + + try { + const to = await resolveUserEmail(sub.userId, subscription.customer) + if (to) { + const tpl = loadEmailTemplate('subscription-ended') + const text = renderTemplate(tpl, { + endedDate: new Date().toISOString(), + }) + await sendPlainEmail({ + to, + subject: 'MailFlow — Your subscription has ended', + text, + }) + } + } catch (e) { + log.warn('subscription.deleted email skipped', { error: e.message }) + } } break } @@ -327,7 +390,27 @@ router.post('/webhook', express.raw({ type: 'application/json' }), asyncHandler( log.warn(`Zahlung fehlgeschlagen: ${invoice.id}`, { customer: invoice.customer, }) - // TODO: Send notification email + try { + let metaUserId + if (invoice.subscription) { + const subStripe = await stripe.subscriptions.retrieve(invoice.subscription) + metaUserId = subStripe.metadata?.userId + } + const to = await resolveUserEmail(metaUserId, invoice.customer) + if (to) { + const tpl = loadEmailTemplate('payment-failed') + const text = renderTemplate(tpl, { + invoiceId: String(invoice.id || ''), + }) + await sendPlainEmail({ + to, + subject: 'MailFlow — Payment failed, please update billing', + text, + }) + } + } catch (e) { + log.warn('invoice.payment_failed email skipped', { error: e.message }) + } break } diff --git a/server/services/database.mjs b/server/services/database.mjs index b524e6c..15b39c0 100644 --- a/server/services/database.mjs +++ b/server/services/database.mjs @@ -4,7 +4,7 @@ */ import { Client, Databases, Query, ID } from 'node-appwrite' -import { config } from '../config/index.mjs' +import { config, isAdmin } from '../config/index.mjs' import { NotFoundError } from '../middleware/errorHandler.mjs' // Initialize Appwrite client @@ -236,22 +236,26 @@ export const emailStats = { }, async resetDaily() { - // Reset daily counters - would be called by a cron job const allStats = await db.list(Collections.EMAIL_STATS, []) + let n = 0 for (const stat of allStats) { await db.update(Collections.EMAIL_STATS, stat.$id, { todaySorted: 0 }) + n++ } + return n }, async resetWeekly() { - // Reset weekly counters - would be called by a cron job const allStats = await db.list(Collections.EMAIL_STATS, []) + let n = 0 for (const stat of allStats) { - await db.update(Collections.EMAIL_STATS, stat.$id, { + await db.update(Collections.EMAIL_STATS, stat.$id, { weekSorted: 0, categoriesJson: '{}', }) + n++ } + return n }, } @@ -299,42 +303,60 @@ export const emailUsage = { * Subscriptions operations */ export const subscriptions = { - async getByUser(userId) { + /** + * @param {string} userId + * @param {string|null} [viewerEmail] - if set and isAdmin(email), effective plan is business (highest tier) + */ + async getByUser(userId, viewerEmail = null) { const subscription = await db.findOne(Collections.SUBSCRIPTIONS, [Query.equal('userId', userId)]) - + + let result + // If no subscription, user is on free tier if (!subscription) { const usage = await emailUsage.getUsage(userId) - return { + result = { plan: 'free', status: 'active', isFreeTier: true, emailsUsedThisMonth: usage.emailsProcessed, emailsLimit: 500, // From config } + } else { + // Check if subscription is active + const isActive = subscription.status === 'active' + const isFreeTier = !isActive || subscription.plan === 'free' + + // Get usage for free tier users + let emailsUsedThisMonth = 0 + let emailsLimit = -1 // Unlimited for paid + + if (isFreeTier) { + const usage = await emailUsage.getUsage(userId) + emailsUsedThisMonth = usage.emailsProcessed + emailsLimit = 500 // From config + } + + result = { + ...subscription, + plan: subscription.plan || 'free', + isFreeTier, + emailsUsedThisMonth, + emailsLimit, + } } - // Check if subscription is active - const isActive = subscription.status === 'active' - const isFreeTier = !isActive || subscription.plan === 'free' - - // Get usage for free tier users - let emailsUsedThisMonth = 0 - let emailsLimit = -1 // Unlimited for paid - - if (isFreeTier) { - const usage = await emailUsage.getUsage(userId) - emailsUsedThisMonth = usage.emailsProcessed - emailsLimit = 500 // From config + if (viewerEmail && isAdmin(viewerEmail)) { + return { + ...result, + plan: config.topSubscriptionPlan, + status: 'active', + isFreeTier: false, + emailsLimit: -1, + } } - return { - ...subscription, - plan: subscription.plan || 'free', - isFreeTier, - emailsUsedThisMonth, - emailsLimit, - } + return result }, async getByStripeId(stripeSubscriptionId) { @@ -352,8 +374,8 @@ export const subscriptions = { }, async upsertByUser(userId, data) { - const existing = await this.getByUser(userId) - if (existing) { + const existing = await db.findOne(Collections.SUBSCRIPTIONS, [Query.equal('userId', userId)]) + if (existing?.$id) { return this.update(existing.$id, data) } return this.create({ userId, ...data }) @@ -377,6 +399,12 @@ export const userPreferences = { autoDetectCompanies: true, version: 1, categoryAdvanced: {}, + profile: { + displayName: '', + timezone: '', + notificationPrefs: {}, + }, + cleanupMeta: {}, cleanup: { enabled: false, readItems: { @@ -413,6 +441,9 @@ export const userPreferences = { companyLabels: preferences.companyLabels || defaults.companyLabels, nameLabels: preferences.nameLabels || defaults.nameLabels, autoDetectCompanies: preferences.autoDetectCompanies !== undefined ? preferences.autoDetectCompanies : defaults.autoDetectCompanies, + profile: preferences.profile != null ? { ...defaults.profile, ...preferences.profile } : defaults.profile, + cleanupMeta: + preferences.cleanupMeta !== undefined ? preferences.cleanupMeta : defaults.cleanupMeta, } }, diff --git a/server/utils/crypto.mjs b/server/utils/crypto.mjs new file mode 100644 index 0000000..3a8f7ea --- /dev/null +++ b/server/utils/crypto.mjs @@ -0,0 +1,74 @@ +/** + * AES-256-GCM for IMAP passwords. ENCRYPTION_KEY = 64 hex chars (32 bytes). + * Legacy: if decrypt fails or key missing, value treated as plaintext. + */ + +import crypto from 'crypto' + +const ALGO = 'aes-256-gcm' +const IV_LEN = 16 +const AUTH_TAG_LEN = 16 + +function getKeyBuffer() { + const hex = process.env.ENCRYPTION_KEY || '' + if (hex.length !== 64) { + throw new Error('ENCRYPTION_KEY must be 64 hex characters (32 bytes). Generate: openssl rand -hex 32') + } + return Buffer.from(hex, 'hex') +} + +export function encrypt(text) { + if (text == null || text === '') return '' + const key = getKeyBuffer() + const iv = crypto.randomBytes(IV_LEN) + const cipher = crypto.createCipheriv(ALGO, key, iv, { authTagLength: AUTH_TAG_LEN }) + const enc = Buffer.concat([cipher.update(String(text), 'utf8'), cipher.final()]) + const authTag = cipher.getAuthTag() + const combined = Buffer.concat([iv, authTag, enc]) + return combined.toString('base64url') +} + +export function decrypt(encoded) { + if (!encoded) return '' + const buf = Buffer.from(String(encoded), 'base64url') + if (buf.length < IV_LEN + AUTH_TAG_LEN + 1) { + throw new Error('invalid ciphertext') + } + const key = getKeyBuffer() + const iv = buf.subarray(0, IV_LEN) + const authTag = buf.subarray(IV_LEN, IV_LEN + AUTH_TAG_LEN) + const data = buf.subarray(IV_LEN + AUTH_TAG_LEN) + const decipher = crypto.createDecipheriv(ALGO, key, iv, { authTagLength: AUTH_TAG_LEN }) + decipher.setAuthTag(authTag) + return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8') +} + +/** Encrypt IMAP password when ENCRYPTION_KEY is set; otherwise store plaintext. */ +export function encryptImapSecret(plain) { + if (plain == null || plain === '') return '' + if (!process.env.ENCRYPTION_KEY) return String(plain) + try { + return encrypt(plain) + } catch (e) { + logWarnOnce('encryptImapSecret', e.message) + return String(plain) + } +} + +/** Decrypt IMAP secret; on failure return as plaintext (legacy). */ +export function decryptImapSecret(stored) { + if (stored == null || stored === '') return '' + if (!process.env.ENCRYPTION_KEY) return String(stored) + try { + return decrypt(stored) + } catch { + return String(stored) + } +} + +let warnedEncrypt = false +function logWarnOnce(tag, msg) { + if (warnedEncrypt) return + warnedEncrypt = true + console.warn(`[crypto] ${tag}: ${msg}`) +} diff --git a/server/utils/mailer.mjs b/server/utils/mailer.mjs new file mode 100644 index 0000000..8361e7f --- /dev/null +++ b/server/utils/mailer.mjs @@ -0,0 +1,68 @@ +/** + * Plain SMTP mailer (nodemailer). Optional: if SMTP not configured, send is a no-op. + */ + +import nodemailer from 'nodemailer' +import { readFileSync } from 'fs' +import { join, dirname } from 'path' +import { fileURLToPath } from 'url' +import { log } from '../middleware/logger.mjs' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +let transporter = null + +function getTransporter() { + const host = process.env.SMTP_HOST + const user = process.env.SMTP_USER + const pass = process.env.SMTP_PASS + if (!host || !user || !pass) { + return null + } + if (!transporter) { + transporter = nodemailer.createTransport({ + host, + port: parseInt(process.env.SMTP_PORT || '587', 10), + secure: process.env.SMTP_SECURE === 'true', + auth: { user, pass }, + }) + } + return transporter +} + +export function renderTemplate(text, vars) { + let out = text + for (const [k, v] of Object.entries(vars || {})) { + out = out.split(`{{${k}}}`).join(v != null ? String(v) : '') + } + return out +} + +export function loadEmailTemplate(name) { + const path = join(__dirname, '..', 'emails', `${name}.txt`) + return readFileSync(path, 'utf8') +} + +/** + * Send plain-text email. Returns false if SMTP not configured or send failed (logged). + */ +export async function sendPlainEmail({ to, subject, text }) { + const from = process.env.SMTP_FROM || process.env.SMTP_USER + if (!to || !subject || !text) { + log.warn('sendPlainEmail: missing to/subject/text') + return false + } + const tx = getTransporter() + if (!tx) { + log.warn('SMTP not configured (SMTP_HOST/SMTP_USER/SMTP_PASS); email skipped') + return false + } + try { + await tx.sendMail({ from, to, subject, text }) + log.info(`Email sent to ${to}: ${subject}`) + return true + } catch (e) { + log.error('sendPlainEmail failed', { error: e.message, to }) + return false + } +} diff --git a/server/utils/oauth-state.mjs b/server/utils/oauth-state.mjs new file mode 100644 index 0000000..270754f --- /dev/null +++ b/server/utils/oauth-state.mjs @@ -0,0 +1,44 @@ +/** + * Signed OAuth state (userId) to prevent tampering when OAUTH_STATE_SECRET is set. + */ + +import crypto from 'crypto' +import { config } from '../config/index.mjs' + +export function buildOAuthState(userId) { + const secret = config.oauthStateSecret + if (!secret) { + return JSON.stringify({ userId }) + } + const body = JSON.stringify({ userId, exp: Date.now() + 15 * 60 * 1000 }) + const sig = crypto.createHmac('sha256', secret).update(body).digest('hex') + return Buffer.from(JSON.stringify({ b: body, s: sig })).toString('base64url') +} + +export function parseOAuthState(state) { + if (!state || typeof state !== 'string') { + throw new Error('invalid_state') + } + const trimmed = state.trim() + const secret = config.oauthStateSecret + + if (trimmed.startsWith('{')) { + const legacy = JSON.parse(trimmed) + if (!legacy.userId) throw new Error('invalid_state') + if (secret) { + throw new Error('unsigned_state_rejected') + } + return { userId: legacy.userId } + } + + const raw = Buffer.from(trimmed, 'base64url').toString('utf8') + const outer = JSON.parse(raw) + if (!outer.b || !outer.s) throw new Error('invalid_state') + if (!secret) throw new Error('signed_state_requires_secret') + const expected = crypto.createHmac('sha256', secret).update(outer.b).digest('hex') + if (outer.s !== expected) throw new Error('invalid_state_signature') + const payload = JSON.parse(outer.b) + if (payload.exp != null && payload.exp < Date.now()) throw new Error('state_expired') + if (!payload.userId) throw new Error('invalid_state') + return { userId: payload.userId } +}