diff --git a/.env.example b/.env.example index fe5cdc2..ef5255e 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,12 @@ PRODUCT_CURRENCY=eur STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key_here STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here +# Gitea Webhook (Deployment) +# Secret für X-Gitea-Signature und optional Bearer-Header (gleicher Wert möglich) +GITEA_WEBHOOK_SECRET=your_webhook_secret_here +# Optional: anderer Token nur für Authorization: Bearer (sonst wird GITEA_WEBHOOK_SECRET verwendet) +# GITEA_WEBHOOK_AUTH_TOKEN= + # Server Configuration PORT=3000 BASE_URL=http://localhost:3000 diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index adee46f..762e028 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -56,7 +56,7 @@ export const api = { return fetchApi>(`/email/accounts?userId=${userId}`) @@ -69,6 +69,24 @@ export const api = { }) }, + async connectImapAccount( + userId: string, + params: { email: string; password: string; imapHost?: string; imapPort?: number; imapSecure?: boolean } + ) { + return fetchApi<{ accountId: string }>('/email/connect', { + method: 'POST', + body: JSON.stringify({ + userId, + provider: 'imap', + email: params.email, + accessToken: params.password, + imapHost: params.imapHost, + imapPort: params.imapPort, + imapSecure: params.imapSecure, + }), + }) + }, + async disconnectEmailAccount(accountId: string, userId: string) { return fetchApi<{ success: boolean }>(`/email/accounts/${accountId}?userId=${userId}`, { method: 'DELETE', @@ -403,6 +421,49 @@ export const api = { }) }, + // ═══════════════════════════════════════════════════════════════════════════ + // ME / ADMIN + // ═══════════════════════════════════════════════════════════════════════════ + + async getMe(email: string) { + return fetchApi<{ isAdmin: boolean }>(`/me?email=${encodeURIComponent(email)}`) + }, + + // ═══════════════════════════════════════════════════════════════════════════ + // NAME LABELS (Workers – Admin only) + // ═══════════════════════════════════════════════════════════════════════════ + + async getNameLabels(userId: string, email: string) { + return fetchApi>(`/preferences/name-labels?userId=${userId}&email=${encodeURIComponent(email)}`) + }, + + async saveNameLabel( + userId: string, + userEmail: string, + nameLabel: { id?: string; name: string; email?: string; keywords?: string[]; enabled: boolean } + ) { + return fetchApi<{ id?: string; name: string; email?: string; keywords?: string[]; enabled: boolean }>( + '/preferences/name-labels', + { + method: 'POST', + body: JSON.stringify({ userId, email: userEmail, nameLabel }), + } + ) + }, + + async deleteNameLabel(userId: string, userEmail: string, labelId: string) { + return fetchApi<{ success: boolean }>( + `/preferences/name-labels/${labelId}?userId=${userId}&email=${encodeURIComponent(userEmail)}`, + { method: 'DELETE' } + ) + }, + // ═══════════════════════════════════════════════════════════════════════════ // PRODUCTS & QUESTIONS (Legacy) // ═══════════════════════════════════════════════════════════════════════════ diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx index d7d1cd0..7d35a30 100644 --- a/client/src/pages/Dashboard.tsx +++ b/client/src/pages/Dashboard.tsx @@ -905,10 +905,10 @@ export function Dashboard() { >

{account.email}

diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx index f7e86de..6ec45f3 100644 --- a/client/src/pages/Settings.tsx +++ b/client/src/pages/Settings.tsx @@ -22,6 +22,7 @@ import { api } from '@/lib/api' import { Mail, User, + Users, CreditCard, Shield, Settings as SettingsIcon, @@ -54,15 +55,15 @@ import { Save, Edit2, } from 'lucide-react' -import type { AIControlSettings, CompanyLabel, CategoryInfo, CleanupSettings, CleanupStatus, CategoryAdvanced } from '@/types/settings' +import type { AIControlSettings, CompanyLabel, NameLabel, CategoryInfo, CleanupSettings, CleanupStatus, CategoryAdvanced } from '@/types/settings' import { PrivacySecurity } from '@/components/PrivacySecurity' -type TabType = 'profile' | 'accounts' | 'vip' | 'ai-control' | 'subscription' | 'privacy' | 'referrals' +type TabType = 'profile' | 'accounts' | 'vip' | 'ai-control' | 'name-labels' | 'subscription' | 'privacy' | 'referrals' interface EmailAccount { id: string email: string - provider: 'gmail' | 'outlook' + provider: 'gmail' | 'outlook' | 'imap' connected: boolean lastSync?: string } @@ -97,6 +98,9 @@ export function Settings() { const savedProfileRef = useRef<{ name: string; language: string; timezone: string } | null>(null) const [accounts, setAccounts] = useState([]) const [connectingProvider, setConnectingProvider] = useState(null) + const [showImapForm, setShowImapForm] = useState(false) + const [imapForm, setImapForm] = useState({ email: '', password: '', imapHost: 'imap.porkbun.com', imapPort: 993, imapSecure: true }) + const [imapConnecting, setImapConnecting] = useState(false) const [vipSenders, setVipSenders] = useState([]) const [newVipEmail, setNewVipEmail] = useState('') const [subscription, setSubscription] = useState(null) @@ -126,6 +130,10 @@ export function Settings() { }) const [categories, setCategories] = useState([]) const [companyLabels, setCompanyLabels] = useState([]) + const [isAdmin, setIsAdmin] = useState(false) + const [nameLabels, setNameLabels] = useState([]) + const [editingNameLabel, setEditingNameLabel] = useState(null) + const [showNameLabelPanel, setShowNameLabelPanel] = useState(false) const [referralData, setReferralData] = useState<{ referralCode: string; referralCount: number } | null>(null) const [loadingReferral, setLoadingReferral] = useState(false) @@ -185,16 +193,24 @@ export function Settings() { setLoading(true) try { - const [accountsRes, subsRes, prefsRes, aiControlRes, companyLabelsRes] = await Promise.all([ + const [accountsRes, subsRes, prefsRes, aiControlRes, companyLabelsRes, meRes] = await Promise.all([ api.getEmailAccounts(user.$id), api.getSubscriptionStatus(user.$id), api.getUserPreferences(user.$id), api.getAIControlSettings(user.$id), api.getCompanyLabels(user.$id), + user?.email ? api.getMe(user.email) : Promise.resolve({ data: { isAdmin: false } }), ]) if (accountsRes.data) setAccounts(accountsRes.data) if (subsRes.data) setSubscription(subsRes.data) + if (meRes.data?.isAdmin) { + setIsAdmin(true) + const nameLabelsRes = await api.getNameLabels(user.$id, user.email) + if (nameLabelsRes.data) setNameLabels(nameLabelsRes.data) + } else { + setIsAdmin(false) + } if (prefsRes.data?.vipSenders) setVipSenders(prefsRes.data.vipSenders) if (aiControlRes.data) { // Merge cleanup defaults if not present @@ -478,6 +494,31 @@ export function Settings() { } } + const handleConnectImap = async (e: React.FormEvent) => { + e.preventDefault() + if (!user?.$id || !imapForm.email.trim() || !imapForm.password) return + setImapConnecting(true) + const res = await api.connectImapAccount(user.$id, { + email: imapForm.email.trim(), + password: imapForm.password, + imapHost: imapForm.imapHost || undefined, + imapPort: imapForm.imapPort || 993, + imapSecure: imapForm.imapSecure, + }) + if (res.error) { + const msg = res.error.message || 'Connection failed' + showMessage('error', msg.includes('credentials') || msg.includes('auth') || msg.includes('password') ? 'Login failed – check email and password' : msg) + setImapConnecting(false) + return + } + const list = await api.getEmailAccounts(user.$id) + setAccounts(list.data ?? []) + setShowImapForm(false) + setImapForm({ email: '', password: '', imapHost: 'imap.porkbun.com', imapPort: 993, imapSecure: true }) + showMessage('success', 'IMAP account connected') + setImapConnecting(false) + } + const handleAddVip = () => { if (!newVipEmail.trim() || !newVipEmail.includes('@')) return @@ -535,14 +576,18 @@ export function Settings() { } } - const tabs = [ - { id: 'profile' as TabType, label: 'Profile', icon: User }, - { id: 'accounts' as TabType, label: 'Email Accounts', icon: Mail }, - { id: 'vip' as TabType, label: 'VIP List', icon: Star }, - { id: 'ai-control' as TabType, label: 'Control Panel', icon: Brain }, - { id: 'subscription' as TabType, label: 'Subscription', icon: CreditCard }, - { id: 'privacy' as TabType, label: 'Privacy & Security', icon: Lock }, - ] + const tabs = useMemo(() => { + const base = [ + { id: 'profile' as TabType, label: 'Profile', icon: User }, + { id: 'accounts' as TabType, label: 'Email Accounts', icon: Mail }, + { id: 'vip' as TabType, label: 'VIP List', icon: Star }, + { id: 'ai-control' as TabType, label: 'Control Panel', icon: Brain }, + ...(isAdmin ? [{ id: 'name-labels' as TabType, label: 'Name Labels (Team)', icon: Users }] : []), + { id: 'subscription' as TabType, label: 'Subscription', icon: CreditCard }, + { id: 'privacy' as TabType, label: 'Privacy & Security', icon: Lock }, + ] + return base + }, [isAdmin]) return (
@@ -853,13 +898,13 @@ export function Settings() {
- +

{account.email}

-

{account.provider}

+

{account.provider === 'imap' ? 'IMAP' : account.provider}

@@ -928,7 +973,100 @@ export function Settings() {

Connect Microsoft account

+ +
+ + {showImapForm && ( +
+
+ + setImapForm((f) => ({ ...f, email: e.target.value }))} + required + autoComplete="email" + className="bg-white dark:bg-slate-900" + /> +
+
+ + setImapForm((f) => ({ ...f, password: e.target.value }))} + required + autoComplete="current-password" + className="bg-white dark:bg-slate-900" + /> +
+
+ Advanced (host, port, SSL) +
+
+ + setImapForm((f) => ({ ...f, imapHost: e.target.value }))} + className="bg-white dark:bg-slate-900 text-sm" + /> +
+
+ + setImapForm((f) => ({ ...f, imapPort: Number(e.target.value) || 993 }))} + className="bg-white dark:bg-slate-900 text-sm" + /> +
+
+ +
+
+
+
+ + +
+
+ )}
@@ -2045,6 +2183,226 @@ export function Settings() { {editingLabel?.id ? 'Save Changes' : 'Create Label'} + + +
+ )} + + {activeTab === 'name-labels' && isAdmin && ( +
+ + +
+ + Name Labels (Team) +
+ + Personal labels for each team member. The AI will assign emails to a worker when they are clearly for that person (e.g. "für Max", "an Anna", subject/body mentions). + +
+ + {nameLabels.length > 0 ? ( +
+ {nameLabels.map((label) => ( +
+
+

{label.name}

+ {label.email && ( +

{label.email}

+ )} + {label.keywords?.length ? ( +

+ Keywords: {label.keywords.join(', ')} +

+ ) : null} +
+
+ + + +
+
+ ))} + +
+ ) : ( +
+ +

No name labels yet

+

Add team members so the AI can assign emails to the right person

+ +
+ )} +
+
+ + {/* Name Label Editor Side Panel */} + + + + + + {editingNameLabel?.id ? 'Edit Name Label' : 'Add Team Member'} + + + {editingNameLabel?.id + ? 'Update the name label' + : 'Add a team member. The AI will assign emails to this person when they are clearly for them (e.g. "für Max", subject mentions).'} + + + {editingNameLabel && ( + +
+
+ + setEditingNameLabel({ ...editingNameLabel, name: e.target.value })} + className="mt-2" + /> +
+
+ + setEditingNameLabel({ ...editingNameLabel, email: e.target.value || undefined })} + className="mt-2" + /> +
+
+ + setEditingNameLabel({ + ...editingNameLabel, + keywords: e.target.value.split(',').map(k => k.trim()).filter(Boolean), + })} + className="mt-2" + /> +

Hints for the AI to recognize emails for this person

+
+
+
+ +

This label will be used when sorting

+
+ +
+
+
+ )} + + + +
diff --git a/client/src/pages/Setup.tsx b/client/src/pages/Setup.tsx index 6ec0e75..5b1429e 100644 --- a/client/src/pages/Setup.tsx +++ b/client/src/pages/Setup.tsx @@ -464,6 +464,11 @@ export function Setup() { + +

+ Using Porkbun, Nextcloud Mail, or another IMAP provider?{' '} + Add your account in Settings → Accounts. +

diff --git a/client/src/types/settings.ts b/client/src/types/settings.ts index 1159c0c..d486c5c 100644 --- a/client/src/types/settings.ts +++ b/client/src/types/settings.ts @@ -66,6 +66,15 @@ export interface CompanyLabel { category?: string } +/** Name label = personal label per worker (admin only). AI assigns emails to a worker when clearly for them. */ +export interface NameLabel { + id?: string + name: string + email?: string + keywords?: string[] + enabled: boolean +} + export interface CategoryInfo { key: string name: string diff --git a/docs/deployment/GITEA_WEBHOOK_SETUP.md b/docs/deployment/GITEA_WEBHOOK_SETUP.md index 7009aa6..e099f88 100644 --- a/docs/deployment/GITEA_WEBHOOK_SETUP.md +++ b/docs/deployment/GITEA_WEBHOOK_SETUP.md @@ -146,7 +146,19 @@ tail -f server/logs/webhook.log - ✅ Prüfe, ob der Server erreichbar ist (`curl https://emailsorter.webklar.com/api/webhook/status`) - ✅ Prüfe Gitea-Logs: **Settings** → **Webhooks** → **Delivery Log** -### "Ungültige Webhook-Signatur" (401) +### 502 Bad Gateway (von nginx) + +Nginx meldet 502, wenn das Backend (Node/PM2) nicht antwortet oder abstürzt. + +- ✅ **Backend läuft:** `pm2 list` – Prozess muss „online“ sein +- ✅ **Backend neu starten:** `pm2 restart all` oder `pm2 start ecosystem.config.js` +- ✅ **Logs prüfen:** `pm2 logs` – beim nächsten „Test Push“ sofort Fehler ansehen +- ✅ **Health prüfen:** `curl -s -o /dev/null -w "%{http_code}" https://emailsorter.webklar.com/api/health` → sollte `200` sein +- ✅ **Nginx-Upstream:** `proxy_pass` muss auf den richtigen Port zeigen (z. B. `http://127.0.0.1:3000`) + +Nach einem Code-Deploy (größeres Body-Limit, robustere Fehlerbehandlung) Backend neu starten: `pm2 restart all`. + +### "Ungültige Webhook-Signatur" (401/403) - ✅ Prüfe, ob `GITEA_WEBHOOK_SECRET` in `server/.env` gesetzt ist - ✅ Prüfe, ob das Secret in Gitea **genau gleich** ist (keine Leerzeichen!) diff --git a/docs/deployment/SSH-WEBHOOK-FIX-PROMPT.md b/docs/deployment/SSH-WEBHOOK-FIX-PROMPT.md new file mode 100644 index 0000000..8c1074e --- /dev/null +++ b/docs/deployment/SSH-WEBHOOK-FIX-PROMPT.md @@ -0,0 +1,41 @@ +# Anleitung für SSH – nur EmailSorter (emailsorter.webklar.com) fixen + +**Kopiere den folgenden Abschnitt und schick ihn an die Person am Server (oder nutze ihn als eigene Checkliste):** + +--- + +## Kontext + +- **Nur diese Website:** **emailsorter.webklar.com** (EmailSorter / Gitea-Webhook). +- **Nicht anfassen:** Alle anderen Websites/Projekte auf dem gleichen Server. +- **Problem:** Beim Gitea-Webhook („Test Push Event“) kommt **502 Bad Gateway** von nginx. Das Backend (Node/PM2) für emailsorter.webklar.com soll geprüft und ggf. neu gestartet werden. + +## Was ich brauche + +1. **PM2 prüfen (nur für EmailSorter):** + - `pm2 list` ausführen. + - Den Prozess finden, der zu **emailsorter.webklar.com** / EmailSorter gehört (Name oder Script-Pfad wie `server/index.mjs` oder `emailsorter`). + - Prüfen: Läuft er (Status „online“)? Wenn „stopped“ oder „errored“: das ist wahrscheinlich die Ursache für den 502. + +2. **Backend für EmailSorter neu starten:** + - Nur den PM2-Prozess für EmailSorter neu starten (nicht `pm2 restart all`, wenn andere Sites davon betroffen wären). + - Beispiel, wenn der Prozess „emailsorter“ heißt: `pm2 restart emailsorter` + - Oder nur den einen Eintrag in der Liste per Name/ID neu starten. + +3. **Env für EmailSorter prüfen (optional, nur wenn Webhook weiter 502/401 gibt):** + - In das Projektverzeichnis von EmailSorter wechseln (z. B. `/var/www/emailsorter` oder wo auch immer es liegt). + - Prüfen, ob in `server/.env` (oder im Root-`.env`) steht: + `GITEA_WEBHOOK_SECRET=` + - Wenn nicht: diese Zeile in der richtigen `.env` ergänzen (Secret bekommst du separat / steht in Gitea unter Webhook → Secret). Danach nur den EmailSorter-PM2-Prozess neu starten. + +4. **Kurz testen:** + - `curl -s -o /dev/null -w "%{http_code}" https://emailsorter.webklar.com/api/health` + Sollte `200` ausgeben. + - `curl -s -o /dev/null -w "%{http_code}" https://emailsorter.webklar.com/api/webhook/status` + Sollte ebenfalls `200` ausgeben. + +5. **Nichts anderes ändern:** Keine anderen Projekte, keine globalen nginx-/System-Konfigurationen anpassen – nur EmailSorter (emailsorter.webklar.com) wie oben beschrieben. + +--- + +**Wenn du den Code gerade neu deployed hast (git pull für EmailSorter):** Danach bitte nur den PM2-Prozess für EmailSorter neu starten (z. B. `pm2 restart `), damit die neuen Webhook-Fixes aktiv sind. diff --git a/docs/deployment/WEBHOOK_QUICK_START.md b/docs/deployment/WEBHOOK_QUICK_START.md index 9fefc21..328555f 100644 --- a/docs/deployment/WEBHOOK_QUICK_START.md +++ b/docs/deployment/WEBHOOK_QUICK_START.md @@ -27,8 +27,9 @@ USE_PM2=true 1. Gehe zu deinem Repository → **Settings** → **Webhooks** 2. Klicke **Add Webhook** → **Gitea** 3. Fülle aus: - - **Target URL:** `https://emailsorter.webklar.com/api/webhook/gitea` + - **Target URL:** `https://emailsorter.webklar.com/api/webhook/gitea` (Produktion) - **Secret:** `dein_generiertes_secret_hier` (aus Schritt 1) + - **Authorization Header (optional):** `Bearer dein_generiertes_secret_hier` (gleicher Wert wie Secret) - **Trigger On:** ✅ **Push Events** - **Branch Filter:** `main` oder `master` 4. Klicke **Add Webhook** diff --git a/docs/development/IMAP_IMPLEMENTATION_PLAN.md b/docs/development/IMAP_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..70fe25a --- /dev/null +++ b/docs/development/IMAP_IMPLEMENTATION_PLAN.md @@ -0,0 +1,183 @@ +# Implementierungsplan: IMAP / Porkbun / Nextcloud + +Plan, um EmailSorter um einen **IMAP-Provider** (z. B. Porkbun) zu erweitern. Dann funktioniert die Sortierung auch für Postfächer, die in Nextcloud Mail genutzt werden. + +--- + +## Übersicht + +| Phase | Inhalt | Aufwand (grobe Schätzung) | +|-------|--------|----------------------------| +| **1** | IMAP-Bibliothek + Service-Grundgerüst | 1–2 h | +| **2** | Datenbank + Connect-Route für IMAP | 1 h | +| **3** | Sortier-Logik für IMAP (Ordner statt Labels) | 2–3 h | +| **4** | Frontend: IMAP-Verbindung anlegen | 1–2 h | +| **5** | Testen, Feinschliff, Doku | 1 h | + +--- + +## Phase 1: IMAP-Bibliothek und Service + +**Ziel:** Backend kann sich per IMAP (z. B. Porkbun) verbinden, INBOX auflisten und E-Mails lesen. + +### 1.1 Abhängigkeit hinzufügen + +- **Datei:** `server/package.json` +- **Aktion:** Dependency `imapflow` hinzufügen (moderner IMAP-Client für Node, SSL-Support). +- **Befehl:** `npm install imapflow` im Ordner `server/`. + +### 1.2 Neuer Service + +- **Datei (neu):** `server/services/imap.mjs` +- **Inhalt (Kern-Interface):** + - **Konstruktor:** `ImapService({ host, port, secure, user, password })` – z. B. für Porkbun: `host: 'imap.porkbun.com', port: 993, secure: true`. + - **connect()** – Verbindung aufbauen (login). + - **listEmails(maxResults, fromSeq?)** – Nachrichten aus INBOX (z. B. per FETCH ENVELOPE), Rückgabe: `{ messages: [{ id, uid, ... }], nextSeq }`. + - **getEmail(messageId)** bzw. **batchGetEmails(ids)** – eine bzw. mehrere Mails laden, Rückgabe-Format wie Gmail/Outlook: `{ id, headers: { from, subject }, snippet }`. + - **close()** – Verbindung sauber trennen (LOGOUT). +- **Hinweis:** IMAP nutzt oft UID oder Sequence Number als „id“; einheitlich als `id` nach außen geben (String), damit die Sortier-Route wie bei Gmail/Outlook arbeitet. + +### 1.3 Akzeptanz Phase 1 + +- Ein kleines Test-Script (z. B. `server/scripts/test-imap.mjs`) oder ein temporärer Route-Handler liest Umgebungsvariablen (IMAP_HOST, IMAP_PORT, IMAP_USER, IMAP_PASSWORD), baut `ImapService` auf, ruft `listEmails(10)` und `getEmail(...)` auf und loggt das Ergebnis. Keine Credentials im Repo – nur `.env` / Umgebungsvariablen. + +--- + +## Phase 2: Datenbank und Connect-Route + +**Ziel:** Ein neuer Account-Typ „imap“ kann angelegt werden; Zugangsdaten werden gespeichert. + +### 2.1 Datenbank (Appwrite) + +- **Datei:** `server/bootstrap-v2.mjs` (oder separates Migrations-Script). +- **Aktion:** In der Collection `email_accounts` optionale Attribute anlegen: + - `imapHost` (String, optional) + - `imapPort` (Integer, optional) + - `imapSecure` (Boolean, optional) +- **Alternative (einfacher für nur Porkbun):** Keine neuen Felder; Host/Port im Code fest (imap.porkbun.com, 993). Dann nur `email` + Passwort nötig; Passwort in bestehendem Feld `accessToken` speichern (semantisch „geheimer Token für IMAP“). Für spätere andere IMAP-Server die optionalen Felder nachziehen. + +### 2.2 Connect-Route erweitern + +- **Datei:** `server/routes/email.mjs` +- **Route:** `POST /api/email/connect` (bzw. die Route, die Accounts anlegt). +- **Aktionen:** + - Im Validierungs-Schema `provider` um `'imap'` erweitern: z. B. `rules.isIn('provider', ['gmail', 'outlook', 'demo', 'imap'])`. + - Body für IMAP: mindestens `userId`, `provider: 'imap'`, `email`, `password` (oder `accessToken` als Passwort). Optional: `imapHost`, `imapPort`, `imapSecure`. + - Wenn `provider === 'imap'`: + - Host/Port/Secure aus Body oder Default (Porkbun: imap.porkbun.com, 993, true). + - Passwort nicht loggen; in DB in `accessToken` (oder neuem Feld) speichern. + - Optional: einmalig `ImapService` instanziieren, `connect()` + `listEmails(1)` aufrufen; bei Erfolg Account anlegen, sonst Fehler zurückgeben („Ungültige Anmeldedaten“). + - Account-Dokument anlegen mit `provider: 'imap'`, `email`, `accessToken` (= Passwort), ggf. `imapHost`, `imapPort`, `imapSecure`. + +### 2.3 Middleware/Validierung + +- **Datei:** `server/middleware/validate.mjs` (falls dort Regeln liegen) oder direkt in der Route. +- **Aktion:** Für IMAP ggf. zusätzliche Felder erlauben: `imapHost`, `imapPort`, `imapSecure`, `password` (oder wie du das Feld nennst). + +### 2.4 Akzeptanz Phase 2 + +- Per API-Client (Postman/curl) oder Frontend: POST mit `provider: 'imap'`, `email`, `password` (und optional Host/Port) an `/connect` senden. Erwartung: 201, Account in Appwrite mit `provider: 'imap'`. Bei falschem Passwort: 4xx mit verständlicher Meldung. + +--- + +## Phase 3: Sortier-Logik für IMAP + +**Ziel:** `POST /api/email/sort` funktioniert für Accounts mit `provider === 'imap'`: E-Mails werden per KI kategorisiert und in IMAP-Ordner verschoben. + +### 3.1 Ordner-Mapping + +- **Konzept:** Kategorien (z. B. `vip`, `promotions`, `newsletters`, `archive`) auf Ordner-Namen mappen. Z. B.: + - `archive` / `archive_read` → Ordner `Archive` oder `EmailSorter/Archive` + - `promotions` → `Promotions` oder `EmailSorter/Promotions` + - usw. +- **Datei:** Entweder in `server/services/imap.mjs` (Funktion `getFolderNameForCategory(category)`) oder in `server/services/ai-sorter.mjs` / Config. Einheitliche Liste (z. B. Objekt `categoryToFolder`) verwenden. + +### 3.2 IMAP-Service erweitern + +- **Datei:** `server/services/imap.mjs` +- **Neue Methoden:** + - **ensureFolder(folderName)** – Ordner anlegen (CREATE), falls nicht vorhanden; Fehler „existiert bereits“ ignorieren. + - **moveToFolder(messageId, folderName)** – Nachricht aus INBOX in den Ordner verschieben (MOVE oder COPY + DELETE aus INBOX). + - Optional: **markAsRead(messageId)** – falls „archive_read“ = verschieben + als gelesen markieren. + +### 3.3 Sortier-Route erweitern + +- **Datei:** `server/routes/email.mjs` +- **Stelle:** Dort, wo `account.provider === 'gmail'` und `=== 'outlook'` abgefragt werden (und Demo). +- **Aktion:** Neuen Block `else if (account.provider === 'imap')` hinzufügen: + 1. `ImapService` aus Account-Daten instanziieren (host, port, secure, user = email, password = accessToken). + 2. `connect()`. + 3. In einer Schleife (analog Gmail/Outlook): + - `listEmails(batchSize, nextSeq)` → Liste von Nachrichten. + - `batchGetEmails(ids)` → From, Subject, Snippet. + - Für jede E-Mail: KI-Kategorie ermitteln (bestehender `AISorterService`), dann `ensureFolder(categoryToFolder[category])` und `moveToFolder(id, folderName)`. + - Bei „archive_read“ ggf. zusätzlich als gelesen markieren. + 4. Statistiken aktualisieren (wie bei Gmail/Outlook). + 5. `close()` aufrufen. +- **Fehlerbehandlung:** Bei IMAP-Fehlern (z. B. „Invalid credentials“) sinnvolle Meldung zurückgeben und ggf. Account als „reconnect nötig“ markieren. + +### 3.4 Akzeptanz Phase 3 + +- Ein IMAP-Account ist verbunden. Aufruf von `POST /api/email/sort` mit `userId` und `accountId`. Erwartung: E-Mails aus INBOX werden kategorisiert und in die richtigen Ordner verschoben; Response enthält z. B. `sortedCount` und Kategorie-Statistiken. In Nextcloud Mail (oder anderem IMAP-Client) erscheinen die neuen Ordner und verschobenen Mails. + +--- + +## Phase 4: Frontend – IMAP verbinden + +**Ziel:** Nutzer können im UI „Anderes Postfach (IMAP)“ wählen und E-Mail + Passwort eingeben. + +### 4.1 Verbindungs-Flow + +- **Datei(en):** Dort, wo heute Gmail/Outlook/Demo angeboten werden (z. B. Setup, Settings, „E-Mail verbinden“). +- **Aktion:** + - Neue Option „IMAP / anderes Postfach“ (oder „Porkbun / eigenes Postfach“). + - Beim Klick: Formular anzeigen mit: + - E-Mail (Pflicht) + - Passwort / App-Passwort (Pflicht, Typ Passwort) + - Optional (z. B. für Power-User): Host, Port, SSL (Checkbox); Defaults: imap.porkbun.com, 993, SSL an. + - Submit: POST an Backend (z. B. `/api/email/connect`) mit `provider: 'imap'`, `email`, `password`, optional `imapHost`, `imapPort`, `imapSecure`. + - Bei Erfolg: Erfolgsmeldung, Account-Liste aktualisieren. Bei Fehler: Meldung anzeigen (z. B. „Anmeldung fehlgeschlagen – prüfe E-Mail und Passwort“). + +### 4.2 API-Client (Frontend) + +- **Datei:** z. B. `client/src/lib/api.ts` +- **Aktion:** Methode `connectImapAccount(userId, { email, password, imapHost?, imapPort?, imapSecure? })` hinzufügen, die `POST /api/email/connect` mit diesen Daten aufruft. + +### 4.3 Akzeptanz Phase 4 + +- Im UI „IMAP verbinden“ auswählen, E-Mail + Passwort eingeben, absenden. Account erscheint in der Account-Liste. Danach „Sortieren“ auslösbar und funktioniert wie in Phase 3. + +--- + +## Phase 5: Testen und Doku + +- **Manuell:** Mit einem echten Porkbun-Account (oder anderem IMAP) verbinden, Sortierung ausführen, in Nextcloud prüfen, ob Ordner und Mails stimmen. +- **Sicherheit:** Prüfen, dass Passwörter nirgends geloggt werden und nicht im Frontend gespeichert werden. +- **Doku:** `docs/setup/IMAP_NEXTCLOUD_PORKBUN.md` ggf. um „Konfiguration Porkbun“ und „Troubleshooting“ ergänzen (z. B. App-Passwort, 2FA). + +--- + +## Kurz-Checkliste + +- [x] Phase 1: `imapflow` installiert, `server/services/imap.mjs` mit connect, listEmails, getEmail, close; Test mit .env-Credentials. +- [x] Phase 2: Appwrite `email_accounts` ggf. um IMAP-Felder erweitert; Connect-Route akzeptiert `imap` und speichert Zugangsdaten; Test: Account per API anlegen. +- [x] Phase 3: Ordner-Mapping; ImapService: ensureFolder, moveToFolder; Sortier-Route: Block für `provider === 'imap'`; Test: Sortierung für IMAP-Account. +- [x] Phase 4: Frontend-Option „IMAP“, Formular E-Mail/Passwort, API-Anbindung; Test: End-to-End Verbindung + Sortierung aus UI. +- [ ] Phase 5: Manueller Test mit Porkbun/Nextcloud; Sicherheits-Check; Doku aktualisiert. + +--- + +## Dateien-Übersicht + +| Aktion | Datei | +|--------|--------| +| Neu | `server/services/imap.mjs` | +| Neu (optional) | `server/scripts/test-imap.mjs` | +| Ändern | `server/package.json` (imapflow) | +| Ändern | `server/bootstrap-v2.mjs` (optional: IMAP-Attribute) | +| Ändern | `server/routes/email.mjs` (provider imap, connect + sort) | +| Ändern | `server/middleware/validate.mjs` (falls nötig) | +| Ändern | Frontend: Connect-UI (Setup/Settings) + `client/src/lib/api.ts` | +| Ändern | `docs/setup/IMAP_NEXTCLOUD_PORKBUN.md` (Feinschliff) | + +Wenn du mit Phase 1 startest, reicht zunächst: `imapflow` einbinden und `imap.mjs` mit connect + listEmails + getEmail implementieren und lokal mit Porkbun testen. diff --git a/docs/setup/IMAP_NEXTCLOUD_PORKBUN.md b/docs/setup/IMAP_NEXTCLOUD_PORKBUN.md new file mode 100644 index 0000000..644cbcb --- /dev/null +++ b/docs/setup/IMAP_NEXTCLOUD_PORKBUN.md @@ -0,0 +1,189 @@ +# IMAP / Nextcloud / Porkbun – Integration + +## Ziel + +EmailSorter soll E-Mails nutzen, die über **Porkbun** (SMTP/IMAP) laufen und ggf. in **Nextcloud Mail** genutzt werden. + +**Porkbun (von dir genutzt):** + +| Dienst | Host | Port | Verschlüsselung | +|--------|------|------|-----------------| +| IMAP | imap.porkbun.com | 993 | SSL (SSL/TLS) | +| SMTP | smtp.porkbun.com | 587 | STARTTLS | +| SMTP (Alt.) | smtp.porkbun.com | 50587 | STARTTLS | +| SMTP | smtp.porkbun.com | 465 | Implicit TLS | +| POP | pop.porkbun.com | 995 | SSL (SSL/TLS) | + +Für **Sortieren/Lesen** reicht **IMAP** (993, SSL). SMTP wird nur zum Senden gebraucht; EmailSorter sortiert nur, also: IMAP-Anbindung ist der relevante Teil. + +--- + +## Aktueller Stand in EmailSorter + +- **Unterstützt:** **Gmail** (OAuth), **Outlook** (OAuth), **IMAP** (E-Mail + Passwort/App-Passwort), **Demo** (Fake-Daten). +- **IMAP:** Generischer IMAP-Provider ist implementiert; Standard ist Porkbun (`imap.porkbun.com`, 993, SSL), andere IMAP-Server über „Advanced“ (Host/Port/SSL) konfigurierbar. + +Ablauf: + +- **Gmail:** `GmailService(accessToken, refreshToken)` → Gmail API (messages.list, get, labels). +- **Outlook:** `OutlookService(accessToken)` → Microsoft Graph (Mail API). +- **IMAP:** `ImapService(host, port, secure, user, password)` → IMAP (INBOX lesen, Ordner anlegen, Mails verschieben). +- **Demo:** feste Test-E-Mails, kein echter Zugriff. + +Accounts werden in `email_accounts` mit `provider`, `email`, `accessToken` (bei IMAP = Passwort), optional `imapHost`, `imapPort`, `imapSecure` gespeichert. + +--- + +## Was „Nextcloud integrieren“ bedeuten kann + +1. **Nextcloud nur als Mail-Client** + - Nextcloud Mail nutzt im Hintergrund IMAP/SMTP (z. B. Porkbun). + - EmailSorter spricht **direkt mit dem gleichen IMAP-Server** (Porkbun), nicht mit Nextcloud. + - Nutzer verbindet in EmailSorter sein **Porkbun-Postfach** (IMAP: imap.porkbun.com, 993, E-Mail + App-Passwort). + - Dann: E-Mails, die in Nextcloud sichtbar sind, sind auch für EmailSorter über IMAP erreichbar – und umgekehrt (Sortierung über EmailSorter wirkt in Nextcloud, weil dasselbe Postfach). + +2. **Nextcloud als Identity/SSO** + - Würde bedeuten: Login bei EmailSorter über Nextcloud (OIDC/SAML). Das ist ein separates Thema (Auth), nicht die E-Mail-Sortierung. + +3. **Nextcloud Mail API** + - Theoretisch könnte man die Nextcloud Mail-API ansprechen; typischerweise nutzt man aber direkt IMAP, weil es einfacher und überall gleich ist. + +**Pragmatisch:** „In Nextcloud integrieren“ heißt hier: **IMAP-Provider in EmailSorter** so einbauen, dass du **Porkbun (IMAP)** verbinden kannst. Alles, was in Nextcloud über dieses Postfach läuft, wird damit automatisch mit EmailSorter synchron sein. + +--- + +## Technisch: Was für IMAP (Porkbun) nötig ist + +### 1. Neuer Provider `imap` + +- In **Backend** (`server/routes/email.mjs`): `provider` um `'imap'` erweitern (z. B. neben `gmail`, `outlook`, `demo`). +- Beim **Verbinden** eines Accounts: für IMAP keine OAuth-Tokens, sondern z. B.: + - `imapHost` (z. B. `imap.porkbun.com`) + - `imapPort` (993) + - `imapSecure` (true für SSL) + - `email` (Login = E-Mail-Adresse) + - Passwort/App-Passwort (sicher speichern, z. B. in einem bestehenden Token-Feld oder neuem verschlüsselten Feld) + +### 2. Datenbank (Appwrite) `email_accounts` + +- Optional neue Attribute, z. B.: + - `imapHost` (string) + - `imapPort` (integer) + - `imapSecure` (boolean) +- Oder: für **nur Porkbun** Host/Port fest im Code (imap.porkbun.com, 993) und nur E-Mail + Passwort in DB speichern (z. B. in `accessToken` als Passwort, oder eigenes Feld). + +### 3. Neuer Service `server/services/imap.mjs` + +- **IMAP-Client** in Node (z. B. `imapflow` – gut für Node, SSL, modern). +- Interface analog zu Gmail/Outlook: + - **listEmails(maxResults, pageToken)** → Liste von Nachrichten aus INBOX (UIDs/Seq + ggf. Envelope). + - **getEmail(messageId)** / **batchGetEmails(ids)** → From, Subject, Snippet (Body-Preview). + - **applySorting(messageId, category)** → bei IMAP: **Ordner** statt Labels (z. B. „Archive“, „Promotions“). D. h.: + - Ordner anlegen, falls nicht vorhanden (CREATE wenn nötig). + - Nachricht in den passenden Ordner **verschieben** (MOVE oder COPY + DELETE aus INBOX). +- Gmail nutzt Labels; IMAP nutzt **Folders**. Die Logik „Kategorie X“ muss also auf „Folder X“ gemappt werden (z. B. `Archive`, `Promotions`, `Newsletter`). + +### 4. Sortier-Route `POST /api/email/sort` + +- Wenn `account.provider === 'imap'`: + - `ImapService` mit gespeicherten IMAP-Daten instanziieren. + - Wie bei Gmail/Outlook: E-Mails holen → KI kategorisieren → Aktionen anwenden. Bei IMAP: Aktion = „in Ordner X verschieben“ statt „Label setzen“. + +### 5. Frontend (Client) + +- Neue Option „E-Mail mit IMAP verbinden“ (z. B. „Anderes Postfach (IMAP)“). +- Formular: E-Mail, App-Passwort; optional Host/Port (oder vorkonfiguriert für Porkbun). +- Kein OAuth-Flow; nach Submit werden Zugangsdaten an das Backend geschickt, Backend speichert sie und testet die Verbindung (z. B. einmaliger LOGIN + SELECT INBOX + DISCONNECT). + +### 6. Sicherheit + +- Passwort/App-Passwort **niemals** im Frontend speichern; nur beim Verbinden einmal an Backend senden. +- Im Backend: verschlüsselt oder in sicherem Secret-Storage ablegen (z. B. nur in DB, Zugriff nur server-seitig). + +--- + +## Konfiguration in EmailSorter + +1. **Einstellungen → Accounts** (oder Setup-Seite: Link „Add your account in Settings → Accounts“). +2. Auf **„IMAP / Other“** klicken – es öffnet sich ein Formular. +3. **E-Mail** und **Passwort** (bzw. App-Passwort bei 2FA) eintragen. +4. Optional **„Advanced (host, port, SSL)“** aufklappen: + - **IMAP host:** Standard `imap.porkbun.com` (für andere Anbieter z. B. `imap.gmail.com` oder Nextcloud-IMAP-Host). + - **Port:** Standard **993** (SSL). + - **Use SSL:** aktiviert lassen für 993. +5. **„Connect IMAP“** klicken. Das Backend testet die Verbindung; bei Erfolg erscheint das Konto in der Account-Liste. Danach kann **„Sortieren“** wie bei Gmail/Outlook genutzt werden (E-Mails werden in IMAP-Ordner verschoben). + +--- + +## So richtest du es in Nextcloud ein + +EmailSorter wird **nicht in Nextcloud installiert**. Beide nutzen **dasselbe Postfach per IMAP**: Nextcloud Mail als Client zum Lesen/Schreiben, EmailSorter zum automatischen Sortieren. Ordner und verschobene Mails sind in beiden sichtbar. + +### 1. In Nextcloud Mail: Postfach hinzufügen (falls noch nicht vorhanden) + +1. In Nextcloud einloggen → **Mail**-App öffnen. +2. **Konto hinzufügen** (oder **Einstellungen** des Mail-Kontos). +3. **E-Mail-Adresse** und **Passwort** (bzw. **App-Passwort** bei 2FA) eintragen. +4. **IMAP-Server** manuell einstellen (nicht „Auto“), damit dieselben Werte wie in EmailSorter genutzt werden: + - **IMAP:** + - Server: `imap.porkbun.com` (bzw. dein IMAP-Host) + - Port: **993** + - Verschlüsselung: **SSL/TLS** + - **SMTP** (zum Senden): + - Server: `smtp.porkbun.com` + - Port: **587** (STARTTLS) oder **465** (SSL) + - Nutzer/Passwort wie IMAP +5. Speichern. Das Postfach erscheint in Nextcloud Mail; du liest und schreibst wie gewohnt. + +### 2. In EmailSorter: dasselbe Postfach verbinden + +1. Bei **EmailSorter** einloggen (z. B. emailsorter.webklar.com). +2. **Einstellungen → Accounts** → **„IMAP / Other“** klicken. +3. **Gleiche E-Mail-Adresse** und **gleiches Passwort** (bzw. App-Passwort) wie in Nextcloud eintragen. +4. Bei Porkbun reicht der Standard (**Advanced** geschlossen). Anderer Anbieter: **Advanced** öffnen und **IMAP-Host** (z. B. `imap.porkbun.com`), **Port 993**, **Use SSL** an setzen. +5. **„Connect IMAP“** klicken. Wenn die Verbindung klappt, erscheint das Konto unter „Connected Email Accounts“. + +### 3. Nutzung + +- **Nextcloud Mail:** E-Mails lesen, schreiben, Ordner manuell nutzen – wie bisher. +- **EmailSorter:** Im Dashboard **„Sortieren“** ausführen. EmailSorter liest die INBOX, kategorisiert per KI und **verschiebt** Mails in Ordner (z. B. Archive, Promotions, Newsletter). +- **In Nextcloud:** Diese Ordner und die verschobenen Mails erscheinen automatisch, weil dasselbe IMAP-Postfach genutzt wird. Gegebenenfalls Mail-App aktualisieren oder kurz warten, bis die Ordnerliste neu geladen ist. + +Es ist **keine Installation oder App in Nextcloud** nötig – nur dasselbe Konto in Nextcloud Mail (IMAP) und in EmailSorter (IMAP) einrichten. + +--- + +## Porkbun-spezifisch (kurz) + +- **IMAP:** `imap.porkbun.com`, Port **993**, SSL. +- **Login:** volle E-Mail-Adresse + Passwort oder **App-Passwort** (wenn 2FA aktiv). +- In EmailSorter: Provider **IMAP** mit Standard Host/Port für Porkbun; andere IMAP-Server über „Advanced“ einstellbar. + +--- + +## Troubleshooting + +- **„Login failed – check email and password“** + - E-Mail-Adresse exakt wie beim Anbieter (Groß-/Kleinschreibung bei manchen Servern relevant). + - Bei **2FA (Porkbun/Provider):** normales Passwort reicht oft nicht – **App-Passwort** in den Account-Einstellungen des Anbieters erzeugen und dieses im EmailSorter-Formular eintragen. + +- **Verbindung baut nicht auf (Timeout / SSL-Fehler)** + - Port **993** und **Use SSL** aktiviert für TLS. + - Firewall/Netzwerk: ausgehende Verbindung zu `imap.porkbun.com:993` erlauben. + - Bei eigenem IMAP-Server: Host/Port in „Advanced“ prüfen (z. B. 143 nur mit STARTTLS, nicht „Use SSL“ im gleichen Sinne – bei Zweifel 993 + SSL verwenden). + +- **Sortierung läuft, Ordner erscheinen in Nextcloud nicht** + - Nextcloud Mail nutzt dasselbe IMAP-Postfach; Ordner sollten nach kurzer Zeit sichtbar sein. Mail-App ggf. aktualisieren oder Abo des Postfachs prüfen. + +--- + +## Reihenfolge der Umsetzung (Vorschlag) + +1. **IMAP-Bibliothek** im Backend (z. B. `imapflow`) einbinden. +2. **`server/services/imap.mjs`** implementieren: connect, listEmails, getEmail, moveToFolder, createFolder. +3. **DB/Bootstrap:** `email_accounts` um IMAP-Felder erweitern (oder Nutzung bestehender Felder definieren). +4. **Route `/connect`:** für `provider: 'imap'` Host/Port/User/Passwort entgegennehmen und Account anlegen. +5. **Route `/sort`:** für `provider === 'imap'` die gleiche Sortier-Pipeline wie bei Gmail/Outlook, aber mit `ImapService` und Ordner-Verschiebung statt Labels. +6. **Frontend:** Verbindungs-UI für IMAP (E-Mail + Passwort, ggf. Host/Port). + +Wenn du willst, kann als Nächstes ein konkreter Implementierungsplan (mit Dateinamen und API-Skizzen) oder ein kleines Proof-of-Concept nur für „Connect + Liste INBOX“ für Porkbun-IMAP ausgearbeitet werden. diff --git a/server/bootstrap-v2.mjs b/server/bootstrap-v2.mjs index c05e189..d595c5a 100644 --- a/server/bootstrap-v2.mjs +++ b/server/bootstrap-v2.mjs @@ -161,6 +161,12 @@ async function setupCollections() { db.createBooleanAttribute(DB_ID, 'email_accounts', 'isActive', true)); await ensureAttribute('email_accounts', 'lastSync', () => db.createDatetimeAttribute(DB_ID, 'email_accounts', 'lastSync', false)); + await ensureAttribute('email_accounts', 'imapHost', () => + db.createStringAttribute(DB_ID, 'email_accounts', 'imapHost', 256, false)); + await ensureAttribute('email_accounts', 'imapPort', () => + db.createIntegerAttribute(DB_ID, 'email_accounts', 'imapPort', false)); + await ensureAttribute('email_accounts', 'imapSecure', () => + db.createBooleanAttribute(DB_ID, 'email_accounts', 'imapSecure', false)); // ==================== Email Stats ==================== await ensureCollection('email_stats', 'Email Stats', PERM_AUTHENTICATED); diff --git a/server/config/index.mjs b/server/config/index.mjs index 76a1f16..010c7f5 100644 --- a/server/config/index.mjs +++ b/server/config/index.mjs @@ -75,6 +75,18 @@ export const config = { emailAccounts: 1, autoSchedule: false, // manual only }, + + // Admin: comma-separated list of emails with admin rights (e.g. support) + adminEmails: (process.env.ADMIN_EMAILS || '') + .split(',') + .map((e) => e.trim().toLowerCase()) + .filter(Boolean), + + // Gitea Webhook (Deployment) + gitea: { + webhookSecret: process.env.GITEA_WEBHOOK_SECRET || '', + webhookAuthToken: process.env.GITEA_WEBHOOK_AUTH_TOKEN || process.env.GITEA_WEBHOOK_SECRET || '', + }, } /** @@ -141,4 +153,12 @@ export const features = { ai: () => Boolean(config.mistral.apiKey), } +/** + * Check if an email has admin rights (support, etc.) + */ +export function isAdmin(email) { + if (!email || typeof email !== 'string') return false + return config.adminEmails.includes(email.trim().toLowerCase()) +} + export default config diff --git a/server/env.example b/server/env.example index dc6fa20..659a561 100644 --- a/server/env.example +++ b/server/env.example @@ -65,6 +65,23 @@ MICROSOFT_CLIENT_ID=xxx-xxx-xxx MICROSOFT_CLIENT_SECRET=xxx MICROSOFT_REDIRECT_URI=http://localhost:3000/api/oauth/outlook/callback +# ───────────────────────────────────────────────────────────────────────────── +# Admin (OPTIONAL) +# ───────────────────────────────────────────────────────────────────────────── +# Comma-separated list of admin emails (e.g. support@webklar.com). Used by isAdmin(). +# ADMIN_EMAILS=support@webklar.com + +# Initial password for admin user when running: npm run create-admin +# ADMIN_INITIAL_PASSWORD=your-secure-password + +# ───────────────────────────────────────────────────────────────────────────── +# Gitea Webhook (OPTIONAL – Deployment bei Push) +# ───────────────────────────────────────────────────────────────────────────── +# Secret für X-Gitea-Signature und optional Bearer-Header (gleicher Wert möglich) +# GITEA_WEBHOOK_SECRET=dein_webhook_secret +# Optional: anderer Token nur für Authorization: Bearer (sonst wird GITEA_WEBHOOK_SECRET verwendet) +# GITEA_WEBHOOK_AUTH_TOKEN= + # ───────────────────────────────────────────────────────────────────────────── # Rate Limiting (OPTIONAL) # ───────────────────────────────────────────────────────────────────────────── diff --git a/server/index.mjs b/server/index.mjs index 09bef98..3642231 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -11,7 +11,7 @@ import { dirname, join } from 'path' // Config & Middleware import { config, validateConfig } from './config/index.mjs' -import { errorHandler, asyncHandler, NotFoundError, ValidationError } from './middleware/errorHandler.mjs' +import { errorHandler, asyncHandler, NotFoundError, ValidationError, AuthorizationError } from './middleware/errorHandler.mjs' import { respond } from './utils/response.mjs' import { logger, log } from './middleware/logger.mjs' import { limiters } from './middleware/rateLimit.mjs' @@ -22,6 +22,7 @@ import emailRoutes from './routes/email.mjs' import stripeRoutes from './routes/stripe.mjs' import apiRoutes from './routes/api.mjs' import analyticsRoutes from './routes/analytics.mjs' +import webhookRoutes from './routes/webhook.mjs' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) @@ -56,6 +57,11 @@ app.use('/api', limiters.api) // Static files app.use(express.static(join(__dirname, '..', 'public'))) +// Gitea webhook: raw body for X-Gitea-Signature verification (must be before JSON parser) +// Limit 2mb so large Gitea payloads (full repo JSON) don't get rejected and cause 502 +app.use('/api/webhook', express.raw({ type: 'application/json', limit: '2mb' })) +app.use('/api/webhook', webhookRoutes) + // Body parsing (BEFORE routes, AFTER static) // Note: Stripe webhook needs raw body, handled in stripe routes app.use('/api', express.json({ limit: '1mb' })) @@ -84,6 +90,19 @@ app.use('/api', apiRoutes) // Preferences endpoints (inline for simplicity) import { userPreferences } from './services/database.mjs' +import { isAdmin } from './config/index.mjs' + +/** + * GET /api/me?email=xxx + * Returns current user context (e.g. isAdmin) for the given email. + */ +app.get('/api/me', asyncHandler(async (req, res) => { + const { email } = req.query + if (!email || typeof email !== 'string') { + throw new ValidationError('email is required') + } + respond.success(res, { isAdmin: isAdmin(email) }) +})) app.get('/api/preferences', asyncHandler(async (req, res) => { const { userId } = req.query @@ -207,6 +226,69 @@ app.delete('/api/preferences/company-labels/:id', asyncHandler(async (req, res) respond.success(res, null, 'Company label deleted') })) +/** + * GET /api/preferences/name-labels + * Get name labels (worker labels). Admin only. + */ +app.get('/api/preferences/name-labels', asyncHandler(async (req, res) => { + const { userId, email } = req.query + if (!userId) throw new ValidationError('userId is required') + if (!email || typeof email !== 'string') throw new ValidationError('email is required') + if (!isAdmin(email)) throw new AuthorizationError('Admin access required for name labels') + + const prefs = await userPreferences.getByUser(userId) + const preferences = prefs?.preferences || userPreferences.getDefaults() + respond.success(res, preferences.nameLabels || []) +})) + +/** + * POST /api/preferences/name-labels + * Save/Update name label (worker). Admin only. + */ +app.post('/api/preferences/name-labels', asyncHandler(async (req, res) => { + const { userId, email, nameLabel } = req.body + if (!userId) throw new ValidationError('userId is required') + if (!email || typeof email !== 'string') throw new ValidationError('email is required') + if (!isAdmin(email)) throw new AuthorizationError('Admin access required for name labels') + if (!nameLabel) throw new ValidationError('nameLabel is required') + + const prefs = await userPreferences.getByUser(userId) + const preferences = prefs?.preferences || userPreferences.getDefaults() + const nameLabels = preferences.nameLabels || [] + + if (!nameLabel.id) { + nameLabel.id = `namelabel_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` + } + const existingIndex = nameLabels.findIndex(l => l.id === nameLabel.id) + if (existingIndex >= 0) { + nameLabels[existingIndex] = nameLabel + } else { + nameLabels.push(nameLabel) + } + + await userPreferences.upsert(userId, { nameLabels }) + respond.success(res, nameLabel, 'Name label saved') +})) + +/** + * DELETE /api/preferences/name-labels/:id + * Delete name label. Admin only. + */ +app.delete('/api/preferences/name-labels/:id', asyncHandler(async (req, res) => { + const { userId, email } = req.query + const { id } = req.params + if (!userId) throw new ValidationError('userId is required') + if (!email || typeof email !== 'string') throw new ValidationError('email is required') + if (!isAdmin(email)) throw new AuthorizationError('Admin access required for name labels') + if (!id) throw new ValidationError('label id is required') + + const prefs = await userPreferences.getByUser(userId) + const preferences = prefs?.preferences || userPreferences.getDefaults() + const nameLabels = (preferences.nameLabels || []).filter(l => l.id !== id) + await userPreferences.upsert(userId, { nameLabels }) + respond.success(res, null, 'Name label deleted') +})) + // Legacy Stripe webhook endpoint app.use('/stripe', stripeRoutes) diff --git a/server/node_modules/.package-lock.json b/server/node_modules/.package-lock.json index 97bd738..4b65b52 100644 --- a/server/node_modules/.package-lock.json +++ b/server/node_modules/.package-lock.json @@ -233,6 +233,12 @@ "zod-to-json-schema": "^3.24.1" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.0.8", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.8.tgz", @@ -242,6 +248,17 @@ "undici-types": "~7.16.0" } }, + "node_modules/@zone-eu/mailsplit": { + "version": "5.4.8", + "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz", + "integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==", + "license": "(MIT OR EUPL-1.1+)", + "dependencies": { + "libbase64": "1.3.0", + "libmime": "5.3.7", + "libqp": "2.1.1" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -270,6 +287,15 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -555,6 +581,15 @@ "node": ">= 0.8" } }, + "node_modules/encoding-japanese": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz", + "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==", + "license": "MIT", + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -1028,12 +1063,54 @@ "node": ">=0.10.0" } }, + "node_modules/imapflow": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.2.8.tgz", + "integrity": "sha512-ym7FF2tKOlOzfRvxehs4eLkhjP8Mme3sSp2tcxEbyoeJuJwtEWxaVDv12+DnaMG2LXm0zuQGWZiClq31FLPUNg==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.8", + "encoding-japanese": "2.2.0", + "iconv-lite": "0.7.2", + "libbase64": "1.3.0", + "libmime": "5.3.7", + "libqp": "2.1.1", + "nodemailer": "7.0.13", + "pino": "10.3.0", + "socks": "2.8.7" + } + }, + "node_modules/imapflow/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1160,6 +1237,42 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/libbase64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", + "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==", + "license": "MIT" + }, + "node_modules/libmime": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz", + "integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==", + "license": "MIT", + "dependencies": { + "encoding-japanese": "2.2.0", + "iconv-lite": "0.6.3", + "libbase64": "1.3.0", + "libqp": "2.1.1" + } + }, + "node_modules/libmime/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/libqp": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz", + "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -1360,6 +1473,15 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1381,6 +1503,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -1421,6 +1552,59 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/pino": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.0.tgz", + "integrity": "sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1459,6 +1643,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -1483,6 +1673,15 @@ "node": ">= 0.8" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -1513,6 +1712,15 @@ ], "license": "MIT" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -1667,6 +1875,39 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", + "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1677,6 +1918,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1706,6 +1956,18 @@ "dev": true, "license": "MIT" }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tldts": { "version": "7.0.19", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", diff --git a/server/package-lock.json b/server/package-lock.json index 84f39d0..bf49185 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -16,6 +16,7 @@ "express": "^4.21.2", "google-auth-library": "^9.14.2", "googleapis": "^144.0.0", + "imapflow": "^1.2.8", "node-appwrite": "^14.1.0", "stripe": "^17.4.0" }, @@ -255,6 +256,12 @@ "zod-to-json-schema": "^3.24.1" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.0.8", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.8.tgz", @@ -264,6 +271,17 @@ "undici-types": "~7.16.0" } }, + "node_modules/@zone-eu/mailsplit": { + "version": "5.4.8", + "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz", + "integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==", + "license": "(MIT OR EUPL-1.1+)", + "dependencies": { + "libbase64": "1.3.0", + "libmime": "5.3.7", + "libqp": "2.1.1" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -292,6 +310,15 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -577,6 +604,15 @@ "node": ">= 0.8" } }, + "node_modules/encoding-japanese": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz", + "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==", + "license": "MIT", + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -1050,12 +1086,54 @@ "node": ">=0.10.0" } }, + "node_modules/imapflow": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.2.8.tgz", + "integrity": "sha512-ym7FF2tKOlOzfRvxehs4eLkhjP8Mme3sSp2tcxEbyoeJuJwtEWxaVDv12+DnaMG2LXm0zuQGWZiClq31FLPUNg==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.8", + "encoding-japanese": "2.2.0", + "iconv-lite": "0.7.2", + "libbase64": "1.3.0", + "libmime": "5.3.7", + "libqp": "2.1.1", + "nodemailer": "7.0.13", + "pino": "10.3.0", + "socks": "2.8.7" + } + }, + "node_modules/imapflow/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1182,6 +1260,42 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/libbase64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", + "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==", + "license": "MIT" + }, + "node_modules/libmime": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz", + "integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==", + "license": "MIT", + "dependencies": { + "encoding-japanese": "2.2.0", + "iconv-lite": "0.6.3", + "libbase64": "1.3.0", + "libqp": "2.1.1" + } + }, + "node_modules/libmime/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/libqp": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz", + "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -1382,6 +1496,15 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1403,6 +1526,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -1443,6 +1575,59 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/pino": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.0.tgz", + "integrity": "sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1481,6 +1666,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -1505,6 +1696,15 @@ "node": ">= 0.8" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -1535,6 +1735,15 @@ ], "license": "MIT" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -1689,6 +1898,39 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", + "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1699,6 +1941,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1728,6 +1979,18 @@ "dev": true, "license": "MIT" }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tldts": { "version": "7.0.19", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", diff --git a/server/package.json b/server/package.json index d6c7c52..16dcb97 100644 --- a/server/package.json +++ b/server/package.json @@ -16,6 +16,7 @@ "test": "node e2e-test.mjs", "test:frontend": "node test-frontend.mjs", "verify": "node verify-setup.mjs", + "create-admin": "node scripts/create-admin-user.mjs", "cleanup": "node cleanup.mjs", "lint": "eslint --ext .mjs ." }, @@ -38,6 +39,7 @@ "express": "^4.21.2", "google-auth-library": "^9.14.2", "googleapis": "^144.0.0", + "imapflow": "^1.2.8", "node-appwrite": "^14.1.0", "stripe": "^17.4.0" }, diff --git a/server/routes/email.mjs b/server/routes/email.mjs index 804c0e2..7685ab1 100644 --- a/server/routes/email.mjs +++ b/server/routes/email.mjs @@ -78,12 +78,20 @@ router.post('/connect', validate({ body: { userId: [rules.required('userId')], - provider: [rules.required('provider'), rules.isIn('provider', ['gmail', 'outlook', 'demo'])], + provider: [rules.required('provider'), rules.isIn('provider', ['gmail', 'outlook', 'demo', 'imap'])], email: [rules.required('email'), rules.email()], }, }), asyncHandler(async (req, res) => { - const { userId, provider, email, accessToken, refreshToken, expiresAt } = req.body + const { userId, provider, email, accessToken, refreshToken, expiresAt, password, imapHost, imapPort, imapSecure } = req.body + + // IMAP: require password (or accessToken as password) + if (provider === 'imap') { + const imapPassword = password || accessToken + if (!imapPassword) { + throw new ValidationError('IMAP account requires a password or app password', { password: ['Required for IMAP'] }) + } + } // Check if account already exists const existingAccounts = await emailAccounts.getByUser(userId) @@ -95,17 +103,44 @@ router.post('/connect', }) } + // IMAP: verify connection before saving + if (provider === 'imap') { + const { ImapService } = await import('../services/imap.mjs') + const imapPassword = password || accessToken + const imap = new ImapService({ + host: imapHost || 'imap.porkbun.com', + port: imapPort != null ? Number(imapPort) : 993, + secure: imapSecure !== false, + user: email, + password: imapPassword, + }) + try { + await imap.connect() + await imap.listEmails(1) + await imap.close() + } catch (err) { + log.warn('IMAP connection test failed', { email, error: err.message }) + throw new ValidationError('IMAP connection failed. Check email and password (use app password if 2FA is on).', { password: [err.message || 'Connection failed'] }) + } + } + // Create account - const account = await emailAccounts.create({ + const accountData = { userId, provider, email, - accessToken: accessToken || '', - refreshToken: refreshToken || '', - expiresAt: expiresAt || 0, + accessToken: provider === 'imap' ? (password || accessToken) : (accessToken || ''), + refreshToken: provider === 'imap' ? '' : (refreshToken || ''), + expiresAt: provider === 'imap' ? 0 : (expiresAt || 0), isActive: true, 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) log.success(`Email account connected: ${email} (${provider})`) @@ -487,6 +522,24 @@ router.post('/sort', } } + // Create name labels (workers) – personal labels per team member + const nameLabelMap = {} + if (preferences.nameLabels?.length) { + for (const nl of preferences.nameLabels) { + if (!nl.enabled) continue + try { + const labelName = `EmailSorter/Team/${nl.name}` + const label = await gmail.createLabel(labelName, '#4a86e8') + if (label) { + nameLabelMap[nl.id || nl.name] = label.id + if (nl.name !== (nl.id || nl.name)) nameLabelMap[nl.name] = label.id + } + } catch (err) { + log.warn(`Failed to create name label: ${nl.name}`) + } + } + } + // Fetch and process ALL emails with pagination let pageToken = null let totalProcessed = 0 @@ -518,6 +571,7 @@ router.post('/sort', let category = null let companyLabel = null + let assignedTo = null let skipAI = false // PRIORITY 1: Check custom company labels @@ -548,6 +602,7 @@ router.post('/sort', if (!skipAI) { const classification = await sorter.categorize(emailData, preferences) category = classification.category + assignedTo = classification.assignedTo || null // If category is disabled, fallback to review if (!enabledCategories.includes(category)) { @@ -559,6 +614,7 @@ router.post('/sort', email, category, companyLabel, + assignedTo, }) // Collect samples for suggested rules (first run only, max 50) @@ -573,7 +629,7 @@ router.post('/sort', } // Apply labels/categories and actions - for (const { email, category, companyLabel } of processedEmails) { + for (const { email, category, companyLabel, assignedTo } of processedEmails) { const action = sorter.getCategoryAction(category, preferences) try { @@ -585,6 +641,11 @@ router.post('/sort', labelsToAdd.push(companyLabelMap[companyLabel]) } + // Add name label (worker) if AI assigned email to a person + if (assignedTo && nameLabelMap[assignedTo]) { + labelsToAdd.push(nameLabelMap[assignedTo]) + } + // Add category label/category if (labelMap[category]) { labelsToAdd.push(labelMap[category]) @@ -794,6 +855,160 @@ router.post('/sort', throw new ValidationError(`Outlook error: ${err.message}. Please reconnect account.`) } } + // ═══════════════════════════════════════════════════════════════════════ + // IMAP (Porkbun, Nextcloud mail backend, etc.) + // ═══════════════════════════════════════════════════════════════════════ + else if (account.provider === 'imap') { + if (!features.ai()) { + throw new ValidationError('AI sorting is not configured. Please set MISTRAL_API_KEY.') + } + + if (!account.accessToken) { + throw new ValidationError('IMAP account needs to be reconnected (password missing)') + } + + log.info(`IMAP sorting started for ${account.email}`) + + const { ImapService, getFolderNameForCategory } = await import('../services/imap.mjs') + const imap = new ImapService({ + host: account.imapHost || 'imap.porkbun.com', + port: account.imapPort != null ? account.imapPort : 993, + secure: account.imapSecure !== false, + user: account.email, + password: account.accessToken, + }) + + try { + await imap.connect() + + const enabledCategories = sorter.getEnabledCategories(preferences) + // 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 { + await imap.ensureFolder(folderName) + nameLabelMap[nl.id || nl.name] = folderName + if (nl.name !== (nl.id || nl.name)) nameLabelMap[nl.name] = folderName + } catch (err) { + log.warn(`IMAP name label folder failed: ${nl.name}`, { error: err.message }) + } + } + } + + 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/ 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 { + await imap.close() + } catch { + // ignore + } + log.error('IMAP sorting failed', { error: err.message }) + throw new ValidationError(`IMAP error: ${err.message}. Check credentials or reconnect.`) + } + } // Update last sync await emailAccounts.updateLastSync(accountId) diff --git a/server/routes/webhook.mjs b/server/routes/webhook.mjs new file mode 100644 index 0000000..d1040b5 --- /dev/null +++ b/server/routes/webhook.mjs @@ -0,0 +1,125 @@ +/** + * Webhook Routes (Gitea etc.) + * Production: https://emailsorter.webklar.com/api/webhook/gitea + * POST /api/webhook/gitea - Deployment on push (validates Bearer or X-Gitea-Signature) + */ + +import express from 'express' +import crypto from 'crypto' +import { asyncHandler, AuthorizationError } from '../middleware/errorHandler.mjs' +import { config } from '../config/index.mjs' +import { log } from '../middleware/logger.mjs' + +const router = express.Router() +const secret = config.gitea.webhookSecret +const authToken = config.gitea.webhookAuthToken + +/** + * Validate Gitea webhook request: + * - Authorization: Bearer (Gitea 1.19+ or manual calls) + * - X-Gitea-Signature: HMAC-SHA256 hex of raw body (Gitea default) + */ +function validateGiteaWebhook(req) { + const rawBody = req.body + if (!rawBody || !Buffer.isBuffer(rawBody)) { + throw new AuthorizationError('Raw body fehlt (Webhook-Route muss vor JSON-Parser registriert sein)') + } + + // 1) Bearer token (Header) + const authHeader = req.get('Authorization') + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.slice(7).trim() + const expected = authToken || secret + if (expected && token === expected) { + return true + } + } + + // 2) X-Gitea-Signature (HMAC-SHA256 hex) + const signatureHeader = req.get('X-Gitea-Signature') + if (signatureHeader && secret) { + try { + const expectedHex = crypto.createHmac('sha256', secret).update(rawBody).digest('hex') + const received = signatureHeader.trim() + const receivedHex = received.startsWith('sha256=') ? received.slice(7) : received + if (expectedHex.length === receivedHex.length && expectedHex.length > 0) { + const a = Buffer.from(expectedHex, 'hex') + const b = Buffer.from(receivedHex, 'hex') + if (a.length === b.length && crypto.timingSafeEqual(a, b)) return true + } + } catch (_) { + // invalid hex or comparison error – fall through to reject + } + } + + if (!secret && !authToken) { + throw new AuthorizationError('GITEA_WEBHOOK_SECRET nicht konfiguriert') + } + throw new AuthorizationError('Ungültige Webhook-Signatur oder fehlender Authorization-Header') +} + +/** + * POST /api/webhook/gitea + * Gitea push webhook – validates Bearer or X-Gitea-Signature, then accepts event + */ +router.post('/gitea', asyncHandler(async (req, res) => { + try { + validateGiteaWebhook(req) + } catch (err) { + if (err.name === 'AuthorizationError' || err.statusCode === 401) throw err + log.error('Gitea Webhook: Validierung fehlgeschlagen', { error: err.message }) + return res.status(401).json({ error: 'Webhook validation failed' }) + } + + let payload + try { + const raw = req.body && typeof req.body.toString === 'function' ? req.body.toString('utf8') : '' + payload = raw ? JSON.parse(raw) : {} + } catch (e) { + log.warn('Gitea Webhook: ungültiges JSON', { error: e.message }) + return res.status(400).json({ error: 'Invalid JSON body' }) + } + + const ref = payload.ref || '' + const branch = ref.replace(/^refs\/heads\//, '') + const event = req.get('X-Gitea-Event') || 'push' + log.info('Gitea Webhook empfangen', { ref, branch, event }) + + // Optional: trigger deploy script in background (do not block response) + setImmediate(async () => { + try { + const { spawn } = await import('child_process') + const { fileURLToPath } = await import('url') + const { dirname, join } = await import('path') + const { existsSync } = await import('fs') + const __dirname = dirname(fileURLToPath(import.meta.url)) + const deployScript = join(__dirname, '..', '..', 'scripts', 'deploy-to-server.mjs') + if (existsSync(deployScript)) { + const child = spawn('node', [deployScript], { + cwd: join(__dirname, '..', '..'), + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, + }) + child.unref() + child.stdout?.on('data', (d) => log.info('Deploy stdout:', d.toString().trim())) + child.stderr?.on('data', (d) => log.warn('Deploy stderr:', d.toString().trim())) + } + } catch (_) {} + }) + + res.status(202).json({ received: true, ref, branch }) +})) + +/** + * GET /api/webhook/status + * Simple status for webhook endpoint (e.g. health check) + */ +router.get('/status', (req, res) => { + res.json({ + ok: true, + webhook: 'gitea', + configured: Boolean(secret || authToken), + }) +}) + +export default router diff --git a/server/scripts/create-admin-user.mjs b/server/scripts/create-admin-user.mjs new file mode 100644 index 0000000..22dcbfd --- /dev/null +++ b/server/scripts/create-admin-user.mjs @@ -0,0 +1,75 @@ +/** + * Create admin user in Appwrite (e.g. support@webklar.com). + * Requires: APPWRITE_* env vars. Optionally ADMIN_INITIAL_PASSWORD (otherwise one is generated). + * After creation, add the email to ADMIN_EMAILS in .env so the backend treats them as admin. + * + * Usage: node scripts/create-admin-user.mjs [email] + * Default email: support@webklar.com + */ + +import 'dotenv/config' +import { Client, Users, ID } from 'node-appwrite' + +const ADMIN_EMAIL = process.argv[2] || 'support@webklar.com' +const ADMIN_NAME = 'Support (Admin)' + +const required = ['APPWRITE_ENDPOINT', 'APPWRITE_PROJECT_ID', 'APPWRITE_API_KEY'] +for (const k of required) { + if (!process.env[k]) { + console.error(`Missing env: ${k}`) + process.exit(1) + } +} + +let password = process.env.ADMIN_INITIAL_PASSWORD +if (!password || password.length < 8) { + const bytes = new Uint8Array(12) + if (typeof globalThis.crypto !== 'undefined' && globalThis.crypto.getRandomValues) { + globalThis.crypto.getRandomValues(bytes) + } else { + const { randomFillSync } = await import('node:crypto') + randomFillSync(bytes) + } + password = + Array.from(bytes).map((b) => 'abcdefghjkmnpqrstuvwxyz23456789'[b % 32]).join('') + 'A1!' + console.log('No ADMIN_INITIAL_PASSWORD set – using generated password (save it!):') + console.log(' ' + password) + console.log('') +} + +const client = new Client() + .setEndpoint(process.env.APPWRITE_ENDPOINT) + .setProject(process.env.APPWRITE_PROJECT_ID) + .setKey(process.env.APPWRITE_API_KEY) + +const users = new Users(client) + +async function main() { + try { + const existing = await users.list([], ADMIN_EMAIL) + const found = existing.users?.find((u) => u.email?.toLowerCase() === ADMIN_EMAIL.toLowerCase()) + if (found) { + console.log(`User already exists: ${ADMIN_EMAIL} (ID: ${found.$id})`) + console.log('Add to server/.env: ADMIN_EMAILS=' + ADMIN_EMAIL) + return + } + + const user = await users.create(ID.unique(), ADMIN_EMAIL, undefined, password, ADMIN_NAME) + + console.log('Admin user created:') + console.log(' Email:', user.email) + console.log(' ID:', user.$id) + console.log(' Name:', user.name) + console.log('') + console.log('Add to server/.env: ADMIN_EMAILS=' + ADMIN_EMAIL) + console.log('Then the backend will treat this user as admin (isAdmin() returns true).') + } catch (err) { + console.error('Error:', err.message || err) + if (err.code === 409) { + console.error('User with this email may already exist. Check Appwrite Console → Auth → Users.') + } + process.exit(1) + } +} + +main() diff --git a/server/services/ai-sorter.mjs b/server/services/ai-sorter.mjs index b84ad06..029fb7d 100644 --- a/server/services/ai-sorter.mjs +++ b/server/services/ai-sorter.mjs @@ -417,7 +417,8 @@ Subject: ${subject} Preview: ${snippet?.substring(0, 500) || 'No preview'} RESPONSE FORMAT (JSON ONLY): -{"category": "category_key", "confidence": 0.0-1.0, "reason": "brief explanation"} +{"category": "category_key", "confidence": 0.0-1.0, "reason": "brief explanation", "assignedTo": "name_label_id_or_name_or_null"} +If the email is clearly FOR a specific worker (e.g. "für Max", "an Anna", subject/body mentions them), set assignedTo to that worker's id or name. Otherwise set assignedTo to null or omit it. Respond ONLY with the JSON object.` @@ -438,6 +439,15 @@ Respond ONLY with the JSON object.` result.category = 'review' } + // Validate assignedTo against name labels (id or name) + if (result.assignedTo && preferences.nameLabels?.length) { + const match = preferences.nameLabels.find( + l => l.enabled && (l.id === result.assignedTo || l.name === result.assignedTo) + ) + if (!match) result.assignedTo = null + else result.assignedTo = match.id || match.name + } + return result } catch (error) { log.error('AI categorization failed', { error: error.message }) @@ -484,7 +494,8 @@ EMAILS: ${emailList} RESPONSE FORMAT (JSON ARRAY ONLY): -[{"index": 0, "category": "key"}, {"index": 1, "category": "key"}, ...] +[{"index": 0, "category": "key", "assignedTo": "id_or_name_or_null"}, ...] +If an email is clearly FOR a specific worker, set assignedTo to that worker's id or name. Otherwise omit or null. Respond ONLY with the JSON array.` @@ -515,9 +526,16 @@ Respond ONLY with the JSON array.` return emails.map((email, i) => { const result = parsed.find(r => r.index === i) const category = result?.category && CATEGORIES[result.category] ? result.category : 'review' + let assignedTo = result?.assignedTo || null + if (assignedTo && preferences.nameLabels?.length) { + const match = preferences.nameLabels.find( + l => l.enabled && (l.id === assignedTo || l.name === assignedTo) + ) + assignedTo = match ? (match.id || match.name) : null + } return { email, - classification: { category, confidence: 0.8, reason: 'Batch' }, + classification: { category, confidence: 0.8, reason: 'Batch', assignedTo }, } }) } catch (error) { @@ -578,6 +596,14 @@ Respond ONLY with the JSON array.` } } + // Name labels (workers) – assign email to a person when clearly for them + if (preferences.nameLabels?.length) { + const activeNameLabels = preferences.nameLabels.filter(l => l.enabled) + if (activeNameLabels.length > 0) { + parts.push(`NAME LABELS (workers) – assign email to ONE person when the email is clearly FOR that person (e.g. "für Max", "an Anna", "Max bitte prüfen", subject/body mentions them):\n${activeNameLabels.map(l => `- id: "${l.id}", name: "${l.name}"${l.keywords?.length ? `, keywords: ${JSON.stringify(l.keywords)}` : ''}`).join('\n')}\nIf the email is for a specific worker, set "assignedTo" to that label's id or name. Otherwise omit assignedTo.`) + } + } + return parts.length > 0 ? `USER PREFERENCES:\n${parts.join('\n')}\n` : '' } diff --git a/server/services/database.mjs b/server/services/database.mjs index 3e936ea..b524e6c 100644 --- a/server/services/database.mjs +++ b/server/services/database.mjs @@ -373,6 +373,7 @@ export const userPreferences = { enabledCategories: ['vip', 'customers', 'invoices', 'newsletters', 'promotions', 'social', 'security', 'calendar', 'review'], categoryActions: {}, companyLabels: [], + nameLabels: [], autoDetectCompanies: true, version: 1, categoryAdvanced: {}, @@ -410,6 +411,7 @@ export const userPreferences = { enabledCategories: preferences.enabledCategories || defaults.enabledCategories, categoryActions: preferences.categoryActions || defaults.categoryActions, companyLabels: preferences.companyLabels || defaults.companyLabels, + nameLabels: preferences.nameLabels || defaults.nameLabels, autoDetectCompanies: preferences.autoDetectCompanies !== undefined ? preferences.autoDetectCompanies : defaults.autoDetectCompanies, } }, diff --git a/server/services/imap.mjs b/server/services/imap.mjs new file mode 100644 index 0000000..c886c6c --- /dev/null +++ b/server/services/imap.mjs @@ -0,0 +1,181 @@ +/** + * IMAP Service + * Generic IMAP (e.g. Porkbun, Nextcloud mail backend) – connect, list, fetch, move to folder + */ + +import { ImapFlow } from 'imapflow' +import { log } from '../middleware/logger.mjs' + +const INBOX = 'INBOX' +const FOLDER_PREFIX = 'EmailSorter' + +/** Map category key to IMAP folder name */ +export function getFolderNameForCategory(category) { + const map = { + vip: 'VIP', + customers: 'Clients', + invoices: 'Invoices', + newsletters: 'Newsletters', + promotions: 'Promotions', + social: 'Social', + security: 'Security', + calendar: 'Calendar', + review: 'Review', + archive: 'Archive', + } + return map[category] || 'Review' +} + +/** + * IMAP Service – same conceptual interface as GmailService/OutlookService + */ +export class ImapService { + /** + * @param {object} opts + * @param {string} opts.host - e.g. imap.porkbun.com + * @param {number} opts.port - e.g. 993 + * @param {boolean} opts.secure - true for SSL/TLS + * @param {string} opts.user - email address + * @param {string} opts.password - app password + */ + constructor(opts) { + const { host, port = 993, secure = true, user, password } = opts + this.client = new ImapFlow({ + host: host || 'imap.porkbun.com', + port: port || 993, + secure: secure !== false, + auth: { user, pass: password }, + logger: false, + }) + this.lock = null + } + + async connect() { + await this.client.connect() + } + + async close() { + try { + if (this.lock) await this.lock.release().catch(() => {}) + await this.client.logout() + } catch { + this.client.close() + } + } + + /** + * List messages from INBOX (returns ids = UIDs for use with getEmail/batchGetEmails) + * @param {number} maxResults + * @param {string|null} _pageToken - reserved for future pagination + */ + async listEmails(maxResults = 50, _pageToken = null) { + const lock = await this.client.getMailboxLock(INBOX) + this.lock = lock + try { + const uids = await this.client.search({ all: true }, { uid: true }) + const slice = uids.slice(0, maxResults) + const nextPageToken = uids.length > maxResults ? String(slice[slice.length - 1]) : null + return { + messages: slice.map((uid) => ({ id: String(uid) })), + nextPageToken, + } + } finally { + lock.release() + this.lock = null + } + } + + /** Normalize ImapFlow message to same shape as Gmail/Outlook (id, headers.from, headers.subject, snippet) */ + _normalize(msg) { + if (!msg || !msg.envelope) return null + const from = msg.envelope.from && msg.envelope.from[0] ? (msg.envelope.from[0].address || msg.envelope.from[0].name || '') : '' + const subject = msg.envelope.subject || '' + return { + id: String(msg.uid), + headers: { from, subject }, + snippet: subject.slice(0, 200), + } + } + + /** + * Get one message by id (UID string) + */ + async getEmail(messageId) { + const lock = await this.client.getMailboxLock(INBOX) + this.lock = lock + try { + const list = await this.client.fetchAll(String(messageId), { envelope: true }, { uid: true }) + return this._normalize(list && list[0]) + } finally { + lock.release() + this.lock = null + } + } + + /** + * Batch get multiple messages by id (UID strings) – single lock, one fetch + */ + async batchGetEmails(messageIds) { + if (!messageIds.length) return [] + const lock = await this.client.getMailboxLock(INBOX) + this.lock = lock + try { + const uids = messageIds.map((id) => (typeof id === 'string' ? Number(id) : id)).filter((n) => !Number.isNaN(n)) + if (!uids.length) return [] + const list = await this.client.fetchAll(uids, { envelope: true }, { uid: true }) + return (list || []).map((m) => this._normalize(m)).filter(Boolean) + } catch (e) { + log.warn('IMAP batchGetEmails failed', { error: e.message }) + return [] + } finally { + lock.release() + this.lock = null + } + } + + /** + * Ensure folder exists (create if not). Use subfolder under EmailSorter to avoid clutter. + */ + async ensureFolder(folderName) { + const path = `${FOLDER_PREFIX}/${folderName}` + try { + await this.client.mailboxCreate(path) + log.info(`IMAP folder created: ${path}`) + } catch (err) { + if (err.code !== 'ALREADYEXISTS' && !/already exists/i.test(err.message)) { + throw err + } + } + return path + } + + /** + * Move message (by UID) from INBOX to folder name (under EmailSorter/) + */ + async moveToFolder(messageId, folderName) { + const path = `${FOLDER_PREFIX}/${folderName}` + await this.ensureFolder(folderName) + const lock = await this.client.getMailboxLock(INBOX) + this.lock = lock + try { + await this.client.messageMove(String(messageId), path, { uid: true }) + } finally { + lock.release() + this.lock = null + } + } + + /** + * Mark message as read (\\Seen) + */ + async markAsRead(messageId) { + const lock = await this.client.getMailboxLock(INBOX) + this.lock = lock + try { + await this.client.messageFlagsAdd(String(messageId), ['\\Seen'], { uid: true }) + } finally { + lock.release() + this.lock = null + } + } +}