Try
dfssdfsfdsf
This commit is contained in:
@@ -41,18 +41,18 @@ GITEA_WEBHOOK_SECRET=your_webhook_secret_here
|
|||||||
# GITEA_WEBHOOK_AUTH_TOKEN=
|
# GITEA_WEBHOOK_AUTH_TOKEN=
|
||||||
|
|
||||||
# Server Configuration
|
# Server Configuration
|
||||||
PORT=3000
|
PORT=3030
|
||||||
BASE_URL=http://localhost:3000
|
BASE_URL=http://localhost:3030
|
||||||
FRONTEND_URL=http://localhost:5173
|
FRONTEND_URL=http://localhost:5173
|
||||||
# CORS_ORIGIN=http://localhost:5173
|
# CORS_ORIGIN=http://localhost:5173
|
||||||
|
|
||||||
# OAuth (optional; Gmail / Outlook)
|
# OAuth (optional; Gmail / Outlook)
|
||||||
# GOOGLE_CLIENT_ID=
|
# GOOGLE_CLIENT_ID=
|
||||||
# GOOGLE_CLIENT_SECRET=
|
# GOOGLE_CLIENT_SECRET=
|
||||||
# GOOGLE_REDIRECT_URI=http://localhost:3000/api/oauth/gmail/callback
|
# GOOGLE_REDIRECT_URI=http://localhost:3030/api/oauth/gmail/callback
|
||||||
# MICROSOFT_CLIENT_ID=
|
# MICROSOFT_CLIENT_ID=
|
||||||
# MICROSOFT_CLIENT_SECRET=
|
# MICROSOFT_CLIENT_SECRET=
|
||||||
# MICROSOFT_REDIRECT_URI=http://localhost:3000/api/oauth/outlook/callback
|
# MICROSOFT_REDIRECT_URI=http://localhost:3030/api/oauth/outlook/callback
|
||||||
|
|
||||||
# HMAC secret for OAuth state (recommended in production). If unset, state is unsigned JSON (dev only).
|
# HMAC secret for OAuth state (recommended in production). If unset, state is unsigned JSON (dev only).
|
||||||
# OAUTH_STATE_SECRET=
|
# OAUTH_STATE_SECRET=
|
||||||
|
|||||||
98
AUDIT_RESULTS.md
Normal file
98
AUDIT_RESULTS.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# Audit, testing, and cleanup — results
|
||||||
|
|
||||||
|
Generated as part of the three-phase plan. The plan file itself was not edited.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Functional testing (automated)
|
||||||
|
|
||||||
|
**Environment:** `GET http://127.0.0.1:3030/...` without `Authorization: Bearer` unless noted.
|
||||||
|
|
||||||
|
| Area | Endpoint / check | Result | Notes |
|
||||||
|
|------|------------------|--------|--------|
|
||||||
|
| Health | `GET /api/health` | **Works** | 200, JSON `service: mailflow-api` |
|
||||||
|
| Auth | `GET /api/me` | **401** | Expected without JWT |
|
||||||
|
| Email | `GET /api/email/accounts` | **401** | Protected |
|
||||||
|
| Email | `GET /api/email/stats` | **401** | Protected |
|
||||||
|
| Email | `GET /api/email/categories` | **401** | Protected |
|
||||||
|
| Email | `POST /api/email/sort` (body `{"accountId":"test"}` via `--data-binary @file`) | **401** | JSON parses; fails at **auth**, not body-parser |
|
||||||
|
| Onboarding | `GET /api/onboarding/status` | **401** | Protected |
|
||||||
|
| OAuth | `GET /api/oauth/status` | **Works** | 200, gmail/outlook config (no auth on this route) |
|
||||||
|
| Digest / subscription / referrals | (from earlier smoke) | **401** | Without token |
|
||||||
|
|
||||||
|
**Manual (browser) — not automated here**
|
||||||
|
|
||||||
|
| Area | Result |
|
||||||
|
|------|--------|
|
||||||
|
| Login / logout, JWT expiry, `ProtectedRoute` | **Not run** — requires Appwrite session in browser |
|
||||||
|
| Dashboard loads, stats, digest, subscription, referral UI | **Not run** |
|
||||||
|
| `POST /api/email/connect`, Gmail/Outlook/IMAP | **Not run** |
|
||||||
|
| `GET /api/oauth/gmail/connect` redirect | **Not run** |
|
||||||
|
| Admin bypass `support@webklar.com` | **Not run** |
|
||||||
|
| Mistral key validity | **Not run** — needs sort path that calls AI |
|
||||||
|
|
||||||
|
**Sort failure diagnosis (without JWT)**
|
||||||
|
|
||||||
|
- Valid JSON + no Bearer → **401** at `requireAuthUnlessEmailWebhook` — **before** subscription, account fetch, or sorting.
|
||||||
|
|
||||||
|
**Recommended next step:** With a real JWT, call `POST /api/email/sort` with a **demo** `accountId` first, then inspect server logs for the first error after auth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Code audit (summary)
|
||||||
|
|
||||||
|
**Legend:** A=dead code, B=duplicate logic, C=performance, D=inconsistent errors, E=console/debug, F=TODO/FIXME, G=file >500 lines, H=hardcoded config
|
||||||
|
|
||||||
|
### Server (`server/`)
|
||||||
|
|
||||||
|
| File | Lines (approx) | A–H notes |
|
||||||
|
|------|----------------|-----------|
|
||||||
|
| [index.mjs](server/index.mjs) | ~415 | E: startup `console.log` (acceptable). |
|
||||||
|
| [routes/email.mjs](server/routes/email.mjs) | **~1700+** | **G** — primary candidate to split (handlers vs Gmail/IMAP/demo). **F** — one `TODO` (~line 1659). D: collection errors partly handled via `db` + route try/catch. |
|
||||||
|
| [routes/stripe.mjs](server/routes/stripe.mjs) | ~400 | D: admin + collection fallbacks present. |
|
||||||
|
| [routes/oauth.mjs](server/routes/oauth.mjs) | — | OAuth flows; review token refresh paths when testing. |
|
||||||
|
| [routes/api.mjs](server/routes/api.mjs) | ~400 | Referral handler null-checks `getOrCreateCode`. |
|
||||||
|
| [routes/analytics.mjs](server/routes/analytics.mjs) | — | E: `console.log` only when `NODE_ENV === 'development'`. |
|
||||||
|
| [routes/webhook.mjs](server/routes/webhook.mjs) | — | — |
|
||||||
|
| [services/database.mjs](server/services/database.mjs) | **~730** | **G.** B: `isCollectionNotFound` vs inline check in `list()` — aligned in Phase 3. D: `create`/`update` return `null` if collection missing — callers must tolerate. |
|
||||||
|
| [middleware/*](server/middleware/) | — | Centralized errors, auth, validate. |
|
||||||
|
| [config/index.mjs](server/config/index.mjs) | — | H: env-driven; admin emails include `support@webklar.com`. |
|
||||||
|
| [utils/*](server/utils/) | — | `appwriteErrors.mjs` for route-level collection detection. |
|
||||||
|
|
||||||
|
### Client (`client/src/`)
|
||||||
|
|
||||||
|
| File | Lines (approx) | A–H notes |
|
||||||
|
|------|----------------|-----------|
|
||||||
|
| [pages/Dashboard.tsx](client/src/pages/Dashboard.tsx) | **~970** | **G.** Auth-gated `loadData`. |
|
||||||
|
| [pages/Settings.tsx](client/src/pages/Settings.tsx) | **~2660+** | **G** — largest UI file; future split by section. |
|
||||||
|
| [lib/api.ts](client/src/lib/api.ts) | ~749 | Central API; `resolveApiBase` type guard for `raw`. |
|
||||||
|
| [lib/appwrite.ts](client/src/lib/appwrite.ts) | ~172 | JWT cache, session checks. |
|
||||||
|
| [context/AuthContext.tsx](client/src/context/AuthContext.tsx) | ~107 | Not under `contexts/` (path note from plan). |
|
||||||
|
| [lib/analytics.ts](client/src/lib/analytics.ts) | — | E: `console.log` in `DEV` for events. |
|
||||||
|
|
||||||
|
**TODO/FIXME grep (sample):** server `email.mjs` — 1 TODO; client — analytics dev log only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Changes executed (after scope approval)
|
||||||
|
|
||||||
|
| File | Change | Risk | Verify |
|
||||||
|
|------|--------|------|--------|
|
||||||
|
| [server/services/database.mjs](server/services/database.mjs) | `list()` catch delegates to `isCollectionNotFound(err)` (DRY). `emailStats.increment` / `emailUsage.increment`: if `db.update`/`db.create` returns `null`, `log.warn` in **development** only (avoids silent failure when collections are missing). | Low | Restart server; `GET /api/health` 200; sort path unchanged except optional dev warnings. |
|
||||||
|
|
||||||
|
**Not done in this pass (defer):** splitting `email.mjs` / `Settings.tsx`, removing all dead imports project-wide (high churn).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files touched (Phase 3)
|
||||||
|
|
||||||
|
- `server/services/database.mjs`
|
||||||
|
- `AUDIT_RESULTS.md` (this file)
|
||||||
|
|
||||||
|
Temporary file `_sort-body.json` removed if present (curl helper).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Restart
|
||||||
|
|
||||||
|
After changes: `cd server && node index.mjs` — confirm `GET /api/health` returns 200.
|
||||||
@@ -100,7 +100,7 @@ npm run dev
|
|||||||
|
|
||||||
Die App ist nun erreichbar unter:
|
Die App ist nun erreichbar unter:
|
||||||
- Frontend: http://localhost:5173
|
- Frontend: http://localhost:5173
|
||||||
- Backend: http://localhost:3000
|
- Backend: http://localhost:3030 (siehe `server/.env` → `PORT` / `mailflow.dev.port.json`)
|
||||||
|
|
||||||
## Konfiguration
|
## Konfiguration
|
||||||
|
|
||||||
@@ -121,13 +121,13 @@ Die App ist nun erreichbar unter:
|
|||||||
1. Erstelle ein Projekt in der [Google Cloud Console](https://console.cloud.google.com)
|
1. Erstelle ein Projekt in der [Google Cloud Console](https://console.cloud.google.com)
|
||||||
2. Aktiviere die Gmail API
|
2. Aktiviere die Gmail API
|
||||||
3. Erstelle OAuth 2.0 Credentials
|
3. Erstelle OAuth 2.0 Credentials
|
||||||
4. Füge `http://localhost:3000/api/oauth/gmail/callback` als Redirect URI hinzu
|
4. Füge `http://localhost:3030/api/oauth/gmail/callback` als Redirect URI hinzu (Port wie in `server/.env`)
|
||||||
|
|
||||||
### Microsoft OAuth (Outlook)
|
### Microsoft OAuth (Outlook)
|
||||||
|
|
||||||
1. Registriere eine App in [Azure AD](https://portal.azure.com)
|
1. Registriere eine App in [Azure AD](https://portal.azure.com)
|
||||||
2. Füge Microsoft Graph Berechtigungen hinzu (Mail.Read, Mail.ReadWrite)
|
2. Füge Microsoft Graph Berechtigungen hinzu (Mail.Read, Mail.ReadWrite)
|
||||||
3. Füge `http://localhost:3000/api/oauth/outlook/callback` als Redirect URI hinzu
|
3. Füge `http://localhost:3030/api/oauth/outlook/callback` als Redirect URI hinzu (Port wie in `server/.env`)
|
||||||
|
|
||||||
### Mistral AI API
|
### Mistral AI API
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ VITE_APPWRITE_PROJECT_ID=
|
|||||||
# APPWRITE_DEV_ORIGIN=
|
# APPWRITE_DEV_ORIGIN=
|
||||||
# VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
|
# VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
|
||||||
|
|
||||||
# Backend: leer = Browser ruft /api auf (Vite-Proxy → 127.0.0.1:3000/api/…).
|
# Backend lokal (empfohlen, gleicher Port wie ../server/.env PORT):
|
||||||
# Nicht VITE_APPWRITE_ENDPOINT (/v1) als VITE_API_URL verwenden — sonst 404.
|
# VITE_DEV_API_ORIGIN = nur Origin, kein /api
|
||||||
# Wenn /api 404: nur EIN Vite auf 5173 (strictPort) — anderen Prozess auf 5173 beenden oder FRONTEND_URL anpassen.
|
# VITE_API_URL = gleicher Host:Port; /api wird in api.ts angehängt
|
||||||
# Wenn /api im Browser 404 liefert: PORT prüfen und ggf. Proxy-Ziel setzen:
|
VITE_DEV_API_ORIGIN=http://127.0.0.1:3030
|
||||||
# VITE_DEV_API_ORIGIN=http://127.0.0.1:3000
|
VITE_API_URL=http://127.0.0.1:3030
|
||||||
# Optional direkt (ohne Proxy): http://localhost:3000 — fehlendes /api wird ergänzt.
|
# Alternativ nur Proxy (relativ): VITE_API_URL=/api und VITE_DEV_API_ORIGIN weglassen — dann muss der Vite-Proxy zum Backend passen.
|
||||||
# VITE_API_URL=
|
# Nicht VITE_APPWRITE_ENDPOINT (/v1) als VITE_API_URL setzen — sonst 404.
|
||||||
|
|||||||
@@ -6,5 +6,5 @@ VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
|
|||||||
VITE_APPWRITE_PROJECT_ID=your-project-id
|
VITE_APPWRITE_PROJECT_ID=your-project-id
|
||||||
|
|
||||||
# OAuth URLs (generated by your backend)
|
# OAuth URLs (generated by your backend)
|
||||||
VITE_GMAIL_OAUTH_URL=http://localhost:3000/api/oauth/gmail
|
VITE_GMAIL_OAUTH_URL=http://localhost:3030/api/oauth/gmail
|
||||||
VITE_OUTLOOK_OAUTH_URL=http://localhost:3000/api/oauth/outlook
|
VITE_OUTLOOK_OAUTH_URL=http://localhost:3030/api/oauth/outlook
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getApiJwt } from './appwrite'
|
import { jwtCache } from './appwrite'
|
||||||
|
import { API_BASE, collapseDoubleApi } from './api'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Analytics & Tracking Utility
|
* Analytics & Tracking Utility
|
||||||
@@ -164,10 +165,10 @@ export async function trackEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const jwt = await getApiJwt()
|
const jwt = jwtCache?.token ?? null // use cached only, don't fetch
|
||||||
if (!jwt) return
|
if (!jwt) return
|
||||||
|
|
||||||
await fetch('/api/analytics/track', {
|
await fetch(collapseDoubleApi(`${API_BASE.replace(/\/$/, '')}/analytics/track`), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -1,29 +1,92 @@
|
|||||||
import { getApiJwt } from './appwrite'
|
import { getApiJwt } from './appwrite'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace every `/api/api` in the path with `/api` until stable (avoids Express catch-all 404).
|
||||||
|
* Call on every outgoing API URL before `fetch`.
|
||||||
|
*/
|
||||||
|
export function collapseDoubleApi(url: string): string {
|
||||||
|
if (!url) return url
|
||||||
|
if (/^https?:\/\//i.test(url)) {
|
||||||
|
try {
|
||||||
|
const u = new URL(url)
|
||||||
|
let p = u.pathname
|
||||||
|
let prev = ''
|
||||||
|
while (p !== prev && p.includes('/api/api')) {
|
||||||
|
prev = p
|
||||||
|
p = p.replace(/\/api\/api/g, '/api')
|
||||||
|
}
|
||||||
|
// z. B. //api → /api (sonst 404 auf dem Server)
|
||||||
|
p = p.replace(/\/+/g, '/')
|
||||||
|
if (p !== '/' && !p.startsWith('/')) {
|
||||||
|
p = `/${p}`
|
||||||
|
}
|
||||||
|
u.pathname = p
|
||||||
|
return u.toString()
|
||||||
|
} catch {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let s = url
|
||||||
|
let prev = ''
|
||||||
|
while (s !== prev && s.includes('/api/api')) {
|
||||||
|
prev = s
|
||||||
|
s = s.replace(/\/api\/api/g, '/api')
|
||||||
|
}
|
||||||
|
s = s.replace(/\/+/g, '/')
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `import.meta.env.VITE_DEV_BACKEND_ORIGIN` (from Vite define) must be origin ONLY, e.g. `http://127.0.0.1:3030`.
|
||||||
|
* Do NOT set a trailing `/api` in .env / .env.local (`VITE_DEV_API_ORIGIN`) — the client appends `/api` once.
|
||||||
|
*/
|
||||||
|
function stripTrailingApiFromOrigin(origin: string): string {
|
||||||
|
let o = origin.replace(/\/+$/, '')
|
||||||
|
while (o.endsWith('/api')) {
|
||||||
|
o = o.slice(0, -4).replace(/\/+$/, '')
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Endpoints in this file are paths like `/subscription/status`.
|
* Endpoints in this file are paths like `/subscription/status`.
|
||||||
* Express mounts the API under `/api`, so the base must end with `/api`.
|
* 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 {
|
function resolveApiBase(): string {
|
||||||
const raw = (import.meta.env.VITE_API_URL as string | undefined)?.trim()
|
const raw = (import.meta.env.VITE_API_URL as string | undefined)?.trim()
|
||||||
if (!raw) return '/api'
|
/** Empty / unset → use Vite define (VITE_DEV_BACKEND_ORIGIN) or relative /api */
|
||||||
|
const hasExplicitViteApiUrl = Boolean(raw)
|
||||||
|
|
||||||
|
if (import.meta.env.DEV && !hasExplicitViteApiUrl) {
|
||||||
|
const origin = (
|
||||||
|
import.meta.env as { VITE_DEV_BACKEND_ORIGIN?: string }
|
||||||
|
).VITE_DEV_BACKEND_ORIGIN?.trim()
|
||||||
|
if (origin) {
|
||||||
|
const o = stripTrailingApiFromOrigin(origin).replace(/\/+$/, '')
|
||||||
|
const withApi = `${o}/api`
|
||||||
|
return collapseDoubleApi(withApi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasExplicitViteApiUrl) return collapseDoubleApi('/api')
|
||||||
|
|
||||||
|
if (!raw) return collapseDoubleApi('/api')
|
||||||
|
|
||||||
if (raw.startsWith('/')) {
|
if (raw.startsWith('/')) {
|
||||||
const p = raw.replace(/\/+$/, '') || '/api'
|
const p = raw.replace(/\/+$/, '') || '/api'
|
||||||
return p
|
return collapseDoubleApi(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!/^https?:\/\//i.test(raw)) {
|
if (!/^https?:\/\//i.test(raw)) {
|
||||||
const p = raw.replace(/^\/+/, '').replace(/\/+$/, '') || 'api'
|
const p = raw.replace(/^\/+/, '').replace(/\/+$/, '') || 'api'
|
||||||
return p.startsWith('api') ? `/${p}` : `/api`
|
const rel = p.startsWith('api') ? `/${p}` : `/api`
|
||||||
|
return collapseDoubleApi(rel)
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = raw.replace(/\/+$/, '')
|
const normalized = collapseDoubleApi(raw.replace(/\/+$/, '').trim())
|
||||||
try {
|
try {
|
||||||
const u = new URL(normalized)
|
const u = new URL(normalized)
|
||||||
const path = u.pathname.replace(/\/$/, '') || '/'
|
const path = u.pathname.replace(/\/+/g, '/').replace(/\/$/, '') || '/'
|
||||||
// Same host as Vite + /v1 = Appwrite-Proxy; never append /api (would hit /v1/api/… → 404)
|
|
||||||
const localVite =
|
const localVite =
|
||||||
/^(localhost|127\.0\.0\.1)$/i.test(u.hostname) &&
|
/^(localhost|127\.0\.0\.1)$/i.test(u.hostname) &&
|
||||||
(u.port === '5173' || (u.port === '' && u.hostname === 'localhost'))
|
(u.port === '5173' || (u.port === '' && u.hostname === 'localhost'))
|
||||||
@@ -31,31 +94,40 @@ function resolveApiBase(): string {
|
|||||||
return '/api'
|
return '/api'
|
||||||
}
|
}
|
||||||
if (path === '/' || path === '') {
|
if (path === '/' || path === '') {
|
||||||
return `${normalized}/api`
|
const originOnly = normalized.replace(/\/+$/, '')
|
||||||
|
return collapseDoubleApi(`${originOnly}/api`)
|
||||||
}
|
}
|
||||||
if (path.endsWith('/api')) {
|
if (path.endsWith('/api')) {
|
||||||
return normalized
|
return collapseDoubleApi(normalized)
|
||||||
}
|
}
|
||||||
return `${normalized}/api`
|
const originOnly = normalized.replace(/\/+$/, '')
|
||||||
|
return collapseDoubleApi(`${originOnly}/api`)
|
||||||
} catch {
|
} catch {
|
||||||
return '/api'
|
return '/api'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_BASE = resolveApiBase()
|
export const API_BASE = collapseDoubleApi(resolveApiBase())
|
||||||
|
|
||||||
/** Root-relative or absolute API URL; avoids `api/foo` (relative to current route). */
|
/** Join API base (ends with `/api`) and endpoint (`/email/...`). If endpoint starts with `/api`, strip it once so we never produce `/api/api`. */
|
||||||
function joinApiUrl(base: string, endpoint: string): string {
|
function joinApiUrl(base: string, endpoint: string): string {
|
||||||
const ep = endpoint.startsWith('/') ? endpoint : `/${endpoint}`
|
let ep = endpoint.startsWith('/') ? endpoint : `/${endpoint}`
|
||||||
|
if (ep === '/api' || ep.startsWith('/api/')) {
|
||||||
|
ep = ep === '/api' ? '/' : ep.slice(4)
|
||||||
|
if (ep !== '' && !ep.startsWith('/')) {
|
||||||
|
ep = `/${ep}`
|
||||||
|
}
|
||||||
|
}
|
||||||
if (/^https?:\/\//i.test(base)) {
|
if (/^https?:\/\//i.test(base)) {
|
||||||
return `${base.replace(/\/+$/, '')}${ep}`
|
const joined = `${base.replace(/\/+$/, '')}${ep}`
|
||||||
|
return collapseDoubleApi(joined)
|
||||||
}
|
}
|
||||||
let b = base.trim()
|
let b = base.trim()
|
||||||
if (!b.startsWith('/')) {
|
if (!b.startsWith('/')) {
|
||||||
b = `/${b.replace(/^\/+/, '')}`
|
b = `/${b.replace(/^\/+/, '')}`
|
||||||
}
|
}
|
||||||
b = b.replace(/\/+$/, '') || '/api'
|
b = b.replace(/\/+$/, '') || '/api'
|
||||||
return `${b}${ep}`
|
return collapseDoubleApi(`${b}${ep}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
@@ -86,7 +158,7 @@ async function fetchApi<T>(
|
|||||||
headers['Authorization'] = `Bearer ${jwt}`
|
headers['Authorization'] = `Bearer ${jwt}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const urlJoined = joinApiUrl(API_BASE, endpoint)
|
const urlJoined = collapseDoubleApi(joinApiUrl(API_BASE, endpoint))
|
||||||
|
|
||||||
const response = await fetch(urlJoined, {
|
const response = await fetch(urlJoined, {
|
||||||
...options,
|
...options,
|
||||||
@@ -100,12 +172,16 @@ async function fetchApi<T>(
|
|||||||
: { success: false as const, error: undefined }
|
: { success: false as const, error: undefined }
|
||||||
|
|
||||||
if (!isJson) {
|
if (!isJson) {
|
||||||
|
const devHint =
|
||||||
|
import.meta.env.DEV && response.status === 404
|
||||||
|
? ` API_BASE=${API_BASE}`
|
||||||
|
: ''
|
||||||
return {
|
return {
|
||||||
error: {
|
error: {
|
||||||
code: response.status === 404 ? 'NOT_FOUND' : 'INVALID_RESPONSE',
|
code: response.status === 404 ? 'NOT_FOUND' : 'INVALID_RESPONSE',
|
||||||
message:
|
message:
|
||||||
response.status === 404
|
response.status === 404
|
||||||
? 'API 404: backend unreachable or wrong port — check server is running and VITE_DEV_API_ORIGIN matches PORT.'
|
? `API 404: Backend antwortet nicht (falscher Port oder alter Prozess). Server starten: cd server && npm run start. PORT wie in server/.env.${devHint}`
|
||||||
: `Expected JSON, got ${ct || 'unknown'} (HTTP ${response.status})`,
|
: `Expected JSON, got ${ct || 'unknown'} (HTTP ${response.status})`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -122,10 +198,15 @@ async function fetchApi<T>(
|
|||||||
|
|
||||||
return { success: true, data: data.data ?? data }
|
return { success: true, data: data.data ?? data }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const base = error instanceof Error ? error.message : 'Network error'
|
||||||
|
const devHint =
|
||||||
|
import.meta.env.DEV
|
||||||
|
? ` API_BASE=${API_BASE} — Ist der Server an diesem Port gestartet? (cd server && npm run start)`
|
||||||
|
: ''
|
||||||
return {
|
return {
|
||||||
error: {
|
error: {
|
||||||
code: 'NETWORK_ERROR',
|
code: 'NETWORK_ERROR',
|
||||||
message: error instanceof Error ? error.message : 'Network error',
|
message: `${base}${devHint}`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,6 +298,30 @@ export const api = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async recoverEmails(accountId: string) {
|
||||||
|
return fetchApi<{
|
||||||
|
recovered: number
|
||||||
|
folders: Array<{ folder: string; count: number }>
|
||||||
|
message: string
|
||||||
|
}>(`/email/recover/${accountId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Move mail from sort-related folders (Junk, Archive, MailFlow/…) back to INBOX and strip $MailFlow-* keywords (IMAP only). */
|
||||||
|
async reSortEmails(accountId: string) {
|
||||||
|
return fetchApi<{
|
||||||
|
recovered: number
|
||||||
|
folders: Array<{ folder: string; count: number }>
|
||||||
|
mailFlowKeywordsStripped: number
|
||||||
|
message: string
|
||||||
|
}>(`/email/re-sort/${accountId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
async sortDemo(count: number = 10) {
|
async sortDemo(count: number = 10) {
|
||||||
return fetchApi<{
|
return fetchApi<{
|
||||||
sorted: number
|
sorted: number
|
||||||
@@ -514,6 +619,17 @@ export const api = {
|
|||||||
return fetchApi<{ isAdmin: boolean }>('/me')
|
return fetchApi<{ isAdmin: boolean }>('/me')
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async resetSortData(email: string) {
|
||||||
|
return fetchApi<{
|
||||||
|
reset: boolean
|
||||||
|
deleted?: { stats: number; digests: number; usage: number }
|
||||||
|
imapCleared?: number
|
||||||
|
}>('/admin/reset-user-sort-data', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
// NAME LABELS (Workers – Admin only)
|
// NAME LABELS (Workers – Admin only)
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -51,35 +51,51 @@ 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
|
export let jwtCache: { token: string; expMs: number } | null = null
|
||||||
let jwtCache: { token: string; expMs: number } | null = null
|
let jwtFetchPromise: Promise<string | null> | null = null
|
||||||
|
|
||||||
export function clearApiJwtCache() {
|
export function clearApiJwtCache() {
|
||||||
jwtCache = null
|
jwtCache = null
|
||||||
|
jwtFetchPromise = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Short-lived JWT for MailFlow API (Bearer). Cached until near expiry. */
|
/** Short-lived JWT for MailFlow API (Bearer). Cached until near expiry. */
|
||||||
export async function getApiJwt(): Promise<string | null> {
|
export async function getApiJwt(): Promise<string | null> {
|
||||||
if (!isAppwriteClientConfigured()) {
|
if (!isAppwriteClientConfigured()) return null
|
||||||
return null
|
if (isLocalViteAppwriteProxy() && !hasCookieFallbackSession()) return null
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
// Return cached token if still valid
|
||||||
|
if (jwtCache && jwtCache.expMs > now + 30_000) {
|
||||||
|
return jwtCache.token
|
||||||
}
|
}
|
||||||
if (isLocalViteAppwriteProxy() && !hasCookieFallbackSession()) {
|
|
||||||
return null
|
// If a fetch is already in progress, wait for it (no parallel requests)
|
||||||
|
if (jwtFetchPromise) {
|
||||||
|
return jwtFetchPromise
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
const now = Date.now()
|
// Start a new fetch
|
||||||
if (jwtCache && jwtCache.expMs > now + JWT_BUFFER_MS) {
|
jwtFetchPromise = (async () => {
|
||||||
return jwtCache.token
|
try {
|
||||||
|
const res = await account.createJWT()
|
||||||
|
const token = res.jwt
|
||||||
|
jwtCache = { token, expMs: Date.now() + 14 * 60 * 1000 }
|
||||||
|
return token
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// On 429: return cached token if we have one (even if expired)
|
||||||
|
if (jwtCache?.token && (err as { code?: number })?.code === 429) {
|
||||||
|
return jwtCache.token
|
||||||
|
}
|
||||||
|
jwtCache = null
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
jwtFetchPromise = null
|
||||||
}
|
}
|
||||||
const res = await account.createJWT()
|
})()
|
||||||
const token = res.jwt
|
|
||||||
const expireSec = (res as { expire?: number }).expire
|
return jwtFetchPromise
|
||||||
const expMs = expireSec != null ? expireSec * 1000 : now + 14 * 60 * 1000
|
|
||||||
jwtCache = { token, expMs }
|
|
||||||
return token
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth helper functions
|
// Auth helper functions
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
import { API_BASE } from './lib/api'
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.info('[MailFlow] API_BASE =', API_BASE, '(muss zu server/.env PORT passen; Vite nach .env-Änderung neu starten)')
|
||||||
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useNavigate, Link } from 'react-router-dom'
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
import { useAuth } from '@/context/AuthContext'
|
import { useAuth } from '@/context/AuthContext'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -79,7 +79,7 @@ interface Digest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const { user, logout } = useAuth()
|
const { user, logout, loading: authLoading } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [stats, setStats] = useState<EmailStats | null>(null)
|
const [stats, setStats] = useState<EmailStats | null>(null)
|
||||||
const [accounts, setAccounts] = useState<EmailAccount[]>([])
|
const [accounts, setAccounts] = useState<EmailAccount[]>([])
|
||||||
@@ -103,17 +103,11 @@ export function Dashboard() {
|
|||||||
setTimeout(() => setMessage(null), 5000)
|
setTimeout(() => setMessage(null), 5000)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
const loadData = useCallback(async () => {
|
||||||
if (user?.$id) {
|
|
||||||
loadData()
|
|
||||||
}
|
|
||||||
}, [user?.$id])
|
|
||||||
|
|
||||||
const loadData = async () => {
|
|
||||||
if (!user?.$id) return
|
if (!user?.$id) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [statsRes, accountsRes, digestRes, subscriptionRes, referralRes] = await Promise.all([
|
const [statsRes, accountsRes, digestRes, subscriptionRes, referralRes] = await Promise.all([
|
||||||
api.getEmailStats(),
|
api.getEmailStats(),
|
||||||
@@ -122,7 +116,7 @@ export function Dashboard() {
|
|||||||
api.getSubscriptionStatus(),
|
api.getSubscriptionStatus(),
|
||||||
api.getReferralCode().catch(() => ({ data: null })),
|
api.getReferralCode().catch(() => ({ data: null })),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (statsRes.data) setStats(statsRes.data)
|
if (statsRes.data) setStats(statsRes.data)
|
||||||
if (accountsRes.data) setAccounts(accountsRes.data)
|
if (accountsRes.data) setAccounts(accountsRes.data)
|
||||||
if (digestRes.data) setDigest(digestRes.data)
|
if (digestRes.data) setDigest(digestRes.data)
|
||||||
@@ -130,57 +124,83 @@ export function Dashboard() {
|
|||||||
if (referralRes.data) setReferralCode(referralRes.data.referralCode)
|
if (referralRes.data) setReferralCode(referralRes.data.referralCode)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading dashboard data:', err)
|
console.error('Error loading dashboard data:', err)
|
||||||
setError('Couldn’t load your data. Check your connection and refresh.')
|
setError('Couldn’t load your data. Check your connection and refresh.')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [user?.$id])
|
||||||
|
|
||||||
const handleSortNow = async () => {
|
useEffect(() => {
|
||||||
if (!user?.$id || accounts.length === 0) {
|
if (authLoading) return
|
||||||
setError('Connect your inbox first, then click Sort Now.')
|
if (!user?.$id) {
|
||||||
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
loadData()
|
||||||
|
}, [authLoading, user?.$id, loadData])
|
||||||
|
|
||||||
|
const handleSortNow = async () => {
|
||||||
|
if (!user?.$id) {
|
||||||
|
setError('You must be signed in to sort emails.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const primary = accounts.find((a) => Boolean(a?.id))
|
||||||
|
if (!primary?.id) {
|
||||||
|
const msg =
|
||||||
|
'Please connect an email account first. Add Gmail or Outlook in Settings, then try again.'
|
||||||
|
setError(msg)
|
||||||
|
showMessage('error', msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setSorting(true)
|
setSorting(true)
|
||||||
setSortResult(null)
|
setSortResult(null)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api.sortEmails(accounts[0].id)
|
const result = await api.sortEmails(primary.id)
|
||||||
if (result.data) {
|
|
||||||
setSortResult(result.data)
|
if (result.error) {
|
||||||
|
if (result.error.code === 'LIMIT_REACHED') {
|
||||||
// Track sort completed
|
setError(result.error.message || 'Monthly limit reached')
|
||||||
trackSortCompleted(user.$id, result.data.sorted, result.data.isFirstRun || false)
|
trackLimitReached(user.$id, result.error.limit || 500, result.error.used || 500)
|
||||||
|
const subscriptionRes = await api.getSubscriptionStatus()
|
||||||
// Refresh stats, digest, and subscription
|
|
||||||
const [statsRes, digestRes, subscriptionRes] = await Promise.all([
|
|
||||||
api.getEmailStats(),
|
|
||||||
api.getDigest(),
|
|
||||||
api.getSubscriptionStatus(),
|
|
||||||
])
|
|
||||||
if (statsRes.data) setStats(statsRes.data)
|
|
||||||
if (digestRes.data) setDigest(digestRes.data)
|
|
||||||
if (subscriptionRes.data) setSubscription(subscriptionRes.data)
|
if (subscriptionRes.data) setSubscription(subscriptionRes.data)
|
||||||
} else if (result.error) {
|
} else {
|
||||||
// Check if it's a limit reached error
|
setError(
|
||||||
if (result.error.code === 'LIMIT_REACHED') {
|
result.error.message ||
|
||||||
setError(result.error.message || 'Monthly limit reached')
|
'Email sorting failed. Please try again or reconnect your account.'
|
||||||
trackLimitReached(user.$id, result.error.limit || 500, result.error.used || 500)
|
)
|
||||||
// Refresh subscription to show updated usage
|
|
||||||
const subscriptionRes = await api.getSubscriptionStatus()
|
|
||||||
if (subscriptionRes.data) setSubscription(subscriptionRes.data)
|
|
||||||
} else {
|
|
||||||
setError(result.error.message || 'Email sorting failed. Please try again or reconnect your account.')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.data) {
|
||||||
|
setSortResult(result.data)
|
||||||
|
showMessage(
|
||||||
|
'success',
|
||||||
|
`Sorted ${result.data.sorted} email${result.data.sorted === 1 ? '' : 's'}.`
|
||||||
|
)
|
||||||
|
trackSortCompleted(user.$id, result.data.sorted, result.data.isFirstRun || false)
|
||||||
|
|
||||||
|
const [statsRes, digestRes, subscriptionRes] = await Promise.all([
|
||||||
|
api.getEmailStats(),
|
||||||
|
api.getDigest(),
|
||||||
|
api.getSubscriptionStatus(),
|
||||||
|
])
|
||||||
|
if (statsRes.data) setStats(statsRes.data)
|
||||||
|
if (digestRes.data) setDigest(digestRes.data)
|
||||||
|
if (subscriptionRes.data) setSubscription(subscriptionRes.data)
|
||||||
|
} else {
|
||||||
|
setError('No data returned from the server. Please try again.')
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error sorting emails:', err)
|
console.error('Error sorting emails:', err)
|
||||||
setError('Something went wrong. Check your connection and try again.')
|
setError('Something went wrong. Check your connection and try again.')
|
||||||
} finally {
|
} finally {
|
||||||
setSorting(false)
|
setSorting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
@@ -290,6 +310,43 @@ export function Dashboard() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="w-full px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
<main className="w-full px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||||
|
{!loading && accounts.length === 0 && (
|
||||||
|
<div
|
||||||
|
className="mb-6 p-4 rounded-xl border border-amber-200 dark:border-amber-800 bg-amber-50/90 dark:bg-amber-950/40 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-amber-900 dark:text-amber-200">
|
||||||
|
No email account connected
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-amber-800/90 dark:text-amber-300/90 mt-1">
|
||||||
|
Connect Gmail or Outlook to run sorting and see stats here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 shrink-0">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate('/setup')}
|
||||||
|
className="bg-amber-600 hover:bg-amber-700 text-white"
|
||||||
|
aria-label="Connect email inbox"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-1.5" />
|
||||||
|
Connect inbox
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate('/settings?tab=accounts')}
|
||||||
|
className="border-amber-300 dark:border-amber-700"
|
||||||
|
aria-label="Open account settings"
|
||||||
|
>
|
||||||
|
Account settings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Dashboard Header */}
|
{/* Dashboard Header */}
|
||||||
<div className="mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -188,6 +188,8 @@ export function Settings() {
|
|||||||
const [showImapForm, setShowImapForm] = useState(false)
|
const [showImapForm, setShowImapForm] = useState(false)
|
||||||
const [imapForm, setImapForm] = useState({ email: '', password: '', imapHost: 'imap.porkbun.com', imapPort: 993, imapSecure: true })
|
const [imapForm, setImapForm] = useState({ email: '', password: '', imapHost: 'imap.porkbun.com', imapPort: 993, imapSecure: true })
|
||||||
const [imapConnecting, setImapConnecting] = useState(false)
|
const [imapConnecting, setImapConnecting] = useState(false)
|
||||||
|
const [recoveringAccountId, setRecoveringAccountId] = useState<string | null>(null)
|
||||||
|
const [reSortingAccountId, setReSortingAccountId] = useState<string | null>(null)
|
||||||
const [vipSenders, setVipSenders] = useState<VIPSender[]>([])
|
const [vipSenders, setVipSenders] = useState<VIPSender[]>([])
|
||||||
const [newVipEmail, setNewVipEmail] = useState('')
|
const [newVipEmail, setNewVipEmail] = useState('')
|
||||||
const [subscription, setSubscription] = useState<Subscription | null>(null)
|
const [subscription, setSubscription] = useState<Subscription | null>(null)
|
||||||
@@ -224,6 +226,7 @@ export function Settings() {
|
|||||||
const [showNameLabelPanel, setShowNameLabelPanel] = useState(false)
|
const [showNameLabelPanel, setShowNameLabelPanel] = useState(false)
|
||||||
const [referralData, setReferralData] = useState<{ referralCode: string; referralCount: number } | null>(null)
|
const [referralData, setReferralData] = useState<{ referralCode: string; referralCount: number } | null>(null)
|
||||||
const [loadingReferral, setLoadingReferral] = useState(false)
|
const [loadingReferral, setLoadingReferral] = useState(false)
|
||||||
|
const [resettingSort, setResettingSort] = useState(false)
|
||||||
|
|
||||||
// Control Panel Sub-Tabs
|
// Control Panel Sub-Tabs
|
||||||
const [controlPanelTab, setControlPanelTab] = useState<'rules' | 'cleanup' | 'labels'>('rules')
|
const [controlPanelTab, setControlPanelTab] = useState<'rules' | 'cleanup' | 'labels'>('rules')
|
||||||
@@ -612,6 +615,51 @@ export function Settings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleRecoverEmails = async (accountId: string) => {
|
||||||
|
if (!user?.$id) return
|
||||||
|
setRecoveringAccountId(accountId)
|
||||||
|
try {
|
||||||
|
const res = await api.recoverEmails(accountId)
|
||||||
|
if (res.error) {
|
||||||
|
showMessage('error', res.error.message || 'Recovery failed')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = res.data as { recovered?: number; message?: string } | undefined
|
||||||
|
const n = data?.recovered ?? 0
|
||||||
|
const text =
|
||||||
|
data?.message ||
|
||||||
|
(n > 0 ? `${n} emails recovered to inbox` : 'No emails found outside inbox')
|
||||||
|
showMessage('success', text)
|
||||||
|
} finally {
|
||||||
|
setRecoveringAccountId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReSortEmails = async (accountId: string) => {
|
||||||
|
if (!user?.$id) return
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
'Move messages from Junk, Archive, MailFlow/EmailSorter folders (and similar sort targets) back to INBOX and remove MailFlow category tags? Then run Sort again. Sent/Drafts/Trash are not touched.',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setReSortingAccountId(accountId)
|
||||||
|
try {
|
||||||
|
const res = await api.reSortEmails(accountId)
|
||||||
|
if (res.error) {
|
||||||
|
showMessage('error', res.error.message || 'Re-sort prep failed')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = res.data as
|
||||||
|
| { recovered?: number; mailFlowKeywordsStripped?: number; message?: string }
|
||||||
|
| undefined
|
||||||
|
showMessage('success', data?.message || 'Re-sort prep completed')
|
||||||
|
} finally {
|
||||||
|
setReSortingAccountId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleConnectImap = async (e: React.FormEvent) => {
|
const handleConnectImap = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!user?.$id || !imapForm.email.trim() || !imapForm.password) return
|
if (!user?.$id || !imapForm.email.trim() || !imapForm.password) return
|
||||||
@@ -1025,10 +1073,45 @@ export function Settings() {
|
|||||||
<p className="text-sm text-slate-500 dark:text-slate-400 capitalize">{account.provider === 'imap' ? 'IMAP' : account.provider}</p>
|
<p className="text-sm text-slate-500 dark:text-slate-400 capitalize">{account.provider === 'imap' ? 'IMAP' : account.provider}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 flex-wrap justify-end">
|
||||||
<Badge variant={account.connected ? 'success' : 'secondary'}>
|
<Badge variant={account.connected ? 'success' : 'secondary'}>
|
||||||
{account.connected ? 'Connected' : 'Disconnected'}
|
{account.connected ? 'Connected' : 'Disconnected'}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{account.provider === 'imap' && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={
|
||||||
|
recoveringAccountId === account.id ||
|
||||||
|
reSortingAccountId === account.id
|
||||||
|
}
|
||||||
|
onClick={() => handleRecoverEmails(account.id)}
|
||||||
|
>
|
||||||
|
{recoveringAccountId === account.id ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||||
|
) : null}
|
||||||
|
Recover Emails
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={
|
||||||
|
recoveringAccountId === account.id ||
|
||||||
|
reSortingAccountId === account.id
|
||||||
|
}
|
||||||
|
onClick={() => handleReSortEmails(account.id)}
|
||||||
|
title="Reset wrongly sorted mail: move from Junk/Archive/MailFlow folders to INBOX and clear MailFlow tags"
|
||||||
|
>
|
||||||
|
{reSortingAccountId === account.id ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||||
|
) : null}
|
||||||
|
Re-sort all
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Button variant="ghost" size="icon" onClick={() => handleDisconnectAccount(account.id)}>
|
<Button variant="ghost" size="icon" onClick={() => handleDisconnectAccount(account.id)}>
|
||||||
<Trash2 className="w-4 h-4 text-red-500" />
|
<Trash2 className="w-4 h-4 text-red-500" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -2325,6 +2408,67 @@ export function Settings() {
|
|||||||
|
|
||||||
{activeTab === 'name-labels' && isAdmin && (
|
{activeTab === 'name-labels' && isAdmin && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<Card className="border-amber-200 dark:border-amber-900">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="w-5 h-5 text-amber-600 dark:text-amber-400" />
|
||||||
|
<CardTitle>Reset sort data (admin)</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
Clears email stats, digests, usage, and onboarding progress for the chosen user. Removes the{' '}
|
||||||
|
<code className="text-xs">$MailFlow-sorted</code> flag from all messages in each IMAP account's INBOX.
|
||||||
|
Does not remove email connections or subscriptions.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={resettingSort}
|
||||||
|
onClick={async () => {
|
||||||
|
const email = window.prompt('User email to reset:', 'support@webklar.com')
|
||||||
|
if (email == null) return
|
||||||
|
const trimmed = email.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
showMessage('error', 'Email is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Reset ALL sort-related data for ${trimmed}? This cannot be undone.`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setResettingSort(true)
|
||||||
|
try {
|
||||||
|
const res = await api.resetSortData(trimmed)
|
||||||
|
if (res.error) {
|
||||||
|
showMessage('error', res.error.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const d = res.data
|
||||||
|
showMessage(
|
||||||
|
'success',
|
||||||
|
`Reset OK. Stats/digests/usage cleared; IMAP $MailFlow-sorted removed from ${d?.imapCleared ?? 0} message(s).`,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setResettingSort(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{resettingSort ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Resetting…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Reset sort data'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
12
client/src/vite-env.d.ts
vendored
Normal file
12
client/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_URL?: string
|
||||||
|
readonly VITE_DEV_BACKEND_ORIGIN?: string
|
||||||
|
readonly VITE_APPWRITE_ENDPOINT?: string
|
||||||
|
readonly VITE_APPWRITE_PROJECT_ID?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
@@ -2,16 +2,54 @@ 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'
|
||||||
|
import fs from 'fs'
|
||||||
|
|
||||||
|
/** Same default as server when `server/.env` has no PORT (see repo root `mailflow.dev.port.json`). */
|
||||||
|
function readMailflowDevPort(): string {
|
||||||
|
const f = path.resolve(__dirname, '../mailflow.dev.port.json')
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(fs.readFileSync(f, 'utf8')) as { port?: number }
|
||||||
|
if (j.port != null && Number.isFinite(Number(j.port))) return String(j.port)
|
||||||
|
} catch {
|
||||||
|
/* missing or invalid */
|
||||||
|
}
|
||||||
|
return '3030'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Align Vite proxy with server/.env PORT (client/.env does not load server PORT). */
|
||||||
|
function readBackendPortFromServerEnv(): string {
|
||||||
|
const envFile = path.resolve(__dirname, '../server/.env')
|
||||||
|
try {
|
||||||
|
let text = fs.readFileSync(envFile, 'utf8')
|
||||||
|
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1)
|
||||||
|
for (const line of text.split(/\r?\n/)) {
|
||||||
|
if (/^\s*#/.test(line)) continue
|
||||||
|
const noComment = line.split('#')[0]?.trim() || ''
|
||||||
|
const m = /^\s*PORT\s*=\s*"?(\d+)"?/i.exec(noComment)
|
||||||
|
if (m) return m[1]
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* no server/.env */
|
||||||
|
}
|
||||||
|
return readMailflowDevPort()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Origin only (no trailing /api) — matches client api.ts / VITE_DEV_BACKEND_ORIGIN contract. */
|
||||||
|
function stripTrailingApiFromOrigin(o: string): string {
|
||||||
|
let s = o.replace(/\/$/, '')
|
||||||
|
while (s.endsWith('/api')) {
|
||||||
|
s = s.slice(0, -4).replace(/\/+$/, '')
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const env = loadEnv(mode, __dirname, '')
|
const env = loadEnv(mode, __dirname, '')
|
||||||
const appwriteDevOrigin = (env.APPWRITE_DEV_ORIGIN || '').replace(/\/$/, '')
|
const appwriteDevOrigin = (env.APPWRITE_DEV_ORIGIN || '').replace(/\/$/, '')
|
||||||
|
const defaultApiOrigin = `http://127.0.0.1:${readBackendPortFromServerEnv()}`
|
||||||
// 127.0.0.1 avoids Windows localhost → IPv6 (::1) vs backend listening on IPv4-only
|
// 127.0.0.1 avoids Windows localhost → IPv6 (::1) vs backend listening on IPv4-only
|
||||||
const apiDevTarget = (env.VITE_DEV_API_ORIGIN || 'http://127.0.0.1:3000').replace(
|
const apiDevTarget = stripTrailingApiFromOrigin(env.VITE_DEV_API_ORIGIN || defaultApiOrigin)
|
||||||
/\/$/,
|
|
||||||
''
|
|
||||||
)
|
|
||||||
|
|
||||||
const proxy: Record<
|
const proxy: Record<
|
||||||
string,
|
string,
|
||||||
@@ -37,6 +75,13 @@ export default defineConfig(({ mode }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
// Browser ruft Express direkt an (CORS) — vermeidet 404, wenn der Vite-/api-Proxy nicht greift
|
||||||
|
define:
|
||||||
|
mode === 'development'
|
||||||
|
? {
|
||||||
|
'import.meta.env.VITE_DEV_BACKEND_ORIGIN': JSON.stringify(apiDevTarget),
|
||||||
|
}
|
||||||
|
: {},
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
@@ -44,10 +89,13 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
host: true,
|
||||||
port: 5173,
|
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.
|
// 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,
|
strictPort: true,
|
||||||
proxy,
|
proxy,
|
||||||
},
|
},
|
||||||
|
// `vite preview` hat standardmäßig keinen Dev-Proxy — sonst wäre /api ein 404.
|
||||||
|
preview: { proxy },
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
3
mailflow.dev.port.json
Normal file
3
mailflow.dev.port.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"port": 3030
|
||||||
|
}
|
||||||
327
package-lock.json
generated
Normal file
327
package-lock.json
generated
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
{
|
||||||
|
"name": "emailsorter",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "emailsorter",
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chalk": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.1.0",
|
||||||
|
"supports-color": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chalk/node_modules/supports-color": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-flag": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cliui": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.1",
|
||||||
|
"wrap-ansi": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-convert": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "~1.1.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-name": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/concurrently": {
|
||||||
|
"version": "9.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||||
|
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chalk": "4.1.2",
|
||||||
|
"rxjs": "7.8.2",
|
||||||
|
"shell-quote": "1.8.3",
|
||||||
|
"supports-color": "8.1.1",
|
||||||
|
"tree-kill": "1.2.2",
|
||||||
|
"yargs": "17.7.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"conc": "dist/bin/concurrently.js",
|
||||||
|
"concurrently": "dist/bin/concurrently.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/escalade": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-caller-file": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-flag": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/require-directory": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rxjs": {
|
||||||
|
"version": "7.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||||
|
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/shell-quote": {
|
||||||
|
"version": "1.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||||
|
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/supports-color": {
|
||||||
|
"version": "8.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||||
|
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-flag": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tree-kill": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"tree-kill": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/y18n": {
|
||||||
|
"version": "5.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs": {
|
||||||
|
"version": "17.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||||
|
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^8.0.1",
|
||||||
|
"escalade": "^3.1.1",
|
||||||
|
"get-caller-file": "^2.0.5",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"string-width": "^4.2.3",
|
||||||
|
"y18n": "^5.0.5",
|
||||||
|
"yargs-parser": "^21.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs-parser": {
|
||||||
|
"version": "21.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||||
|
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
package.json
Normal file
10
package.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "emailsorter",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently -k -n api,web -c blue,magenta \"npm run dev --prefix server\" \"npm run dev --prefix client\""
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.2.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Server
|
# Server
|
||||||
PORT=3000
|
PORT=3030
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
BASE_URL=http://localhost:3000
|
BASE_URL=http://localhost:3030
|
||||||
FRONTEND_URL=http://localhost:5173
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
|
||||||
# Appwrite (Self-Hosted)
|
# Appwrite (Self-Hosted)
|
||||||
@@ -23,4 +23,4 @@ MISTRAL_API_KEY=yPe00wetm26x9FW4Ifjom2UaEd0hf1ND
|
|||||||
# Google OAuth (NEU)
|
# Google OAuth (NEU)
|
||||||
GOOGLE_CLIENT_ID=1073365670500-a6t1srj1ogu1bumoo20511mq4nesouul.apps.googleusercontent.com
|
GOOGLE_CLIENT_ID=1073365670500-a6t1srj1ogu1bumoo20511mq4nesouul.apps.googleusercontent.com
|
||||||
GOOGLE_CLIENT_SECRET=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
|
GOOGLE_CLIENT_SECRET=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
|
||||||
GOOGLE_REDIRECT_URI=http://localhost:3000/api/oauth/gmail/callback
|
GOOGLE_REDIRECT_URI=http://localhost:3030/api/oauth/gmail/callback
|
||||||
|
|||||||
@@ -3,20 +3,47 @@
|
|||||||
* Centralized configuration management
|
* Centralized configuration management
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, existsSync } from 'fs'
|
||||||
|
import { dirname, join } from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
import { log } from '../middleware/logger.mjs'
|
import { log } from '../middleware/logger.mjs'
|
||||||
|
|
||||||
|
const _configDir = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const _repoRoot = join(_configDir, '..', '..')
|
||||||
|
|
||||||
|
/** Default dev API port from repo root `mailflow.dev.port.json` (avoids clashes with other apps on 3000). */
|
||||||
|
function readDevPortFromFile() {
|
||||||
|
try {
|
||||||
|
const f = join(_repoRoot, 'mailflow.dev.port.json')
|
||||||
|
if (!existsSync(f)) return 3030
|
||||||
|
const j = JSON.parse(readFileSync(f, 'utf8'))
|
||||||
|
const p = parseInt(String(j.port), 10)
|
||||||
|
return Number.isFinite(p) && p > 0 ? p : 3030
|
||||||
|
} catch {
|
||||||
|
return 3030
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeEnv = process.env.NODE_ENV || 'development'
|
||||||
|
const listenPort = process.env.PORT
|
||||||
|
? parseInt(process.env.PORT, 10)
|
||||||
|
: nodeEnv === 'production'
|
||||||
|
? 3000
|
||||||
|
: readDevPortFromFile()
|
||||||
|
const defaultLocalBase = `http://localhost:${listenPort}`
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Environment configuration
|
* Environment configuration
|
||||||
*/
|
*/
|
||||||
export const config = {
|
export const config = {
|
||||||
// Server
|
// Server
|
||||||
port: parseInt(process.env.PORT || '3000', 10),
|
port: listenPort,
|
||||||
nodeEnv: process.env.NODE_ENV || 'development',
|
nodeEnv,
|
||||||
isDev: process.env.NODE_ENV !== 'production',
|
isDev: nodeEnv !== 'production',
|
||||||
isProd: process.env.NODE_ENV === 'production',
|
isProd: nodeEnv === 'production',
|
||||||
|
|
||||||
// URLs
|
// URLs
|
||||||
baseUrl: process.env.BASE_URL || 'http://localhost:3000',
|
baseUrl: process.env.BASE_URL || defaultLocalBase,
|
||||||
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173',
|
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||||
|
|
||||||
// Appwrite
|
// Appwrite
|
||||||
@@ -42,14 +69,14 @@ export const config = {
|
|||||||
google: {
|
google: {
|
||||||
clientId: process.env.GOOGLE_CLIENT_ID,
|
clientId: process.env.GOOGLE_CLIENT_ID,
|
||||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||||
redirectUri: process.env.GOOGLE_REDIRECT_URI || 'http://localhost:3000/api/oauth/gmail/callback',
|
redirectUri: process.env.GOOGLE_REDIRECT_URI || `${defaultLocalBase}/api/oauth/gmail/callback`,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Microsoft OAuth
|
// Microsoft OAuth
|
||||||
microsoft: {
|
microsoft: {
|
||||||
clientId: process.env.MICROSOFT_CLIENT_ID,
|
clientId: process.env.MICROSOFT_CLIENT_ID,
|
||||||
clientSecret: process.env.MICROSOFT_CLIENT_SECRET,
|
clientSecret: process.env.MICROSOFT_CLIENT_SECRET,
|
||||||
redirectUri: process.env.MICROSOFT_REDIRECT_URI || 'http://localhost:3000/api/oauth/outlook/callback',
|
redirectUri: process.env.MICROSOFT_REDIRECT_URI || `${defaultLocalBase}/api/oauth/outlook/callback`,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Mistral AI
|
// Mistral AI
|
||||||
@@ -63,9 +90,19 @@ export const config = {
|
|||||||
max: parseInt(process.env.RATE_LIMIT_MAX || '100', 10),
|
max: parseInt(process.env.RATE_LIMIT_MAX || '100', 10),
|
||||||
},
|
},
|
||||||
|
|
||||||
// CORS
|
// CORS (Dev: localhost + 127.0.0.1 für Vite, Browser ruft API oft direkt auf 127.0.0.1:PORT)
|
||||||
cors: {
|
cors: {
|
||||||
origin: process.env.CORS_ORIGIN || process.env.FRONTEND_URL || 'http://localhost:5173',
|
origin:
|
||||||
|
process.env.NODE_ENV === 'production'
|
||||||
|
? process.env.CORS_ORIGIN || process.env.FRONTEND_URL || 'http://localhost:5173'
|
||||||
|
: [
|
||||||
|
process.env.CORS_ORIGIN,
|
||||||
|
process.env.FRONTEND_URL,
|
||||||
|
'http://localhost:5173',
|
||||||
|
'http://127.0.0.1:5173',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((o, i, a) => a.indexOf(o) === i),
|
||||||
credentials: true,
|
credentials: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -79,11 +116,15 @@ export const config = {
|
|||||||
/** Highest product tier (admin comped plan, PLANS key in stripe.mjs). Optional env: TOP_SUBSCRIPTION_PLAN */
|
/** 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(),
|
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 || '')
|
// support@webklar.com is always included; env adds more.
|
||||||
.split(',')
|
adminEmails: (() => {
|
||||||
.map((e) => e.trim().toLowerCase())
|
const fromEnv = (process.env.ADMIN_EMAILS || '')
|
||||||
.filter(Boolean),
|
.split(',')
|
||||||
|
.map((e) => e.trim().toLowerCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
return [...new Set(['support@webklar.com', ...fromEnv])]
|
||||||
|
})(),
|
||||||
|
|
||||||
// Gitea Webhook (Deployment) — trim: trailing newlines in .env break HMAC/Bearer match
|
// Gitea Webhook (Deployment) — trim: trailing newlines in .env break HMAC/Bearer match
|
||||||
gitea: (() => {
|
gitea: (() => {
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Server Einstellungen
|
# Server Einstellungen
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
PORT=3000
|
PORT=3030
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
BASE_URL=http://localhost:3000
|
BASE_URL=http://localhost:3030
|
||||||
FRONTEND_URL=http://localhost:5173
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
|
||||||
# CORS Einstellungen (optional, nutzt FRONTEND_URL als Default)
|
# CORS Einstellungen (optional, nutzt FRONTEND_URL als Default)
|
||||||
@@ -51,7 +51,7 @@ MISTRAL_API_KEY=dein_mistral_api_key
|
|||||||
# 4. OAuth 2.0 Credentials erstellen
|
# 4. OAuth 2.0 Credentials erstellen
|
||||||
GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com
|
GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com
|
||||||
GOOGLE_CLIENT_SECRET=GOCSPX-xxx
|
GOOGLE_CLIENT_SECRET=GOCSPX-xxx
|
||||||
GOOGLE_REDIRECT_URI=http://localhost:3000/api/oauth/gmail/callback
|
GOOGLE_REDIRECT_URI=http://localhost:3030/api/oauth/gmail/callback
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Microsoft OAuth - Outlook Integration (OPTIONAL)
|
# Microsoft OAuth - Outlook Integration (OPTIONAL)
|
||||||
@@ -63,7 +63,7 @@ GOOGLE_REDIRECT_URI=http://localhost:3000/api/oauth/gmail/callback
|
|||||||
# 4. Redirect URI konfigurieren
|
# 4. Redirect URI konfigurieren
|
||||||
MICROSOFT_CLIENT_ID=xxx-xxx-xxx
|
MICROSOFT_CLIENT_ID=xxx-xxx-xxx
|
||||||
MICROSOFT_CLIENT_SECRET=xxx
|
MICROSOFT_CLIENT_SECRET=xxx
|
||||||
MICROSOFT_REDIRECT_URI=http://localhost:3000/api/oauth/outlook/callback
|
MICROSOFT_REDIRECT_URI=http://localhost:3030/api/oauth/outlook/callback
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Admin (OPTIONAL)
|
# Admin (OPTIONAL)
|
||||||
|
|||||||
@@ -11,20 +11,24 @@ 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, AppError, NotFoundError, ValidationError, AuthorizationError } from './middleware/errorHandler.mjs'
|
import { errorHandler, asyncHandler, AppError, 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'
|
import { requireAuth, requireAuthUnlessEmailWebhook } from './middleware/auth.mjs'
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
import oauthRoutes from './routes/oauth.mjs'
|
import oauthRoutes from './routes/oauth.mjs'
|
||||||
import emailRoutes from './routes/email.mjs'
|
import emailRoutes from './routes/email.mjs'
|
||||||
|
import { handleGetDigest } from './routes/email.mjs'
|
||||||
import stripeRoutes from './routes/stripe.mjs'
|
import stripeRoutes from './routes/stripe.mjs'
|
||||||
|
import { handleGetSubscriptionStatus } from './routes/stripe.mjs'
|
||||||
import apiRoutes from './routes/api.mjs'
|
import apiRoutes from './routes/api.mjs'
|
||||||
|
import { handleGetReferralCode } 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'
|
import { startCounterJobs } from './jobs/reset-counters.mjs'
|
||||||
|
import { startAutoSortJob } from './jobs/auto-sort.mjs'
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
const __dirname = dirname(__filename)
|
const __dirname = dirname(__filename)
|
||||||
@@ -45,6 +49,21 @@ app.use((req, res, next) => {
|
|||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Safety net (before all routers): collapse /api/api → /api in path until stable — old clients / bad env
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
const [pathPart, ...q] = req.originalUrl.split('?')
|
||||||
|
let p = pathPart
|
||||||
|
let prev = ''
|
||||||
|
while (p !== prev && p.includes('/api/api')) {
|
||||||
|
prev = p
|
||||||
|
p = p.replace(/\/api\/api/g, '/api')
|
||||||
|
}
|
||||||
|
if (p !== pathPart) {
|
||||||
|
req.url = q.length ? `${p}?${q.join('?')}` : p
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
// CORS
|
// CORS
|
||||||
app.use(cors(config.cors))
|
app.use(cors(config.cors))
|
||||||
|
|
||||||
@@ -74,7 +93,9 @@ app.get('/api/health', (req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
|
service: 'mailflow-api',
|
||||||
status: 'healthy',
|
status: 'healthy',
|
||||||
|
port: config.port,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
version: process.env.npm_package_version || '1.0.0',
|
version: process.env.npm_package_version || '1.0.0',
|
||||||
environment: config.nodeEnv,
|
environment: config.nodeEnv,
|
||||||
@@ -83,6 +104,18 @@ app.get('/api/health', (req, res) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Route index — these three are implemented in routes/*.mjs and registered here FIRST
|
||||||
|
* (before app.use mounts) so GET always matches real handlers, not the JSON 404 catch-all.
|
||||||
|
*
|
||||||
|
* GET /api/email/digest → handleGetDigest (routes/email.mjs)
|
||||||
|
* GET /api/subscription/status → handleGetSubscriptionStatus (routes/stripe.mjs)
|
||||||
|
* GET /api/referrals/code → handleGetReferralCode (routes/api.mjs)
|
||||||
|
*/
|
||||||
|
app.get('/api/email/digest', requireAuthUnlessEmailWebhook, asyncHandler(handleGetDigest))
|
||||||
|
app.get('/api/subscription/status', requireAuth, asyncHandler(handleGetSubscriptionStatus))
|
||||||
|
app.get('/api/referrals/code', requireAuth, asyncHandler(handleGetReferralCode))
|
||||||
|
|
||||||
// API Routes
|
// API Routes
|
||||||
app.use('/api/oauth', oauthRoutes)
|
app.use('/api/oauth', oauthRoutes)
|
||||||
app.use('/api/email', emailRoutes)
|
app.use('/api/email', emailRoutes)
|
||||||
@@ -302,20 +335,12 @@ app.delete('/api/preferences/name-labels/:id', requireAuth, asyncHandler(async (
|
|||||||
// Legacy Stripe webhook endpoint
|
// Legacy Stripe webhook endpoint
|
||||||
app.use('/stripe', stripeRoutes)
|
app.use('/stripe', stripeRoutes)
|
||||||
|
|
||||||
// Unmatched /api → JSON 404 (Express 4 treats '/api/*' as a literal path, not a wildcard)
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
const pathOnly = req.originalUrl.split('?')[0]
|
|
||||||
if (!pathOnly.startsWith('/api')) {
|
|
||||||
return next()
|
|
||||||
}
|
|
||||||
next(new NotFoundError('Endpoint'))
|
|
||||||
})
|
|
||||||
|
|
||||||
// SPA fallback: never send index.html for /api (avoids 404/HTML when public/index.html is missing)
|
// SPA fallback: never send index.html for /api (avoids 404/HTML when public/index.html is missing)
|
||||||
app.get('*', (req, res, next) => {
|
app.get('*', (req, res, next) => {
|
||||||
const pathOnly = req.originalUrl.split('?')[0]
|
const pathOnly = req.originalUrl.split('?')[0]
|
||||||
if (pathOnly.startsWith('/api')) {
|
if (pathOnly.startsWith('/api')) {
|
||||||
return next(new NotFoundError('Endpoint'))
|
console.warn('[404] Unmatched route:', req.method, req.originalUrl)
|
||||||
|
return res.status(404).json({ error: 'Endpoint not found', path: req.originalUrl })
|
||||||
}
|
}
|
||||||
const indexPath = join(__dirname, '..', 'public', 'index.html')
|
const indexPath = join(__dirname, '..', 'public', 'index.html')
|
||||||
res.sendFile(indexPath, (err) => {
|
res.sendFile(indexPath, (err) => {
|
||||||
@@ -331,6 +356,15 @@ app.get('*', (req, res, next) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Catch-all: any method/path that did not send a response (e.g. POST /unknown)
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (res.headersSent) {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
console.warn('[404] Unmatched route:', req.method, req.originalUrl)
|
||||||
|
res.status(404).json({ error: 'Endpoint not found', path: req.originalUrl })
|
||||||
|
})
|
||||||
|
|
||||||
// Global error handler (must be last)
|
// Global error handler (must be last)
|
||||||
app.use(errorHandler)
|
app.use(errorHandler)
|
||||||
|
|
||||||
@@ -376,6 +410,7 @@ server = app.listen(config.port, () => {
|
|||||||
console.log(` 💚 Health: http://localhost:${config.port}/api/health`)
|
console.log(` 💚 Health: http://localhost:${config.port}/api/health`)
|
||||||
console.log('')
|
console.log('')
|
||||||
startCounterJobs()
|
startCounterJobs()
|
||||||
|
startAutoSortJob()
|
||||||
})
|
})
|
||||||
|
|
||||||
export default app
|
export default app
|
||||||
|
|||||||
@@ -4,13 +4,34 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { asyncHandler, NotFoundError, ValidationError } from '../middleware/errorHandler.mjs'
|
import { Client, Users, Query as AppwriteQuery } from 'node-appwrite'
|
||||||
|
import { asyncHandler, NotFoundError, ValidationError, AuthorizationError } from '../middleware/errorHandler.mjs'
|
||||||
import { validate, schemas, rules } from '../middleware/validate.mjs'
|
import { validate, schemas, rules } from '../middleware/validate.mjs'
|
||||||
import { respond } from '../utils/response.mjs'
|
import { respond } from '../utils/response.mjs'
|
||||||
import { products, questions, submissions, orders, onboardingState, emailAccounts, emailStats, emailDigests, userPreferences, subscriptions, emailUsage, referrals, db, Collections, Query } from '../services/database.mjs'
|
import {
|
||||||
|
products,
|
||||||
|
questions,
|
||||||
|
submissions,
|
||||||
|
orders,
|
||||||
|
onboardingState,
|
||||||
|
emailAccounts,
|
||||||
|
emailStats,
|
||||||
|
emailDigests,
|
||||||
|
userPreferences,
|
||||||
|
subscriptions,
|
||||||
|
emailUsage,
|
||||||
|
referrals,
|
||||||
|
db,
|
||||||
|
Collections,
|
||||||
|
Query,
|
||||||
|
deleteAllDocumentsForUser,
|
||||||
|
} from '../services/database.mjs'
|
||||||
import Stripe from 'stripe'
|
import Stripe from 'stripe'
|
||||||
import { config } from '../config/index.mjs'
|
import { config, isAdmin } from '../config/index.mjs'
|
||||||
import { log } from '../middleware/logger.mjs'
|
import { log } from '../middleware/logger.mjs'
|
||||||
|
import { parseImapAccountAccess } from './email.mjs'
|
||||||
|
import { ImapService } from '../services/imap.mjs'
|
||||||
|
import { isAppwriteCollectionMissing } from '../utils/appwriteErrors.mjs'
|
||||||
import { requireAuth } from '../middleware/auth.mjs'
|
import { requireAuth } from '../middleware/auth.mjs'
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
@@ -328,18 +349,31 @@ router.delete('/account/delete',
|
|||||||
/**
|
/**
|
||||||
* GET /api/referrals/code
|
* GET /api/referrals/code
|
||||||
* Get or create referral code for user
|
* Get or create referral code for user
|
||||||
|
* (Also registered on app in index.mjs before router mount.)
|
||||||
*/
|
*/
|
||||||
router.get('/referrals/code',
|
export async function handleGetReferralCode(req, res) {
|
||||||
requireAuth,
|
const userId = req.appwriteUser.id
|
||||||
asyncHandler(async (req, res) => {
|
try {
|
||||||
const userId = req.appwriteUser.id
|
const result = await referrals.getOrCreateCode(userId)
|
||||||
const referral = await referrals.getOrCreateCode(userId)
|
if (!result) {
|
||||||
respond.success(res, {
|
return respond.success(res, { referralCode: null, referralCount: 0 })
|
||||||
referralCode: referral.referralCode,
|
}
|
||||||
referralCount: referral.referralCount || 0,
|
return respond.success(res, {
|
||||||
|
referralCode: result.referralCode,
|
||||||
|
referralCount: result.referralCount || 0,
|
||||||
})
|
})
|
||||||
})
|
} catch (err) {
|
||||||
)
|
if (isAppwriteCollectionMissing(err)) {
|
||||||
|
return respond.success(res, {
|
||||||
|
referralCode: null,
|
||||||
|
referralCount: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/referrals/code', requireAuth, asyncHandler(handleGetReferralCode))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/referrals/track
|
* POST /api/referrals/track
|
||||||
@@ -383,4 +417,105 @@ router.post('/referrals/track',
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async function resolveUserIdByEmail(email) {
|
||||||
|
const normalized = String(email).trim().toLowerCase()
|
||||||
|
const c = new Client()
|
||||||
|
.setEndpoint(config.appwrite.endpoint)
|
||||||
|
.setProject(config.appwrite.projectId)
|
||||||
|
.setKey(config.appwrite.apiKey)
|
||||||
|
const users = new Users(c)
|
||||||
|
try {
|
||||||
|
const res = await users.list([AppwriteQuery.equal('email', normalized)])
|
||||||
|
const uid = res.users?.[0]?.$id
|
||||||
|
if (uid) return uid
|
||||||
|
} catch (e) {
|
||||||
|
log.warn('resolveUserIdByEmail: Users.list failed', { email: normalized, error: e.message })
|
||||||
|
}
|
||||||
|
const acc = await db.findOne(Collections.EMAIL_ACCOUNTS, [Query.equal('email', normalized)])
|
||||||
|
return acc?.userId || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/reset-user-sort-data
|
||||||
|
* Admin only: clear sort-related data for a user (by email).
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/admin/reset-user-sort-data',
|
||||||
|
requireAuth,
|
||||||
|
validate({
|
||||||
|
body: {
|
||||||
|
email: [rules.required('email'), rules.email()],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
if (!isAdmin(req.appwriteUser?.email)) {
|
||||||
|
throw new AuthorizationError('Admin access required')
|
||||||
|
}
|
||||||
|
const targetEmail = String(req.body.email).trim().toLowerCase()
|
||||||
|
const targetUserId = await resolveUserIdByEmail(targetEmail)
|
||||||
|
if (!targetUserId) {
|
||||||
|
throw new NotFoundError('User for this email')
|
||||||
|
}
|
||||||
|
|
||||||
|
const statsDoc = await emailStats.getByUser(targetUserId)
|
||||||
|
let statsDeleted = 0
|
||||||
|
if (statsDoc?.$id) {
|
||||||
|
await db.delete(Collections.EMAIL_STATS, statsDoc.$id)
|
||||||
|
statsDeleted = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const digestsDeleted = await deleteAllDocumentsForUser(Collections.EMAIL_DIGESTS, targetUserId)
|
||||||
|
const usageDeleted = await deleteAllDocumentsForUser(Collections.EMAIL_USAGE, targetUserId)
|
||||||
|
|
||||||
|
await onboardingState.resetToInitial(targetUserId)
|
||||||
|
|
||||||
|
let imapCleared = 0
|
||||||
|
const imapAccounts = await db.list(Collections.EMAIL_ACCOUNTS, [
|
||||||
|
Query.equal('userId', targetUserId),
|
||||||
|
Query.equal('provider', 'imap'),
|
||||||
|
])
|
||||||
|
for (const acc of imapAccounts) {
|
||||||
|
if (!acc.accessToken) continue
|
||||||
|
try {
|
||||||
|
const cfg = parseImapAccountAccess(acc)
|
||||||
|
if (!cfg.password) continue
|
||||||
|
const imap = new ImapService({
|
||||||
|
host: cfg.host,
|
||||||
|
port: cfg.port,
|
||||||
|
secure: cfg.secure,
|
||||||
|
user: acc.email,
|
||||||
|
password: cfg.password,
|
||||||
|
})
|
||||||
|
const n = await imap.removeAllSortedFlags()
|
||||||
|
imapCleared += n
|
||||||
|
} catch (e) {
|
||||||
|
log.warn('reset-user-sort-data: IMAP flags failed for account', {
|
||||||
|
accountId: acc.$id,
|
||||||
|
error: e.message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('Admin reset-user-sort-data', {
|
||||||
|
admin: req.appwriteUser.email,
|
||||||
|
targetEmail,
|
||||||
|
targetUserId,
|
||||||
|
statsDeleted,
|
||||||
|
digestsDeleted,
|
||||||
|
usageDeleted,
|
||||||
|
imapCleared,
|
||||||
|
})
|
||||||
|
|
||||||
|
respond.success(res, {
|
||||||
|
reset: true,
|
||||||
|
deleted: {
|
||||||
|
stats: statsDeleted,
|
||||||
|
digests: digestsDeleted,
|
||||||
|
usage: usageDeleted,
|
||||||
|
},
|
||||||
|
imapCleared,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ 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 { requireAuthUnlessEmailWebhook } from '../middleware/auth.mjs'
|
||||||
import { encryptImapSecret, decryptImapSecret } from '../utils/crypto.mjs'
|
import { encryptImapSecret, decryptImapSecret } from '../utils/crypto.mjs'
|
||||||
|
import { isAppwriteCollectionMissing } from '../utils/appwriteErrors.mjs'
|
||||||
|
import { AI_BATCH_CHUNK_SIZE, AI_BATCH_CHUNK_DELAY_MS } from '../services/ai-sorter.mjs'
|
||||||
|
import { CATEGORY_FOLDER_KEYWORDS, findBestFolder, findPersonFolder } from '../services/imap.mjs'
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
@@ -47,6 +50,49 @@ async function getAISorter() {
|
|||||||
return aiSorterInstance
|
return aiSorterInstance
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reject after `ms` so the IMAP sort handler cannot hang indefinitely. */
|
||||||
|
function imapSortRaceWithTimeout(promise, ms, message) {
|
||||||
|
let timer
|
||||||
|
const timeout = new Promise((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new ValidationError(message)), ms)
|
||||||
|
})
|
||||||
|
return Promise.race([
|
||||||
|
promise.finally(() => clearTimeout(timer)),
|
||||||
|
timeout,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IMAP credentials: new accounts store JSON in accessToken; legacy uses encrypted password only.
|
||||||
|
*/
|
||||||
|
export function parseImapAccountAccess(account) {
|
||||||
|
const fallbackHost = account.imapHost || 'imap.porkbun.com'
|
||||||
|
const fallbackPort = account.imapPort != null ? Number(account.imapPort) : 993
|
||||||
|
const fallbackSecure = account.imapSecure !== false
|
||||||
|
if (!account.accessToken) {
|
||||||
|
return { host: fallbackHost, port: fallbackPort, secure: fallbackSecure, password: '' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(account.accessToken)
|
||||||
|
if (parsed && typeof parsed === 'object' && typeof parsed.password === 'string') {
|
||||||
|
return {
|
||||||
|
host: parsed.host ?? fallbackHost,
|
||||||
|
port: parsed.port != null ? Number(parsed.port) : fallbackPort,
|
||||||
|
secure: parsed.secure !== false,
|
||||||
|
password: decryptImapSecret(parsed.password),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// legacy: entire accessToken is encrypted secret
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
host: fallbackHost,
|
||||||
|
port: fallbackPort,
|
||||||
|
secure: fallbackSecure,
|
||||||
|
password: decryptImapSecret(account.accessToken),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
// DEMO DATA - Realistic Test Emails
|
// DEMO DATA - Realistic Test Emails
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
@@ -128,23 +174,26 @@ router.post('/connect',
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create account
|
// Create account (IMAP: encode host/port/secure inside accessToken JSON — no Appwrite attrs)
|
||||||
const rawImapSecret = provider === 'imap' ? (password || accessToken) : ''
|
const rawImapSecret = provider === 'imap' ? (password || accessToken) : ''
|
||||||
const accountData = {
|
const accountData = {
|
||||||
userId,
|
userId,
|
||||||
provider,
|
provider,
|
||||||
email,
|
email,
|
||||||
accessToken: provider === 'imap' ? encryptImapSecret(rawImapSecret) : (accessToken || ''),
|
accessToken:
|
||||||
|
provider === 'imap'
|
||||||
|
? JSON.stringify({
|
||||||
|
password: encryptImapSecret(rawImapSecret),
|
||||||
|
host: imapHost || 'imap.porkbun.com',
|
||||||
|
port: imapPort != null ? Number(imapPort) : 993,
|
||||||
|
secure: imapSecure !== false,
|
||||||
|
})
|
||||||
|
: (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,
|
||||||
lastSync: null,
|
lastSync: null,
|
||||||
}
|
}
|
||||||
if (provider === 'imap') {
|
|
||||||
if (imapHost != null) accountData.imapHost = String(imapHost)
|
|
||||||
if (imapPort != null) accountData.imapPort = Number(imapPort)
|
|
||||||
if (imapSecure !== undefined) accountData.imapSecure = Boolean(imapSecure)
|
|
||||||
}
|
|
||||||
const account = await emailAccounts.create(accountData)
|
const account = await emailAccounts.create(accountData)
|
||||||
|
|
||||||
log.success(`Email account connected: ${email} (${provider})`)
|
log.success(`Email account connected: ${email} (${provider})`)
|
||||||
@@ -280,7 +329,7 @@ router.get('/stats', asyncHandler(async (req, res) => {
|
|||||||
* Trigger email sorting for an account
|
* Trigger email sorting for an account
|
||||||
*
|
*
|
||||||
* Options:
|
* Options:
|
||||||
* - maxEmails: Maximum emails to process (default: 500, max: 2000)
|
* - maxEmails: Maximum emails to process (default: 500; IMAP default/cap 50, max 2000 non-IMAP)
|
||||||
* - processAll: If true, process entire inbox with pagination
|
* - processAll: If true, process entire inbox with pagination
|
||||||
*/
|
*/
|
||||||
router.post('/sort',
|
router.post('/sort',
|
||||||
@@ -292,13 +341,33 @@ router.post('/sort',
|
|||||||
}),
|
}),
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const userId = req.appwriteUser.id
|
const userId = req.appwriteUser.id
|
||||||
const { accountId, maxEmails = 500, processAll = true } = req.body
|
const { accountId, processAll = true } = req.body
|
||||||
|
|
||||||
// Check subscription status and free tier limits
|
console.log('[SORT] Step 1: Auth OK, userId:', userId)
|
||||||
const subscription = await subscriptions.getByUser(userId, req.appwriteUser?.email)
|
|
||||||
|
const subscriptionFreeTierDefaults = () => ({
|
||||||
|
plan: 'free',
|
||||||
|
status: 'active',
|
||||||
|
isFreeTier: true,
|
||||||
|
emailsUsedThisMonth: 0,
|
||||||
|
emailsLimit: config.freeTier.emailsPerMonth,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check subscription status and free tier limits (missing collections → treat as free tier)
|
||||||
|
let subscription
|
||||||
|
try {
|
||||||
|
subscription = await subscriptions.getByUser(userId, req.appwriteUser?.email)
|
||||||
|
} catch {
|
||||||
|
subscription = null
|
||||||
|
}
|
||||||
|
if (!subscription) {
|
||||||
|
subscription = subscriptionFreeTierDefaults()
|
||||||
|
}
|
||||||
const isFreeTier = subscription?.isFreeTier || false
|
const isFreeTier = subscription?.isFreeTier || false
|
||||||
const adminUser = isAdmin(req.appwriteUser?.email)
|
const adminUser = isAdmin(req.appwriteUser?.email)
|
||||||
|
|
||||||
|
console.log('[SORT] Step 3: Subscription:', subscription?.plan, 'isFreeTier:', subscription?.isFreeTier)
|
||||||
|
|
||||||
// Check free tier limit (admins: unlimited)
|
// Check free tier limit (admins: unlimited)
|
||||||
if (isFreeTier && !adminUser) {
|
if (isFreeTier && !adminUser) {
|
||||||
const usage = await emailUsage.getUsage(userId)
|
const usage = await emailUsage.getUsage(userId)
|
||||||
@@ -314,22 +383,40 @@ router.post('/sort',
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is first run (no stats exist)
|
|
||||||
const existingStats = await emailStats.getByUser(userId)
|
const existingStats = await emailStats.getByUser(userId)
|
||||||
const isFirstRun = !existingStats || existingStats.totalSorted === 0
|
const isFirstRun = !existingStats || existingStats.totalSorted === 0
|
||||||
|
|
||||||
// For first run, limit to 50 emails for speed
|
|
||||||
const effectiveMax = isFirstRun
|
|
||||||
? Math.min(maxEmails, 50)
|
|
||||||
: Math.min(maxEmails, 2000) // Cap at 2000 emails
|
|
||||||
|
|
||||||
// Get account
|
|
||||||
const account = await emailAccounts.get(accountId)
|
const account = await emailAccounts.get(accountId)
|
||||||
|
if (!account) {
|
||||||
|
throw new NotFoundError('Email account')
|
||||||
|
}
|
||||||
|
|
||||||
if (account.userId !== userId) {
|
if (account.userId !== userId) {
|
||||||
throw new AuthorizationError('No permission for this account')
|
throw new AuthorizationError('No permission for this account')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let maxEmails = req.body.maxEmails
|
||||||
|
if (maxEmails == null) {
|
||||||
|
maxEmails = 500
|
||||||
|
} else {
|
||||||
|
maxEmails = Number(maxEmails)
|
||||||
|
if (!Number.isFinite(maxEmails) || maxEmails < 0) {
|
||||||
|
maxEmails = 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (account.provider === 'imap') {
|
||||||
|
maxEmails = Math.min(maxEmails, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveMax =
|
||||||
|
account.provider === 'imap'
|
||||||
|
? Math.min(maxEmails, 500)
|
||||||
|
: isFirstRun
|
||||||
|
? Math.min(maxEmails, 50)
|
||||||
|
: Math.min(maxEmails, 2000)
|
||||||
|
|
||||||
|
console.log('[SORT] Step 2: Account fetched:', account?.$id ?? 'NULL', 'provider:', account?.provider)
|
||||||
|
|
||||||
// Get user preferences
|
// Get user preferences
|
||||||
const prefs = await userPreferences.getByUser(userId)
|
const prefs = await userPreferences.getByUser(userId)
|
||||||
const preferences = prefs?.preferences || {}
|
const preferences = prefs?.preferences || {}
|
||||||
@@ -337,6 +424,7 @@ router.post('/sort',
|
|||||||
// Get AI sorter
|
// Get AI sorter
|
||||||
const sorter = await getAISorter()
|
const sorter = await getAISorter()
|
||||||
let sortedCount = 0
|
let sortedCount = 0
|
||||||
|
let timedOut = false
|
||||||
const results = { byCategory: {} }
|
const results = { byCategory: {} }
|
||||||
let emailSamples = [] // For suggested rules generation
|
let emailSamples = [] // For suggested rules generation
|
||||||
|
|
||||||
@@ -351,11 +439,15 @@ router.post('/sort',
|
|||||||
const shuffled = [...DEMO_EMAILS].sort(() => Math.random() - 0.5)
|
const shuffled = [...DEMO_EMAILS].sort(() => Math.random() - 0.5)
|
||||||
const emailsToSort = shuffled.slice(0, emailCount)
|
const emailsToSort = shuffled.slice(0, emailCount)
|
||||||
|
|
||||||
|
console.log('[SORT] Step 4: Emails fetched (demo):', emailsToSort?.length ?? 0)
|
||||||
|
|
||||||
// Check if AI is available
|
// Check if AI is available
|
||||||
if (features.ai()) {
|
if (features.ai()) {
|
||||||
// Real AI sorting with demo data
|
// Real AI sorting with demo data
|
||||||
const classified = await sorter.batchCategorize(emailsToSort, preferences)
|
const classified = await sorter.batchCategorize(emailsToSort, preferences)
|
||||||
|
|
||||||
|
console.log('[SORT] Step 5: Categorized (demo AI):', classified?.length ?? 0)
|
||||||
|
|
||||||
for (const { email, classification } of classified) {
|
for (const { email, classification } of classified) {
|
||||||
const category = classification.category
|
const category = classification.category
|
||||||
sortedCount++
|
sortedCount++
|
||||||
@@ -427,6 +519,8 @@ router.post('/sort',
|
|||||||
|
|
||||||
log.success(`Rule-based sorting completed: ${sortedCount} demo emails`)
|
log.success(`Rule-based sorting completed: ${sortedCount} demo emails`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[SORT] Step 5: Categorized (demo final sortedCount):', sortedCount)
|
||||||
}
|
}
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// GMAIL - Real Gmail sorting with native categories
|
// GMAIL - Real Gmail sorting with native categories
|
||||||
@@ -548,6 +642,8 @@ router.post('/sort',
|
|||||||
// Get full email details
|
// Get full email details
|
||||||
const emails = await gmail.batchGetEmails(messages.map(m => m.id))
|
const emails = await gmail.batchGetEmails(messages.map(m => m.id))
|
||||||
|
|
||||||
|
console.log('[SORT] Step 4: Emails fetched (gmail batch):', emails?.length ?? 0)
|
||||||
|
|
||||||
// Process each email: check company labels first, then AI categorization
|
// Process each email: check company labels first, then AI categorization
|
||||||
const processedEmails = []
|
const processedEmails = []
|
||||||
|
|
||||||
@@ -693,6 +789,8 @@ router.post('/sort',
|
|||||||
}
|
}
|
||||||
} while (pageToken && processAll)
|
} while (pageToken && processAll)
|
||||||
|
|
||||||
|
console.log('[SORT] Step 5: Categorized (gmail sortedCount):', sortedCount)
|
||||||
|
|
||||||
log.success(`Gmail sorting completed: ${sortedCount} emails processed`)
|
log.success(`Gmail sorting completed: ${sortedCount} emails processed`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('Gmail sorting failed', { error: err.message })
|
log.error('Gmail sorting failed', { error: err.message })
|
||||||
@@ -728,6 +826,8 @@ router.post('/sort',
|
|||||||
|
|
||||||
if (!messages?.length) break
|
if (!messages?.length) break
|
||||||
|
|
||||||
|
console.log('[SORT] Step 4: Emails fetched (outlook batch):', messages?.length ?? 0)
|
||||||
|
|
||||||
// Process each email: check company labels first, then AI categorization
|
// Process each email: check company labels first, then AI categorization
|
||||||
const enabledCategories = sorter.getEnabledCategories(preferences)
|
const enabledCategories = sorter.getEnabledCategories(preferences)
|
||||||
const processedEmails = []
|
const processedEmails = []
|
||||||
@@ -785,6 +885,8 @@ router.post('/sort',
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[SORT] Step 5: Categorized (outlook batch processed):', processedEmails.length)
|
||||||
|
|
||||||
// Apply categories and actions
|
// Apply categories and actions
|
||||||
for (const { email, category, companyLabel } of processedEmails) {
|
for (const { email, category, companyLabel } of processedEmails) {
|
||||||
const action = sorter.getCategoryAction(category, preferences)
|
const action = sorter.getCategoryAction(category, preferences)
|
||||||
@@ -838,6 +940,8 @@ router.post('/sort',
|
|||||||
}
|
}
|
||||||
} while (skipToken && processAll)
|
} while (skipToken && processAll)
|
||||||
|
|
||||||
|
console.log('[SORT] Step 5: Categorized (outlook sortedCount):', sortedCount)
|
||||||
|
|
||||||
log.success(`Outlook sorting completed: ${sortedCount} emails processed`)
|
log.success(`Outlook sorting completed: ${sortedCount} emails processed`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('Outlook sorting failed', { error: err.message })
|
log.error('Outlook sorting failed', { error: err.message })
|
||||||
@@ -858,147 +962,272 @@ router.post('/sort',
|
|||||||
|
|
||||||
log.info(`IMAP sorting started for ${account.email}`)
|
log.info(`IMAP sorting started for ${account.email}`)
|
||||||
|
|
||||||
const { ImapService, getFolderNameForCategory } = await import('../services/imap.mjs')
|
const { ImapService } = await import('../services/imap.mjs')
|
||||||
|
const imapCfg = parseImapAccountAccess(account)
|
||||||
const imap = new ImapService({
|
const imap = new ImapService({
|
||||||
host: account.imapHost || 'imap.porkbun.com',
|
host: imapCfg.host,
|
||||||
port: account.imapPort != null ? account.imapPort : 993,
|
port: imapCfg.port,
|
||||||
secure: account.imapSecure !== false,
|
secure: imapCfg.secure,
|
||||||
user: account.email,
|
user: account.email,
|
||||||
password: decryptImapSecret(account.accessToken),
|
password: imapCfg.password,
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await imap.connect()
|
try {
|
||||||
|
await imapSortRaceWithTimeout(
|
||||||
const enabledCategories = sorter.getEnabledCategories(preferences)
|
(async () => {
|
||||||
// Name labels (workers): create Team subfolders for IMAP/Nextcloud
|
|
||||||
const nameLabelMap = {}
|
|
||||||
if (preferences.nameLabels?.length) {
|
|
||||||
for (const nl of preferences.nameLabels) {
|
|
||||||
if (!nl.enabled) continue
|
|
||||||
const folderName = `Team/${nl.name}`
|
|
||||||
try {
|
try {
|
||||||
await imap.ensureFolder(folderName)
|
await imap.connect()
|
||||||
nameLabelMap[nl.id || nl.name] = folderName
|
|
||||||
if (nl.name !== (nl.id || nl.name)) nameLabelMap[nl.name] = folderName
|
const enabledCategories = sorter.getEnabledCategories(preferences)
|
||||||
|
|
||||||
|
const existingFolders = await imap.listAllFolders()
|
||||||
|
console.log('[SORT-IMAP] All available folders:', existingFolders)
|
||||||
|
const folderPathSet = new Set(existingFolders)
|
||||||
|
console.log(`[SORT-IMAP] Folders discovered: ${existingFolders.length}`)
|
||||||
|
|
||||||
|
const folderMap = {}
|
||||||
|
for (const cat of Object.keys(CATEGORY_FOLDER_KEYWORDS)) {
|
||||||
|
folderMap[cat] = findBestFolder(cat, existingFolders)
|
||||||
|
}
|
||||||
|
console.log('[SORT-IMAP] Folder map:', JSON.stringify(folderMap))
|
||||||
|
|
||||||
|
const fetchCap = Math.min(500, effectiveMax)
|
||||||
|
const { messages } = await imap.listEmails(fetchCap, null)
|
||||||
|
if (!messages?.length) {
|
||||||
|
console.log('[SORT-IMAP] No messages in INBOX to process')
|
||||||
|
log.success('IMAP sorting completed: 0 emails processed')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const emails = await imap.batchGetEmails(messages.map((m) => m.id))
|
||||||
|
console.log('[SORT-IMAP] Emails fetched (batch):', emails?.length ?? 0)
|
||||||
|
|
||||||
|
const processedEmails = []
|
||||||
|
|
||||||
|
for (let chunkStart = 0; chunkStart < emails.length; chunkStart += AI_BATCH_CHUNK_SIZE) {
|
||||||
|
const chunk = emails.slice(chunkStart, chunkStart + AI_BATCH_CHUNK_SIZE)
|
||||||
|
for (const email of chunk) {
|
||||||
|
const emailData = {
|
||||||
|
from: email.headers?.from || '',
|
||||||
|
subject: email.headers?.subject || '',
|
||||||
|
snippet: email.snippet || '',
|
||||||
|
}
|
||||||
|
|
||||||
|
let category = null
|
||||||
|
let companyLabel = null
|
||||||
|
let assignedTo = null
|
||||||
|
let skipAI = false
|
||||||
|
|
||||||
|
if (preferences.companyLabels?.length) {
|
||||||
|
for (const companyLabelConfig of preferences.companyLabels) {
|
||||||
|
if (!companyLabelConfig.enabled) continue
|
||||||
|
if (sorter.matchesCompanyLabel(emailData, companyLabelConfig)) {
|
||||||
|
category = companyLabelConfig.category || 'promotions'
|
||||||
|
companyLabel = companyLabelConfig.name
|
||||||
|
skipAI = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!skipAI && preferences.autoDetectCompanies) {
|
||||||
|
const detected = sorter.detectCompany(emailData)
|
||||||
|
if (detected) {
|
||||||
|
category = 'promotions'
|
||||||
|
companyLabel = detected.label
|
||||||
|
skipAI = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!skipAI) {
|
||||||
|
const classification = await sorter.categorize(emailData, preferences)
|
||||||
|
category = classification.category
|
||||||
|
assignedTo = classification.assignedTo || null
|
||||||
|
if (!enabledCategories.includes(category)) category = 'review'
|
||||||
|
}
|
||||||
|
|
||||||
|
processedEmails.push({ email, category, companyLabel, assignedTo })
|
||||||
|
|
||||||
|
if (isFirstRun && emailSamples.length < 50) {
|
||||||
|
emailSamples.push({
|
||||||
|
from: emailData.from,
|
||||||
|
subject: emailData.subject,
|
||||||
|
snippet: emailData.snippet,
|
||||||
|
category,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (chunkStart + AI_BATCH_CHUNK_SIZE < emails.length) {
|
||||||
|
await new Promise((r) => setTimeout(r, AI_BATCH_CHUNK_DELAY_MS))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[SORT-IMAP] Categorized:', processedEmails.length)
|
||||||
|
|
||||||
|
const MOVE_OUT_COMPLETELY = ['newsletters', 'promotions', 'social']
|
||||||
|
|
||||||
|
let movedCount = 0
|
||||||
|
let copiedCount = 0
|
||||||
|
let sortDecisionLogCount = 0
|
||||||
|
|
||||||
|
for (const { email, category, companyLabel, assignedTo } of processedEmails) {
|
||||||
|
const resolvedCategory = companyLabel
|
||||||
|
? preferences.companyLabels?.find((c) => c.name === companyLabel)?.category || 'promotions'
|
||||||
|
: category
|
||||||
|
|
||||||
|
const emailDataForPerson = {
|
||||||
|
from: email.headers?.from || '',
|
||||||
|
subject: email.headers?.subject || '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const personFolder = findPersonFolder(emailDataForPerson, existingFolders)
|
||||||
|
const categoryFolder = folderMap[resolvedCategory] || null
|
||||||
|
|
||||||
|
const action = sorter.getCategoryAction
|
||||||
|
? sorter.getCategoryAction(resolvedCategory, preferences)
|
||||||
|
: 'inbox'
|
||||||
|
|
||||||
|
const didEarlyArchiveRead =
|
||||||
|
MOVE_OUT_COMPLETELY.includes(resolvedCategory) &&
|
||||||
|
categoryFolder &&
|
||||||
|
action === 'archive_read'
|
||||||
|
|
||||||
|
if (didEarlyArchiveRead) {
|
||||||
|
try {
|
||||||
|
await imap.markAsRead(email.id)
|
||||||
|
} catch {
|
||||||
|
// ignore — message may already be moved in edge cases
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (personFolder) {
|
||||||
|
try {
|
||||||
|
const copied = await imap.copyToFolder(email.id, personFolder, folderPathSet)
|
||||||
|
if (copied) {
|
||||||
|
copiedCount++
|
||||||
|
console.log(
|
||||||
|
`[SORT-IMAP] Email ${email.id} → copied to person folder "${personFolder}"`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categoryFolder && categoryFolder !== personFolder) {
|
||||||
|
if (MOVE_OUT_COMPLETELY.includes(resolvedCategory)) {
|
||||||
|
try {
|
||||||
|
const moved = await imap.moveMessageToExistingPath(
|
||||||
|
email.id,
|
||||||
|
categoryFolder,
|
||||||
|
folderPathSet
|
||||||
|
)
|
||||||
|
if (moved) {
|
||||||
|
movedCount++
|
||||||
|
console.log(`[SORT-IMAP] Email ${email.id} → MOVED to "${categoryFolder}"`)
|
||||||
|
} else {
|
||||||
|
const copied = await imap.copyToFolder(
|
||||||
|
email.id,
|
||||||
|
categoryFolder,
|
||||||
|
folderPathSet
|
||||||
|
)
|
||||||
|
if (copied) {
|
||||||
|
copiedCount++
|
||||||
|
console.log(
|
||||||
|
`[SORT-IMAP] Email ${email.id} → copied (move failed) to "${categoryFolder}"`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const copied = await imap.copyToFolder(
|
||||||
|
email.id,
|
||||||
|
categoryFolder,
|
||||||
|
folderPathSet
|
||||||
|
)
|
||||||
|
if (copied) {
|
||||||
|
copiedCount++
|
||||||
|
console.log(
|
||||||
|
`[SORT-IMAP] Email ${email.id} → copied to "${categoryFolder}"`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!MOVE_OUT_COMPLETELY.includes(resolvedCategory) || personFolder) {
|
||||||
|
try {
|
||||||
|
await imap.addMailFlowCategoryKeyword(
|
||||||
|
email.id,
|
||||||
|
resolvedCategory,
|
||||||
|
assignedTo || null
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (action === 'archive_read' && !didEarlyArchiveRead) {
|
||||||
|
await imap.markAsRead(email.id)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore — e.g. message already moved out of INBOX
|
||||||
|
}
|
||||||
|
|
||||||
|
sortedCount++
|
||||||
|
results.byCategory[resolvedCategory] = (results.byCategory[resolvedCategory] || 0) + 1
|
||||||
|
|
||||||
|
if (sortDecisionLogCount < 20) {
|
||||||
|
sortDecisionLogCount++
|
||||||
|
console.log(
|
||||||
|
`[SORT-IMAP] Decision ${sortDecisionLogCount}/20: uid=${email.id} category=${resolvedCategory} ` +
|
||||||
|
`personFolder=${personFolder || '—'} categoryFolder=${categoryFolder || '—'} ` +
|
||||||
|
`moveOut=${MOVE_OUT_COMPLETELY.includes(resolvedCategory)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[SORT-IMAP] Complete: ${movedCount} moved, ${copiedCount} copied`
|
||||||
|
)
|
||||||
|
|
||||||
|
log.success(`IMAP sorting completed: ${sortedCount} emails processed`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn(`IMAP name label folder failed: ${nl.name}`, { error: err.message })
|
log.error('IMAP sorting failed', { error: err.message })
|
||||||
|
throw new ValidationError(`IMAP error: ${err.message}. Check credentials or reconnect.`)
|
||||||
}
|
}
|
||||||
|
})(),
|
||||||
|
300_000,
|
||||||
|
'IMAP sort timed out after 300 seconds. Check host, port, or network.'
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
if (String(err?.message || '').includes('timed out')) {
|
||||||
|
timedOut = true
|
||||||
|
log.warn(
|
||||||
|
`[SORT-IMAP] Timed out after 300s — saving partial results (${sortedCount} sorted so far)`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
let pageToken = null
|
|
||||||
let totalProcessed = 0
|
|
||||||
const batchSize = 100
|
|
||||||
|
|
||||||
do {
|
|
||||||
const { messages, nextPageToken } = await imap.listEmails(batchSize, pageToken)
|
|
||||||
pageToken = nextPageToken
|
|
||||||
|
|
||||||
if (!messages?.length) break
|
|
||||||
|
|
||||||
const emails = await imap.batchGetEmails(messages.map((m) => m.id))
|
|
||||||
const processedEmails = []
|
|
||||||
|
|
||||||
for (const email of emails) {
|
|
||||||
const emailData = {
|
|
||||||
from: email.headers?.from || '',
|
|
||||||
subject: email.headers?.subject || '',
|
|
||||||
snippet: email.snippet || '',
|
|
||||||
}
|
|
||||||
|
|
||||||
let category = null
|
|
||||||
let companyLabel = null
|
|
||||||
let assignedTo = null
|
|
||||||
let skipAI = false
|
|
||||||
|
|
||||||
if (preferences.companyLabels?.length) {
|
|
||||||
for (const companyLabelConfig of preferences.companyLabels) {
|
|
||||||
if (!companyLabelConfig.enabled) continue
|
|
||||||
if (sorter.matchesCompanyLabel(emailData, companyLabelConfig)) {
|
|
||||||
category = companyLabelConfig.category || 'promotions'
|
|
||||||
companyLabel = companyLabelConfig.name
|
|
||||||
skipAI = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!skipAI && preferences.autoDetectCompanies) {
|
|
||||||
const detected = sorter.detectCompany(emailData)
|
|
||||||
if (detected) {
|
|
||||||
category = 'promotions'
|
|
||||||
companyLabel = detected.label
|
|
||||||
skipAI = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!skipAI) {
|
|
||||||
const classification = await sorter.categorize(emailData, preferences)
|
|
||||||
category = classification.category
|
|
||||||
assignedTo = classification.assignedTo || null
|
|
||||||
if (!enabledCategories.includes(category)) category = 'review'
|
|
||||||
}
|
|
||||||
|
|
||||||
processedEmails.push({ email, category, companyLabel, assignedTo })
|
|
||||||
|
|
||||||
if (isFirstRun && emailSamples.length < 50) {
|
|
||||||
emailSamples.push({
|
|
||||||
from: emailData.from,
|
|
||||||
subject: emailData.subject,
|
|
||||||
snippet: emailData.snippet,
|
|
||||||
category,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const actionMap = sorter.getCategoryAction ? (cat) => sorter.getCategoryAction(cat, preferences) : () => 'inbox'
|
|
||||||
|
|
||||||
for (const { email, category, companyLabel, assignedTo } of processedEmails) {
|
|
||||||
try {
|
|
||||||
const action = actionMap(category)
|
|
||||||
// If AI assigned to a worker, move to Team/<Name> folder; else use category folder
|
|
||||||
const folderName = (assignedTo && nameLabelMap[assignedTo])
|
|
||||||
? nameLabelMap[assignedTo]
|
|
||||||
: getFolderNameForCategory(companyLabel ? (preferences.companyLabels?.find((c) => c.name === companyLabel)?.category || 'promotions') : category)
|
|
||||||
|
|
||||||
await imap.moveToFolder(email.id, folderName)
|
|
||||||
|
|
||||||
if (action === 'archive_read') {
|
|
||||||
try {
|
|
||||||
await imap.markAsRead(email.id)
|
|
||||||
} catch {
|
|
||||||
// already moved; mark as read optional
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sortedCount++
|
|
||||||
results.byCategory[category] = (results.byCategory[category] || 0) + 1
|
|
||||||
} catch (err) {
|
|
||||||
log.warn(`IMAP sort failed: ${email.id}`, { error: err.message })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
totalProcessed += emails.length
|
|
||||||
log.info(`IMAP processed ${totalProcessed} emails so far...`)
|
|
||||||
|
|
||||||
if (totalProcessed >= effectiveMax) break
|
|
||||||
if (pageToken) await new Promise((r) => setTimeout(r, 200))
|
|
||||||
} while (pageToken && processAll)
|
|
||||||
|
|
||||||
await imap.close()
|
|
||||||
log.success(`IMAP sorting completed: ${sortedCount} emails processed`)
|
|
||||||
} catch (err) {
|
|
||||||
try {
|
try {
|
||||||
await imap.close()
|
await imap.close()
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore — dead connection must not block HTTP response
|
||||||
}
|
}
|
||||||
log.error('IMAP sorting failed', { error: err.message })
|
|
||||||
throw new ValidationError(`IMAP error: ${err.message}. Check credentials or reconnect.`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[SORT] Step 6: Saving results (sync, stats, usage, digest)...')
|
||||||
|
|
||||||
// Update last sync
|
// Update last sync
|
||||||
await emailAccounts.updateLastSync(accountId)
|
await emailAccounts.updateLastSync(accountId)
|
||||||
|
|
||||||
@@ -1075,6 +1304,8 @@ router.post('/sort',
|
|||||||
log.warn('Digest update failed', { error: err.message })
|
log.warn('Digest update failed', { error: err.message })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[SORT] Step 6: Results saved, sortedCount:', sortedCount)
|
||||||
|
|
||||||
log.success(`Sorting completed: ${sortedCount} emails for ${account.email}`)
|
log.success(`Sorting completed: ${sortedCount} emails for ${account.email}`)
|
||||||
|
|
||||||
// Generate suggested rules for first run
|
// Generate suggested rules for first run
|
||||||
@@ -1102,10 +1333,122 @@ router.post('/sort',
|
|||||||
suggestions,
|
suggestions,
|
||||||
provider: account.provider,
|
provider: account.provider,
|
||||||
isDemo: account.provider === 'demo',
|
isDemo: account.provider === 'demo',
|
||||||
|
timedOut: timedOut || undefined,
|
||||||
|
message: timedOut
|
||||||
|
? `Sorted ${sortedCount} emails (sort timed out, will continue next run)`
|
||||||
|
: undefined,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/email/recover/:accountId
|
||||||
|
* Move messages from all non-INBOX folders back to INBOX (IMAP only).
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/recover/:accountId',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const userId = req.appwriteUser.id
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
const account = await emailAccounts.get(accountId)
|
||||||
|
if (!account) throw new NotFoundError('Email account')
|
||||||
|
if (account.userId !== userId) throw new AuthorizationError('No permission for this account')
|
||||||
|
|
||||||
|
if (account.provider !== 'imap') {
|
||||||
|
return respond.success(res, {
|
||||||
|
recovered: 0,
|
||||||
|
folders: [],
|
||||||
|
message: 'Only available for IMAP accounts',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ImapService } = await import('../services/imap.mjs')
|
||||||
|
const imapCfg = parseImapAccountAccess(account)
|
||||||
|
const imap = new ImapService({
|
||||||
|
host: imapCfg.host,
|
||||||
|
port: imapCfg.port,
|
||||||
|
secure: imapCfg.secure,
|
||||||
|
user: account.email,
|
||||||
|
password: imapCfg.password,
|
||||||
|
})
|
||||||
|
|
||||||
|
log.info(`Email recovery started for ${account.email}`)
|
||||||
|
|
||||||
|
const result = await imap.recoverAllToInbox()
|
||||||
|
|
||||||
|
log.success(`Recovery complete: ${result.recovered} emails returned to INBOX`)
|
||||||
|
respond.success(res, {
|
||||||
|
recovered: result.recovered,
|
||||||
|
folders: result.folders,
|
||||||
|
message:
|
||||||
|
result.recovered > 0
|
||||||
|
? `${result.recovered} emails recovered back to inbox`
|
||||||
|
: 'No emails found outside inbox',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/email/re-sort/:accountId
|
||||||
|
* IMAP: move messages from sort-related folders (Junk, Archive, MailFlow/*, …) back to INBOX and strip $MailFlow-* keywords.
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/re-sort/:accountId',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const userId = req.appwriteUser.id
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
const account = await emailAccounts.get(accountId)
|
||||||
|
if (!account) throw new NotFoundError('Email account')
|
||||||
|
if (account.userId !== userId) throw new AuthorizationError('No permission for this account')
|
||||||
|
|
||||||
|
if (account.provider !== 'imap') {
|
||||||
|
return respond.success(res, {
|
||||||
|
recovered: 0,
|
||||||
|
folders: [],
|
||||||
|
mailFlowKeywordsStripped: 0,
|
||||||
|
message: 'Only available for IMAP accounts',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ImapService } = await import('../services/imap.mjs')
|
||||||
|
const imapCfg = parseImapAccountAccess(account)
|
||||||
|
const imap = new ImapService({
|
||||||
|
host: imapCfg.host,
|
||||||
|
port: imapCfg.port,
|
||||||
|
secure: imapCfg.secure,
|
||||||
|
user: account.email,
|
||||||
|
password: imapCfg.password,
|
||||||
|
})
|
||||||
|
|
||||||
|
log.info(`IMAP re-sort prep started for ${account.email}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await imap.connect()
|
||||||
|
const result = await imap.reSortRecoverAndStripKeywords()
|
||||||
|
log.success(
|
||||||
|
`Re-sort prep: ${result.recovered} to INBOX, MailFlow keywords stripped on ${result.mailFlowKeywordsStripped} message(s)`
|
||||||
|
)
|
||||||
|
respond.success(res, {
|
||||||
|
recovered: result.recovered,
|
||||||
|
folders: result.folders,
|
||||||
|
mailFlowKeywordsStripped: result.mailFlowKeywordsStripped,
|
||||||
|
message:
|
||||||
|
result.recovered > 0 || result.mailFlowKeywordsStripped > 0
|
||||||
|
? `Moved ${result.recovered} message(s) to INBOX; stripped MailFlow tags from ${result.mailFlowKeywordsStripped} INBOX message(s). Run sort again.`
|
||||||
|
: 'Nothing to reset — INBOX already clean of sort folders / keywords.',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await imap.close()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/email/sort-demo
|
* POST /api/email/sort-demo
|
||||||
* Quick demo sorting without account (for testing)
|
* Quick demo sorting without account (for testing)
|
||||||
@@ -1233,16 +1576,13 @@ router.post('/cleanup/mailflow-labels',
|
|||||||
/**
|
/**
|
||||||
* GET /api/email/digest
|
* GET /api/email/digest
|
||||||
* Get today's sorting digest summary
|
* Get today's sorting digest summary
|
||||||
|
* (Also registered on app in index.mjs before router mount.)
|
||||||
*/
|
*/
|
||||||
router.get('/digest', asyncHandler(async (req, res) => {
|
export async function handleGetDigest(req, res) {
|
||||||
const userId = req.appwriteUser.id
|
const userId = req.appwriteUser.id
|
||||||
|
const emptyDigest = () =>
|
||||||
const digest = await emailDigests.getByUserToday(userId)
|
respond.success(res, {
|
||||||
|
date: new Date().toISOString(),
|
||||||
if (!digest) {
|
|
||||||
// Return empty digest for new users
|
|
||||||
return respond.success(res, {
|
|
||||||
date: new Date().toISOString().split('T')[0],
|
|
||||||
totalSorted: 0,
|
totalSorted: 0,
|
||||||
inboxCleared: 0,
|
inboxCleared: 0,
|
||||||
timeSavedMinutes: 0,
|
timeSavedMinutes: 0,
|
||||||
@@ -1251,19 +1591,33 @@ router.get('/digest', asyncHandler(async (req, res) => {
|
|||||||
suggestions: [],
|
suggestions: [],
|
||||||
hasData: false,
|
hasData: false,
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
respond.success(res, {
|
try {
|
||||||
date: digest.date,
|
const digest = await emailDigests.getByUserToday(userId)
|
||||||
totalSorted: digest.totalSorted,
|
|
||||||
inboxCleared: digest.inboxCleared,
|
if (!digest) {
|
||||||
timeSavedMinutes: digest.timeSavedMinutes,
|
return emptyDigest()
|
||||||
stats: digest.stats,
|
}
|
||||||
highlights: digest.highlights,
|
|
||||||
suggestions: digest.suggestions,
|
return respond.success(res, {
|
||||||
hasData: true,
|
date: digest.date,
|
||||||
})
|
totalSorted: digest.totalSorted,
|
||||||
}))
|
inboxCleared: digest.inboxCleared,
|
||||||
|
timeSavedMinutes: digest.timeSavedMinutes,
|
||||||
|
stats: digest.stats,
|
||||||
|
highlights: digest.highlights,
|
||||||
|
suggestions: digest.suggestions,
|
||||||
|
hasData: true,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (isAppwriteCollectionMissing(err)) {
|
||||||
|
return emptyDigest()
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/digest', asyncHandler(handleGetDigest))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/email/digest/history
|
* GET /api/email/digest/history
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ 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 { subscriptions, submissions } from '../services/database.mjs'
|
import { subscriptions, submissions } from '../services/database.mjs'
|
||||||
import { config } from '../config/index.mjs'
|
import { config, isAdmin } from '../config/index.mjs'
|
||||||
import { log } from '../middleware/logger.mjs'
|
import { log } from '../middleware/logger.mjs'
|
||||||
import { requireAuth } from '../middleware/auth.mjs'
|
import { requireAuth } from '../middleware/auth.mjs'
|
||||||
import { loadEmailTemplate, renderTemplate, sendPlainEmail } from '../utils/mailer.mjs'
|
import { loadEmailTemplate, renderTemplate, sendPlainEmail } from '../utils/mailer.mjs'
|
||||||
|
import { isAppwriteCollectionMissing } from '../utils/appwriteErrors.mjs'
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
@@ -165,30 +166,91 @@ router.post('/checkout',
|
|||||||
/**
|
/**
|
||||||
* GET /api/subscription/status
|
* GET /api/subscription/status
|
||||||
* Get user's subscription status
|
* Get user's subscription status
|
||||||
|
* (Also registered on app in index.mjs before router mount.)
|
||||||
*/
|
*/
|
||||||
router.get('/status', asyncHandler(async (req, res) => {
|
export async function handleGetSubscriptionStatus(req, res) {
|
||||||
const userId = req.appwriteUser.id
|
const userId = req.appwriteUser.id
|
||||||
|
|
||||||
const sub = await subscriptions.getByUser(userId, req.appwriteUser?.email)
|
if (isAdmin(req.appwriteUser.email)) {
|
||||||
const topKey = config.topSubscriptionPlan
|
return respond.success(res, {
|
||||||
const plan = sub.plan || topKey
|
status: 'active',
|
||||||
const features =
|
plan: 'business',
|
||||||
PLANS[plan]?.features ||
|
planDisplayName: 'Business (Admin)',
|
||||||
PLANS[topKey]?.features ||
|
isFreeTier: false,
|
||||||
PLANS.business.features
|
emailsUsedThisMonth: 0,
|
||||||
|
emailsLimit: 999999,
|
||||||
|
features: {
|
||||||
|
emailAccounts: 999,
|
||||||
|
emailsPerDay: 999999,
|
||||||
|
historicalSync: true,
|
||||||
|
customRules: true,
|
||||||
|
prioritySupport: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
respond.success(res, {
|
try {
|
||||||
status: sub.status || 'active',
|
const sub = await subscriptions.getByUser(userId, req.appwriteUser?.email)
|
||||||
plan,
|
|
||||||
planDisplayName: PLAN_DISPLAY_NAMES[plan] || PLAN_DISPLAY_NAMES[topKey] || 'Business',
|
// No subscription document yet (synthetic free tier from DB layer) — safe defaults, not 404
|
||||||
isFreeTier: Boolean(sub.isFreeTier),
|
if (!sub?.$id && sub?.plan === 'free') {
|
||||||
emailsUsedThisMonth: sub.emailsUsedThisMonth ?? 0,
|
return respond.success(res, {
|
||||||
emailsLimit: sub.emailsLimit ?? -1,
|
status: 'free',
|
||||||
features,
|
plan: 'free',
|
||||||
currentPeriodEnd: sub.currentPeriodEnd,
|
planDisplayName: PLAN_DISPLAY_NAMES.free,
|
||||||
cancelAtPeriodEnd: Boolean(sub.cancelAtPeriodEnd),
|
isFreeTier: true,
|
||||||
})
|
emailsUsedThisMonth: sub.emailsUsedThisMonth ?? 0,
|
||||||
}))
|
emailsLimit: sub.emailsLimit ?? 500,
|
||||||
|
features: {
|
||||||
|
emailAccounts: 1,
|
||||||
|
emailsPerDay: 50,
|
||||||
|
historicalSync: false,
|
||||||
|
customRules: false,
|
||||||
|
prioritySupport: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const topKey = config.topSubscriptionPlan
|
||||||
|
const plan = sub.plan || topKey
|
||||||
|
const features =
|
||||||
|
PLANS[plan]?.features ||
|
||||||
|
PLANS[topKey]?.features ||
|
||||||
|
PLANS.business.features
|
||||||
|
|
||||||
|
return respond.success(res, {
|
||||||
|
status: sub.status || 'active',
|
||||||
|
plan,
|
||||||
|
planDisplayName: PLAN_DISPLAY_NAMES[plan] || PLAN_DISPLAY_NAMES[topKey] || 'Business',
|
||||||
|
isFreeTier: Boolean(sub.isFreeTier),
|
||||||
|
emailsUsedThisMonth: sub.emailsUsedThisMonth ?? 0,
|
||||||
|
emailsLimit: sub.emailsLimit ?? -1,
|
||||||
|
features,
|
||||||
|
currentPeriodEnd: sub.currentPeriodEnd,
|
||||||
|
cancelAtPeriodEnd: Boolean(sub.cancelAtPeriodEnd),
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (isAppwriteCollectionMissing(err)) {
|
||||||
|
return respond.success(res, {
|
||||||
|
status: 'free',
|
||||||
|
plan: 'free',
|
||||||
|
isFreeTier: true,
|
||||||
|
emailsUsedThisMonth: 0,
|
||||||
|
emailsLimit: 50,
|
||||||
|
features: {
|
||||||
|
emailAccounts: 1,
|
||||||
|
emailsPerDay: 50,
|
||||||
|
historicalSync: false,
|
||||||
|
customRules: false,
|
||||||
|
prioritySupport: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/status', asyncHandler(handleGetSubscriptionStatus))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/subscription/portal
|
* POST /api/subscription/portal
|
||||||
|
|||||||
@@ -7,6 +7,132 @@ import { Mistral } from '@mistralai/mistralai'
|
|||||||
import { config } from '../config/index.mjs'
|
import { config } from '../config/index.mjs'
|
||||||
import { log } from '../middleware/logger.mjs'
|
import { log } from '../middleware/logger.mjs'
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((r) => setTimeout(r, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
function is503Error(error) {
|
||||||
|
const status = error?.status ?? error?.statusCode ?? error?.response?.status
|
||||||
|
if (status === 503) return true
|
||||||
|
const msg = String(error?.message || '').toLowerCase()
|
||||||
|
return msg.includes('503') || msg.includes('service unavailable')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRetryableError(err) {
|
||||||
|
if (is503Error(err)) return true
|
||||||
|
const status = err?.status ?? err?.statusCode ?? err?.response?.status
|
||||||
|
if (status === 429) return true
|
||||||
|
const msg = (err?.message || '').toLowerCase()
|
||||||
|
return (
|
||||||
|
msg.includes('429') ||
|
||||||
|
msg.includes('rate limit') ||
|
||||||
|
msg.includes('too many requests')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rule-based fallback when Mistral is unavailable or rate-limited.
|
||||||
|
* @param {{ from?: string, subject?: string, snippet?: string }} emailData
|
||||||
|
*/
|
||||||
|
export function ruleBasedCategory(emailData) {
|
||||||
|
const from = (emailData.from || '').toLowerCase()
|
||||||
|
const subject = (emailData.subject || '').toLowerCase()
|
||||||
|
const snippet = (emailData.snippet || '').toLowerCase()
|
||||||
|
|
||||||
|
// NEWSLETTERS — mass emails, unsubscribe links
|
||||||
|
if (
|
||||||
|
from.includes('noreply') ||
|
||||||
|
from.includes('no-reply') ||
|
||||||
|
from.includes('newsletter') ||
|
||||||
|
from.includes('marketing') ||
|
||||||
|
subject.includes('newsletter') ||
|
||||||
|
subject.includes('unsubscribe') ||
|
||||||
|
subject.includes('abbestellen')
|
||||||
|
) {
|
||||||
|
return 'newsletters'
|
||||||
|
}
|
||||||
|
|
||||||
|
// PROMOTIONS — sales, offers, discounts
|
||||||
|
if (
|
||||||
|
subject.includes('sale') ||
|
||||||
|
subject.includes('offer') ||
|
||||||
|
subject.includes('deal') ||
|
||||||
|
subject.includes('discount') ||
|
||||||
|
subject.includes('% off') ||
|
||||||
|
subject.includes('angebot') ||
|
||||||
|
subject.includes('rabatt') ||
|
||||||
|
from.includes('promo') ||
|
||||||
|
from.includes('deals') ||
|
||||||
|
from.includes('offers')
|
||||||
|
) {
|
||||||
|
return 'promotions'
|
||||||
|
}
|
||||||
|
|
||||||
|
// INVOICES — billing documents
|
||||||
|
if (
|
||||||
|
subject.includes('invoice') ||
|
||||||
|
subject.includes('rechnung') ||
|
||||||
|
subject.includes('payment') ||
|
||||||
|
subject.includes('zahlung') ||
|
||||||
|
subject.includes('bill ') ||
|
||||||
|
subject.includes('receipt') ||
|
||||||
|
subject.includes('quittung')
|
||||||
|
) {
|
||||||
|
return 'invoices'
|
||||||
|
}
|
||||||
|
|
||||||
|
// SECURITY — ONLY real security alerts (very specific)
|
||||||
|
if (
|
||||||
|
(subject.includes('security alert') ||
|
||||||
|
subject.includes('sign-in') ||
|
||||||
|
subject.includes('new login') ||
|
||||||
|
subject.includes('suspicious') ||
|
||||||
|
subject.includes('verify your') ||
|
||||||
|
subject.includes('2fa') ||
|
||||||
|
subject.includes('two-factor') ||
|
||||||
|
subject.includes('password reset') ||
|
||||||
|
(subject.includes('passwort') && subject.includes('zurücksetzen'))) &&
|
||||||
|
(from.includes('security') ||
|
||||||
|
from.includes('noreply') ||
|
||||||
|
from.includes('accounts') ||
|
||||||
|
from.includes('alerts'))
|
||||||
|
) {
|
||||||
|
return 'security'
|
||||||
|
}
|
||||||
|
|
||||||
|
// CALENDAR — meetings and events
|
||||||
|
if (
|
||||||
|
subject.includes('meeting') ||
|
||||||
|
subject.includes('invitation') ||
|
||||||
|
subject.includes('calendar') ||
|
||||||
|
subject.includes('appointment') ||
|
||||||
|
subject.includes('termin') ||
|
||||||
|
subject.includes('einladung') ||
|
||||||
|
subject.endsWith('.ics')
|
||||||
|
) {
|
||||||
|
return 'calendar'
|
||||||
|
}
|
||||||
|
|
||||||
|
// VIP — personal direct emails (not noreply, short subject)
|
||||||
|
if (
|
||||||
|
!from.includes('noreply') &&
|
||||||
|
!from.includes('no-reply') &&
|
||||||
|
!from.includes('newsletter') &&
|
||||||
|
!from.includes('info@') &&
|
||||||
|
subject.length < 60 &&
|
||||||
|
subject.length > 3
|
||||||
|
) {
|
||||||
|
return 'vip'
|
||||||
|
}
|
||||||
|
|
||||||
|
// DEFAULT — review (not security!)
|
||||||
|
return 'review'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pace Mistral calls (IMAP sort uses these in email.mjs) */
|
||||||
|
export const AI_BATCH_CHUNK_SIZE = 5
|
||||||
|
export const AI_BATCH_CHUNK_DELAY_MS = 2000
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Email categories with metadata
|
* Email categories with metadata
|
||||||
* Uses Gmail categories where available
|
* Uses Gmail categories where available
|
||||||
@@ -67,7 +193,8 @@ const CATEGORIES = {
|
|||||||
},
|
},
|
||||||
security: {
|
security: {
|
||||||
name: 'Security',
|
name: 'Security',
|
||||||
description: 'Security codes and notifications',
|
description:
|
||||||
|
'ONLY real account-security mail: login alerts (new sign-in, suspicious activity), password reset/change, 2FA/MFA codes, device verification. NOT marketing, shipping alerts, price drops, social notifications, or generic “notification” subjects.',
|
||||||
color: '#f44336',
|
color: '#f44336',
|
||||||
gmailCategory: null,
|
gmailCategory: null,
|
||||||
action: 'inbox', // Keep in inbox (important!)
|
action: 'inbox', // Keep in inbox (important!)
|
||||||
@@ -396,7 +523,12 @@ export class AISorterService {
|
|||||||
*/
|
*/
|
||||||
async categorize(email, preferences = {}) {
|
async categorize(email, preferences = {}) {
|
||||||
if (!this.enabled) {
|
if (!this.enabled) {
|
||||||
return { category: 'review', confidence: 0, reason: 'AI not configured' }
|
return {
|
||||||
|
category: ruleBasedCategory(email),
|
||||||
|
confidence: 0,
|
||||||
|
reason: 'AI not configured',
|
||||||
|
assignedTo: null,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { from, subject, snippet } = email
|
const { from, subject, snippet } = email
|
||||||
@@ -409,6 +541,13 @@ export class AISorterService {
|
|||||||
AVAILABLE CATEGORIES:
|
AVAILABLE CATEGORIES:
|
||||||
${Object.entries(CATEGORIES).map(([key, cat]) => `- ${key}: ${cat.name} - ${cat.description}`).join('\n')}
|
${Object.entries(CATEGORIES).map(([key, cat]) => `- ${key}: ${cat.name} - ${cat.description}`).join('\n')}
|
||||||
|
|
||||||
|
CLASSIFICATION RULES (important):
|
||||||
|
- security: Use ONLY for genuine account safety: password reset/change, 2FA/MFA codes, new device login, suspicious sign-in warnings from the service itself. Do NOT use security for marketing, newsletters, order/shipping "alerts", price alerts, social network notifications, or anything that merely says "alert" or "notification".
|
||||||
|
- social: Social networks, likes, follows, mentions, friend requests, activity digests.
|
||||||
|
- newsletters: Recurring digests, blogs, Substack, product updates that are not personal.
|
||||||
|
- promotions: Sales, discounts, ads, deals.
|
||||||
|
- review: When unsure or mixed — prefer review over guessing security.
|
||||||
|
|
||||||
${preferenceContext}
|
${preferenceContext}
|
||||||
|
|
||||||
EMAIL:
|
EMAIL:
|
||||||
@@ -422,36 +561,78 @@ If the email is clearly FOR a specific worker (e.g. "für Max", "an Anna", subje
|
|||||||
|
|
||||||
Respond ONLY with the JSON object.`
|
Respond ONLY with the JSON object.`
|
||||||
|
|
||||||
try {
|
const parseAndValidate = (content) => {
|
||||||
const response = await this.client.chat.complete({
|
|
||||||
model: this.model,
|
|
||||||
messages: [{ role: 'user', content: prompt }],
|
|
||||||
temperature: 0.1,
|
|
||||||
maxTokens: 150,
|
|
||||||
responseFormat: { type: 'json_object' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const content = response.choices[0]?.message?.content
|
|
||||||
const result = JSON.parse(content)
|
const result = JSON.parse(content)
|
||||||
|
|
||||||
// Validate category
|
|
||||||
if (!CATEGORIES[result.category]) {
|
if (!CATEGORIES[result.category]) {
|
||||||
result.category = 'review'
|
result.category = 'review'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate assignedTo against name labels (id or name)
|
|
||||||
if (result.assignedTo && preferences.nameLabels?.length) {
|
if (result.assignedTo && preferences.nameLabels?.length) {
|
||||||
const match = preferences.nameLabels.find(
|
const match = preferences.nameLabels.find(
|
||||||
l => l.enabled && (l.id === result.assignedTo || l.name === result.assignedTo)
|
(l) => l.enabled && (l.id === result.assignedTo || l.name === result.assignedTo)
|
||||||
)
|
)
|
||||||
if (!match) result.assignedTo = null
|
if (!match) result.assignedTo = null
|
||||||
else result.assignedTo = match.id || match.name
|
else result.assignedTo = match.id || match.name
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
}
|
||||||
log.error('AI categorization failed', { error: error.message })
|
|
||||||
return { category: 'review', confidence: 0, reason: 'Categorization error' }
|
let attempt = 0
|
||||||
|
let used503Backoff = false
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const response = await this.client.chat.complete({
|
||||||
|
model: this.model,
|
||||||
|
messages: [{ role: 'user', content: prompt }],
|
||||||
|
temperature: 0.1,
|
||||||
|
maxTokens: 150,
|
||||||
|
responseFormat: { type: 'json_object' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const content = response.choices[0]?.message?.content
|
||||||
|
return parseAndValidate(content)
|
||||||
|
} catch (error) {
|
||||||
|
if (!isRetryableError(error)) {
|
||||||
|
log.error('AI categorization failed', { error: error.message })
|
||||||
|
return {
|
||||||
|
category: ruleBasedCategory(email),
|
||||||
|
confidence: 0,
|
||||||
|
reason: 'Categorization error',
|
||||||
|
assignedTo: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (is503Error(error)) {
|
||||||
|
if (!used503Backoff) {
|
||||||
|
used503Backoff = true
|
||||||
|
log.warn('Mistral 503 (service unavailable), retry in 5s', { attempt: attempt + 1 })
|
||||||
|
await sleep(5000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.warn('Mistral 503 after retry, using rule-based fallback')
|
||||||
|
return {
|
||||||
|
category: ruleBasedCategory(email),
|
||||||
|
confidence: 0,
|
||||||
|
reason: '503 — rule-based fallback',
|
||||||
|
assignedTo: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (attempt >= 2) {
|
||||||
|
log.warn('Mistral rate limit after retries, using rule-based fallback')
|
||||||
|
return {
|
||||||
|
category: ruleBasedCategory(email),
|
||||||
|
confidence: 0,
|
||||||
|
reason: 'Rate limit — rule-based fallback',
|
||||||
|
assignedTo: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (attempt === 0) {
|
||||||
|
log.warn('Mistral rate limit (429), retry in 2s', { attempt: attempt + 1 })
|
||||||
|
await sleep(2000)
|
||||||
|
} else {
|
||||||
|
log.warn('Mistral rate limit (429), retry in 5s', { attempt: attempt + 1 })
|
||||||
|
await sleep(5000)
|
||||||
|
}
|
||||||
|
attempt++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,9 +641,14 @@ Respond ONLY with the JSON object.`
|
|||||||
*/
|
*/
|
||||||
async batchCategorize(emails, preferences = {}) {
|
async batchCategorize(emails, preferences = {}) {
|
||||||
if (!this.enabled || emails.length === 0) {
|
if (!this.enabled || emails.length === 0) {
|
||||||
return emails.map(e => ({
|
return emails.map((e) => ({
|
||||||
email: e,
|
email: e,
|
||||||
classification: { category: 'review', confidence: 0, reason: 'AI not available' },
|
classification: {
|
||||||
|
category: ruleBasedCategory(e),
|
||||||
|
confidence: 0,
|
||||||
|
reason: 'AI not available',
|
||||||
|
assignedTo: null,
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,7 +672,9 @@ Respond ONLY with the JSON object.`
|
|||||||
const prompt = `You are an email sorting assistant. Categorize the following ${emails.length} emails.
|
const prompt = `You are an email sorting assistant. Categorize the following ${emails.length} emails.
|
||||||
|
|
||||||
CATEGORIES:
|
CATEGORIES:
|
||||||
${Object.entries(CATEGORIES).map(([key, cat]) => `${key}: ${cat.name}`).join(' | ')}
|
${Object.entries(CATEGORIES).map(([key, cat]) => `${key}: ${cat.name} — ${cat.description}`).join('\n')}
|
||||||
|
|
||||||
|
RULES: Use "security" ONLY for real account safety (password/2FA/login alerts). NOT for marketing alerts, shipping updates, or social notifications — use promotions, newsletters, social, or review instead.
|
||||||
|
|
||||||
${preferenceContext}
|
${preferenceContext}
|
||||||
|
|
||||||
@@ -499,7 +687,7 @@ If an email is clearly FOR a specific worker, set assignedTo to that worker's id
|
|||||||
|
|
||||||
Respond ONLY with the JSON array.`
|
Respond ONLY with the JSON array.`
|
||||||
|
|
||||||
try {
|
const runBatchRequest = async () => {
|
||||||
const response = await this.client.chat.complete({
|
const response = await this.client.chat.complete({
|
||||||
model: this.model,
|
model: this.model,
|
||||||
messages: [{ role: 'user', content: prompt }],
|
messages: [{ role: 'user', content: prompt }],
|
||||||
@@ -511,7 +699,6 @@ Respond ONLY with the JSON array.`
|
|||||||
const content = response.choices[0]?.message?.content
|
const content = response.choices[0]?.message?.content
|
||||||
let parsed
|
let parsed
|
||||||
|
|
||||||
// Handle both array and object responses
|
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(content)
|
parsed = JSON.parse(content)
|
||||||
if (parsed.results) parsed = parsed.results
|
if (parsed.results) parsed = parsed.results
|
||||||
@@ -519,17 +706,16 @@ Respond ONLY with the JSON array.`
|
|||||||
throw new Error('Not an array')
|
throw new Error('Not an array')
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback to individual processing
|
|
||||||
return this._fallbackBatch(emails, preferences)
|
return this._fallbackBatch(emails, preferences)
|
||||||
}
|
}
|
||||||
|
|
||||||
return emails.map((email, i) => {
|
return emails.map((email, i) => {
|
||||||
const result = parsed.find(r => r.index === i)
|
const result = parsed.find((r) => r.index === i)
|
||||||
const category = result?.category && CATEGORIES[result.category] ? result.category : 'review'
|
const category = result?.category && CATEGORIES[result.category] ? result.category : 'review'
|
||||||
let assignedTo = result?.assignedTo || null
|
let assignedTo = result?.assignedTo || null
|
||||||
if (assignedTo && preferences.nameLabels?.length) {
|
if (assignedTo && preferences.nameLabels?.length) {
|
||||||
const match = preferences.nameLabels.find(
|
const match = preferences.nameLabels.find(
|
||||||
l => l.enabled && (l.id === assignedTo || l.name === assignedTo)
|
(l) => l.enabled && (l.id === assignedTo || l.name === assignedTo)
|
||||||
)
|
)
|
||||||
assignedTo = match ? (match.id || match.name) : null
|
assignedTo = match ? (match.id || match.name) : null
|
||||||
}
|
}
|
||||||
@@ -538,10 +724,68 @@ Respond ONLY with the JSON array.`
|
|||||||
classification: { category, confidence: 0.8, reason: 'Batch', assignedTo },
|
classification: { category, confidence: 0.8, reason: 'Batch', assignedTo },
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (error) {
|
|
||||||
log.error('Batch categorization failed', { error: error.message })
|
|
||||||
return this._fallbackBatch(emails, preferences)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let attempt = 0
|
||||||
|
let used503Backoff = false
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
return await runBatchRequest()
|
||||||
|
} catch (error) {
|
||||||
|
if (!isRetryableError(error)) {
|
||||||
|
log.error('Batch categorization failed', { error: error.message })
|
||||||
|
return this._fallbackBatch(emails, preferences)
|
||||||
|
}
|
||||||
|
if (is503Error(error)) {
|
||||||
|
if (!used503Backoff) {
|
||||||
|
used503Backoff = true
|
||||||
|
log.warn('Mistral batch 503 (service unavailable), retry in 5s', { attempt: attempt + 1 })
|
||||||
|
await sleep(5000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.warn('Mistral batch 503 after retry, rule-based per email')
|
||||||
|
return emails.map((email) => ({
|
||||||
|
email,
|
||||||
|
classification: {
|
||||||
|
category: ruleBasedCategory(email),
|
||||||
|
confidence: 0,
|
||||||
|
reason: '503 — rule-based fallback',
|
||||||
|
assignedTo: null,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
if (attempt >= 2) {
|
||||||
|
log.warn('Mistral batch rate limit after retries, rule-based per email')
|
||||||
|
return emails.map((email) => ({
|
||||||
|
email,
|
||||||
|
classification: {
|
||||||
|
category: ruleBasedCategory(email),
|
||||||
|
confidence: 0,
|
||||||
|
reason: 'Rate limit — rule-based fallback',
|
||||||
|
assignedTo: null,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
if (attempt === 0) {
|
||||||
|
log.warn('Mistral batch rate limit (429), retry in 2s', { attempt: attempt + 1 })
|
||||||
|
await sleep(2000)
|
||||||
|
} else {
|
||||||
|
log.warn('Mistral batch rate limit (429), retry in 5s', { attempt: attempt + 1 })
|
||||||
|
await sleep(5000)
|
||||||
|
}
|
||||||
|
attempt++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return emails.map((email) => ({
|
||||||
|
email,
|
||||||
|
classification: {
|
||||||
|
category: ruleBasedCategory(email),
|
||||||
|
confidence: 0,
|
||||||
|
reason: 'Rate limit — rule-based fallback',
|
||||||
|
assignedTo: null,
|
||||||
|
},
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { Client, Databases, Query, ID } from 'node-appwrite'
|
import { Client, Databases, Query, ID } from 'node-appwrite'
|
||||||
import { config, isAdmin } from '../config/index.mjs'
|
import { config, isAdmin } from '../config/index.mjs'
|
||||||
import { NotFoundError } from '../middleware/errorHandler.mjs'
|
import { NotFoundError } from '../middleware/errorHandler.mjs'
|
||||||
|
import { log } from '../middleware/logger.mjs'
|
||||||
|
|
||||||
// Initialize Appwrite client
|
// Initialize Appwrite client
|
||||||
const client = new Client()
|
const client = new Client()
|
||||||
@@ -15,6 +16,19 @@ const client = new Client()
|
|||||||
|
|
||||||
const databases = new Databases(client)
|
const databases = new Databases(client)
|
||||||
const DB_ID = config.appwrite.databaseId
|
const DB_ID = config.appwrite.databaseId
|
||||||
|
const DATABASE_ID = DB_ID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appwrite: database/collection missing (schema not provisioned yet)
|
||||||
|
*/
|
||||||
|
function isCollectionNotFound(err) {
|
||||||
|
if (!err) return false
|
||||||
|
const msg = typeof err.message === 'string' ? err.message : ''
|
||||||
|
return (
|
||||||
|
err.type === 'collection_not_found' ||
|
||||||
|
msg.includes('Collection with the requested ID')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collection names
|
* Collection names
|
||||||
@@ -44,7 +58,12 @@ export const db = {
|
|||||||
* Create a document
|
* Create a document
|
||||||
*/
|
*/
|
||||||
async create(collection, data, id = ID.unique()) {
|
async create(collection, data, id = ID.unique()) {
|
||||||
return await databases.createDocument(DB_ID, collection, id, data)
|
try {
|
||||||
|
return await databases.createDocument(DB_ID, collection, id, data)
|
||||||
|
} catch (err) {
|
||||||
|
if (isCollectionNotFound(err)) return null
|
||||||
|
throw err
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,6 +73,9 @@ export const db = {
|
|||||||
try {
|
try {
|
||||||
return await databases.getDocument(DB_ID, collection, id)
|
return await databases.getDocument(DB_ID, collection, id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isCollectionNotFound(error)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
if (error.code === 404) {
|
if (error.code === 404) {
|
||||||
throw new NotFoundError(collection)
|
throw new NotFoundError(collection)
|
||||||
}
|
}
|
||||||
@@ -65,22 +87,43 @@ export const db = {
|
|||||||
* Update a document
|
* Update a document
|
||||||
*/
|
*/
|
||||||
async update(collection, id, data) {
|
async update(collection, id, data) {
|
||||||
return await databases.updateDocument(DB_ID, collection, id, data)
|
try {
|
||||||
|
return await databases.updateDocument(DB_ID, collection, id, data)
|
||||||
|
} catch (err) {
|
||||||
|
if (isCollectionNotFound(err)) return null
|
||||||
|
throw err
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a document
|
* Delete a document
|
||||||
*/
|
*/
|
||||||
async delete(collection, id) {
|
async delete(collection, id) {
|
||||||
return await databases.deleteDocument(DB_ID, collection, id)
|
try {
|
||||||
|
return await databases.deleteDocument(DB_ID, collection, id)
|
||||||
|
} catch (err) {
|
||||||
|
if (isCollectionNotFound(err)) return null
|
||||||
|
throw err
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List documents with optional queries
|
* List documents with optional queries
|
||||||
*/
|
*/
|
||||||
async list(collection, queries = []) {
|
async list(collectionId, queries = []) {
|
||||||
const response = await databases.listDocuments(DB_ID, collection, queries)
|
try {
|
||||||
return response.documents
|
const response = await databases.listDocuments(
|
||||||
|
DATABASE_ID,
|
||||||
|
collectionId,
|
||||||
|
queries
|
||||||
|
)
|
||||||
|
return response.documents
|
||||||
|
} catch (err) {
|
||||||
|
if (isCollectionNotFound(err)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,7 +141,8 @@ export const db = {
|
|||||||
try {
|
try {
|
||||||
await databases.getDocument(DB_ID, collection, id)
|
await databases.getDocument(DB_ID, collection, id)
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
if (isCollectionNotFound(err)) return false
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -107,11 +151,16 @@ export const db = {
|
|||||||
* Count documents
|
* Count documents
|
||||||
*/
|
*/
|
||||||
async count(collection, queries = []) {
|
async count(collection, queries = []) {
|
||||||
const response = await databases.listDocuments(DB_ID, collection, [
|
try {
|
||||||
...queries,
|
const response = await databases.listDocuments(DB_ID, collection, [
|
||||||
Query.limit(1),
|
...queries,
|
||||||
])
|
Query.limit(1),
|
||||||
return response.total
|
])
|
||||||
|
return response.total
|
||||||
|
} catch (err) {
|
||||||
|
if (isCollectionNotFound(err)) return 0
|
||||||
|
throw err
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,21 +257,28 @@ export const emailStats = {
|
|||||||
const stats = await this.getByUser(userId)
|
const stats = await this.getByUser(userId)
|
||||||
|
|
||||||
if (stats) {
|
if (stats) {
|
||||||
return db.update(Collections.EMAIL_STATS, stats.$id, {
|
const updated = await db.update(Collections.EMAIL_STATS, stats.$id, {
|
||||||
totalSorted: (stats.totalSorted || 0) + (counts.total || 0),
|
totalSorted: (stats.totalSorted || 0) + (counts.total || 0),
|
||||||
todaySorted: (stats.todaySorted || 0) + (counts.today || 0),
|
todaySorted: (stats.todaySorted || 0) + (counts.today || 0),
|
||||||
weekSorted: (stats.weekSorted || 0) + (counts.week || 0),
|
weekSorted: (stats.weekSorted || 0) + (counts.week || 0),
|
||||||
timeSavedMinutes: (stats.timeSavedMinutes || 0) + (counts.timeSaved || 0),
|
timeSavedMinutes: (stats.timeSavedMinutes || 0) + (counts.timeSaved || 0),
|
||||||
})
|
})
|
||||||
} else {
|
if (updated == null && process.env.NODE_ENV === 'development') {
|
||||||
return this.create(userId, {
|
log.warn('emailStats.increment: update skipped (missing collection or document)', { userId })
|
||||||
totalSorted: counts.total || 0,
|
}
|
||||||
todaySorted: counts.today || 0,
|
return updated
|
||||||
weekSorted: counts.week || 0,
|
|
||||||
timeSavedMinutes: counts.timeSaved || 0,
|
|
||||||
categoriesJson: '{}',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
const created = await this.create(userId, {
|
||||||
|
totalSorted: counts.total || 0,
|
||||||
|
todaySorted: counts.today || 0,
|
||||||
|
weekSorted: counts.week || 0,
|
||||||
|
timeSavedMinutes: counts.timeSaved || 0,
|
||||||
|
categoriesJson: '{}',
|
||||||
|
})
|
||||||
|
if (created == null && process.env.NODE_ENV === 'development') {
|
||||||
|
log.warn('emailStats.increment: create skipped (missing collection)', { userId })
|
||||||
|
}
|
||||||
|
return created
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateCategories(userId, categories) {
|
async updateCategories(userId, categories) {
|
||||||
@@ -276,18 +332,26 @@ export const emailUsage = {
|
|||||||
const existing = await this.getCurrentMonth(userId)
|
const existing = await this.getCurrentMonth(userId)
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return db.update(Collections.EMAIL_USAGE, existing.$id, {
|
const updated = await db.update(Collections.EMAIL_USAGE, existing.$id, {
|
||||||
emailsProcessed: (existing.emailsProcessed || 0) + count,
|
emailsProcessed: (existing.emailsProcessed || 0) + count,
|
||||||
lastReset: new Date().toISOString(),
|
lastReset: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
|
if (updated == null && process.env.NODE_ENV === 'development') {
|
||||||
|
log.warn('emailUsage.increment: update skipped (missing collection or document)', { userId })
|
||||||
|
}
|
||||||
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
return db.create(Collections.EMAIL_USAGE, {
|
const created = await db.create(Collections.EMAIL_USAGE, {
|
||||||
userId,
|
userId,
|
||||||
month,
|
month,
|
||||||
emailsProcessed: count,
|
emailsProcessed: count,
|
||||||
lastReset: new Date().toISOString(),
|
lastReset: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
|
if (created == null && process.env.NODE_ENV === 'development') {
|
||||||
|
log.warn('emailUsage.increment: create skipped (missing collection)', { userId })
|
||||||
|
}
|
||||||
|
return created
|
||||||
},
|
},
|
||||||
|
|
||||||
async getUsage(userId) {
|
async getUsage(userId) {
|
||||||
@@ -578,6 +642,26 @@ export const onboardingState = {
|
|||||||
last_updated: new Date().toISOString(),
|
last_updated: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset onboarding to initial state (does not delete the document).
|
||||||
|
*/
|
||||||
|
async resetToInitial(userId) {
|
||||||
|
const existing = await db.findOne(Collections.ONBOARDING_STATE, [
|
||||||
|
Query.equal('userId', userId),
|
||||||
|
])
|
||||||
|
const data = {
|
||||||
|
onboarding_step: 'not_started',
|
||||||
|
completed_steps_json: JSON.stringify([]),
|
||||||
|
skipped_at: null,
|
||||||
|
first_value_seen_at: null,
|
||||||
|
last_updated: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
if (existing) {
|
||||||
|
return db.update(Collections.ONBOARDING_STATE, existing.$id, data)
|
||||||
|
}
|
||||||
|
return db.create(Collections.ONBOARDING_STATE, { userId, ...data })
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -609,12 +693,17 @@ export const referrals = {
|
|||||||
attempts++
|
attempts++
|
||||||
}
|
}
|
||||||
|
|
||||||
return db.create(Collections.REFERRALS, {
|
const created = await db.create(Collections.REFERRALS, {
|
||||||
userId,
|
userId,
|
||||||
referralCode: uniqueCode,
|
referralCode: uniqueCode,
|
||||||
referralCount: 0,
|
referralCount: 0,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Collection missing → return null safely
|
||||||
|
if (!created) return null
|
||||||
|
|
||||||
|
return created
|
||||||
},
|
},
|
||||||
|
|
||||||
async getByCode(code) {
|
async getByCode(code) {
|
||||||
@@ -746,6 +835,25 @@ export const emailDigests = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all documents in a collection where attribute `userId` matches (paginated batches of 100).
|
||||||
|
*/
|
||||||
|
export async function deleteAllDocumentsForUser(collection, userId) {
|
||||||
|
let deleted = 0
|
||||||
|
for (;;) {
|
||||||
|
const batch = await db.list(collection, [
|
||||||
|
Query.equal('userId', userId),
|
||||||
|
Query.limit(100),
|
||||||
|
])
|
||||||
|
if (!batch.length) break
|
||||||
|
for (const doc of batch) {
|
||||||
|
await db.delete(collection, doc.$id)
|
||||||
|
deleted++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deleted
|
||||||
|
}
|
||||||
|
|
||||||
export { Query }
|
export { Query }
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@@ -9,6 +9,33 @@ import { log } from '../middleware/logger.mjs'
|
|||||||
const INBOX = 'INBOX'
|
const INBOX = 'INBOX'
|
||||||
const FOLDER_PREFIX = 'MailFlow'
|
const FOLDER_PREFIX = 'MailFlow'
|
||||||
|
|
||||||
|
/** Timeouts (ms) — avoid hanging on bad host/firewall */
|
||||||
|
const IMAP_CONNECT_TIMEOUT_MS = 10_000
|
||||||
|
const IMAP_OPERATION_TIMEOUT_MS = 10_000
|
||||||
|
|
||||||
|
/** Per-command cap so dead connections fail fast (sort try/catch + race can complete). */
|
||||||
|
const IMAP_OP_TIMEOUT_MS = 5000
|
||||||
|
|
||||||
|
async function withOpTimeout(promise, label = 'IMAP op') {
|
||||||
|
return Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error(`${label} timed out`)), IMAP_OP_TIMEOUT_MS)
|
||||||
|
),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectAfter(ms, message) {
|
||||||
|
return new Promise((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error(message)), ms)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withTimeout(promise, ms, label) {
|
||||||
|
const msg = `${label} timed out after ${ms / 1000} seconds`
|
||||||
|
return Promise.race([promise, rejectAfter(ms, msg)])
|
||||||
|
}
|
||||||
|
|
||||||
/** Map category key to IMAP folder name */
|
/** Map category key to IMAP folder name */
|
||||||
export function getFolderNameForCategory(category) {
|
export function getFolderNameForCategory(category) {
|
||||||
const map = {
|
const map = {
|
||||||
@@ -26,6 +53,212 @@ export function getFolderNameForCategory(category) {
|
|||||||
return map[category] || 'Review'
|
return map[category] || 'Review'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Folder last segments that must NEVER be used as sort targets (Trash, Sent, Drafts, Deleted only).
|
||||||
|
* Junk, Spam, Archive, Favorites are valid targets.
|
||||||
|
*/
|
||||||
|
const FORBIDDEN_LAST_SEGMENTS = new Set(['trash', 'sent', 'drafts', 'deleted'])
|
||||||
|
|
||||||
|
/** Full paths that are never valid move targets (case-insensitive). */
|
||||||
|
const FORBIDDEN_FULL_PATHS = new Set([
|
||||||
|
'trash',
|
||||||
|
'sent',
|
||||||
|
'drafts',
|
||||||
|
'deleted',
|
||||||
|
'inbox.trash',
|
||||||
|
'inbox.sent',
|
||||||
|
'inbox.drafts',
|
||||||
|
'inbox.deleted',
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keywords per AI category for matching existing mailbox paths (case-insensitive).
|
||||||
|
* First match wins (keyword order, then folder list order).
|
||||||
|
*/
|
||||||
|
export const CATEGORY_FOLDER_KEYWORDS = {
|
||||||
|
vip: ['favorites', 'vip', 'important', 'priority', 'wichtig'],
|
||||||
|
customers: ['clients', 'customers', 'kunden', 'client'],
|
||||||
|
invoices: ['invoices', 'invoice', 'rechnungen', 'rechnung', 'billing'],
|
||||||
|
newsletters: ['junk', 'newsletters', 'newsletter', 'subscriptions'],
|
||||||
|
promotions: ['junk', 'promotions', 'promotion', 'marketing', 'spam'],
|
||||||
|
social: ['social', 'notifications', 'team'],
|
||||||
|
security: ['security', 'alerts', 'sicherheit'],
|
||||||
|
calendar: ['calendar', 'meetings'],
|
||||||
|
review: ['archive', 'review', 'later'],
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastMailboxSegment(path) {
|
||||||
|
if (!path || typeof path !== 'string') return ''
|
||||||
|
const parts = path.split(/[/\\]/).filter(Boolean)
|
||||||
|
const leaf = parts.length ? parts[parts.length - 1] : path
|
||||||
|
const dotted = leaf.split('.')
|
||||||
|
return dotted[dotted.length - 1].toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if this path must not be used as a sort move destination.
|
||||||
|
*/
|
||||||
|
export function isForbiddenMoveTarget(folderPath) {
|
||||||
|
if (!folderPath || typeof folderPath !== 'string') return true
|
||||||
|
const norm = folderPath.trim()
|
||||||
|
if (!norm) return true
|
||||||
|
if (norm.toUpperCase() === 'INBOX') return true
|
||||||
|
const fullLower = norm.toLowerCase().replace(/\\/g, '/')
|
||||||
|
if (FORBIDDEN_FULL_PATHS.has(fullLower)) return true
|
||||||
|
const last = lastMailboxSegment(norm)
|
||||||
|
return FORBIDDEN_LAST_SEGMENTS.has(last)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the best existing folder for a category, or null (keep in INBOX).
|
||||||
|
* Prefers exact last-segment match (e.g. path "Junk" or "*.Junk") over substring (avoids wrong "junk" hits).
|
||||||
|
* @param {string} category
|
||||||
|
* @param {string[]} existingFolders - paths from LIST
|
||||||
|
* @returns {string | null}
|
||||||
|
*/
|
||||||
|
export function findBestFolder(category, existingFolders) {
|
||||||
|
if (!existingFolders?.length) return null
|
||||||
|
const keywords = CATEGORY_FOLDER_KEYWORDS[category]
|
||||||
|
if (!keywords?.length) return null
|
||||||
|
|
||||||
|
for (const keyword of keywords) {
|
||||||
|
const kw = keyword.toLowerCase()
|
||||||
|
for (const folderPath of existingFolders) {
|
||||||
|
if (!folderPath || isForbiddenMoveTarget(folderPath)) continue
|
||||||
|
if (lastMailboxSegment(folderPath) === kw) return folderPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const keyword of keywords) {
|
||||||
|
const kw = keyword.toLowerCase()
|
||||||
|
for (const folderPath of existingFolders) {
|
||||||
|
if (!folderPath || isForbiddenMoveTarget(folderPath)) continue
|
||||||
|
const pathLower = folderPath.toLowerCase()
|
||||||
|
if (pathLower.includes(kw)) return folderPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** System mailboxes we never pull from during "re-sort" recovery (keep Sent/Drafts/Trash untouched). */
|
||||||
|
const RESORT_SKIP_LAST_SEGMENTS = new Set([
|
||||||
|
'sent',
|
||||||
|
'drafts',
|
||||||
|
'trash',
|
||||||
|
'deleted',
|
||||||
|
'templates',
|
||||||
|
'outbox',
|
||||||
|
'inbox',
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Folders whose messages we move back to INBOX before a full re-sort (sort destinations + MailFlow/EmailSorter trees).
|
||||||
|
* @param {string} folderPath
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isReSortRecoveryFolder(folderPath) {
|
||||||
|
if (!folderPath || typeof folderPath !== 'string') return false
|
||||||
|
const norm = folderPath.trim()
|
||||||
|
if (!norm || norm.toUpperCase() === 'INBOX') return false
|
||||||
|
const last = lastMailboxSegment(norm)
|
||||||
|
if (RESORT_SKIP_LAST_SEGMENTS.has(last)) return false
|
||||||
|
|
||||||
|
const low = norm.toLowerCase().replace(/\\/g, '/')
|
||||||
|
if (low.includes('mailflow') || low.includes('emailsorter')) return true
|
||||||
|
|
||||||
|
const sortLeaves = new Set([
|
||||||
|
'junk',
|
||||||
|
'spam',
|
||||||
|
'archive',
|
||||||
|
'newsletters',
|
||||||
|
'newsletter',
|
||||||
|
'promotions',
|
||||||
|
'promotion',
|
||||||
|
'social',
|
||||||
|
'review',
|
||||||
|
'security',
|
||||||
|
'calendar',
|
||||||
|
'invoices',
|
||||||
|
'invoice',
|
||||||
|
'clients',
|
||||||
|
'customers',
|
||||||
|
'client',
|
||||||
|
'vip',
|
||||||
|
'favorites',
|
||||||
|
'important',
|
||||||
|
'subscriptions',
|
||||||
|
'marketing',
|
||||||
|
'team',
|
||||||
|
'meetings',
|
||||||
|
'later',
|
||||||
|
'billing',
|
||||||
|
'rechnungen',
|
||||||
|
'rechnung',
|
||||||
|
])
|
||||||
|
return sortLeaves.has(last)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match a person/team folder by local name appearing in From or Subject.
|
||||||
|
* @param {{ from?: string, subject?: string }} emailData
|
||||||
|
* @param {string[]} existingFolders
|
||||||
|
* @returns {string | null}
|
||||||
|
*/
|
||||||
|
export function findPersonFolder(emailData, existingFolders) {
|
||||||
|
const from = (emailData.from || '').toLowerCase()
|
||||||
|
const subject = (emailData.subject || '').toLowerCase()
|
||||||
|
|
||||||
|
for (const folder of existingFolders) {
|
||||||
|
const folderName = folder.split('.').pop() || folder
|
||||||
|
if (folderName.length < 3) continue
|
||||||
|
|
||||||
|
const skip = [
|
||||||
|
'inbox',
|
||||||
|
'sent',
|
||||||
|
'trash',
|
||||||
|
'junk',
|
||||||
|
'spam',
|
||||||
|
'drafts',
|
||||||
|
'archive',
|
||||||
|
'deleted',
|
||||||
|
'favorites',
|
||||||
|
'emailsorter',
|
||||||
|
'vip',
|
||||||
|
'clients',
|
||||||
|
'invoices',
|
||||||
|
'newsletters',
|
||||||
|
'promotions',
|
||||||
|
'security',
|
||||||
|
'calendar',
|
||||||
|
'review',
|
||||||
|
]
|
||||||
|
if (skip.includes(folderName.toLowerCase())) continue
|
||||||
|
|
||||||
|
const name = folderName.toLowerCase()
|
||||||
|
if (from.includes(name) || subject.includes(name)) {
|
||||||
|
return folder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** IMAP keywords ($MailFlow-*) — reliable on hosts that block custom folders */
|
||||||
|
export function getMailFlowKeywordForCategory(category) {
|
||||||
|
const map = {
|
||||||
|
vip: '$MailFlow-VIP',
|
||||||
|
customers: '$MailFlow-Clients',
|
||||||
|
invoices: '$MailFlow-Invoices',
|
||||||
|
newsletters: '$MailFlow-Newsletters',
|
||||||
|
promotions: '$MailFlow-Promotions',
|
||||||
|
social: '$MailFlow-Social',
|
||||||
|
security: '$MailFlow-Security',
|
||||||
|
calendar: '$MailFlow-Calendar',
|
||||||
|
review: '$MailFlow-Review',
|
||||||
|
archive: '$MailFlow-Archive',
|
||||||
|
}
|
||||||
|
return map[category] || '$MailFlow-Review'
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IMAP Service – same conceptual interface as GmailService/OutlookService
|
* IMAP Service – same conceptual interface as GmailService/OutlookService
|
||||||
*/
|
*/
|
||||||
@@ -40,18 +273,47 @@ export class ImapService {
|
|||||||
*/
|
*/
|
||||||
constructor(opts) {
|
constructor(opts) {
|
||||||
const { host, port = 993, secure = true, user, password } = opts
|
const { host, port = 993, secure = true, user, password } = opts
|
||||||
this.client = new ImapFlow({
|
this.host = host || 'imap.porkbun.com'
|
||||||
host: host || 'imap.porkbun.com',
|
this.port = port || 993
|
||||||
port: port || 993,
|
this._imapFlowOptions = {
|
||||||
|
host: this.host,
|
||||||
|
port: this.port,
|
||||||
secure: secure !== false,
|
secure: secure !== false,
|
||||||
auth: { user, pass: password },
|
auth: { user, pass: password },
|
||||||
logger: false,
|
logger: false,
|
||||||
})
|
}
|
||||||
|
this.client = new ImapFlow({ ...this._imapFlowOptions })
|
||||||
this.lock = null
|
this.lock = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async ensureConnected() {
|
||||||
|
try {
|
||||||
|
if (!this.client.usable) throw new Error('not usable')
|
||||||
|
await withOpTimeout(this.client.noop(), 'noop')
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
if (this.lock) await this.lock.release().catch(() => {})
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
this.lock = null
|
||||||
|
try {
|
||||||
|
await this.client.logout()
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
this.client.close()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.client = new ImapFlow({ ...this._imapFlowOptions })
|
||||||
|
await this.connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async connect() {
|
async connect() {
|
||||||
await this.client.connect()
|
console.log('[IMAP] Connecting to:', this.host, this.port)
|
||||||
|
await withTimeout(this.client.connect(), IMAP_CONNECT_TIMEOUT_MS, 'IMAP connect')
|
||||||
}
|
}
|
||||||
|
|
||||||
async close() {
|
async close() {
|
||||||
@@ -69,6 +331,10 @@ export class ImapService {
|
|||||||
* @param {string|null} _pageToken - reserved for future pagination
|
* @param {string|null} _pageToken - reserved for future pagination
|
||||||
*/
|
*/
|
||||||
async listEmails(maxResults = 50, _pageToken = null) {
|
async listEmails(maxResults = 50, _pageToken = null) {
|
||||||
|
return withTimeout(this._listEmailsImpl(maxResults, _pageToken), IMAP_OPERATION_TIMEOUT_MS, 'IMAP listEmails')
|
||||||
|
}
|
||||||
|
|
||||||
|
async _listEmailsImpl(maxResults = 50, _pageToken = null) {
|
||||||
let lock = null
|
let lock = null
|
||||||
try {
|
try {
|
||||||
lock = await this.client.getMailboxLock(INBOX)
|
lock = await this.client.getMailboxLock(INBOX)
|
||||||
@@ -123,6 +389,10 @@ export class ImapService {
|
|||||||
*/
|
*/
|
||||||
async batchGetEmails(messageIds) {
|
async batchGetEmails(messageIds) {
|
||||||
if (!messageIds.length) return []
|
if (!messageIds.length) return []
|
||||||
|
return withTimeout(this._batchGetEmailsImpl(messageIds), IMAP_OPERATION_TIMEOUT_MS, 'IMAP batchGetEmails')
|
||||||
|
}
|
||||||
|
|
||||||
|
async _batchGetEmailsImpl(messageIds) {
|
||||||
let lock = null
|
let lock = null
|
||||||
try {
|
try {
|
||||||
lock = await this.client.getMailboxLock(INBOX)
|
lock = await this.client.getMailboxLock(INBOX)
|
||||||
@@ -142,13 +412,26 @@ export class ImapService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all mailbox paths on the server (for sort: map categories to existing folders only).
|
||||||
|
* @returns {Promise<string[]>}
|
||||||
|
*/
|
||||||
|
async listAllFolders() {
|
||||||
|
await this.ensureConnected()
|
||||||
|
const allFolders = await withOpTimeout(this.client.list(), 'list')
|
||||||
|
return (allFolders || [])
|
||||||
|
.map((f) => f.path || f.name)
|
||||||
|
.filter((p) => typeof p === 'string' && p.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure folder exists (create if not). Use subfolder under MailFlow to avoid clutter.
|
* Ensure folder exists (create if not). Use subfolder under MailFlow to avoid clutter.
|
||||||
|
* NOT USED IN SORT — automatic sort must not create folders; use existing paths only.
|
||||||
*/
|
*/
|
||||||
async ensureFolder(folderName) {
|
async ensureFolder(folderName) {
|
||||||
const path = `${FOLDER_PREFIX}/${folderName}`
|
const path = `${FOLDER_PREFIX}/${folderName}`
|
||||||
try {
|
try {
|
||||||
await this.client.mailboxCreate(path)
|
await withOpTimeout(this.client.mailboxCreate(path), 'mailboxCreate')
|
||||||
log.info(`IMAP folder created: ${path}`)
|
log.info(`IMAP folder created: ${path}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.code !== 'ALREADYEXISTS' && !/already exists/i.test(err.message)) {
|
if (err.code !== 'ALREADYEXISTS' && !/already exists/i.test(err.message)) {
|
||||||
@@ -159,16 +442,274 @@ export class ImapService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* NOT USED IN SORT — sort uses `copyToFolder` (no folder creation; originals stay in INBOX).
|
||||||
* Move message (by UID) from INBOX to folder name (under MailFlow/)
|
* Move message (by UID) from INBOX to folder name (under MailFlow/)
|
||||||
*/
|
*/
|
||||||
async moveToFolder(messageId, folderName) {
|
async moveToFolder(messageId, folderName) {
|
||||||
|
await this.ensureConnected()
|
||||||
const path = `${FOLDER_PREFIX}/${folderName}`
|
const path = `${FOLDER_PREFIX}/${folderName}`
|
||||||
await this.ensureFolder(folderName)
|
const runMove = async () => {
|
||||||
|
let lock = null
|
||||||
|
try {
|
||||||
|
lock = await withOpTimeout(this.client.getMailboxLock(INBOX), 'getMailboxLock')
|
||||||
|
this.lock = lock
|
||||||
|
await withOpTimeout(
|
||||||
|
this.client.messageMove(String(messageId), path, { uid: true }),
|
||||||
|
'messageMove'
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
if (lock) {
|
||||||
|
lock.release()
|
||||||
|
this.lock = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.ensureFolder(folderName)
|
||||||
|
await runMove()
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('IMAP moveToFolder first attempt failed', { error: err.message })
|
||||||
|
await this.ensureConnected()
|
||||||
|
await this.ensureFolder(folderName)
|
||||||
|
await runMove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add MailFlow category keyword (and optional team tag) on INBOX message — no folder required.
|
||||||
|
* Never throws; returns false if tagging failed (sort can still count the email as categorized).
|
||||||
|
* @param {string | null} [assignedTo]
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async addMailFlowCategoryKeyword(messageId, category, assignedTo = null) {
|
||||||
|
const flags = [getMailFlowKeywordForCategory(category)]
|
||||||
|
if (assignedTo) {
|
||||||
|
const safe = String(assignedTo).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 50)
|
||||||
|
flags.push(`$MailFlow-Team-${safe}`)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.ensureConnected()
|
||||||
|
let lock = null
|
||||||
|
try {
|
||||||
|
lock = await withOpTimeout(this.client.getMailboxLock(INBOX), 'getMailboxLock')
|
||||||
|
this.lock = lock
|
||||||
|
await withOpTimeout(
|
||||||
|
this.client.messageFlagsAdd(String(messageId), flags, { uid: true }),
|
||||||
|
'messageFlagsAdd'
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
if (lock) {
|
||||||
|
lock.release()
|
||||||
|
this.lock = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err?.message || String(err)
|
||||||
|
log.warn('IMAP addMailFlowCategoryKeyword failed', { messageId, error: msg })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move UID from INBOX to an existing mailbox path only. Does not create folders.
|
||||||
|
* IMAP sort (POST /sort) uses `copyToFolder` instead — originals stay in INBOX.
|
||||||
|
* Returns true on success; false on validation failure or IMAP error (never throws).
|
||||||
|
* @param {string} messageId - UID string
|
||||||
|
* @param {string} destPath - exact path from LIST
|
||||||
|
* @param {Set<string>} existingFolderSet - paths that exist on server
|
||||||
|
*/
|
||||||
|
async moveMessageToExistingPath(messageId, destPath, existingFolderSet) {
|
||||||
|
const uid = typeof messageId === 'string' ? Number(messageId) : Number(messageId)
|
||||||
|
if (messageId == null || Number.isNaN(uid) || uid < 1) {
|
||||||
|
log.warn('IMAP moveMessageToExistingPath: invalid UID', { messageId })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!destPath || !existingFolderSet || !(existingFolderSet instanceof Set)) {
|
||||||
|
log.warn('IMAP moveMessageToExistingPath: bad dest or set', { destPath })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!existingFolderSet.has(destPath)) {
|
||||||
|
log.warn('IMAP moveMessageToExistingPath: path not in existing set', { destPath })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (isForbiddenMoveTarget(destPath)) {
|
||||||
|
log.warn('IMAP moveMessageToExistingPath: forbidden destination', { destPath })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.ensureConnected()
|
||||||
|
let lock = null
|
||||||
|
try {
|
||||||
|
lock = await withOpTimeout(this.client.getMailboxLock(INBOX), 'getMailboxLock')
|
||||||
|
this.lock = lock
|
||||||
|
await withOpTimeout(
|
||||||
|
this.client.messageMove(String(uid), destPath, { uid: true }),
|
||||||
|
'messageMove'
|
||||||
|
)
|
||||||
|
const uidRange = `${uid}:${uid}`
|
||||||
|
const stillInInbox = await withOpTimeout(
|
||||||
|
this.client.search({ uid: uidRange }, { uid: true }),
|
||||||
|
'search'
|
||||||
|
)
|
||||||
|
if (stillInInbox?.length) {
|
||||||
|
log.warn('IMAP moveMessageToExistingPath: UID still in INBOX after MOVE (path may be wrong)', {
|
||||||
|
messageId,
|
||||||
|
destPath,
|
||||||
|
uid,
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} finally {
|
||||||
|
if (lock) {
|
||||||
|
lock.release()
|
||||||
|
this.lock = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('IMAP moveMessageToExistingPath failed', { messageId, destPath, error: err?.message })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy UID from INBOX to an existing mailbox path (original stays in INBOX).
|
||||||
|
* Returns true on success; false on validation failure or IMAP error (never throws).
|
||||||
|
* @param {string} messageId - UID string
|
||||||
|
* @param {string} destPath - exact path from LIST
|
||||||
|
* @param {Set<string>} existingFolderSet - paths that exist on server
|
||||||
|
*/
|
||||||
|
async copyToFolder(messageId, destPath, existingFolderSet) {
|
||||||
|
const uid = typeof messageId === 'string' ? Number(messageId) : Number(messageId)
|
||||||
|
if (messageId == null || Number.isNaN(uid) || uid < 1) {
|
||||||
|
log.warn('IMAP copyToFolder: invalid UID', { messageId })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!destPath || !existingFolderSet || !(existingFolderSet instanceof Set)) {
|
||||||
|
log.warn('IMAP copyToFolder: bad dest or set', { destPath })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!existingFolderSet.has(destPath)) {
|
||||||
|
log.warn('IMAP copyToFolder: path not in existing set', { destPath })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (isForbiddenMoveTarget(destPath)) {
|
||||||
|
log.warn('IMAP copyToFolder: forbidden destination', { destPath })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.ensureConnected()
|
||||||
|
let lock = null
|
||||||
|
try {
|
||||||
|
lock = await withOpTimeout(this.client.getMailboxLock(INBOX), 'getMailboxLock')
|
||||||
|
this.lock = lock
|
||||||
|
await withOpTimeout(
|
||||||
|
this.client.messageCopy(String(uid), destPath, { uid: true }),
|
||||||
|
'messageCopy'
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
} finally {
|
||||||
|
if (lock) {
|
||||||
|
lock.release()
|
||||||
|
this.lock = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('IMAP copyToFolder failed', { messageId, destPath, error: err?.message })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove $MailFlow-sorted keyword from all messages in INBOX (UID SEARCH + STORE).
|
||||||
|
* @returns {Promise<number>} count of UIDs processed (same as messages touched)
|
||||||
|
*/
|
||||||
|
async removeAllSortedFlags() {
|
||||||
|
await this.connect()
|
||||||
let lock = null
|
let lock = null
|
||||||
try {
|
try {
|
||||||
lock = await this.client.getMailboxLock(INBOX)
|
lock = await withOpTimeout(this.client.getMailboxLock(INBOX), 'getMailboxLock')
|
||||||
this.lock = lock
|
this.lock = lock
|
||||||
await this.client.messageMove(String(messageId), path, { uid: true })
|
const allUids = await withOpTimeout(
|
||||||
|
this.client.search({ all: true }, { uid: true }),
|
||||||
|
'search'
|
||||||
|
)
|
||||||
|
const list = (allUids || [])
|
||||||
|
.map((u) => (typeof u === 'bigint' ? Number(u) : u))
|
||||||
|
.filter((n) => n != null && !Number.isNaN(Number(n)))
|
||||||
|
if (list.length > 0) {
|
||||||
|
await withOpTimeout(
|
||||||
|
this.client.messageFlagsRemove(list, ['$MailFlow-sorted'], { uid: true }),
|
||||||
|
'messageFlagsRemove'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
log.info(`Removed $MailFlow-sorted from ${list.length} emails`)
|
||||||
|
return list.length
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('IMAP removeAllSortedFlags failed', { error: err?.message })
|
||||||
|
return 0
|
||||||
|
} finally {
|
||||||
|
if (lock) {
|
||||||
|
lock.release()
|
||||||
|
this.lock = null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.close()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NOT USED IN SORT — sort never moves to Trash/Archive for cleanup.
|
||||||
|
* Move to Trash or Archive (first that works) — standard mailboxes on most hosts.
|
||||||
|
*/
|
||||||
|
async moveToArchiveOrTrash(messageId) {
|
||||||
|
const candidates = ['Trash', 'Archive', 'Deleted', 'INBOX.Trash']
|
||||||
|
let lastErr = null
|
||||||
|
for (const dest of candidates) {
|
||||||
|
try {
|
||||||
|
await this.ensureConnected()
|
||||||
|
let lock = null
|
||||||
|
try {
|
||||||
|
lock = await withOpTimeout(this.client.getMailboxLock(INBOX), 'getMailboxLock')
|
||||||
|
this.lock = lock
|
||||||
|
await withOpTimeout(
|
||||||
|
this.client.messageMove(String(messageId), dest, { uid: true }),
|
||||||
|
'messageMove'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
} finally {
|
||||||
|
if (lock) {
|
||||||
|
lock.release()
|
||||||
|
this.lock = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err
|
||||||
|
log.warn(`IMAP moveToArchiveOrTrash: ${dest} failed`, { error: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastErr || new Error('IMAP move to Trash/Archive failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark message as read (\\Seen)
|
||||||
|
*/
|
||||||
|
async markAsRead(messageId) {
|
||||||
|
await this.ensureConnected()
|
||||||
|
let lock = null
|
||||||
|
try {
|
||||||
|
lock = await withOpTimeout(this.client.getMailboxLock(INBOX), 'getMailboxLock')
|
||||||
|
this.lock = lock
|
||||||
|
await withOpTimeout(
|
||||||
|
this.client.messageFlagsAdd(String(messageId), ['\\Seen'], { uid: true }),
|
||||||
|
'messageFlagsAdd'
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
if (lock) {
|
if (lock) {
|
||||||
lock.release()
|
lock.release()
|
||||||
@@ -178,14 +719,48 @@ export class ImapService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark message as read (\\Seen)
|
* Remove every $MailFlow-* keyword from all messages in INBOX (incl. $MailFlow-sorted and $MailFlow-Team-*).
|
||||||
|
* Does not close the connection.
|
||||||
|
* @returns {Promise<number>} number of messages that had at least one MailFlow keyword removed
|
||||||
*/
|
*/
|
||||||
async markAsRead(messageId) {
|
async stripAllMailFlowKeywordsInInbox() {
|
||||||
|
await this.ensureConnected()
|
||||||
let lock = null
|
let lock = null
|
||||||
|
let touched = 0
|
||||||
try {
|
try {
|
||||||
lock = await this.client.getMailboxLock(INBOX)
|
lock = await withOpTimeout(this.client.getMailboxLock(INBOX), 'getMailboxLock')
|
||||||
this.lock = lock
|
this.lock = lock
|
||||||
await this.client.messageFlagsAdd(String(messageId), ['\\Seen'], { uid: true })
|
const allUids = await withOpTimeout(
|
||||||
|
this.client.search({ all: true }, { uid: true }),
|
||||||
|
'search'
|
||||||
|
)
|
||||||
|
const uidList = (allUids || [])
|
||||||
|
.map((u) => (typeof u === 'bigint' ? Number(u) : u))
|
||||||
|
.filter((n) => n != null && !Number.isNaN(Number(n)))
|
||||||
|
if (!uidList.length) return 0
|
||||||
|
|
||||||
|
const messages = await withOpTimeout(
|
||||||
|
this.client.fetchAll(uidList, { flags: true, uid: true }),
|
||||||
|
'fetchAll'
|
||||||
|
)
|
||||||
|
for (const msg of messages || []) {
|
||||||
|
const flagSet = msg.flags
|
||||||
|
if (!flagSet || !flagSet.size) continue
|
||||||
|
const toRemove = [...flagSet].filter(
|
||||||
|
(f) => typeof f === 'string' && f.startsWith('$MailFlow')
|
||||||
|
)
|
||||||
|
if (!toRemove.length) continue
|
||||||
|
await withOpTimeout(
|
||||||
|
this.client.messageFlagsRemove(String(msg.uid), toRemove, { uid: true }),
|
||||||
|
'messageFlagsRemove'
|
||||||
|
)
|
||||||
|
touched++
|
||||||
|
}
|
||||||
|
log.info(`Stripped MailFlow keywords from ${touched} INBOX message(s)`)
|
||||||
|
return touched
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('IMAP stripAllMailFlowKeywordsInInbox failed', { error: err?.message })
|
||||||
|
return touched
|
||||||
} finally {
|
} finally {
|
||||||
if (lock) {
|
if (lock) {
|
||||||
lock.release()
|
lock.release()
|
||||||
@@ -193,4 +768,114 @@ export class ImapService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move messages from sort-related folders back to INBOX, then strip $MailFlow-* keywords in INBOX.
|
||||||
|
* Safer than recoverAllToInbox (does not touch Sent/Drafts/Trash/Deleted).
|
||||||
|
* Caller should connect first; does not call logout/close.
|
||||||
|
* @returns {Promise<{ recovered: number, folders: Array<{ folder: string, count: number }>, mailFlowKeywordsStripped: number }>}
|
||||||
|
*/
|
||||||
|
async reSortRecoverAndStripKeywords() {
|
||||||
|
await this.ensureConnected()
|
||||||
|
let recovered = 0
|
||||||
|
const folders = []
|
||||||
|
try {
|
||||||
|
const listed = await withOpTimeout(this.client.list(), 'list')
|
||||||
|
for (const folder of listed || []) {
|
||||||
|
const name = folder.path || folder.name
|
||||||
|
if (!name || !isReSortRecoveryFolder(name)) continue
|
||||||
|
if (folder.flags?.has?.('\\Noselect') || folder.flags?.has?.('\\NonExistent')) continue
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lock = await withOpTimeout(this.client.getMailboxLock(name), 'getMailboxLock')
|
||||||
|
this.lock = lock
|
||||||
|
try {
|
||||||
|
const uids = await withOpTimeout(
|
||||||
|
this.client.search({ all: true }, { uid: true }),
|
||||||
|
'search'
|
||||||
|
)
|
||||||
|
if (!uids?.length) continue
|
||||||
|
|
||||||
|
const uidList = uids
|
||||||
|
.map((u) => (typeof u === 'bigint' ? Number(u) : u))
|
||||||
|
.filter((n) => n != null && !Number.isNaN(Number(n)))
|
||||||
|
if (!uidList.length) continue
|
||||||
|
|
||||||
|
folders.push({ folder: name, count: uidList.length })
|
||||||
|
await withOpTimeout(
|
||||||
|
this.client.messageMove(uidList, INBOX, { uid: true }),
|
||||||
|
'messageMove'
|
||||||
|
)
|
||||||
|
recovered += uidList.length
|
||||||
|
log.info(`Re-sort recovery: ${uidList.length} message(s) from "${name}" → INBOX`)
|
||||||
|
} finally {
|
||||||
|
lock.release()
|
||||||
|
this.lock = null
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.warn(`Re-sort: could not empty folder "${name}"`, { error: err?.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mailFlowKeywordsStripped = await this.stripAllMailFlowKeywordsInInbox()
|
||||||
|
return { recovered, folders, mailFlowKeywordsStripped }
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('IMAP reSortRecoverAndStripKeywords failed', { error: err?.message })
|
||||||
|
return { recovered, folders, mailFlowKeywordsStripped: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move all messages from every mailbox except INBOX into INBOX (nuclear recovery).
|
||||||
|
* WARNING: Also affects Sent, Drafts, Trash, Junk, etc. — only use to recover mail misplaced by buggy moves.
|
||||||
|
*/
|
||||||
|
async recoverAllToInbox() {
|
||||||
|
await this.connect()
|
||||||
|
const checkedFolders = []
|
||||||
|
let recovered = 0
|
||||||
|
try {
|
||||||
|
const allFolders = await withOpTimeout(this.client.list(), 'list')
|
||||||
|
for (const folder of allFolders) {
|
||||||
|
const name = folder.path || folder.name
|
||||||
|
if (!name || name.toUpperCase() === 'INBOX') continue
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lock = await withOpTimeout(this.client.getMailboxLock(name), 'getMailboxLock')
|
||||||
|
this.lock = lock
|
||||||
|
try {
|
||||||
|
const uids = await withOpTimeout(
|
||||||
|
this.client.search({ all: true }, { uid: true }),
|
||||||
|
'search'
|
||||||
|
)
|
||||||
|
if (!uids || !uids.length) continue
|
||||||
|
|
||||||
|
const uidList = uids
|
||||||
|
.map((u) => (typeof u === 'bigint' ? Number(u) : u))
|
||||||
|
.filter((n) => n != null && !Number.isNaN(Number(n)))
|
||||||
|
if (!uidList.length) continue
|
||||||
|
|
||||||
|
checkedFolders.push({ folder: name, count: uidList.length })
|
||||||
|
await withOpTimeout(
|
||||||
|
this.client.messageMove(uidList, INBOX, { uid: true }),
|
||||||
|
'messageMove'
|
||||||
|
)
|
||||||
|
recovered += uidList.length
|
||||||
|
log.info(`Recovered ${uidList.length} emails from "${name}" → INBOX`)
|
||||||
|
} finally {
|
||||||
|
lock.release()
|
||||||
|
this.lock = null
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.warn(`Could not process folder "${name}"`, { error: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await this.close()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { recovered, folders: checkedFolders }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
server/utils/appwriteErrors.mjs
Normal file
12
server/utils/appwriteErrors.mjs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Detect Appwrite errors when a collection/database resource is missing.
|
||||||
|
*/
|
||||||
|
export function isAppwriteCollectionMissing(err) {
|
||||||
|
if (!err) return false
|
||||||
|
const msg = typeof err.message === 'string' ? err.message : ''
|
||||||
|
return (
|
||||||
|
err.code === 404 ||
|
||||||
|
err.type === 'collection_not_found' ||
|
||||||
|
msg.includes('Collection with the requested ID')
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user