fix(dev): Vite-API-Proxy, Auth, Stripe-Mails und Backend-Erweiterungen
- Client: API-Basis-URL (joinApiUrl, /v1-Falle), Vite strictPort + Proxy 127.0.0.1, Nicht-JSON-Fehler - Server: /api-404 ohne Wildcard-Bug, SPA-Fallback, Auth-Middleware, Cron, Mailer, Crypto - Routen: OAuth-State, Email/Stripe/Analytics; client/.env.example Made-with: Cursor
This commit is contained in:
46
.env.example
46
.env.example
@@ -1,9 +1,16 @@
|
|||||||
# Appwrite Configuration
|
# Appwrite Configuration (Express / node-appwrite)
|
||||||
APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
|
APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
|
||||||
APPWRITE_PROJECT_ID=your_project_id_here
|
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_API_KEY=your_api_key_here
|
||||||
APPWRITE_DATABASE_ID=your_database_id_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)
|
# Database Configuration (for bootstrap script)
|
||||||
DB_ID=your_database_id_here
|
DB_ID=your_database_id_here
|
||||||
DB_NAME=EmailSorter
|
DB_NAME=EmailSorter
|
||||||
@@ -23,6 +30,9 @@ PRODUCT_CURRENCY=eur
|
|||||||
# Stripe Configuration
|
# Stripe Configuration
|
||||||
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key_here
|
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key_here
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_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)
|
# Gitea Webhook (Deployment)
|
||||||
# Secret für X-Gitea-Signature und optional Bearer-Header (gleicher Wert möglich)
|
# 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
|
# Server Configuration
|
||||||
PORT=3000
|
PORT=3000
|
||||||
BASE_URL=http://localhost: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
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
# Environment variables
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
server/.env
|
server/.env
|
||||||
|
client/.env
|
||||||
|
|
||||||
# Node modules
|
# Node modules
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
25
client/.env.example
Normal file
25
client/.env.example
Normal file
@@ -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=
|
||||||
4
client/package-lock.json
generated
4
client/package-lock.json
generated
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "emailsorter-client",
|
"name": "mailflow-client",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "emailsorter-client",
|
"name": "mailflow-client",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
|
|||||||
@@ -1,8 +1,31 @@
|
|||||||
/* eslint-disable react-refresh/only-export-components */
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import React, { createContext, useContext, useEffect, useState } from 'react'
|
import React, { createContext, useContext, useEffect, useState } from 'react'
|
||||||
|
import { AppwriteException } from 'appwrite'
|
||||||
import { auth } from '@/lib/appwrite'
|
import { auth } from '@/lib/appwrite'
|
||||||
import type { Models } from '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 {
|
interface AuthContextType {
|
||||||
user: Models.User<Models.Preferences> | null
|
user: Models.User<Models.Preferences> | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
@@ -36,13 +59,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const login = async (email: string, password: string) => {
|
const login = async (email: string, password: string) => {
|
||||||
await auth.login(email, password)
|
try {
|
||||||
await refreshUser()
|
await auth.login(email, password)
|
||||||
|
await refreshUser()
|
||||||
|
} catch (e) {
|
||||||
|
throw mapLoginError(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const register = async (email: string, password: string, name?: string) => {
|
const register = async (email: string, password: string, name?: string) => {
|
||||||
await auth.register(email, password, name)
|
try {
|
||||||
await refreshUser()
|
await auth.register(email, password, name)
|
||||||
|
await refreshUser()
|
||||||
|
} catch (e) {
|
||||||
|
throw mapLoginError(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getApiJwt } from './appwrite'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Analytics & Tracking Utility
|
* Analytics & Tracking Utility
|
||||||
* Handles UTM parameter tracking and event analytics
|
* Handles UTM parameter tracking and event analytics
|
||||||
@@ -162,17 +164,17 @@ export async function trackEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Send to your analytics endpoint
|
const jwt = await getApiJwt()
|
||||||
|
if (!jwt) return
|
||||||
|
|
||||||
await fetch('/api/analytics/track', {
|
await fetch('/api/analytics/track', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${jwt}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
}).catch(() => {
|
}).catch(() => {})
|
||||||
// Silently fail if analytics endpoint doesn't exist yet
|
|
||||||
// This allows graceful degradation
|
|
||||||
})
|
|
||||||
|
|
||||||
// Also log to console in development
|
// Also log to console in development
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
|
|||||||
@@ -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<T> {
|
interface ApiResponse<T> {
|
||||||
success?: boolean
|
success?: boolean
|
||||||
@@ -17,32 +75,58 @@ async function fetchApi<T>(
|
|||||||
options?: RequestInit
|
options?: RequestInit
|
||||||
): Promise<ApiResponse<T>> {
|
): Promise<ApiResponse<T>> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
const headers: Record<string, string> = {
|
||||||
|
...(options?.headers as Record<string, string>),
|
||||||
|
}
|
||||||
|
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,
|
...options,
|
||||||
headers: {
|
headers,
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...options?.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) {
|
if (!isJson) {
|
||||||
return {
|
return {
|
||||||
error: data.error || {
|
error: {
|
||||||
code: 'UNKNOWN',
|
code: response.status === 404 ? 'NOT_FOUND' : 'INVALID_RESPONSE',
|
||||||
message: `HTTP ${response.status}`
|
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) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
error: {
|
error: {
|
||||||
code: 'NETWORK_ERROR',
|
code: 'NETWORK_ERROR',
|
||||||
message: error instanceof Error ? error.message : 'Network error'
|
message: error instanceof Error ? error.message : 'Network error',
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,32 +135,34 @@ export const api = {
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
// EMAIL ACCOUNTS
|
// EMAIL ACCOUNTS
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
async getEmailAccounts(userId: string) {
|
async getEmailAccounts() {
|
||||||
return fetchApi<Array<{
|
return fetchApi<Array<{
|
||||||
id: string
|
id: string
|
||||||
email: string
|
email: string
|
||||||
provider: 'gmail' | 'outlook' | 'imap'
|
provider: 'gmail' | 'outlook' | 'imap'
|
||||||
connected: boolean
|
connected: boolean
|
||||||
lastSync?: string
|
lastSync?: string
|
||||||
}>>(`/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', {
|
return fetchApi<{ accountId: string }>('/email/connect', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ userId, provider, email, accessToken, refreshToken }),
|
body: JSON.stringify({ provider, email, accessToken, refreshToken }),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
async connectImapAccount(
|
async connectImapAccount(params: {
|
||||||
userId: string,
|
email: string
|
||||||
params: { email: string; password: string; imapHost?: string; imapPort?: number; imapSecure?: boolean }
|
password: string
|
||||||
) {
|
imapHost?: string
|
||||||
|
imapPort?: number
|
||||||
|
imapSecure?: boolean
|
||||||
|
}) {
|
||||||
return fetchApi<{ accountId: string }>('/email/connect', {
|
return fetchApi<{ accountId: string }>('/email/connect', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
userId,
|
|
||||||
provider: 'imap',
|
provider: 'imap',
|
||||||
email: params.email,
|
email: params.email,
|
||||||
accessToken: params.password,
|
accessToken: params.password,
|
||||||
@@ -87,8 +173,8 @@ export const api = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
async disconnectEmailAccount(accountId: string, userId: string) {
|
async disconnectEmailAccount(accountId: string) {
|
||||||
return fetchApi<{ success: boolean }>(`/email/accounts/${accountId}?userId=${userId}`, {
|
return fetchApi<{ success: boolean }>(`/email/accounts/${accountId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -97,17 +183,17 @@ export const api = {
|
|||||||
// EMAIL STATS & SORTING
|
// EMAIL STATS & SORTING
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
async getEmailStats(userId: string) {
|
async getEmailStats() {
|
||||||
return fetchApi<{
|
return fetchApi<{
|
||||||
totalSorted: number
|
totalSorted: number
|
||||||
todaySorted: number
|
todaySorted: number
|
||||||
weekSorted: number
|
weekSorted: number
|
||||||
categories: Record<string, number>
|
categories: Record<string, number>
|
||||||
timeSaved: number
|
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<{
|
return fetchApi<{
|
||||||
sorted: number
|
sorted: number
|
||||||
inboxCleared: number
|
inboxCleared: number
|
||||||
@@ -127,11 +213,10 @@ export const api = {
|
|||||||
}>
|
}>
|
||||||
}>('/email/sort', {
|
}>('/email/sort', {
|
||||||
method: 'POST',
|
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) {
|
async sortDemo(count: number = 10) {
|
||||||
return fetchApi<{
|
return fetchApi<{
|
||||||
sorted: number
|
sorted: number
|
||||||
@@ -152,8 +237,7 @@ export const api = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
// Connect demo account
|
async connectDemoAccount() {
|
||||||
async connectDemoAccount(userId: string) {
|
|
||||||
return fetchApi<{
|
return fetchApi<{
|
||||||
accountId: string
|
accountId: string
|
||||||
email: string
|
email: string
|
||||||
@@ -161,11 +245,10 @@ export const api = {
|
|||||||
message?: string
|
message?: string
|
||||||
}>('/email/connect-demo', {
|
}>('/email/connect-demo', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ userId }),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get categories
|
|
||||||
async getCategories() {
|
async getCategories() {
|
||||||
return fetchApi<Array<{
|
return fetchApi<Array<{
|
||||||
id: string
|
id: string
|
||||||
@@ -177,8 +260,7 @@ export const api = {
|
|||||||
}>>('/email/categories')
|
}>>('/email/categories')
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get today's digest
|
async getDigest() {
|
||||||
async getDigest(userId: string) {
|
|
||||||
return fetchApi<{
|
return fetchApi<{
|
||||||
date: string
|
date: string
|
||||||
totalSorted: number
|
totalSorted: number
|
||||||
@@ -188,11 +270,10 @@ export const api = {
|
|||||||
highlights: Array<{ type: string; count: number; message: string }>
|
highlights: Array<{ type: string; count: number; message: string }>
|
||||||
suggestions: Array<{ type: string; message: string }>
|
suggestions: Array<{ type: string; message: string }>
|
||||||
hasData: boolean
|
hasData: boolean
|
||||||
}>(`/email/digest?userId=${userId}`)
|
}>('/email/digest')
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get digest history
|
async getDigestHistory(days: number = 7) {
|
||||||
async getDigestHistory(userId: string, days: number = 7) {
|
|
||||||
return fetchApi<{
|
return fetchApi<{
|
||||||
days: number
|
days: number
|
||||||
digests: Array<{
|
digests: Array<{
|
||||||
@@ -207,15 +288,15 @@ export const api = {
|
|||||||
inboxCleared: number
|
inboxCleared: number
|
||||||
timeSavedMinutes: number
|
timeSavedMinutes: number
|
||||||
}
|
}
|
||||||
}>(`/email/digest/history?userId=${userId}&days=${days}`)
|
}>(`/email/digest/history?days=${days}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
// OAUTH
|
// OAUTH
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
async getOAuthUrl(provider: 'gmail' | 'outlook', userId: string) {
|
async getOAuthUrl(provider: 'gmail' | 'outlook') {
|
||||||
return fetchApi<{ url: string }>(`/oauth/${provider}/connect?userId=${userId}`)
|
return fetchApi<{ url: string }>(`/oauth/${provider}/connect`)
|
||||||
},
|
},
|
||||||
|
|
||||||
async getOAuthStatus() {
|
async getOAuthStatus() {
|
||||||
@@ -229,10 +310,11 @@ export const api = {
|
|||||||
// SUBSCRIPTION
|
// SUBSCRIPTION
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
async getSubscriptionStatus(userId: string) {
|
async getSubscriptionStatus() {
|
||||||
return fetchApi<{
|
return fetchApi<{
|
||||||
status: string
|
status: string
|
||||||
plan: string
|
plan: string
|
||||||
|
planDisplayName?: string
|
||||||
isFreeTier: boolean
|
isFreeTier: boolean
|
||||||
emailsUsedThisMonth?: number
|
emailsUsedThisMonth?: number
|
||||||
emailsLimit?: number
|
emailsLimit?: number
|
||||||
@@ -245,34 +327,34 @@ export const api = {
|
|||||||
}
|
}
|
||||||
currentPeriodEnd?: string
|
currentPeriodEnd?: string
|
||||||
cancelAtPeriodEnd?: boolean
|
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', {
|
return fetchApi<{ url: string; sessionId: string }>('/subscription/checkout', {
|
||||||
method: 'POST',
|
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', {
|
return fetchApi<{ url: string }>('/subscription/portal', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ userId }),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
async cancelSubscription(userId: string) {
|
async cancelSubscription() {
|
||||||
return fetchApi<{ success: boolean }>('/subscription/cancel', {
|
return fetchApi<{ success: boolean }>('/subscription/cancel', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ userId }),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
async reactivateSubscription(userId: string) {
|
async reactivateSubscription() {
|
||||||
return fetchApi<{ success: boolean }>('/subscription/reactivate', {
|
return fetchApi<{ success: boolean }>('/subscription/reactivate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ userId }),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -280,16 +362,21 @@ export const api = {
|
|||||||
// USER PREFERENCES
|
// USER PREFERENCES
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
async getUserPreferences(userId: string) {
|
async getUserPreferences() {
|
||||||
return fetchApi<{
|
return fetchApi<{
|
||||||
vipSenders: Array<{ email: string; name?: string }>
|
vipSenders: Array<{ email: string; name?: string }>
|
||||||
blockedSenders: string[]
|
blockedSenders: string[]
|
||||||
customRules: Array<{ condition: string; category: string }>
|
customRules: Array<{ condition: string; category: string }>
|
||||||
priorityTopics: string[]
|
priorityTopics: string[]
|
||||||
}>(`/preferences?userId=${userId}`)
|
profile?: {
|
||||||
|
displayName?: string
|
||||||
|
timezone?: string
|
||||||
|
notificationPrefs?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
}>('/preferences')
|
||||||
},
|
},
|
||||||
|
|
||||||
async saveUserPreferences(userId: string, preferences: {
|
async saveUserPreferences(preferences: {
|
||||||
vipSenders?: Array<{ email: string; name?: string }>
|
vipSenders?: Array<{ email: string; name?: string }>
|
||||||
blockedSenders?: string[]
|
blockedSenders?: string[]
|
||||||
customRules?: Array<{ condition: string; category: string }>
|
customRules?: Array<{ condition: string; category: string }>
|
||||||
@@ -298,7 +385,7 @@ export const api = {
|
|||||||
}) {
|
}) {
|
||||||
return fetchApi<{ success: boolean }>('/preferences', {
|
return fetchApi<{ success: boolean }>('/preferences', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ userId, ...preferences }),
|
body: JSON.stringify(preferences),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -306,7 +393,7 @@ export const api = {
|
|||||||
// AI CONTROL
|
// AI CONTROL
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
async getAIControlSettings(userId: string) {
|
async getAIControlSettings() {
|
||||||
return fetchApi<{
|
return fetchApi<{
|
||||||
enabledCategories: string[]
|
enabledCategories: string[]
|
||||||
categoryActions: Record<string, 'inbox' | 'archive_read' | 'star'>
|
categoryActions: Record<string, 'inbox' | 'archive_read' | 'star'>
|
||||||
@@ -314,10 +401,10 @@ export const api = {
|
|||||||
cleanup?: unknown
|
cleanup?: unknown
|
||||||
categoryAdvanced?: Record<string, unknown>
|
categoryAdvanced?: Record<string, unknown>
|
||||||
version?: number
|
version?: number
|
||||||
}>(`/preferences/ai-control?userId=${userId}`)
|
}>('/preferences/ai-control')
|
||||||
},
|
},
|
||||||
|
|
||||||
async saveAIControlSettings(userId: string, settings: {
|
async saveAIControlSettings(settings: {
|
||||||
enabledCategories?: string[]
|
enabledCategories?: string[]
|
||||||
categoryActions?: Record<string, 'inbox' | 'archive_read' | 'star'>
|
categoryActions?: Record<string, 'inbox' | 'archive_read' | 'star'>
|
||||||
autoDetectCompanies?: boolean
|
autoDetectCompanies?: boolean
|
||||||
@@ -327,33 +414,24 @@ export const api = {
|
|||||||
}) {
|
}) {
|
||||||
return fetchApi<{ success: boolean }>('/preferences/ai-control', {
|
return fetchApi<{ success: boolean }>('/preferences/ai-control', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ userId, ...settings }),
|
body: JSON.stringify(settings),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
// Cleanup Preview - shows what would be cleaned up without actually doing it
|
async getCleanupPreview(accountId: string) {
|
||||||
// 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
|
|
||||||
return fetchApi<{
|
return fetchApi<{
|
||||||
preview: Array<{
|
messages: Array<{
|
||||||
id: string
|
id: string
|
||||||
subject: string
|
subject: string
|
||||||
from: string
|
from: string
|
||||||
date: string
|
date: string
|
||||||
reason: 'read' | 'promotion'
|
reason: 'read' | 'promotion'
|
||||||
}>
|
}>
|
||||||
}>(`/preferences/ai-control/cleanup/preview?userId=${userId}`)
|
count: number
|
||||||
|
}>(`/email/${accountId}/cleanup/preview`)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Run cleanup now - executes cleanup for the user
|
async runCleanup() {
|
||||||
// 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
|
|
||||||
return fetchApi<{
|
return fetchApi<{
|
||||||
usersProcessed: number
|
usersProcessed: number
|
||||||
emailsProcessed: {
|
emailsProcessed: {
|
||||||
@@ -363,40 +441,47 @@ export const api = {
|
|||||||
errors: Array<{ userId: string; error: string }>
|
errors: Array<{ userId: string; error: string }>
|
||||||
}>('/email/cleanup', {
|
}>('/email/cleanup', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ userId }),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get cleanup status - last run info and counts
|
async getCleanupStatus(accountId: string) {
|
||||||
// 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
|
|
||||||
return fetchApi<{
|
return fetchApi<{
|
||||||
lastRun?: string
|
lastRun?: string
|
||||||
lastRunCounts?: {
|
lastRunCounts?: {
|
||||||
readItems: number
|
readItems: number
|
||||||
promotions: 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<string, unknown>
|
||||||
|
}) {
|
||||||
|
return fetchApi<{ success: boolean }>('/preferences/profile', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
// COMPANY LABELS
|
// COMPANY LABELS
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
async getCompanyLabels(userId: string) {
|
async getCompanyLabels() {
|
||||||
return fetchApi<Array<{
|
return fetchApi<Array<{
|
||||||
id?: string
|
id?: string
|
||||||
name: string
|
name: string
|
||||||
condition: string
|
condition: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
category?: string
|
category?: string
|
||||||
}>>(`/preferences/company-labels?userId=${userId}`)
|
}>>('/preferences/company-labels')
|
||||||
},
|
},
|
||||||
|
|
||||||
async saveCompanyLabel(userId: string, companyLabel: {
|
async saveCompanyLabel(companyLabel: {
|
||||||
id?: string
|
id?: string
|
||||||
name: string
|
name: string
|
||||||
condition: string
|
condition: string
|
||||||
@@ -411,12 +496,12 @@ export const api = {
|
|||||||
category?: string
|
category?: string
|
||||||
}>('/preferences/company-labels', {
|
}>('/preferences/company-labels', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ userId, companyLabel }),
|
body: JSON.stringify({ companyLabel }),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteCompanyLabel(userId: string, labelId: string) {
|
async deleteCompanyLabel(labelId: string) {
|
||||||
return fetchApi<{ success: boolean }>(`/preferences/company-labels/${labelId}?userId=${userId}`, {
|
return fetchApi<{ success: boolean }>(`/preferences/company-labels/${labelId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -425,43 +510,36 @@ export const api = {
|
|||||||
// ME / ADMIN
|
// ME / ADMIN
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
async getMe(email: string) {
|
async getMe() {
|
||||||
return fetchApi<{ isAdmin: boolean }>(`/me?email=${encodeURIComponent(email)}`)
|
return fetchApi<{ isAdmin: boolean }>('/me')
|
||||||
},
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
// NAME LABELS (Workers – Admin only)
|
// NAME LABELS (Workers – Admin only)
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
async getNameLabels(userId: string, email: string) {
|
async getNameLabels() {
|
||||||
return fetchApi<Array<{
|
return fetchApi<Array<{
|
||||||
id?: string
|
id?: string
|
||||||
name: string
|
name: string
|
||||||
email?: string
|
email?: string
|
||||||
keywords?: string[]
|
keywords?: string[]
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
}>>(`/preferences/name-labels?userId=${userId}&email=${encodeURIComponent(email)}`)
|
}>>('/preferences/name-labels')
|
||||||
},
|
},
|
||||||
|
|
||||||
async saveNameLabel(
|
async saveNameLabel(nameLabel: { id?: string; name: string; email?: string; keywords?: string[]; enabled: boolean }) {
|
||||||
userId: string,
|
|
||||||
userEmail: string,
|
|
||||||
nameLabel: { id?: string; name: string; email?: string; keywords?: string[]; enabled: boolean }
|
|
||||||
) {
|
|
||||||
return fetchApi<{ 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',
|
'/preferences/name-labels',
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ userId, email: userEmail, nameLabel }),
|
body: JSON.stringify({ nameLabel }),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteNameLabel(userId: string, userEmail: string, labelId: string) {
|
async deleteNameLabel(labelId: string) {
|
||||||
return fetchApi<{ success: boolean }>(
|
return fetchApi<{ success: boolean }>(`/preferences/name-labels/${labelId}`, { method: 'DELETE' })
|
||||||
`/preferences/name-labels/${labelId}?userId=${userId}&email=${encodeURIComponent(userEmail)}`,
|
|
||||||
{ method: 'DELETE' }
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
@@ -523,36 +601,36 @@ export const api = {
|
|||||||
// ONBOARDING
|
// ONBOARDING
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
async getOnboardingStatus(userId: string) {
|
async getOnboardingStatus() {
|
||||||
return fetchApi<{
|
return fetchApi<{
|
||||||
onboarding_step: string
|
onboarding_step: string
|
||||||
completedSteps: string[]
|
completedSteps: string[]
|
||||||
first_value_seen_at?: string
|
first_value_seen_at?: string
|
||||||
skipped_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', {
|
return fetchApi<{ step: string; completedSteps: string[] }>('/onboarding/step', {
|
||||||
method: 'POST',
|
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', {
|
return fetchApi<{ skipped: boolean }>('/onboarding/skip', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ userId }),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
async resumeOnboarding(userId: string) {
|
async resumeOnboarding() {
|
||||||
return fetchApi<{
|
return fetchApi<{
|
||||||
onboarding_step: string
|
onboarding_step: string
|
||||||
completedSteps: string[]
|
completedSteps: string[]
|
||||||
}>('/onboarding/resume', {
|
}>('/onboarding/resume', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ userId }),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -560,10 +638,10 @@ export const api = {
|
|||||||
// ACCOUNT MANAGEMENT
|
// ACCOUNT MANAGEMENT
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
async deleteAccount(userId: string) {
|
async deleteAccount() {
|
||||||
return fetchApi<{ success: boolean }>('/account/delete', {
|
return fetchApi<{ success: boolean }>('/account/delete', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
body: JSON.stringify({ userId }),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -571,17 +649,17 @@ export const api = {
|
|||||||
// REFERRALS
|
// REFERRALS
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
async getReferralCode(userId: string) {
|
async getReferralCode() {
|
||||||
return fetchApi<{
|
return fetchApi<{
|
||||||
referralCode: string
|
referralCode: string
|
||||||
referralCount: number
|
referralCount: number
|
||||||
}>(`/referrals/code?userId=${userId}`)
|
}>('/referrals/code')
|
||||||
},
|
},
|
||||||
|
|
||||||
async trackReferral(userId: string, referralCode: string) {
|
async trackReferral(referralCode: string) {
|
||||||
return fetchApi<{ success: boolean }>('/referrals/track', {
|
return fetchApi<{ success: boolean }>('/referrals/track', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ userId, referralCode }),
|
body: JSON.stringify({ referralCode }),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,20 +4,89 @@ const client = new Client()
|
|||||||
|
|
||||||
// Configure these in your .env file
|
// Configure these in your .env file
|
||||||
const APPWRITE_ENDPOINT = import.meta.env.VITE_APPWRITE_ENDPOINT || 'https://cloud.appwrite.io/v1'
|
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
|
client
|
||||||
.setEndpoint(APPWRITE_ENDPOINT)
|
.setEndpoint(APPWRITE_ENDPOINT)
|
||||||
.setProject(APPWRITE_PROJECT_ID)
|
.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<string, unknown>
|
||||||
|
return Boolean(o[`a_session_${APPWRITE_PROJECT_ID}`])
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const account = new Account(client)
|
export const account = new Account(client)
|
||||||
export const databases = new Databases(client)
|
export const databases = new Databases(client)
|
||||||
export { ID }
|
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<string | null> {
|
||||||
|
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
|
// Auth helper functions
|
||||||
export const auth = {
|
export const auth = {
|
||||||
// Create a new account
|
// Create a new account
|
||||||
async register(email: string, password: string, name?: string) {
|
async register(email: string, password: string, name?: string) {
|
||||||
|
assertAppwriteConfigured()
|
||||||
const user = await account.create(ID.unique(), email, password, name)
|
const user = await account.create(ID.unique(), email, password, name)
|
||||||
await this.login(email, password)
|
await this.login(email, password)
|
||||||
return user
|
return user
|
||||||
@@ -25,16 +94,27 @@ export const auth = {
|
|||||||
|
|
||||||
// Login with email and password
|
// Login with email and password
|
||||||
async login(email: string, password: string) {
|
async login(email: string, password: string) {
|
||||||
|
assertAppwriteConfigured()
|
||||||
return await account.createEmailPasswordSession(email, password)
|
return await account.createEmailPasswordSession(email, password)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Logout current session
|
// Logout current session
|
||||||
async logout() {
|
async logout() {
|
||||||
|
clearApiJwtCache()
|
||||||
|
if (!isAppwriteClientConfigured()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
return await account.deleteSession('current')
|
return await account.deleteSession('current')
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get current logged in user
|
// Get current logged in user
|
||||||
async getCurrentUser() {
|
async getCurrentUser() {
|
||||||
|
if (!isAppwriteClientConfigured()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (isLocalViteAppwriteProxy() && !hasCookieFallbackSession()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return await account.get()
|
return await account.get()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -44,6 +124,12 @@ export const auth = {
|
|||||||
|
|
||||||
// Check if user is logged in
|
// Check if user is logged in
|
||||||
async isLoggedIn() {
|
async isLoggedIn() {
|
||||||
|
if (!isAppwriteClientConfigured()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (isLocalViteAppwriteProxy() && !hasCookieFallbackSession()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await account.get()
|
await account.get()
|
||||||
return true
|
return true
|
||||||
@@ -54,6 +140,7 @@ export const auth = {
|
|||||||
|
|
||||||
// Send password recovery email
|
// Send password recovery email
|
||||||
async forgotPassword(email: string) {
|
async forgotPassword(email: string) {
|
||||||
|
assertAppwriteConfigured()
|
||||||
return await account.createRecovery(
|
return await account.createRecovery(
|
||||||
email,
|
email,
|
||||||
`${window.location.origin}/reset-password`
|
`${window.location.origin}/reset-password`
|
||||||
@@ -62,11 +149,13 @@ export const auth = {
|
|||||||
|
|
||||||
// Complete password recovery
|
// Complete password recovery
|
||||||
async resetPassword(userId: string, secret: string, newPassword: string) {
|
async resetPassword(userId: string, secret: string, newPassword: string) {
|
||||||
|
assertAppwriteConfigured()
|
||||||
return await account.updateRecovery(userId, secret, newPassword)
|
return await account.updateRecovery(userId, secret, newPassword)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Send verification email
|
// Send verification email
|
||||||
async sendVerification() {
|
async sendVerification() {
|
||||||
|
assertAppwriteConfigured()
|
||||||
return await account.createVerification(
|
return await account.createVerification(
|
||||||
`${window.location.origin}/verify`
|
`${window.location.origin}/verify`
|
||||||
)
|
)
|
||||||
@@ -74,6 +163,7 @@ export const auth = {
|
|||||||
|
|
||||||
// Complete email verification
|
// Complete email verification
|
||||||
async verifyEmail(userId: string, secret: string) {
|
async verifyEmail(userId: string, secret: string) {
|
||||||
|
assertAppwriteConfigured()
|
||||||
return await account.updateVerification(userId, secret)
|
return await account.updateVerification(userId, secret)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export function Dashboard() {
|
|||||||
const [digest, setDigest] = useState<Digest | null>(null)
|
const [digest, setDigest] = useState<Digest | null>(null)
|
||||||
const [subscription, setSubscription] = useState<{
|
const [subscription, setSubscription] = useState<{
|
||||||
plan: string
|
plan: string
|
||||||
|
planDisplayName?: string
|
||||||
isFreeTier: boolean
|
isFreeTier: boolean
|
||||||
emailsUsedThisMonth?: number
|
emailsUsedThisMonth?: number
|
||||||
emailsLimit?: number
|
emailsLimit?: number
|
||||||
@@ -115,11 +116,11 @@ export function Dashboard() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const [statsRes, accountsRes, digestRes, subscriptionRes, referralRes] = await Promise.all([
|
const [statsRes, accountsRes, digestRes, subscriptionRes, referralRes] = await Promise.all([
|
||||||
api.getEmailStats(user.$id),
|
api.getEmailStats(),
|
||||||
api.getEmailAccounts(user.$id),
|
api.getEmailAccounts(),
|
||||||
api.getDigest(user.$id),
|
api.getDigest(),
|
||||||
api.getSubscriptionStatus(user.$id),
|
api.getSubscriptionStatus(),
|
||||||
api.getReferralCode(user.$id).catch(() => ({ data: null })),
|
api.getReferralCode().catch(() => ({ data: null })),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (statsRes.data) setStats(statsRes.data)
|
if (statsRes.data) setStats(statsRes.data)
|
||||||
@@ -146,7 +147,7 @@ export function Dashboard() {
|
|||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api.sortEmails(user.$id, accounts[0].id)
|
const result = await api.sortEmails(accounts[0].id)
|
||||||
if (result.data) {
|
if (result.data) {
|
||||||
setSortResult(result.data)
|
setSortResult(result.data)
|
||||||
|
|
||||||
@@ -155,9 +156,9 @@ export function Dashboard() {
|
|||||||
|
|
||||||
// Refresh stats, digest, and subscription
|
// Refresh stats, digest, and subscription
|
||||||
const [statsRes, digestRes, subscriptionRes] = await Promise.all([
|
const [statsRes, digestRes, subscriptionRes] = await Promise.all([
|
||||||
api.getEmailStats(user.$id),
|
api.getEmailStats(),
|
||||||
api.getDigest(user.$id),
|
api.getDigest(),
|
||||||
api.getSubscriptionStatus(user.$id),
|
api.getSubscriptionStatus(),
|
||||||
])
|
])
|
||||||
if (statsRes.data) setStats(statsRes.data)
|
if (statsRes.data) setStats(statsRes.data)
|
||||||
if (digestRes.data) setDigest(digestRes.data)
|
if (digestRes.data) setDigest(digestRes.data)
|
||||||
@@ -168,7 +169,7 @@ export function Dashboard() {
|
|||||||
setError(result.error.message || 'Monthly limit reached')
|
setError(result.error.message || 'Monthly limit reached')
|
||||||
trackLimitReached(user.$id, result.error.limit || 500, result.error.used || 500)
|
trackLimitReached(user.$id, result.error.limit || 500, result.error.used || 500)
|
||||||
// Refresh subscription to show updated usage
|
// Refresh subscription to show updated usage
|
||||||
const subscriptionRes = await api.getSubscriptionStatus(user.$id)
|
const subscriptionRes = await api.getSubscriptionStatus()
|
||||||
if (subscriptionRes.data) setSubscription(subscriptionRes.data)
|
if (subscriptionRes.data) setSubscription(subscriptionRes.data)
|
||||||
} else {
|
} else {
|
||||||
setError(result.error.message || 'Email sorting failed. Please try again or reconnect your account.')
|
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) {
|
if (Object.keys(updates).length > 0) {
|
||||||
await api.saveUserPreferences(user.$id, updates)
|
await api.saveUserPreferences(updates)
|
||||||
trackRulesApplied(user.$id, sortResult.suggestedRules.length)
|
trackRulesApplied(user.$id, sortResult.suggestedRules.length)
|
||||||
showMessage('success', `${sortResult.suggestedRules.length} rules applied. Your inbox will stay organized.`)
|
showMessage('success', `${sortResult.suggestedRules.length} rules applied. Your inbox will stay organized.`)
|
||||||
setSortResult({ ...sortResult, suggestedRules: [] })
|
setSortResult({ ...sortResult, suggestedRules: [] })
|
||||||
@@ -834,7 +835,12 @@ export function Dashboard() {
|
|||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<p className="text-xs font-medium text-slate-700 dark:text-slate-300">Subscription</p>
|
<p className="text-xs font-medium text-slate-700 dark:text-slate-300">Subscription</p>
|
||||||
<Badge variant={subscription?.isFreeTier ? 'secondary' : 'default'} className="text-xs">
|
<Badge variant={subscription?.isFreeTier ? 'secondary' : 'default'} className="text-xs">
|
||||||
{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')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{subscription?.isFreeTier && subscription.emailsLimit && (
|
{subscription?.isFreeTier && subscription.emailsLimit && (
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function Register() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.$id && referralCode) {
|
if (user?.$id && referralCode) {
|
||||||
// Track referral if code exists
|
// Track referral if code exists
|
||||||
api.trackReferral(user.$id, referralCode).catch((err) => {
|
api.trackReferral(referralCode).catch((err) => {
|
||||||
console.error('Failed to track referral:', err)
|
console.error('Failed to track referral:', err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,12 +60,74 @@ import { PrivacySecurity } from '@/components/PrivacySecurity'
|
|||||||
|
|
||||||
type TabType = 'profile' | 'accounts' | 'vip' | 'ai-control' | 'name-labels' | 'subscription' | 'privacy' | 'referrals'
|
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<string>()
|
||||||
|
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<string, unknown>
|
||||||
|
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 {
|
interface EmailAccount {
|
||||||
id: string
|
id: string
|
||||||
email: string
|
email: string
|
||||||
provider: 'gmail' | 'outlook' | 'imap'
|
provider: 'gmail' | 'outlook' | 'imap' | 'demo'
|
||||||
connected: boolean
|
connected: boolean
|
||||||
lastSync?: string
|
lastSync?: string
|
||||||
|
isDemo?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VIPSender {
|
interface VIPSender {
|
||||||
@@ -76,10 +138,35 @@ interface VIPSender {
|
|||||||
interface Subscription {
|
interface Subscription {
|
||||||
status: string
|
status: string
|
||||||
plan: string
|
plan: string
|
||||||
|
planDisplayName?: string
|
||||||
|
isFreeTier?: boolean
|
||||||
currentPeriodEnd?: string
|
currentPeriodEnd?: string
|
||||||
cancelAtPeriodEnd?: boolean
|
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() {
|
export function Settings() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -130,6 +217,7 @@ export function Settings() {
|
|||||||
})
|
})
|
||||||
const [categories, setCategories] = useState<CategoryInfo[]>([])
|
const [categories, setCategories] = useState<CategoryInfo[]>([])
|
||||||
const [companyLabels, setCompanyLabels] = useState<CompanyLabel[]>([])
|
const [companyLabels, setCompanyLabels] = useState<CompanyLabel[]>([])
|
||||||
|
const [labelImportErrors, setLabelImportErrors] = useState<string[]>([])
|
||||||
const [isAdmin, setIsAdmin] = useState(false)
|
const [isAdmin, setIsAdmin] = useState(false)
|
||||||
const [nameLabels, setNameLabels] = useState<NameLabel[]>([])
|
const [nameLabels, setNameLabels] = useState<NameLabel[]>([])
|
||||||
const [editingNameLabel, setEditingNameLabel] = useState<NameLabel | null>(null)
|
const [editingNameLabel, setEditingNameLabel] = useState<NameLabel | null>(null)
|
||||||
@@ -174,11 +262,24 @@ export function Settings() {
|
|||||||
}
|
}
|
||||||
}, [user])
|
}, [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 () => {
|
const loadReferralData = async () => {
|
||||||
if (!user?.$id) return
|
if (!user?.$id) return
|
||||||
setLoadingReferral(true)
|
setLoadingReferral(true)
|
||||||
try {
|
try {
|
||||||
const res = await api.getReferralCode(user.$id)
|
const res = await api.getReferralCode()
|
||||||
if (res.data) setReferralData(res.data)
|
if (res.data) setReferralData(res.data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load referral data:', err)
|
console.error('Failed to load referral data:', err)
|
||||||
@@ -194,24 +295,33 @@ export function Settings() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const [accountsRes, subsRes, prefsRes, aiControlRes, companyLabelsRes, meRes] = await Promise.all([
|
const [accountsRes, subsRes, prefsRes, aiControlRes, companyLabelsRes, meRes] = await Promise.all([
|
||||||
api.getEmailAccounts(user.$id),
|
api.getEmailAccounts(),
|
||||||
api.getSubscriptionStatus(user.$id),
|
api.getSubscriptionStatus(),
|
||||||
api.getUserPreferences(user.$id),
|
api.getUserPreferences(),
|
||||||
api.getAIControlSettings(user.$id),
|
api.getAIControlSettings(),
|
||||||
api.getCompanyLabels(user.$id),
|
api.getCompanyLabels(),
|
||||||
user?.email ? api.getMe(user.email) : Promise.resolve({ data: { isAdmin: false } }),
|
user?.$id ? api.getMe() : Promise.resolve({ data: { isAdmin: false } }),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (accountsRes.data) setAccounts(accountsRes.data)
|
if (accountsRes.data) setAccounts(accountsRes.data)
|
||||||
if (subsRes.data) setSubscription(subsRes.data)
|
if (subsRes.data) setSubscription(subsRes.data)
|
||||||
if (meRes.data?.isAdmin) {
|
if (meRes.data?.isAdmin) {
|
||||||
setIsAdmin(true)
|
setIsAdmin(true)
|
||||||
const nameLabelsRes = await api.getNameLabels(user.$id, user.email)
|
const nameLabelsRes = await api.getNameLabels()
|
||||||
if (nameLabelsRes.data) setNameLabels(nameLabelsRes.data)
|
if (nameLabelsRes.data) setNameLabels(nameLabelsRes.data)
|
||||||
} else {
|
} else {
|
||||||
setIsAdmin(false)
|
setIsAdmin(false)
|
||||||
}
|
}
|
||||||
if (prefsRes.data?.vipSenders) setVipSenders(prefsRes.data.vipSenders)
|
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) {
|
if (aiControlRes.data) {
|
||||||
// Merge cleanup defaults if not present
|
// Merge cleanup defaults if not present
|
||||||
const raw = aiControlRes.data
|
const raw = aiControlRes.data
|
||||||
@@ -297,7 +407,7 @@ export function Settings() {
|
|||||||
if (!user?.$id) return
|
if (!user?.$id) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
await api.saveAIControlSettings(user.$id, {
|
await api.saveAIControlSettings({
|
||||||
enabledCategories: aiControlSettings.enabledCategories,
|
enabledCategories: aiControlSettings.enabledCategories,
|
||||||
categoryActions: aiControlSettings.categoryActions,
|
categoryActions: aiControlSettings.categoryActions,
|
||||||
autoDetectCompanies: aiControlSettings.autoDetectCompanies,
|
autoDetectCompanies: aiControlSettings.autoDetectCompanies,
|
||||||
@@ -326,24 +436,25 @@ export function Settings() {
|
|||||||
|
|
||||||
// Load cleanup status
|
// Load cleanup status
|
||||||
const loadCleanupStatus = async () => {
|
const loadCleanupStatus = async () => {
|
||||||
if (!user?.$id) return
|
const aid = accounts.find((a) => a.provider !== 'demo')?.id
|
||||||
|
if (!aid) return
|
||||||
try {
|
try {
|
||||||
const res = await api.getCleanupStatus(user.$id)
|
const res = await api.getCleanupStatus(aid)
|
||||||
if (res.data) setCleanupStatus(res.data)
|
if (res.data) setCleanupStatus(res.data)
|
||||||
} catch {
|
} catch {
|
||||||
// Silently fail if endpoint doesn't exist yet
|
|
||||||
console.debug('Cleanup status endpoint not available')
|
console.debug('Cleanup status endpoint not available')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load cleanup preview
|
// Load cleanup preview
|
||||||
const loadCleanupPreview = async () => {
|
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 {
|
try {
|
||||||
const res = await api.getCleanupPreview(user.$id)
|
const res = await api.getCleanupPreview(aid)
|
||||||
if (res.data?.preview) setCleanupPreview(res.data.preview)
|
if (res.data?.messages) setCleanupPreview(res.data.messages)
|
||||||
} catch {
|
} catch {
|
||||||
// Silently fail if endpoint doesn't exist yet
|
|
||||||
console.debug('Cleanup preview endpoint not available')
|
console.debug('Cleanup preview endpoint not available')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -356,14 +467,14 @@ export function Settings() {
|
|||||||
loadCleanupPreview()
|
loadCleanupPreview()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [activeTab, controlPanelTab, aiControlSettings.cleanup?.enabled, aiControlSettings.cleanup?.safety.dryRun])
|
}, [activeTab, controlPanelTab, aiControlSettings.cleanup?.enabled, aiControlSettings.cleanup?.safety.dryRun, accounts])
|
||||||
|
|
||||||
// Run cleanup now
|
// Run cleanup now
|
||||||
const handleRunCleanup = async () => {
|
const handleRunCleanup = async () => {
|
||||||
if (!user?.$id) return
|
if (!user?.$id) return
|
||||||
setRunningCleanup(true)
|
setRunningCleanup(true)
|
||||||
try {
|
try {
|
||||||
const res = await api.runCleanup(user.$id)
|
const res = await api.runCleanup()
|
||||||
if (res.data) {
|
if (res.data) {
|
||||||
showMessage('success', `Cleanup completed: ${res.data.emailsProcessed.readItems + res.data.emailsProcessed.promotions} emails processed`)
|
showMessage('success', `Cleanup completed: ${res.data.emailsProcessed.readItems + res.data.emailsProcessed.promotions} emails processed`)
|
||||||
await loadCleanupStatus()
|
await loadCleanupStatus()
|
||||||
@@ -432,8 +543,15 @@ export function Settings() {
|
|||||||
if (!user?.$id) return
|
if (!user?.$id) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
// TODO: Save profile data to backend
|
const res = await api.updateProfile({
|
||||||
// await api.updateUserProfile(user.$id, { name, language, timezone })
|
displayName: name,
|
||||||
|
timezone,
|
||||||
|
notificationPrefs: { language },
|
||||||
|
})
|
||||||
|
if (res.error) {
|
||||||
|
showMessage('error', res.error.message || 'Failed to save profile')
|
||||||
|
return
|
||||||
|
}
|
||||||
savedProfileRef.current = { name, language, timezone }
|
savedProfileRef.current = { name, language, timezone }
|
||||||
setHasProfileChanges(false)
|
setHasProfileChanges(false)
|
||||||
showMessage('success', 'Profile saved successfully!')
|
showMessage('success', 'Profile saved successfully!')
|
||||||
@@ -472,7 +590,7 @@ export function Settings() {
|
|||||||
setConnectingProvider(provider)
|
setConnectingProvider(provider)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.getOAuthUrl(provider, user.$id)
|
const res = await api.getOAuthUrl(provider)
|
||||||
if (res.data?.url) {
|
if (res.data?.url) {
|
||||||
window.location.href = res.data.url
|
window.location.href = res.data.url
|
||||||
}
|
}
|
||||||
@@ -486,7 +604,7 @@ export function Settings() {
|
|||||||
if (!user?.$id) return
|
if (!user?.$id) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.disconnectEmailAccount(accountId, user.$id)
|
await api.disconnectEmailAccount(accountId)
|
||||||
setAccounts(accounts.filter(a => a.id !== accountId))
|
setAccounts(accounts.filter(a => a.id !== accountId))
|
||||||
showMessage('success', 'Account disconnected')
|
showMessage('success', 'Account disconnected')
|
||||||
} catch {
|
} catch {
|
||||||
@@ -498,7 +616,7 @@ export function Settings() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!user?.$id || !imapForm.email.trim() || !imapForm.password) return
|
if (!user?.$id || !imapForm.email.trim() || !imapForm.password) return
|
||||||
setImapConnecting(true)
|
setImapConnecting(true)
|
||||||
const res = await api.connectImapAccount(user.$id, {
|
const res = await api.connectImapAccount({
|
||||||
email: imapForm.email.trim(),
|
email: imapForm.email.trim(),
|
||||||
password: imapForm.password,
|
password: imapForm.password,
|
||||||
imapHost: imapForm.imapHost || undefined,
|
imapHost: imapForm.imapHost || undefined,
|
||||||
@@ -511,7 +629,7 @@ export function Settings() {
|
|||||||
setImapConnecting(false)
|
setImapConnecting(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const list = await api.getEmailAccounts(user.$id)
|
const list = await api.getEmailAccounts()
|
||||||
setAccounts(list.data ?? [])
|
setAccounts(list.data ?? [])
|
||||||
setShowImapForm(false)
|
setShowImapForm(false)
|
||||||
setImapForm({ email: '', password: '', imapHost: 'imap.porkbun.com', imapPort: 993, imapSecure: true })
|
setImapForm({ email: '', password: '', imapHost: 'imap.porkbun.com', imapPort: 993, imapSecure: true })
|
||||||
@@ -541,7 +659,7 @@ export function Settings() {
|
|||||||
setSaving(true)
|
setSaving(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.saveUserPreferences(user.$id, { vipSenders })
|
await api.saveUserPreferences({ vipSenders })
|
||||||
showMessage('success', 'VIP list saved!')
|
showMessage('success', 'VIP list saved!')
|
||||||
} catch {
|
} catch {
|
||||||
showMessage('error', 'Failed to save')
|
showMessage('error', 'Failed to save')
|
||||||
@@ -554,7 +672,7 @@ export function Settings() {
|
|||||||
if (!user?.$id) return
|
if (!user?.$id) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.createPortalSession(user.$id)
|
const res = await api.createPortalSession()
|
||||||
if (res.data?.url) {
|
if (res.data?.url) {
|
||||||
window.location.href = res.data.url
|
window.location.href = res.data.url
|
||||||
}
|
}
|
||||||
@@ -567,7 +685,7 @@ export function Settings() {
|
|||||||
if (!user?.$id) return
|
if (!user?.$id) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.createSubscriptionCheckout(plan, user.$id, user.email)
|
const res = await api.createSubscriptionCheckout(plan, user.email)
|
||||||
if (res.data?.url) {
|
if (res.data?.url) {
|
||||||
window.location.href = res.data.url
|
window.location.href = res.data.url
|
||||||
}
|
}
|
||||||
@@ -1721,6 +1839,7 @@ export function Settings() {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
setLabelImportErrors([])
|
||||||
const input = document.createElement('input')
|
const input = document.createElement('input')
|
||||||
input.type = 'file'
|
input.type = 'file'
|
||||||
input.accept = 'application/json'
|
input.accept = 'application/json'
|
||||||
@@ -1730,12 +1849,18 @@ export function Settings() {
|
|||||||
try {
|
try {
|
||||||
const text = await file.text()
|
const text = await file.text()
|
||||||
const imported = JSON.parse(text)
|
const imported = JSON.parse(text)
|
||||||
if (Array.isArray(imported)) {
|
const { labels, errors } = validateLabelImport(imported, companyLabels)
|
||||||
// TODO: Validate and import labels
|
if (errors.length > 0) {
|
||||||
showMessage('success', `Imported ${imported.length} labels`)
|
setLabelImportErrors(errors)
|
||||||
|
showMessage('error', 'Fix import errors before saving')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
setLabelImportErrors([])
|
||||||
|
setCompanyLabels([...companyLabels, ...labels])
|
||||||
|
showMessage('success', `Imported ${labels.length} labels`)
|
||||||
} catch {
|
} catch {
|
||||||
showMessage('error', 'Invalid JSON file')
|
showMessage('error', 'Invalid JSON file')
|
||||||
|
setLabelImportErrors([])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
input.click()
|
input.click()
|
||||||
@@ -1755,6 +1880,16 @@ export function Settings() {
|
|||||||
Add Label
|
Add Label
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{labelImportErrors.length > 0 && (
|
||||||
|
<div className="mt-3 w-full rounded-md border border-red-200 dark:border-red-900 bg-red-50 dark:bg-red-950/40 p-3 text-sm text-red-800 dark:text-red-200">
|
||||||
|
<p className="font-medium mb-1">Import issues</p>
|
||||||
|
<ul className="list-disc pl-5 space-y-0.5">
|
||||||
|
{labelImportErrors.map((err, i) => (
|
||||||
|
<li key={i}>{err}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Auto-Detection Toggle */}
|
{/* Auto-Detection Toggle */}
|
||||||
@@ -1853,7 +1988,7 @@ export function Settings() {
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!user?.$id || !label.id) return
|
if (!user?.$id || !label.id) return
|
||||||
try {
|
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))
|
setCompanyLabels(companyLabels.map(l => l.id === label.id ? { ...l, enabled: !l.enabled } : l))
|
||||||
showMessage('success', 'Label updated!')
|
showMessage('success', 'Label updated!')
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1874,7 +2009,7 @@ export function Settings() {
|
|||||||
if (!user?.$id || !label.id) return
|
if (!user?.$id || !label.id) return
|
||||||
if (!confirm('Are you sure you want to delete this label?')) return
|
if (!confirm('Are you sure you want to delete this label?')) return
|
||||||
try {
|
try {
|
||||||
await api.deleteCompanyLabel(user.$id, label.id)
|
await api.deleteCompanyLabel(label.id)
|
||||||
setCompanyLabels(companyLabels.filter(l => l.id !== label.id))
|
setCompanyLabels(companyLabels.filter(l => l.id !== label.id))
|
||||||
showMessage('success', 'Label deleted!')
|
showMessage('success', 'Label deleted!')
|
||||||
} catch {
|
} catch {
|
||||||
@@ -2163,7 +2298,7 @@ export function Settings() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const saved = await api.saveCompanyLabel(user.$id, editingLabel)
|
const saved = await api.saveCompanyLabel(editingLabel)
|
||||||
if (saved.data) {
|
if (saved.data) {
|
||||||
if (editingLabel.id) {
|
if (editingLabel.id) {
|
||||||
setCompanyLabels(companyLabels.map(l => l.id === editingLabel.id ? (saved.data || l) : l))
|
setCompanyLabels(companyLabels.map(l => l.id === editingLabel.id ? (saved.data || l) : l))
|
||||||
@@ -2235,7 +2370,7 @@ export function Settings() {
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!user?.$id || !label.id) return
|
if (!user?.$id || !label.id) return
|
||||||
try {
|
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))
|
setNameLabels(nameLabels.map(l => l.id === label.id ? { ...l, enabled: !l.enabled } : l))
|
||||||
showMessage('success', 'Label updated!')
|
showMessage('success', 'Label updated!')
|
||||||
} catch {
|
} catch {
|
||||||
@@ -2256,7 +2391,7 @@ export function Settings() {
|
|||||||
if (!user?.$id || !label.id) return
|
if (!user?.$id || !label.id) return
|
||||||
if (!confirm('Delete this name label?')) return
|
if (!confirm('Delete this name label?')) return
|
||||||
try {
|
try {
|
||||||
await api.deleteNameLabel(user.$id, user.email, label.id)
|
await api.deleteNameLabel(label.id)
|
||||||
setNameLabels(nameLabels.filter(l => l.id !== label.id))
|
setNameLabels(nameLabels.filter(l => l.id !== label.id))
|
||||||
showMessage('success', 'Label deleted!')
|
showMessage('success', 'Label deleted!')
|
||||||
} catch {
|
} catch {
|
||||||
@@ -2383,7 +2518,7 @@ export function Settings() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const saved = await api.saveNameLabel(user.$id, user.email, editingNameLabel)
|
const saved = await api.saveNameLabel(editingNameLabel)
|
||||||
if (saved.data) {
|
if (saved.data) {
|
||||||
if (editingNameLabel.id) {
|
if (editingNameLabel.id) {
|
||||||
setNameLabels(nameLabels.map(l => l.id === editingNameLabel.id ? (saved.data || l) : l))
|
setNameLabels(nameLabels.map(l => l.id === editingNameLabel.id ? (saved.data || l) : l))
|
||||||
@@ -2466,7 +2601,7 @@ export function Settings() {
|
|||||||
onDisconnect={async (accountId) => {
|
onDisconnect={async (accountId) => {
|
||||||
if (!user?.$id) return
|
if (!user?.$id) return
|
||||||
try {
|
try {
|
||||||
const result = await api.disconnectEmailAccount(accountId, user.$id)
|
const result = await api.disconnectEmailAccount(accountId)
|
||||||
if (result.data) {
|
if (result.data) {
|
||||||
setAccounts(accounts.filter(a => a.id !== accountId))
|
setAccounts(accounts.filter(a => a.id !== accountId))
|
||||||
showMessage('success', 'Account disconnected')
|
showMessage('success', 'Account disconnected')
|
||||||
@@ -2479,7 +2614,7 @@ export function Settings() {
|
|||||||
if (!user?.$id) return
|
if (!user?.$id) return
|
||||||
if (!confirm('Are you absolutely sure? This cannot be undone.')) return
|
if (!confirm('Are you absolutely sure? This cannot be undone.')) return
|
||||||
try {
|
try {
|
||||||
const result = await api.deleteAccount(user.$id)
|
const result = await api.deleteAccount()
|
||||||
if (result.data) {
|
if (result.data) {
|
||||||
showMessage('success', 'Account deleted. Redirecting...')
|
showMessage('success', 'Account deleted. Redirecting...')
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -2503,30 +2638,63 @@ export function Settings() {
|
|||||||
<CardDescription>Manage your MailFlow subscription</CardDescription>
|
<CardDescription>Manage your MailFlow subscription</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-center justify-between p-4 bg-gradient-to-r from-primary-50 to-accent-50 dark:from-primary-900/30 dark:to-accent-900/30 rounded-xl border border-primary-100 dark:border-primary-800">
|
{loading ? (
|
||||||
<div className="flex items-center gap-4">
|
<p className="text-sm text-slate-500 dark:text-slate-400 py-6">
|
||||||
<div className="w-14 h-14 rounded-xl bg-white dark:bg-slate-800 shadow-sm flex items-center justify-center">
|
Loading subscription…
|
||||||
<Crown className="w-7 h-7 text-primary-500 dark:text-primary-400" />
|
</p>
|
||||||
</div>
|
) : !subscription ? (
|
||||||
<div>
|
<div className="space-y-3 py-2">
|
||||||
<div className="flex items-center gap-2">
|
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
<h3 className="font-bold text-lg text-slate-900 dark:text-slate-100">{subscription?.plan || 'Trial'}</h3>
|
Subscription status could not be loaded. Make sure you are signed in and the API is running.
|
||||||
<Badge variant={subscription?.status === 'active' ? 'success' : 'warning'}>
|
</p>
|
||||||
{subscription?.status === 'active' ? 'Active' : 'Trial'}
|
<Button
|
||||||
</Badge>
|
variant="outline"
|
||||||
</div>
|
size="sm"
|
||||||
{subscription?.currentPeriodEnd && (
|
onClick={async () => {
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
const r = await api.getSubscriptionStatus()
|
||||||
Next billing: {new Date(subscription.currentPeriodEnd).toLocaleDateString('en-US')}
|
if (r.data) {
|
||||||
</p>
|
setSubscription(r.data)
|
||||||
)}
|
showMessage('success', 'Subscription loaded')
|
||||||
</div>
|
} else {
|
||||||
|
showMessage('error', r.error?.message || 'Failed to load subscription')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleManageSubscription}>
|
) : (
|
||||||
<ExternalLink className="w-4 h-4 mr-2" />
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 p-4 bg-gradient-to-r from-primary-50 to-accent-50 dark:from-primary-900/30 dark:to-accent-900/30 rounded-xl border border-primary-100 dark:border-primary-800">
|
||||||
Manage
|
<div className="flex items-center gap-4">
|
||||||
</Button>
|
<div className="w-14 h-14 rounded-xl bg-white dark:bg-slate-800 shadow-sm flex items-center justify-center shrink-0">
|
||||||
</div>
|
<Crown className="w-7 h-7 text-primary-500 dark:text-primary-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 className="font-bold text-lg text-slate-900 dark:text-slate-100">
|
||||||
|
{subscriptionTitle(subscription)}
|
||||||
|
</h3>
|
||||||
|
{(() => {
|
||||||
|
const b = subscriptionBadge(subscription)
|
||||||
|
return b.label ? (
|
||||||
|
<Badge variant={b.variant}>{b.label}</Badge>
|
||||||
|
) : null
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
{subscription.currentPeriodEnd && (
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||||
|
Next billing:{' '}
|
||||||
|
{new Date(subscription.currentPeriodEnd).toLocaleDateString('en-US')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button className="shrink-0" onClick={handleManageSubscription}>
|
||||||
|
<ExternalLink className="w-4 h-4 mr-2" />
|
||||||
|
Manage
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -2556,8 +2724,8 @@ export function Settings() {
|
|||||||
<span className="text-slate-500 dark:text-slate-400">/month</span>
|
<span className="text-slate-500 dark:text-slate-400">/month</span>
|
||||||
</div>
|
</div>
|
||||||
<ul className="space-y-2 mb-6">
|
<ul className="space-y-2 mb-6">
|
||||||
{plan.features.map((feature) => (
|
{plan.features.map((feature, fi) => (
|
||||||
<li key={feature} className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400">
|
<li key={`${plan.id}-${fi}`} className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400">
|
||||||
<Check className="w-4 h-4 text-green-500 dark:text-green-400" />
|
<Check className="w-4 h-4 text-green-500 dark:text-green-400" />
|
||||||
{feature}
|
{feature}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function Setup() {
|
|||||||
if (user?.$id) {
|
if (user?.$id) {
|
||||||
const loadOnboarding = async () => {
|
const loadOnboarding = async () => {
|
||||||
try {
|
try {
|
||||||
const stateRes = await api.getOnboardingStatus(user.$id)
|
const stateRes = await api.getOnboardingStatus()
|
||||||
if (stateRes.data) {
|
if (stateRes.data) {
|
||||||
setOnboardingState(stateRes.data)
|
setOnboardingState(stateRes.data)
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ export function Setup() {
|
|||||||
if (isFromCheckout && user?.$id) {
|
if (isFromCheckout && user?.$id) {
|
||||||
const checkAccounts = async () => {
|
const checkAccounts = async () => {
|
||||||
try {
|
try {
|
||||||
const accountsRes = await api.getEmailAccounts(user.$id)
|
const accountsRes = await api.getEmailAccounts()
|
||||||
if (accountsRes.data && accountsRes.data.length > 0) {
|
if (accountsRes.data && accountsRes.data.length > 0) {
|
||||||
// User already has accounts connected - redirect to dashboard
|
// User already has accounts connected - redirect to dashboard
|
||||||
navigate('/dashboard?subscription=success&ready=true')
|
navigate('/dashboard?subscription=success&ready=true')
|
||||||
@@ -118,16 +118,16 @@ export function Setup() {
|
|||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.getOAuthUrl('gmail', user.$id)
|
const response = await api.getOAuthUrl('gmail')
|
||||||
if (response.data?.url) {
|
if (response.data?.url) {
|
||||||
// Track onboarding step before redirect
|
// Track onboarding step before redirect
|
||||||
await api.updateOnboardingStep(user.$id, 'connect', ['connect'])
|
await api.updateOnboardingStep('connect', ['connect'])
|
||||||
window.location.href = response.data.url
|
window.location.href = response.data.url
|
||||||
} else {
|
} else {
|
||||||
setConnectedProvider('gmail')
|
setConnectedProvider('gmail')
|
||||||
setConnectedEmail(user.email)
|
setConnectedEmail(user.email)
|
||||||
setCurrentStep('complete')
|
setCurrentStep('complete')
|
||||||
await api.updateOnboardingStep(user.$id, 'see_results', ['connect'])
|
await api.updateOnboardingStep('see_results', ['connect'])
|
||||||
trackOnboardingStep(user.$id, 'first_rule')
|
trackOnboardingStep(user.$id, 'first_rule')
|
||||||
trackProviderConnected(user.$id, 'gmail')
|
trackProviderConnected(user.$id, 'gmail')
|
||||||
}
|
}
|
||||||
@@ -144,16 +144,16 @@ export function Setup() {
|
|||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.getOAuthUrl('outlook', user.$id)
|
const response = await api.getOAuthUrl('outlook')
|
||||||
if (response.data?.url) {
|
if (response.data?.url) {
|
||||||
// Track onboarding step before redirect
|
// Track onboarding step before redirect
|
||||||
await api.updateOnboardingStep(user.$id, 'connect', ['connect'])
|
await api.updateOnboardingStep('connect', ['connect'])
|
||||||
window.location.href = response.data.url
|
window.location.href = response.data.url
|
||||||
} else {
|
} else {
|
||||||
setConnectedProvider('outlook')
|
setConnectedProvider('outlook')
|
||||||
setConnectedEmail(user.email)
|
setConnectedEmail(user.email)
|
||||||
setCurrentStep('complete')
|
setCurrentStep('complete')
|
||||||
await api.updateOnboardingStep(user.$id, 'see_results', ['connect'])
|
await api.updateOnboardingStep('see_results', ['connect'])
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError('Outlook connection failed. Please try again.')
|
setError('Outlook connection failed. Please try again.')
|
||||||
@@ -168,12 +168,12 @@ export function Setup() {
|
|||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.connectDemoAccount(user.$id)
|
const response = await api.connectDemoAccount()
|
||||||
if (response.data) {
|
if (response.data) {
|
||||||
setConnectedProvider('demo')
|
setConnectedProvider('demo')
|
||||||
setConnectedEmail(response.data.email)
|
setConnectedEmail(response.data.email)
|
||||||
setCurrentStep('complete')
|
setCurrentStep('complete')
|
||||||
await api.updateOnboardingStep(user.$id, 'see_results', ['connect'])
|
await api.updateOnboardingStep('see_results', ['connect'])
|
||||||
trackOnboardingStep(user.$id, 'first_rule')
|
trackOnboardingStep(user.$id, 'first_rule')
|
||||||
trackDemoUsed(user.$id)
|
trackDemoUsed(user.$id)
|
||||||
}
|
}
|
||||||
@@ -202,7 +202,7 @@ export function Setup() {
|
|||||||
const completedSteps = onboardingState?.completedSteps || []
|
const completedSteps = onboardingState?.completedSteps || []
|
||||||
if (onboardingStep && !completedSteps.includes(stepMap[currentStep])) {
|
if (onboardingStep && !completedSteps.includes(stepMap[currentStep])) {
|
||||||
const newCompleted = [...completedSteps, stepMap[currentStep]]
|
const newCompleted = [...completedSteps, stepMap[currentStep]]
|
||||||
await api.updateOnboardingStep(user.$id, onboardingStep, newCompleted)
|
await api.updateOnboardingStep(onboardingStep, newCompleted)
|
||||||
setOnboardingState({
|
setOnboardingState({
|
||||||
onboarding_step: onboardingStep,
|
onboarding_step: onboardingStep,
|
||||||
completedSteps: newCompleted,
|
completedSteps: newCompleted,
|
||||||
@@ -227,7 +227,7 @@ export function Setup() {
|
|||||||
|
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
await api.saveUserPreferences(user.$id, {
|
await api.saveUserPreferences({
|
||||||
vipSenders: [],
|
vipSenders: [],
|
||||||
blockedSenders: [],
|
blockedSenders: [],
|
||||||
customRules: [],
|
customRules: [],
|
||||||
@@ -235,7 +235,7 @@ export function Setup() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Mark onboarding as completed
|
// Mark onboarding as completed
|
||||||
await api.updateOnboardingStep(user.$id, 'completed', ['connect', 'see_results'])
|
await api.updateOnboardingStep('completed', ['connect', 'see_results'])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to save preferences:', err)
|
console.error('Failed to save preferences:', err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -248,7 +248,7 @@ export function Setup() {
|
|||||||
if (!user?.$id) return
|
if (!user?.$id) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.skipOnboarding(user.$id)
|
await api.skipOnboarding()
|
||||||
navigate('/dashboard')
|
navigate('/dashboard')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to skip onboarding:', err)
|
console.error('Failed to skip onboarding:', err)
|
||||||
|
|||||||
@@ -1,27 +1,53 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
plugins: [react(), tailwindcss()],
|
const env = loadEnv(mode, __dirname, '')
|
||||||
resolve: {
|
const appwriteDevOrigin = (env.APPWRITE_DEV_ORIGIN || '').replace(/\/$/, '')
|
||||||
alias: {
|
// 127.0.0.1 avoids Windows localhost → IPv6 (::1) vs backend listening on IPv4-only
|
||||||
'@': path.resolve(__dirname, './src'),
|
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,
|
||||||
},
|
},
|
||||||
},
|
'/stripe': {
|
||||||
server: {
|
target: apiDevTarget,
|
||||||
port: 5173,
|
changeOrigin: true,
|
||||||
proxy: {
|
},
|
||||||
'/api': {
|
}
|
||||||
target: 'http://localhost:3000',
|
|
||||||
changeOrigin: true,
|
// Dev: Browser → localhost:5173/v1/* → Appwrite (umgeht CORS, wenn die Console nur z. B. webklar.com erlaubt)
|
||||||
},
|
if (mode === 'development' && appwriteDevOrigin) {
|
||||||
'/stripe': {
|
proxy['/v1'] = {
|
||||||
target: 'http://localhost:3000',
|
target: appwriteDevOrigin,
|
||||||
changeOrigin: true,
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ export const config = {
|
|||||||
autoSchedule: false, // manual only
|
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)
|
// Admin: comma-separated list of emails with admin rights (e.g. support)
|
||||||
adminEmails: (process.env.ADMIN_EMAILS || '')
|
adminEmails: (process.env.ADMIN_EMAILS || '')
|
||||||
.split(',')
|
.split(',')
|
||||||
@@ -87,6 +90,9 @@ export const config = {
|
|||||||
webhookSecret: process.env.GITEA_WEBHOOK_SECRET || '',
|
webhookSecret: process.env.GITEA_WEBHOOK_SECRET || '',
|
||||||
webhookAuthToken: process.env.GITEA_WEBHOOK_AUTH_TOKEN || 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 || '',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
7
server/emails/payment-failed.txt
Normal file
7
server/emails/payment-failed.txt
Normal file
@@ -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
|
||||||
7
server/emails/subscription-ended.txt
Normal file
7
server/emails/subscription-ended.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
Hello,
|
||||||
|
|
||||||
|
Your MailFlow subscription has ended on {{endedDate}}.
|
||||||
|
|
||||||
|
You can resubscribe anytime from your account settings.
|
||||||
|
|
||||||
|
— MailFlow
|
||||||
9
server/emails/subscription-updated.txt
Normal file
9
server/emails/subscription-updated.txt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
Hello,
|
||||||
|
|
||||||
|
Your MailFlow subscription was updated.
|
||||||
|
|
||||||
|
Plan: {{plan}}
|
||||||
|
Status: {{status}}
|
||||||
|
{{periodEndLine}}
|
||||||
|
|
||||||
|
— MailFlow
|
||||||
140
server/index.mjs
140
server/index.mjs
@@ -11,10 +11,11 @@ import { dirname, join } from 'path'
|
|||||||
|
|
||||||
// Config & Middleware
|
// Config & Middleware
|
||||||
import { config, validateConfig } from './config/index.mjs'
|
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 { respond } from './utils/response.mjs'
|
||||||
import { logger, log } from './middleware/logger.mjs'
|
import { logger, log } from './middleware/logger.mjs'
|
||||||
import { limiters } from './middleware/rateLimit.mjs'
|
import { limiters } from './middleware/rateLimit.mjs'
|
||||||
|
import { requireAuth } from './middleware/auth.mjs'
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
import oauthRoutes from './routes/oauth.mjs'
|
import oauthRoutes from './routes/oauth.mjs'
|
||||||
@@ -23,6 +24,7 @@ import stripeRoutes from './routes/stripe.mjs'
|
|||||||
import apiRoutes from './routes/api.mjs'
|
import apiRoutes from './routes/api.mjs'
|
||||||
import analyticsRoutes from './routes/analytics.mjs'
|
import analyticsRoutes from './routes/analytics.mjs'
|
||||||
import webhookRoutes from './routes/webhook.mjs'
|
import webhookRoutes from './routes/webhook.mjs'
|
||||||
|
import { startCounterJobs } from './jobs/reset-counters.mjs'
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
const __dirname = dirname(__filename)
|
const __dirname = dirname(__filename)
|
||||||
@@ -93,21 +95,16 @@ import { userPreferences } from './services/database.mjs'
|
|||||||
import { isAdmin } from './config/index.mjs'
|
import { isAdmin } from './config/index.mjs'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/me?email=xxx
|
* GET /api/me
|
||||||
* Returns current user context (e.g. isAdmin) for the given email.
|
* Returns current user context (JWT). isAdmin from verified email.
|
||||||
*/
|
*/
|
||||||
app.get('/api/me', asyncHandler(async (req, res) => {
|
app.get('/api/me', requireAuth, asyncHandler(async (req, res) => {
|
||||||
const { email } = req.query
|
respond.success(res, { isAdmin: isAdmin(req.appwriteUser.email) })
|
||||||
if (!email || typeof email !== 'string') {
|
|
||||||
throw new ValidationError('email is required')
|
|
||||||
}
|
|
||||||
respond.success(res, { isAdmin: isAdmin(email) })
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
app.get('/api/preferences', asyncHandler(async (req, res) => {
|
app.get('/api/preferences', requireAuth, asyncHandler(async (req, res) => {
|
||||||
const { userId } = req.query
|
const userId = req.appwriteUser.id
|
||||||
if (!userId) throw new ValidationError('userId ist erforderlich')
|
|
||||||
|
|
||||||
const prefs = await userPreferences.getByUser(userId)
|
const prefs = await userPreferences.getByUser(userId)
|
||||||
respond.success(res, prefs?.preferences || {
|
respond.success(res, prefs?.preferences || {
|
||||||
vipSenders: [],
|
vipSenders: [],
|
||||||
@@ -117,22 +114,40 @@ app.get('/api/preferences', asyncHandler(async (req, res) => {
|
|||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
|
|
||||||
app.post('/api/preferences', asyncHandler(async (req, res) => {
|
app.post('/api/preferences', requireAuth, asyncHandler(async (req, res) => {
|
||||||
const { userId, ...preferences } = req.body
|
const userId = req.appwriteUser.id
|
||||||
if (!userId) throw new ValidationError('userId ist erforderlich')
|
const { ...preferences } = req.body
|
||||||
|
|
||||||
await userPreferences.upsert(userId, preferences)
|
await userPreferences.upsert(userId, preferences)
|
||||||
respond.success(res, null, 'Einstellungen gespeichert')
|
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 /api/preferences/ai-control
|
||||||
* Get AI Control settings
|
* Get AI Control settings
|
||||||
*/
|
*/
|
||||||
app.get('/api/preferences/ai-control', asyncHandler(async (req, res) => {
|
app.get('/api/preferences/ai-control', requireAuth, asyncHandler(async (req, res) => {
|
||||||
const { userId } = req.query
|
const userId = req.appwriteUser.id
|
||||||
if (!userId) throw new ValidationError('userId is required')
|
|
||||||
|
|
||||||
const prefs = await userPreferences.getByUser(userId)
|
const prefs = await userPreferences.getByUser(userId)
|
||||||
const preferences = prefs?.preferences || userPreferences.getDefaults()
|
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
|
* POST /api/preferences/ai-control
|
||||||
* Save AI Control settings
|
* Save AI Control settings
|
||||||
*/
|
*/
|
||||||
app.post('/api/preferences/ai-control', asyncHandler(async (req, res) => {
|
app.post('/api/preferences/ai-control', requireAuth, asyncHandler(async (req, res) => {
|
||||||
const { userId, enabledCategories, categoryActions, autoDetectCompanies, cleanup } = req.body
|
const userId = req.appwriteUser.id
|
||||||
if (!userId) throw new ValidationError('userId is required')
|
const { enabledCategories, categoryActions, autoDetectCompanies, cleanup } = req.body
|
||||||
|
|
||||||
const updates = {}
|
const updates = {}
|
||||||
if (enabledCategories !== undefined) updates.enabledCategories = enabledCategories
|
if (enabledCategories !== undefined) updates.enabledCategories = enabledCategories
|
||||||
if (categoryActions !== undefined) updates.categoryActions = categoryActions
|
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 /api/preferences/company-labels
|
||||||
* Get company labels
|
* Get company labels
|
||||||
*/
|
*/
|
||||||
app.get('/api/preferences/company-labels', asyncHandler(async (req, res) => {
|
app.get('/api/preferences/company-labels', requireAuth, asyncHandler(async (req, res) => {
|
||||||
const { userId } = req.query
|
const userId = req.appwriteUser.id
|
||||||
if (!userId) throw new ValidationError('userId is required')
|
|
||||||
|
|
||||||
const prefs = await userPreferences.getByUser(userId)
|
const prefs = await userPreferences.getByUser(userId)
|
||||||
const preferences = prefs?.preferences || userPreferences.getDefaults()
|
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
|
* POST /api/preferences/company-labels
|
||||||
* Save/Update company label
|
* Save/Update company label
|
||||||
*/
|
*/
|
||||||
app.post('/api/preferences/company-labels', asyncHandler(async (req, res) => {
|
app.post('/api/preferences/company-labels', requireAuth, asyncHandler(async (req, res) => {
|
||||||
const { userId, companyLabel } = req.body
|
const userId = req.appwriteUser.id
|
||||||
if (!userId) throw new ValidationError('userId is required')
|
const { companyLabel } = req.body
|
||||||
if (!companyLabel) throw new ValidationError('companyLabel is required')
|
if (!companyLabel) throw new ValidationError('companyLabel is required')
|
||||||
|
|
||||||
const prefs = await userPreferences.getByUser(userId)
|
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 /api/preferences/company-labels/:id
|
||||||
* Delete company label
|
* Delete company label
|
||||||
*/
|
*/
|
||||||
app.delete('/api/preferences/company-labels/:id', asyncHandler(async (req, res) => {
|
app.delete('/api/preferences/company-labels/:id', requireAuth, asyncHandler(async (req, res) => {
|
||||||
const { userId } = req.query
|
const userId = req.appwriteUser.id
|
||||||
const { id } = req.params
|
const { id } = req.params
|
||||||
if (!userId) throw new ValidationError('userId is required')
|
|
||||||
if (!id) throw new ValidationError('label id is required')
|
if (!id) throw new ValidationError('label id is required')
|
||||||
|
|
||||||
const prefs = await userPreferences.getByUser(userId)
|
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 /api/preferences/name-labels
|
||||||
* Get name labels (worker labels). Admin only.
|
* Get name labels (worker labels). Admin only.
|
||||||
*/
|
*/
|
||||||
app.get('/api/preferences/name-labels', asyncHandler(async (req, res) => {
|
app.get('/api/preferences/name-labels', requireAuth, asyncHandler(async (req, res) => {
|
||||||
const { userId, email } = req.query
|
if (!isAdmin(req.appwriteUser.email)) throw new AuthorizationError('Admin access required for name labels')
|
||||||
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')
|
|
||||||
|
|
||||||
|
const userId = req.appwriteUser.id
|
||||||
const prefs = await userPreferences.getByUser(userId)
|
const prefs = await userPreferences.getByUser(userId)
|
||||||
const preferences = prefs?.preferences || userPreferences.getDefaults()
|
const preferences = prefs?.preferences || userPreferences.getDefaults()
|
||||||
respond.success(res, preferences.nameLabels || [])
|
respond.success(res, preferences.nameLabels || [])
|
||||||
@@ -245,11 +256,11 @@ app.get('/api/preferences/name-labels', asyncHandler(async (req, res) => {
|
|||||||
* POST /api/preferences/name-labels
|
* POST /api/preferences/name-labels
|
||||||
* Save/Update name label (worker). Admin only.
|
* Save/Update name label (worker). Admin only.
|
||||||
*/
|
*/
|
||||||
app.post('/api/preferences/name-labels', asyncHandler(async (req, res) => {
|
app.post('/api/preferences/name-labels', requireAuth, asyncHandler(async (req, res) => {
|
||||||
const { userId, email, nameLabel } = req.body
|
if (!isAdmin(req.appwriteUser.email)) throw new AuthorizationError('Admin access required for name labels')
|
||||||
if (!userId) throw new ValidationError('userId is required')
|
|
||||||
if (!email || typeof email !== 'string') throw new ValidationError('email is required')
|
const userId = req.appwriteUser.id
|
||||||
if (!isAdmin(email)) throw new AuthorizationError('Admin access required for name labels')
|
const { nameLabel } = req.body
|
||||||
if (!nameLabel) throw new ValidationError('nameLabel is required')
|
if (!nameLabel) throw new ValidationError('nameLabel is required')
|
||||||
|
|
||||||
const prefs = await userPreferences.getByUser(userId)
|
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 /api/preferences/name-labels/:id
|
||||||
* Delete name label. Admin only.
|
* Delete name label. Admin only.
|
||||||
*/
|
*/
|
||||||
app.delete('/api/preferences/name-labels/:id', asyncHandler(async (req, res) => {
|
app.delete('/api/preferences/name-labels/:id', requireAuth, asyncHandler(async (req, res) => {
|
||||||
const { userId, email } = req.query
|
if (!isAdmin(req.appwriteUser.email)) throw new AuthorizationError('Admin access required for name labels')
|
||||||
|
|
||||||
|
const userId = req.appwriteUser.id
|
||||||
const { id } = req.params
|
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')
|
if (!id) throw new ValidationError('label id is required')
|
||||||
|
|
||||||
const prefs = await userPreferences.getByUser(userId)
|
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
|
// Legacy Stripe webhook endpoint
|
||||||
app.use('/stripe', stripeRoutes)
|
app.use('/stripe', stripeRoutes)
|
||||||
|
|
||||||
// 404 handler for API routes
|
// Unmatched /api → JSON 404 (Express 4 treats '/api/*' as a literal path, not a wildcard)
|
||||||
app.use('/api/*', (req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
|
const pathOnly = req.originalUrl.split('?')[0]
|
||||||
|
if (!pathOnly.startsWith('/api')) {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
next(new NotFoundError('Endpoint'))
|
next(new NotFoundError('Endpoint'))
|
||||||
})
|
})
|
||||||
|
|
||||||
// SPA fallback for non-API routes
|
// SPA fallback: never send index.html for /api (avoids 404/HTML when public/index.html is missing)
|
||||||
app.get('*', (req, res) => {
|
app.get('*', (req, res, next) => {
|
||||||
res.sendFile(join(__dirname, '..', 'public', 'index.html'))
|
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)
|
// 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(` 🌐 API: http://localhost:${config.port}/api`)
|
||||||
console.log(` 💚 Health: http://localhost:${config.port}/api/health`)
|
console.log(` 💚 Health: http://localhost:${config.port}/api/health`)
|
||||||
console.log('')
|
console.log('')
|
||||||
|
startCounterJobs()
|
||||||
})
|
})
|
||||||
|
|
||||||
export default app
|
export default app
|
||||||
|
|||||||
39
server/jobs/reset-counters.mjs
Normal file
39
server/jobs/reset-counters.mjs
Normal file
@@ -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)')
|
||||||
|
}
|
||||||
58
server/middleware/auth.mjs
Normal file
58
server/middleware/auth.mjs
Normal file
@@ -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 <jwt> 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)
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
* Catches all errors and returns consistent JSON responses
|
* Catches all errors and returns consistent JSON responses
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { AppwriteException } from 'node-appwrite'
|
||||||
|
|
||||||
export class AppError extends Error {
|
export class AppError extends Error {
|
||||||
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
|
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
|
||||||
super(message)
|
super(message)
|
||||||
@@ -56,11 +58,28 @@ export function errorHandler(err, req, res, next) {
|
|||||||
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined,
|
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Default error values
|
// Default error values (AppwriteException uses numeric err.code — do not reuse as JSON "code" string)
|
||||||
let statusCode = err.statusCode || 500
|
let statusCode =
|
||||||
let code = err.code || 'INTERNAL_ERROR'
|
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'
|
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
|
// Handle specific error types
|
||||||
if (err.name === 'ValidationError') {
|
if (err.name === 'ValidationError') {
|
||||||
statusCode = 400
|
statusCode = 400
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { RateLimitError } from './errorHandler.mjs'
|
import { RateLimitError } from './errorHandler.mjs'
|
||||||
|
import { isAdmin } from '../config/index.mjs'
|
||||||
|
|
||||||
// In-memory store for rate limiting (use Redis in production)
|
// In-memory store for rate limiting (use Redis in production)
|
||||||
const requestCounts = new Map()
|
const requestCounts = new Map()
|
||||||
@@ -25,6 +26,7 @@ setInterval(() => {
|
|||||||
* @param {number} options.max - Max requests per window
|
* @param {number} options.max - Max requests per window
|
||||||
* @param {string} options.message - Error message
|
* @param {string} options.message - Error message
|
||||||
* @param {Function} options.keyGenerator - Function to generate unique key
|
* @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 = {}) {
|
export function rateLimit(options = {}) {
|
||||||
const {
|
const {
|
||||||
@@ -32,9 +34,14 @@ export function rateLimit(options = {}) {
|
|||||||
max = 100,
|
max = 100,
|
||||||
message = 'Zu viele Anfragen. Bitte versuche es später erneut.',
|
message = 'Zu viele Anfragen. Bitte versuche es später erneut.',
|
||||||
keyGenerator = (req) => req.ip,
|
keyGenerator = (req) => req.ip,
|
||||||
|
skip = () => false,
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
|
if (skip(req)) {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
const key = keyGenerator(req)
|
const key = keyGenerator(req)
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
|
|
||||||
@@ -80,11 +87,12 @@ export const limiters = {
|
|||||||
message: 'Zu viele Anmeldeversuche. Bitte warte 15 Minuten.',
|
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({
|
emailSort: rateLimit({
|
||||||
windowMs: 60000,
|
windowMs: 60000,
|
||||||
max: 30, // Erhöht für Entwicklung
|
max: 30, // Erhöht für Entwicklung
|
||||||
message: 'E-Mail-Sortierung ist limitiert. Bitte warte eine Minute.',
|
message: 'E-Mail-Sortierung ist limitiert. Bitte warte eine Minute.',
|
||||||
|
skip: (req) => isAdmin(req.appwriteUser?.email),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Limit for AI operations
|
// Limit for AI operations
|
||||||
|
|||||||
32
server/package-lock.json
generated
32
server/package-lock.json
generated
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "email-sorter-server",
|
"name": "mailflow-server",
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "email-sorter-server",
|
"name": "mailflow-server",
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -18,7 +18,9 @@
|
|||||||
"googleapis": "^144.0.0",
|
"googleapis": "^144.0.0",
|
||||||
"imapflow": "^1.2.8",
|
"imapflow": "^1.2.8",
|
||||||
"node-appwrite": "^14.1.0",
|
"node-appwrite": "^14.1.0",
|
||||||
"stripe": "^17.4.0"
|
"node-cron": "^4.2.1",
|
||||||
|
"nodemailer": "^8.0.4",
|
||||||
|
"stripe": "^17.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"jsdom": "^27.4.0"
|
"jsdom": "^27.4.0"
|
||||||
@@ -1119,6 +1121,15 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
@@ -1448,6 +1459,15 @@
|
|||||||
"node-fetch-native-with-agent": "1.7.2"
|
"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": {
|
"node_modules/node-fetch": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
@@ -1497,9 +1517,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemailer": {
|
"node_modules/nodemailer": {
|
||||||
"version": "7.0.13",
|
"version": "8.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
|
||||||
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
|
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
|
||||||
"license": "MIT-0",
|
"license": "MIT-0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
|
|||||||
@@ -41,7 +41,9 @@
|
|||||||
"googleapis": "^144.0.0",
|
"googleapis": "^144.0.0",
|
||||||
"imapflow": "^1.2.8",
|
"imapflow": "^1.2.8",
|
||||||
"node-appwrite": "^14.1.0",
|
"node-appwrite": "^14.1.0",
|
||||||
"stripe": "^17.4.0"
|
"node-cron": "^4.2.1",
|
||||||
|
"nodemailer": "^8.0.4",
|
||||||
|
"stripe": "^17.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"jsdom": "^27.4.0"
|
"jsdom": "^27.4.0"
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ import { asyncHandler, ValidationError } from '../middleware/errorHandler.mjs'
|
|||||||
import { respond } from '../utils/response.mjs'
|
import { respond } from '../utils/response.mjs'
|
||||||
import { db, Collections } from '../services/database.mjs'
|
import { db, Collections } from '../services/database.mjs'
|
||||||
import { log } from '../middleware/logger.mjs'
|
import { log } from '../middleware/logger.mjs'
|
||||||
|
import { requireAuth } from '../middleware/auth.mjs'
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
|
router.use(requireAuth)
|
||||||
|
|
||||||
// Whitelist of allowed event types
|
// Whitelist of allowed event types
|
||||||
const ALLOWED_EVENT_TYPES = [
|
const ALLOWED_EVENT_TYPES = [
|
||||||
'page_view',
|
'page_view',
|
||||||
@@ -79,7 +82,6 @@ function stripPII(metadata) {
|
|||||||
router.post('/track', asyncHandler(async (req, res) => {
|
router.post('/track', asyncHandler(async (req, res) => {
|
||||||
const {
|
const {
|
||||||
type,
|
type,
|
||||||
userId,
|
|
||||||
tracking,
|
tracking,
|
||||||
metadata,
|
metadata,
|
||||||
timestamp,
|
timestamp,
|
||||||
@@ -88,6 +90,8 @@ router.post('/track', asyncHandler(async (req, res) => {
|
|||||||
sessionId,
|
sessionId,
|
||||||
} = req.body
|
} = req.body
|
||||||
|
|
||||||
|
const userId = req.appwriteUser.id
|
||||||
|
|
||||||
// Validate event type
|
// Validate event type
|
||||||
if (!type || !ALLOWED_EVENT_TYPES.includes(type)) {
|
if (!type || !ALLOWED_EVENT_TYPES.includes(type)) {
|
||||||
throw new ValidationError(`Invalid event type. Allowed: ${ALLOWED_EVENT_TYPES.join(', ')}`)
|
throw new ValidationError(`Invalid event type. Allowed: ${ALLOWED_EVENT_TYPES.join(', ')}`)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { products, questions, submissions, orders, onboardingState, emailAccount
|
|||||||
import Stripe from 'stripe'
|
import Stripe from 'stripe'
|
||||||
import { config } from '../config/index.mjs'
|
import { config } from '../config/index.mjs'
|
||||||
import { log } from '../middleware/logger.mjs'
|
import { log } from '../middleware/logger.mjs'
|
||||||
|
import { requireAuth } from '../middleware/auth.mjs'
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
const stripe = new Stripe(config.stripe.secretKey)
|
const stripe = new Stripe(config.stripe.secretKey)
|
||||||
@@ -177,13 +178,9 @@ router.get('/config', (req, res) => {
|
|||||||
* Get current onboarding state
|
* Get current onboarding state
|
||||||
*/
|
*/
|
||||||
router.get('/onboarding/status',
|
router.get('/onboarding/status',
|
||||||
validate({
|
requireAuth,
|
||||||
query: {
|
|
||||||
userId: [rules.required('userId')],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const { userId } = req.query
|
const userId = req.appwriteUser.id
|
||||||
const state = await onboardingState.getByUser(userId)
|
const state = await onboardingState.getByUser(userId)
|
||||||
respond.success(res, state)
|
respond.success(res, state)
|
||||||
})
|
})
|
||||||
@@ -194,15 +191,16 @@ router.get('/onboarding/status',
|
|||||||
* Update onboarding step progress
|
* Update onboarding step progress
|
||||||
*/
|
*/
|
||||||
router.post('/onboarding/step',
|
router.post('/onboarding/step',
|
||||||
|
requireAuth,
|
||||||
validate({
|
validate({
|
||||||
body: {
|
body: {
|
||||||
userId: [rules.required('userId')],
|
|
||||||
step: [rules.required('step')],
|
step: [rules.required('step')],
|
||||||
completedSteps: [rules.isArray('completedSteps')],
|
completedSteps: [rules.isArray('completedSteps')],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
asyncHandler(async (req, res) => {
|
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)
|
await onboardingState.updateStep(userId, step, completedSteps)
|
||||||
respond.success(res, { step, completedSteps })
|
respond.success(res, { step, completedSteps })
|
||||||
})
|
})
|
||||||
@@ -213,13 +211,9 @@ router.post('/onboarding/step',
|
|||||||
* Skip onboarding
|
* Skip onboarding
|
||||||
*/
|
*/
|
||||||
router.post('/onboarding/skip',
|
router.post('/onboarding/skip',
|
||||||
validate({
|
requireAuth,
|
||||||
body: {
|
|
||||||
userId: [rules.required('userId')],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const { userId } = req.body
|
const userId = req.appwriteUser.id
|
||||||
await onboardingState.skip(userId)
|
await onboardingState.skip(userId)
|
||||||
respond.success(res, { skipped: true })
|
respond.success(res, { skipped: true })
|
||||||
})
|
})
|
||||||
@@ -230,13 +224,9 @@ router.post('/onboarding/skip',
|
|||||||
* Resume onboarding
|
* Resume onboarding
|
||||||
*/
|
*/
|
||||||
router.post('/onboarding/resume',
|
router.post('/onboarding/resume',
|
||||||
validate({
|
requireAuth,
|
||||||
body: {
|
|
||||||
userId: [rules.required('userId')],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const { userId } = req.body
|
const userId = req.appwriteUser.id
|
||||||
await onboardingState.resume(userId)
|
await onboardingState.resume(userId)
|
||||||
const state = await onboardingState.getByUser(userId)
|
const state = await onboardingState.getByUser(userId)
|
||||||
respond.success(res, state)
|
respond.success(res, state)
|
||||||
@@ -248,13 +238,9 @@ router.post('/onboarding/resume',
|
|||||||
* Delete all user data and account
|
* Delete all user data and account
|
||||||
*/
|
*/
|
||||||
router.delete('/account/delete',
|
router.delete('/account/delete',
|
||||||
validate({
|
requireAuth,
|
||||||
body: {
|
|
||||||
userId: [rules.required('userId')],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const { userId } = req.body
|
const userId = req.appwriteUser.id
|
||||||
|
|
||||||
log.info(`Account deletion requested for user ${userId}`)
|
log.info(`Account deletion requested for user ${userId}`)
|
||||||
|
|
||||||
@@ -301,7 +287,7 @@ router.delete('/account/delete',
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delete subscription
|
// Delete subscription
|
||||||
const subscription = await subscriptions.getByUser(userId)
|
const subscription = await subscriptions.getByUser(userId, req.appwriteUser?.email)
|
||||||
if (subscription && subscription.$id) {
|
if (subscription && subscription.$id) {
|
||||||
try {
|
try {
|
||||||
await db.delete(Collections.SUBSCRIPTIONS, subscription.$id)
|
await db.delete(Collections.SUBSCRIPTIONS, subscription.$id)
|
||||||
@@ -344,13 +330,9 @@ router.delete('/account/delete',
|
|||||||
* Get or create referral code for user
|
* Get or create referral code for user
|
||||||
*/
|
*/
|
||||||
router.get('/referrals/code',
|
router.get('/referrals/code',
|
||||||
validate({
|
requireAuth,
|
||||||
query: {
|
|
||||||
userId: [rules.required('userId')],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const { userId } = req.query
|
const userId = req.appwriteUser.id
|
||||||
const referral = await referrals.getOrCreateCode(userId)
|
const referral = await referrals.getOrCreateCode(userId)
|
||||||
respond.success(res, {
|
respond.success(res, {
|
||||||
referralCode: referral.referralCode,
|
referralCode: referral.referralCode,
|
||||||
@@ -364,14 +346,15 @@ router.get('/referrals/code',
|
|||||||
* Track a referral (when new user signs up with referral code)
|
* Track a referral (when new user signs up with referral code)
|
||||||
*/
|
*/
|
||||||
router.post('/referrals/track',
|
router.post('/referrals/track',
|
||||||
|
requireAuth,
|
||||||
validate({
|
validate({
|
||||||
body: {
|
body: {
|
||||||
userId: [rules.required('userId')],
|
|
||||||
referralCode: [rules.required('referralCode')],
|
referralCode: [rules.required('referralCode')],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const { userId, referralCode } = req.body
|
const userId = req.appwriteUser.id
|
||||||
|
const { referralCode } = req.body
|
||||||
|
|
||||||
// Find referrer by code
|
// Find referrer by code
|
||||||
const referrer = await referrals.getByCode(referralCode)
|
const referrer = await referrals.getByCode(referralCode)
|
||||||
|
|||||||
@@ -9,11 +9,15 @@ import { validate, rules } from '../middleware/validate.mjs'
|
|||||||
import { limiters } from '../middleware/rateLimit.mjs'
|
import { limiters } from '../middleware/rateLimit.mjs'
|
||||||
import { respond } from '../utils/response.mjs'
|
import { respond } from '../utils/response.mjs'
|
||||||
import { emailAccounts, emailStats, emailDigests, userPreferences, emailUsage, subscriptions } from '../services/database.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 { log } from '../middleware/logger.mjs'
|
||||||
|
import { requireAuthUnlessEmailWebhook } from '../middleware/auth.mjs'
|
||||||
|
import { encryptImapSecret, decryptImapSecret } from '../utils/crypto.mjs'
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
|
router.use(requireAuthUnlessEmailWebhook)
|
||||||
|
|
||||||
// Lazy load heavy services
|
// Lazy load heavy services
|
||||||
let gmailServiceClass = null
|
let gmailServiceClass = null
|
||||||
let outlookServiceClass = null
|
let outlookServiceClass = null
|
||||||
@@ -77,13 +81,13 @@ const DEMO_EMAILS = [
|
|||||||
router.post('/connect',
|
router.post('/connect',
|
||||||
validate({
|
validate({
|
||||||
body: {
|
body: {
|
||||||
userId: [rules.required('userId')],
|
|
||||||
provider: [rules.required('provider'), rules.isIn('provider', ['gmail', 'outlook', 'demo', 'imap'])],
|
provider: [rules.required('provider'), rules.isIn('provider', ['gmail', 'outlook', 'demo', 'imap'])],
|
||||||
email: [rules.required('email'), rules.email()],
|
email: [rules.required('email'), rules.email()],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
asyncHandler(async (req, res) => {
|
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)
|
// IMAP: require password (or accessToken as password)
|
||||||
if (provider === 'imap') {
|
if (provider === 'imap') {
|
||||||
@@ -125,11 +129,12 @@ router.post('/connect',
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create account
|
// Create account
|
||||||
|
const rawImapSecret = provider === 'imap' ? (password || accessToken) : ''
|
||||||
const accountData = {
|
const accountData = {
|
||||||
userId,
|
userId,
|
||||||
provider,
|
provider,
|
||||||
email,
|
email,
|
||||||
accessToken: provider === 'imap' ? (password || accessToken) : (accessToken || ''),
|
accessToken: provider === 'imap' ? encryptImapSecret(rawImapSecret) : (accessToken || ''),
|
||||||
refreshToken: provider === 'imap' ? '' : (refreshToken || ''),
|
refreshToken: provider === 'imap' ? '' : (refreshToken || ''),
|
||||||
expiresAt: provider === 'imap' ? 0 : (expiresAt || 0),
|
expiresAt: provider === 'imap' ? 0 : (expiresAt || 0),
|
||||||
isActive: true,
|
isActive: true,
|
||||||
@@ -157,13 +162,8 @@ router.post('/connect',
|
|||||||
* Connect a demo email account for testing
|
* Connect a demo email account for testing
|
||||||
*/
|
*/
|
||||||
router.post('/connect-demo',
|
router.post('/connect-demo',
|
||||||
validate({
|
|
||||||
body: {
|
|
||||||
userId: [rules.required('userId')],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const { userId } = req.body
|
const userId = req.appwriteUser.id
|
||||||
const demoEmail = `demo-${userId.slice(0, 8)}@mailflow.demo`
|
const demoEmail = `demo-${userId.slice(0, 8)}@mailflow.demo`
|
||||||
|
|
||||||
// Check if demo account already exists
|
// Check if demo account already exists
|
||||||
@@ -207,11 +207,7 @@ router.post('/connect-demo',
|
|||||||
* Get user's connected email accounts
|
* Get user's connected email accounts
|
||||||
*/
|
*/
|
||||||
router.get('/accounts', asyncHandler(async (req, res) => {
|
router.get('/accounts', asyncHandler(async (req, res) => {
|
||||||
const { userId } = req.query
|
const userId = req.appwriteUser.id
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
throw new ValidationError('userId is required')
|
|
||||||
}
|
|
||||||
|
|
||||||
const accounts = await emailAccounts.getByUser(userId)
|
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) => {
|
router.delete('/accounts/:accountId', asyncHandler(async (req, res) => {
|
||||||
const { accountId } = req.params
|
const { accountId } = req.params
|
||||||
const { userId } = req.query
|
const userId = req.appwriteUser.id
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
throw new ValidationError('userId is required')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify ownership
|
// Verify ownership
|
||||||
const account = await emailAccounts.get(accountId)
|
const account = await emailAccounts.get(accountId)
|
||||||
@@ -259,11 +251,7 @@ router.delete('/accounts/:accountId', asyncHandler(async (req, res) => {
|
|||||||
* Get email sorting statistics
|
* Get email sorting statistics
|
||||||
*/
|
*/
|
||||||
router.get('/stats', asyncHandler(async (req, res) => {
|
router.get('/stats', asyncHandler(async (req, res) => {
|
||||||
const { userId } = req.query
|
const userId = req.appwriteUser.id
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
throw new ValidationError('userId is required')
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = await emailStats.getByUser(userId)
|
const stats = await emailStats.getByUser(userId)
|
||||||
|
|
||||||
@@ -299,19 +287,20 @@ router.post('/sort',
|
|||||||
limiters.emailSort,
|
limiters.emailSort,
|
||||||
validate({
|
validate({
|
||||||
body: {
|
body: {
|
||||||
userId: [rules.required('userId')],
|
|
||||||
accountId: [rules.required('accountId')],
|
accountId: [rules.required('accountId')],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
asyncHandler(async (req, res) => {
|
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
|
// 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
|
const isFreeTier = subscription?.isFreeTier || false
|
||||||
|
const adminUser = isAdmin(req.appwriteUser?.email)
|
||||||
// Check free tier limit
|
|
||||||
if (isFreeTier) {
|
// Check free tier limit (admins: unlimited)
|
||||||
|
if (isFreeTier && !adminUser) {
|
||||||
const usage = await emailUsage.getUsage(userId)
|
const usage = await emailUsage.getUsage(userId)
|
||||||
const limit = subscription?.emailsLimit || config.freeTier.emailsPerMonth
|
const limit = subscription?.emailsLimit || config.freeTier.emailsPerMonth
|
||||||
|
|
||||||
@@ -875,7 +864,7 @@ router.post('/sort',
|
|||||||
port: account.imapPort != null ? account.imapPort : 993,
|
port: account.imapPort != null ? account.imapPort : 993,
|
||||||
secure: account.imapSecure !== false,
|
secure: account.imapSecure !== false,
|
||||||
user: account.email,
|
user: account.email,
|
||||||
password: account.accessToken,
|
password: decryptImapSecret(account.accessToken),
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1013,8 +1002,8 @@ router.post('/sort',
|
|||||||
// Update last sync
|
// Update last sync
|
||||||
await emailAccounts.updateLastSync(accountId)
|
await emailAccounts.updateLastSync(accountId)
|
||||||
|
|
||||||
// Update email usage (for free tier tracking)
|
// Update email usage (for free tier tracking; admins are "business", skip counter)
|
||||||
if (isFreeTier) {
|
if (isFreeTier && !adminUser) {
|
||||||
await emailUsage.increment(userId, sortedCount)
|
await emailUsage.increment(userId, sortedCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1202,18 +1191,18 @@ router.post('/sort-demo', asyncHandler(async (req, res) => {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/email/cleanup
|
* POST /api/email/cleanup/mailflow-labels
|
||||||
* Cleanup old MailFlow labels from Gmail
|
* Cleanup old MailFlow labels from Gmail (legacy label names)
|
||||||
*/
|
*/
|
||||||
router.post('/cleanup',
|
router.post('/cleanup/mailflow-labels',
|
||||||
validate({
|
validate({
|
||||||
body: {
|
body: {
|
||||||
userId: [rules.required('userId')],
|
|
||||||
accountId: [rules.required('accountId')],
|
accountId: [rules.required('accountId')],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const { userId, accountId } = req.body
|
const userId = req.appwriteUser.id
|
||||||
|
const { accountId } = req.body
|
||||||
|
|
||||||
const account = await emailAccounts.get(accountId)
|
const account = await emailAccounts.get(accountId)
|
||||||
|
|
||||||
@@ -1246,11 +1235,7 @@ router.post('/cleanup',
|
|||||||
* Get today's sorting digest summary
|
* Get today's sorting digest summary
|
||||||
*/
|
*/
|
||||||
router.get('/digest', asyncHandler(async (req, res) => {
|
router.get('/digest', asyncHandler(async (req, res) => {
|
||||||
const { userId } = req.query
|
const userId = req.appwriteUser.id
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
throw new ValidationError('userId is required')
|
|
||||||
}
|
|
||||||
|
|
||||||
const digest = await emailDigests.getByUserToday(userId)
|
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
|
* Get digest history for the last N days
|
||||||
*/
|
*/
|
||||||
router.get('/digest/history', asyncHandler(async (req, res) => {
|
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) {
|
const digests = await emailDigests.getByUserRecent(userId, parseInt(String(days), 10))
|
||||||
throw new ValidationError('userId is required')
|
|
||||||
}
|
|
||||||
|
|
||||||
const digests = await emailDigests.getByUserRecent(userId, parseInt(days))
|
|
||||||
|
|
||||||
// Calculate totals
|
// Calculate totals
|
||||||
const totals = {
|
const totals = {
|
||||||
@@ -1333,6 +1315,77 @@ router.get('/categories', asyncHandler(async (req, res) => {
|
|||||||
respond.success(res, formattedCategories)
|
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
|
* POST /api/email/webhook/gmail
|
||||||
* Gmail push notification webhook
|
* Gmail push notification webhook
|
||||||
@@ -1380,10 +1433,10 @@ router.post('/webhook/outlook', asyncHandler(async (req, res) => {
|
|||||||
* Can be called manually or by cron job
|
* Can be called manually or by cron job
|
||||||
*/
|
*/
|
||||||
router.post('/cleanup', asyncHandler(async (req, res) => {
|
router.post('/cleanup', asyncHandler(async (req, res) => {
|
||||||
const { userId } = req.body // Optional: only process this user, otherwise all users
|
const userId = req.appwriteUser.id
|
||||||
|
|
||||||
log.info('Cleanup job started', { userId: userId || 'all' })
|
log.info('Cleanup job started', { userId })
|
||||||
|
|
||||||
const results = {
|
const results = {
|
||||||
usersProcessed: 0,
|
usersProcessed: 0,
|
||||||
emailsProcessed: {
|
emailsProcessed: {
|
||||||
@@ -1394,72 +1447,60 @@ router.post('/cleanup', asyncHandler(async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get all users with cleanup enabled
|
const prefs = await userPreferences.getByUser(userId)
|
||||||
let usersToProcess = []
|
if (!prefs?.preferences?.cleanup?.enabled) {
|
||||||
|
return respond.success(res, { ...results, message: 'Cleanup not enabled for this user' })
|
||||||
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.')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If userId provided, process that user
|
const accounts = await emailAccounts.getByUser(userId)
|
||||||
if (userId) {
|
if (!accounts || accounts.length === 0) {
|
||||||
const prefs = await userPreferences.getByUser(userId)
|
return respond.success(res, { ...results, message: 'No email accounts found' })
|
||||||
if (!prefs?.preferences?.cleanup?.enabled) {
|
}
|
||||||
return respond.success(res, { ...results, message: 'Cleanup not enabled for this user' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const accounts = await emailAccounts.getByUser(userId)
|
for (const account of accounts) {
|
||||||
if (!accounts || accounts.length === 0) {
|
if (!account.isActive || !account.accessToken) continue
|
||||||
return respond.success(res, { ...results, message: 'No email accounts found' })
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const account of accounts) {
|
try {
|
||||||
if (!account.isActive || !account.accessToken) continue
|
const cleanup = prefs.preferences.cleanup
|
||||||
|
|
||||||
try {
|
if (cleanup.readItems?.enabled) {
|
||||||
const cleanup = prefs.preferences.cleanup
|
const readItemsCount = await processReadItemsCleanup(
|
||||||
|
account,
|
||||||
// Read Items Cleanup
|
cleanup.readItems.action,
|
||||||
if (cleanup.readItems?.enabled) {
|
cleanup.readItems.gracePeriodDays
|
||||||
const readItemsCount = await processReadItemsCleanup(
|
)
|
||||||
account,
|
results.emailsProcessed.readItems += readItemsCount
|
||||||
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.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)
|
log.success('Cleanup job completed', results)
|
||||||
respond.success(res, results, 'Cleanup completed')
|
respond.success(res, results, 'Cleanup completed')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1607,4 +1648,98 @@ async function processPromotionsCleanup(account, action, deleteAfterDays, matchC
|
|||||||
return processedCount
|
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
|
export default router
|
||||||
|
|||||||
@@ -6,14 +6,26 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { OAuth2Client } from 'google-auth-library'
|
import { OAuth2Client } from 'google-auth-library'
|
||||||
import { ConfidentialClientApplication } from '@azure/msal-node'
|
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 { respond } from '../utils/response.mjs'
|
||||||
import { emailAccounts } from '../services/database.mjs'
|
import { emailAccounts } from '../services/database.mjs'
|
||||||
import { config, features } from '../config/index.mjs'
|
import { config, features } from '../config/index.mjs'
|
||||||
import { log } from '../middleware/logger.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()
|
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)
|
// Google OAuth client (lazy initialization)
|
||||||
let googleClient = null
|
let googleClient = null
|
||||||
|
|
||||||
@@ -71,12 +83,6 @@ const OUTLOOK_SCOPES = [
|
|||||||
* Initiate Gmail OAuth flow
|
* Initiate Gmail OAuth flow
|
||||||
*/
|
*/
|
||||||
router.get('/gmail/connect', asyncHandler(async (req, res) => {
|
router.get('/gmail/connect', asyncHandler(async (req, res) => {
|
||||||
const { userId } = req.query
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
throw new ValidationError('userId ist erforderlich')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!features.gmail()) {
|
if (!features.gmail()) {
|
||||||
throw new AppError('Gmail OAuth ist nicht konfiguriert', 503, 'FEATURE_DISABLED')
|
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',
|
access_type: 'offline',
|
||||||
scope: GMAIL_SCOPES,
|
scope: GMAIL_SCOPES,
|
||||||
prompt: 'consent',
|
prompt: 'consent',
|
||||||
state: JSON.stringify({ userId }),
|
state: buildOAuthState(req.appwriteUser.id),
|
||||||
include_granted_scopes: true,
|
include_granted_scopes: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -118,10 +124,10 @@ router.get('/gmail/callback', asyncHandler(async (req, res) => {
|
|||||||
|
|
||||||
let userId
|
let userId
|
||||||
try {
|
try {
|
||||||
const stateData = JSON.parse(state)
|
const stateData = parseOAuthState(state)
|
||||||
userId = stateData.userId
|
userId = stateData.userId
|
||||||
} catch (e) {
|
} 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`)
|
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)
|
const account = await emailAccounts.get(accountId)
|
||||||
|
|
||||||
|
if (account.userId !== req.appwriteUser.id) {
|
||||||
|
throw new AuthorizationError('No permission for this account')
|
||||||
|
}
|
||||||
|
|
||||||
if (account.provider !== 'gmail') {
|
if (account.provider !== 'gmail') {
|
||||||
throw new ValidationError('Kein Gmail-Konto')
|
throw new ValidationError('Kein Gmail-Konto')
|
||||||
}
|
}
|
||||||
@@ -249,12 +259,6 @@ router.post('/gmail/refresh', asyncHandler(async (req, res) => {
|
|||||||
* Initiate Outlook OAuth flow
|
* Initiate Outlook OAuth flow
|
||||||
*/
|
*/
|
||||||
router.get('/outlook/connect', asyncHandler(async (req, res) => {
|
router.get('/outlook/connect', asyncHandler(async (req, res) => {
|
||||||
const { userId } = req.query
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
throw new ValidationError('userId ist erforderlich')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!features.outlook()) {
|
if (!features.outlook()) {
|
||||||
throw new AppError('Outlook OAuth ist nicht konfiguriert', 503, 'FEATURE_DISABLED')
|
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({
|
const authUrl = await client.getAuthCodeUrl({
|
||||||
scopes: OUTLOOK_SCOPES,
|
scopes: OUTLOOK_SCOPES,
|
||||||
redirectUri: config.microsoft.redirectUri,
|
redirectUri: config.microsoft.redirectUri,
|
||||||
state: JSON.stringify({ userId }),
|
state: buildOAuthState(req.appwriteUser.id),
|
||||||
prompt: 'select_account',
|
prompt: 'select_account',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -286,7 +290,14 @@ router.get('/outlook/callback', asyncHandler(async (req, res) => {
|
|||||||
throw new ValidationError('Code und State sind erforderlich')
|
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()
|
const client = getMsalClient()
|
||||||
|
|
||||||
// Exchange code for tokens
|
// Exchange code for tokens
|
||||||
@@ -334,6 +345,10 @@ router.post('/outlook/refresh', asyncHandler(async (req, res) => {
|
|||||||
|
|
||||||
const account = await emailAccounts.get(accountId)
|
const account = await emailAccounts.get(accountId)
|
||||||
|
|
||||||
|
if (account.userId !== req.appwriteUser.id) {
|
||||||
|
throw new AuthorizationError('No permission for this account')
|
||||||
|
}
|
||||||
|
|
||||||
if (account.provider !== 'outlook') {
|
if (account.provider !== 'outlook') {
|
||||||
throw new ValidationError('Kein Outlook-Konto')
|
throw new ValidationError('Kein Outlook-Konto')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
import Stripe from 'stripe'
|
import Stripe from 'stripe'
|
||||||
|
import { Client, Users } from 'node-appwrite'
|
||||||
import { asyncHandler, ValidationError, NotFoundError } from '../middleware/errorHandler.mjs'
|
import { asyncHandler, ValidationError, NotFoundError } from '../middleware/errorHandler.mjs'
|
||||||
import { validate, rules } from '../middleware/validate.mjs'
|
import { validate, rules } from '../middleware/validate.mjs'
|
||||||
import { limiters } from '../middleware/rateLimit.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 { subscriptions, submissions } from '../services/database.mjs'
|
||||||
import { config } from '../config/index.mjs'
|
import { config } from '../config/index.mjs'
|
||||||
import { log } from '../middleware/logger.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()
|
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)
|
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
|
* Plan configuration
|
||||||
*/
|
*/
|
||||||
|
const PLAN_DISPLAY_NAMES = {
|
||||||
|
basic: 'Basic',
|
||||||
|
pro: 'Pro',
|
||||||
|
business: 'Business',
|
||||||
|
free: 'Free',
|
||||||
|
}
|
||||||
|
|
||||||
const PLANS = {
|
const PLANS = {
|
||||||
basic: {
|
basic: {
|
||||||
name: 'Basic',
|
name: 'Basic',
|
||||||
@@ -63,12 +106,12 @@ router.post('/checkout',
|
|||||||
limiters.auth,
|
limiters.auth,
|
||||||
validate({
|
validate({
|
||||||
body: {
|
body: {
|
||||||
userId: [rules.required('userId')],
|
|
||||||
plan: [rules.required('plan'), rules.isIn('plan', ['basic', 'pro', 'business'])],
|
plan: [rules.required('plan'), rules.isIn('plan', ['basic', 'pro', 'business'])],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const { userId, plan, email } = req.body
|
const userId = req.appwriteUser.id
|
||||||
|
const { plan, email } = req.body
|
||||||
|
|
||||||
const planConfig = PLANS[plan]
|
const planConfig = PLANS[plan]
|
||||||
if (!planConfig) {
|
if (!planConfig) {
|
||||||
@@ -76,7 +119,7 @@ router.post('/checkout',
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for existing subscription
|
// Check for existing subscription
|
||||||
const existing = await subscriptions.getByUser(userId)
|
const existing = await subscriptions.getByUser(userId, req.appwriteUser?.email)
|
||||||
let customerId = existing?.stripeCustomerId
|
let customerId = existing?.stripeCustomerId
|
||||||
|
|
||||||
// Create checkout session
|
// Create checkout session
|
||||||
@@ -124,31 +167,26 @@ router.post('/checkout',
|
|||||||
* Get user's subscription status
|
* Get user's subscription status
|
||||||
*/
|
*/
|
||||||
router.get('/status', asyncHandler(async (req, res) => {
|
router.get('/status', asyncHandler(async (req, res) => {
|
||||||
const { userId } = req.query
|
const userId = req.appwriteUser.id
|
||||||
|
|
||||||
if (!userId) {
|
const sub = await subscriptions.getByUser(userId, req.appwriteUser?.email)
|
||||||
throw new ValidationError('userId ist erforderlich')
|
const topKey = config.topSubscriptionPlan
|
||||||
}
|
const plan = sub.plan || topKey
|
||||||
|
const features =
|
||||||
const sub = await subscriptions.getByUser(userId)
|
PLANS[plan]?.features ||
|
||||||
|
PLANS[topKey]?.features ||
|
||||||
if (!sub) {
|
PLANS.business.features
|
||||||
// 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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
respond.success(res, {
|
respond.success(res, {
|
||||||
status: sub.status,
|
status: sub.status || 'active',
|
||||||
plan: sub.plan,
|
plan,
|
||||||
features: PLANS[sub.plan]?.features || PLANS.basic.features,
|
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,
|
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
|
* Create Stripe Customer Portal session
|
||||||
*/
|
*/
|
||||||
router.post('/portal',
|
router.post('/portal',
|
||||||
validate({
|
|
||||||
body: {
|
|
||||||
userId: [rules.required('userId')],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
asyncHandler(async (req, res) => {
|
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) {
|
if (!sub?.stripeCustomerId) {
|
||||||
throw new NotFoundError('Subscription')
|
throw new NotFoundError('Subscription')
|
||||||
@@ -185,15 +218,10 @@ router.post('/portal',
|
|||||||
* Cancel subscription at period end
|
* Cancel subscription at period end
|
||||||
*/
|
*/
|
||||||
router.post('/cancel',
|
router.post('/cancel',
|
||||||
validate({
|
|
||||||
body: {
|
|
||||||
userId: [rules.required('userId')],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
asyncHandler(async (req, res) => {
|
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) {
|
if (!sub?.stripeSubscriptionId) {
|
||||||
throw new NotFoundError('Subscription')
|
throw new NotFoundError('Subscription')
|
||||||
@@ -216,15 +244,10 @@ router.post('/cancel',
|
|||||||
* Reactivate cancelled subscription
|
* Reactivate cancelled subscription
|
||||||
*/
|
*/
|
||||||
router.post('/reactivate',
|
router.post('/reactivate',
|
||||||
validate({
|
|
||||||
body: {
|
|
||||||
userId: [rules.required('userId')],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
asyncHandler(async (req, res) => {
|
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) {
|
if (!sub?.stripeSubscriptionId) {
|
||||||
throw new NotFoundError('Subscription')
|
throw new NotFoundError('Subscription')
|
||||||
@@ -304,6 +327,29 @@ router.post('/webhook', express.raw({ type: 'application/json' }), asyncHandler(
|
|||||||
})
|
})
|
||||||
|
|
||||||
log.info(`Subscription aktualisiert: ${subscription.id}`)
|
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
|
break
|
||||||
}
|
}
|
||||||
@@ -318,6 +364,23 @@ router.post('/webhook', express.raw({ type: 'application/json' }), asyncHandler(
|
|||||||
})
|
})
|
||||||
|
|
||||||
log.info(`Subscription gelöscht: ${subscription.id}`)
|
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
|
break
|
||||||
}
|
}
|
||||||
@@ -327,7 +390,27 @@ router.post('/webhook', express.raw({ type: 'application/json' }), asyncHandler(
|
|||||||
log.warn(`Zahlung fehlgeschlagen: ${invoice.id}`, {
|
log.warn(`Zahlung fehlgeschlagen: ${invoice.id}`, {
|
||||||
customer: invoice.customer,
|
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
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Client, Databases, Query, ID } from 'node-appwrite'
|
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'
|
import { NotFoundError } from '../middleware/errorHandler.mjs'
|
||||||
|
|
||||||
// Initialize Appwrite client
|
// Initialize Appwrite client
|
||||||
@@ -236,22 +236,26 @@ export const emailStats = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async resetDaily() {
|
async resetDaily() {
|
||||||
// Reset daily counters - would be called by a cron job
|
|
||||||
const allStats = await db.list(Collections.EMAIL_STATS, [])
|
const allStats = await db.list(Collections.EMAIL_STATS, [])
|
||||||
|
let n = 0
|
||||||
for (const stat of allStats) {
|
for (const stat of allStats) {
|
||||||
await db.update(Collections.EMAIL_STATS, stat.$id, { todaySorted: 0 })
|
await db.update(Collections.EMAIL_STATS, stat.$id, { todaySorted: 0 })
|
||||||
|
n++
|
||||||
}
|
}
|
||||||
|
return n
|
||||||
},
|
},
|
||||||
|
|
||||||
async resetWeekly() {
|
async resetWeekly() {
|
||||||
// Reset weekly counters - would be called by a cron job
|
|
||||||
const allStats = await db.list(Collections.EMAIL_STATS, [])
|
const allStats = await db.list(Collections.EMAIL_STATS, [])
|
||||||
|
let n = 0
|
||||||
for (const stat of allStats) {
|
for (const stat of allStats) {
|
||||||
await db.update(Collections.EMAIL_STATS, stat.$id, {
|
await db.update(Collections.EMAIL_STATS, stat.$id, {
|
||||||
weekSorted: 0,
|
weekSorted: 0,
|
||||||
categoriesJson: '{}',
|
categoriesJson: '{}',
|
||||||
})
|
})
|
||||||
|
n++
|
||||||
}
|
}
|
||||||
|
return n
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,42 +303,60 @@ export const emailUsage = {
|
|||||||
* Subscriptions operations
|
* Subscriptions operations
|
||||||
*/
|
*/
|
||||||
export const subscriptions = {
|
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)])
|
const subscription = await db.findOne(Collections.SUBSCRIPTIONS, [Query.equal('userId', userId)])
|
||||||
|
|
||||||
|
let result
|
||||||
|
|
||||||
// If no subscription, user is on free tier
|
// If no subscription, user is on free tier
|
||||||
if (!subscription) {
|
if (!subscription) {
|
||||||
const usage = await emailUsage.getUsage(userId)
|
const usage = await emailUsage.getUsage(userId)
|
||||||
return {
|
result = {
|
||||||
plan: 'free',
|
plan: 'free',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
isFreeTier: true,
|
isFreeTier: true,
|
||||||
emailsUsedThisMonth: usage.emailsProcessed,
|
emailsUsedThisMonth: usage.emailsProcessed,
|
||||||
emailsLimit: 500, // From config
|
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
|
if (viewerEmail && isAdmin(viewerEmail)) {
|
||||||
const isActive = subscription.status === 'active'
|
return {
|
||||||
const isFreeTier = !isActive || subscription.plan === 'free'
|
...result,
|
||||||
|
plan: config.topSubscriptionPlan,
|
||||||
// Get usage for free tier users
|
status: 'active',
|
||||||
let emailsUsedThisMonth = 0
|
isFreeTier: false,
|
||||||
let emailsLimit = -1 // Unlimited for paid
|
emailsLimit: -1,
|
||||||
|
}
|
||||||
if (isFreeTier) {
|
|
||||||
const usage = await emailUsage.getUsage(userId)
|
|
||||||
emailsUsedThisMonth = usage.emailsProcessed
|
|
||||||
emailsLimit = 500 // From config
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return result
|
||||||
...subscription,
|
|
||||||
plan: subscription.plan || 'free',
|
|
||||||
isFreeTier,
|
|
||||||
emailsUsedThisMonth,
|
|
||||||
emailsLimit,
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async getByStripeId(stripeSubscriptionId) {
|
async getByStripeId(stripeSubscriptionId) {
|
||||||
@@ -352,8 +374,8 @@ export const subscriptions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async upsertByUser(userId, data) {
|
async upsertByUser(userId, data) {
|
||||||
const existing = await this.getByUser(userId)
|
const existing = await db.findOne(Collections.SUBSCRIPTIONS, [Query.equal('userId', userId)])
|
||||||
if (existing) {
|
if (existing?.$id) {
|
||||||
return this.update(existing.$id, data)
|
return this.update(existing.$id, data)
|
||||||
}
|
}
|
||||||
return this.create({ userId, ...data })
|
return this.create({ userId, ...data })
|
||||||
@@ -377,6 +399,12 @@ export const userPreferences = {
|
|||||||
autoDetectCompanies: true,
|
autoDetectCompanies: true,
|
||||||
version: 1,
|
version: 1,
|
||||||
categoryAdvanced: {},
|
categoryAdvanced: {},
|
||||||
|
profile: {
|
||||||
|
displayName: '',
|
||||||
|
timezone: '',
|
||||||
|
notificationPrefs: {},
|
||||||
|
},
|
||||||
|
cleanupMeta: {},
|
||||||
cleanup: {
|
cleanup: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
readItems: {
|
readItems: {
|
||||||
@@ -413,6 +441,9 @@ export const userPreferences = {
|
|||||||
companyLabels: preferences.companyLabels || defaults.companyLabels,
|
companyLabels: preferences.companyLabels || defaults.companyLabels,
|
||||||
nameLabels: preferences.nameLabels || defaults.nameLabels,
|
nameLabels: preferences.nameLabels || defaults.nameLabels,
|
||||||
autoDetectCompanies: preferences.autoDetectCompanies !== undefined ? preferences.autoDetectCompanies : defaults.autoDetectCompanies,
|
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,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
74
server/utils/crypto.mjs
Normal file
74
server/utils/crypto.mjs
Normal file
@@ -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}`)
|
||||||
|
}
|
||||||
68
server/utils/mailer.mjs
Normal file
68
server/utils/mailer.mjs
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
44
server/utils/oauth-state.mjs
Normal file
44
server/utils/oauth-state.mjs
Normal file
@@ -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 }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user