Initial commit: EmailToChat — multi-channel email manager with AI
Desktop app (Electron + Vite + React + TypeScript) that receives email via IMAP, sends/forwards via SMTP, Telegram (Bot API + MTProto user account) and WhatsApp (whatsapp-web.js), with AI integration (Anthropic, OpenAI, Ollama) for auto-classification, summary, structured extraction and reply drafts. Features: - Multi-account IMAP/SMTP with Gmail OAuth2 (loopback flow) + App Password preset - Telegram Bot API via telegraf, with chat discovery from getUpdates - Telegram MTProto via gram.js for sending to phone numbers like WhatsApp - WhatsApp via whatsapp-web.js with QR-code session (locally generated PNG) - Forwarding engine with rule-based filters incl. AI category match - SQLite persistence (better-sqlite3) with idempotent migrations - Contact picker with autocomplete, CSV/vCard import, search - Dark mode (light/dark/system) with futuristic glass-morphism UI - Settings tabbed: Email Accounts / Telegram / WhatsApp / AI / OAuth creds / Preferences Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
620b8fca67
53 files changed
+18873
No files matched your search
+20
@@ -0,0 +1,20 @@
|
|||||||
|
node_modules
|
||||||
|
out
|
||||||
|
release
|
||||||
|
dist
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# App data
|
||||||
|
data/
|
||||||
|
sessions/
|
||||||
|
|
||||||
|
# whatsapp-web.js runtime
|
||||||
|
.wwebjs_cache/
|
||||||
|
.wwebjs_auth/
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
# EmailToChat
|
||||||
|
|
||||||
|
Gestore email desktop multi-canale con AI integrata.
|
||||||
|
Riceve email via **IMAP**, le inoltra automaticamente su **SMTP**, **Telegram**
|
||||||
|
e **WhatsApp** secondo regole personalizzabili, e permette di analizzarle con
|
||||||
|
**Claude (Anthropic)**, **OpenAI** o **Ollama locale**.
|
||||||
|
|
||||||
|
## Funzionalità
|
||||||
|
|
||||||
|
- **Multi-account**: configura più caselle IMAP/SMTP, più bot Telegram, più
|
||||||
|
sessioni WhatsApp in parallelo.
|
||||||
|
- **Ricezione IMAP** con polling configurabile per casella, parsing completo
|
||||||
|
(mailparser) di body HTML/testo e allegati.
|
||||||
|
- **Invio SMTP** via `nodemailer`, con allegati e from-name personalizzabile.
|
||||||
|
- **Bot Telegram** via `telegraf` (Telegram Bot API). Supporta lunghi messaggi
|
||||||
|
con auto-chunk, foto e documenti.
|
||||||
|
- **WhatsApp** via `whatsapp-web.js`. Login QR-code, sessione persistente
|
||||||
|
locale, invio testo e media.
|
||||||
|
- **Motore di forwarding** con regole basate su: from/to/oggetto/corpo/allegati
|
||||||
|
e **categoria AI**. Template messaggi con placeholder.
|
||||||
|
- **AI multi-provider**: Anthropic Claude, OpenAI, Ollama. Funzioni:
|
||||||
|
- **Classificazione** (automatica al ricevimento o on-demand)
|
||||||
|
- **Riassunto** (on-demand)
|
||||||
|
- **Estrazione informazioni strutturate** in JSON (date, importi, action items…)
|
||||||
|
- **Bozza di risposta** suggerita
|
||||||
|
- **Rubrica contatti** condivisa tra i canali.
|
||||||
|
- **DB SQLite** locale (`better-sqlite3`), salvato nella userData di Electron.
|
||||||
|
- **Toast notifications** per nuovi messaggi ed errori in tempo reale.
|
||||||
|
|
||||||
|
## Stack tecnico
|
||||||
|
|
||||||
|
- Electron + electron-vite
|
||||||
|
- TypeScript
|
||||||
|
- React 18 + Tailwind CSS
|
||||||
|
- better-sqlite3, imapflow, mailparser, nodemailer, telegraf, whatsapp-web.js
|
||||||
|
- @anthropic-ai/sdk, openai (SDK)
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### Prerequisiti
|
||||||
|
|
||||||
|
- Node.js 20+ e npm
|
||||||
|
- macOS, Windows o Linux
|
||||||
|
- (Per WhatsApp) connessione internet stabile + sufficienti risorse per Puppeteer headless
|
||||||
|
|
||||||
|
### Installazione
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
`postinstall` esegue `electron-builder install-app-deps` che ricompila i moduli
|
||||||
|
nativi (`better-sqlite3`) per la versione di Electron usata.
|
||||||
|
|
||||||
|
### Avvio in sviluppo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Apre l'app in modalità dev con hot-reload del renderer.
|
||||||
|
|
||||||
|
### Build di produzione
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm run dist:mac # oppure dist:win / dist:linux
|
||||||
|
```
|
||||||
|
|
||||||
|
L'output finisce in `release/`.
|
||||||
|
|
||||||
|
## Primo utilizzo
|
||||||
|
|
||||||
|
### 1. Configurare un account Email (IMAP+SMTP)
|
||||||
|
|
||||||
|
Pagina **Email Account → + Nuovo account**.
|
||||||
|
|
||||||
|
Esempio Gmail (richiede una "App Password" se hai 2FA attiva):
|
||||||
|
|
||||||
|
| Campo | Valore |
|
||||||
|
|------------------|-----------------------|
|
||||||
|
| IMAP host | `imap.gmail.com` |
|
||||||
|
| IMAP porta | `993` |
|
||||||
|
| IMAP secure | sì |
|
||||||
|
| SMTP host | `smtp.gmail.com` |
|
||||||
|
| SMTP porta | `465` |
|
||||||
|
| SMTP secure | sì |
|
||||||
|
| User / Password | la tua email + app password |
|
||||||
|
|
||||||
|
Clicca **Test** per verificare la connessione. Se l'account è attivo, il
|
||||||
|
polling parte automaticamente.
|
||||||
|
|
||||||
|
### 2. Configurare un bot Telegram
|
||||||
|
|
||||||
|
1. Apri Telegram → scrivi a `@BotFather` → `/newbot` → segui le istruzioni.
|
||||||
|
2. Copia il token e incollalo in **Telegram → + Nuovo bot**.
|
||||||
|
3. Per ottenere il tuo `chat_id`: scrivi al bot, poi apri nel browser:
|
||||||
|
`https://api.telegram.org/bot<TOKEN>/getUpdates` → cerca `"chat":{"id":...}`.
|
||||||
|
|
||||||
|
### 3. Configurare una sessione WhatsApp
|
||||||
|
|
||||||
|
1. Vai su **WhatsApp → + Nuova sessione** → dai un nome.
|
||||||
|
2. Clicca **Avvia**: comparirà un QR code.
|
||||||
|
3. Apri WhatsApp sul telefono → Impostazioni → Dispositivi collegati →
|
||||||
|
Collega un dispositivo → scansiona il QR.
|
||||||
|
4. Quando lo stato passa a `ready`, la sessione è pronta. Resta autenticata
|
||||||
|
anche dopo il riavvio dell'app (sessione salvata in
|
||||||
|
`userData/whatsapp-sessions/`).
|
||||||
|
|
||||||
|
### 4. Configurare l'AI
|
||||||
|
|
||||||
|
Pagina **AI → + Nuovo provider**.
|
||||||
|
|
||||||
|
- **Anthropic**: ottieni una API key da console.anthropic.com,
|
||||||
|
modello consigliato `claude-haiku-4-5-20251001` per velocità o
|
||||||
|
`claude-sonnet-4-6` per qualità.
|
||||||
|
- **OpenAI**: API key da platform.openai.com,
|
||||||
|
modello consigliato `gpt-4o-mini`.
|
||||||
|
- **Ollama**: avvia Ollama localmente (`ollama serve`) e indica
|
||||||
|
`http://localhost:11434` come Base URL. Esempio modello: `llama3.2`.
|
||||||
|
|
||||||
|
Imposta un provider come **predefinito** (per azioni on-demand) e attiva
|
||||||
|
**Classificazione automatica** su uno solo dei provider per categorizzare
|
||||||
|
ogni email all'arrivo.
|
||||||
|
|
||||||
|
### 5. Creare una regola di inoltro
|
||||||
|
|
||||||
|
Esempio: "Inoltra su Telegram tutte le email categorizzate come `fattura`".
|
||||||
|
|
||||||
|
Pagina **Regole Forward → + Nuova regola**:
|
||||||
|
|
||||||
|
- Filtro: `Categoria AI` = `fattura`
|
||||||
|
- Destinazione: canale `telegram`, identifier `1:123456789` (bot id 1, chat 123456789)
|
||||||
|
- Includi corpo: sì
|
||||||
|
- Includi allegati: sì
|
||||||
|
|
||||||
|
Per inoltrare su WhatsApp invece, l'identifier ha formato
|
||||||
|
`sessionId|numero` (es. `1|393331234567`). Se vuoi usare il
|
||||||
|
destinatario predefinito della sessione, scrivi solo `1|`.
|
||||||
|
|
||||||
|
### 6. Comporre e inviare un messaggio
|
||||||
|
|
||||||
|
Pagina **Componi**: scegli canale (SMTP/Telegram/WhatsApp), account/bot/sessione,
|
||||||
|
destinatario, messaggio. I contatti in rubrica appaiono come suggerimenti.
|
||||||
|
|
||||||
|
## Struttura del progetto
|
||||||
|
|
||||||
|
```
|
||||||
|
EmailToChat/
|
||||||
|
├── electron.vite.config.ts # Configurazione electron-vite
|
||||||
|
├── package.json
|
||||||
|
├── src/
|
||||||
|
│ ├── main/ # Electron main process
|
||||||
|
│ │ ├── index.ts # Bootstrap finestra + servizi
|
||||||
|
│ │ ├── ipc.ts # IPC handlers
|
||||||
|
│ │ ├── db/
|
||||||
|
│ │ │ ├── database.ts # SQLite + migrations
|
||||||
|
│ │ │ └── repositories.ts # Repository pattern
|
||||||
|
│ │ └── services/
|
||||||
|
│ │ ├── imap.ts # Polling IMAP
|
||||||
|
│ │ ├── smtp.ts # Invio SMTP
|
||||||
|
│ │ ├── telegram.ts # Bot Telegram
|
||||||
|
│ │ ├── whatsapp.ts # whatsapp-web.js
|
||||||
|
│ │ ├── ai.ts # Anthropic/OpenAI/Ollama
|
||||||
|
│ │ └── forwarder.ts # Motore regole
|
||||||
|
│ ├── preload/
|
||||||
|
│ │ ├── index.ts # contextBridge IPC
|
||||||
|
│ │ └── index.d.ts # Tipi window.api
|
||||||
|
│ ├── renderer/
|
||||||
|
│ │ ├── index.html
|
||||||
|
│ │ └── src/
|
||||||
|
│ │ ├── App.tsx
|
||||||
|
│ │ ├── main.tsx
|
||||||
|
│ │ ├── styles.css
|
||||||
|
│ │ ├── components/ # Sidebar, Modal, Toaster, PageHeader
|
||||||
|
│ │ └── views/ # 8 pagine UI
|
||||||
|
│ └── shared/
|
||||||
|
│ ├── types.ts # DTO condivisi main/renderer
|
||||||
|
│ └── ipc-channels.ts # Costanti canali IPC
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dati e privacy
|
||||||
|
|
||||||
|
- Tutti i dati sono memorizzati **localmente** in:
|
||||||
|
- macOS: `~/Library/Application Support/EmailToChat/`
|
||||||
|
- Windows: `%APPDATA%/EmailToChat/`
|
||||||
|
- Linux: `~/.config/EmailToChat/`
|
||||||
|
- DB: `data/emailtochat.db`
|
||||||
|
- Allegati: `attachments/<accountId>/<messageId>/`
|
||||||
|
- Sessioni WhatsApp: `whatsapp-sessions/wa-<id>/`
|
||||||
|
- Le API key dei provider AI sono salvate **in chiaro** nel DB. Per maggiore
|
||||||
|
sicurezza puoi cifrare il filesystem.
|
||||||
|
|
||||||
|
## Note operative
|
||||||
|
|
||||||
|
- **Gmail App Passwords**: se hai 2FA, devi generare una password app-specifica
|
||||||
|
da `https://myaccount.google.com/apppasswords`.
|
||||||
|
- **whatsapp-web.js** dipende da Chromium scaricato da Puppeteer (~150 MB
|
||||||
|
al primo `npm install`).
|
||||||
|
- **Telegram Bot vs Channel**: per inviare a un canale, il bot deve esserne
|
||||||
|
admin e il chat_id ha la forma `-100xxxxxxxxx`.
|
||||||
|
- **Limite messaggi Telegram**: 4096 caratteri, l'app fa auto-chunk.
|
||||||
|
- **Polling IMAP minimo**: 15 secondi per evitare ban dai provider email.
|
||||||
|
|
||||||
|
## Sviluppi futuri suggeriti
|
||||||
|
|
||||||
|
- Cifratura del DB (sqlcipher) per le credenziali
|
||||||
|
- IMAP IDLE invece di polling per ricezione push
|
||||||
|
- Search globale su messaggi
|
||||||
|
- Allegati Telegram/WhatsApp anche per messaggi outbound dalla compose UI
|
||||||
|
- Support webhook per Telegram in alternativa al long-polling
|
||||||
|
- Pannello "AI Chat" per fare domande sull'intera casella
|
||||||
|
|
||||||
|
## Licenza
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { resolve } from 'path'
|
||||||
|
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
main: {
|
||||||
|
plugins: [externalizeDepsPlugin()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@shared': resolve('src/shared')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
preload: {
|
||||||
|
plugins: [externalizeDepsPlugin()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@shared': resolve('src/shared')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@renderer': resolve('src/renderer/src'),
|
||||||
|
'@shared': resolve('src/shared')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [react()]
|
||||||
|
}
|
||||||
|
})
|
||||||
Generated
+10610
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"name": "email-to-chat",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Gestore email completo con inoltro multi-canale (SMTP, Telegram, WhatsApp)",
|
||||||
|
"main": "out/main/index.js",
|
||||||
|
"author": "Luciano",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "electron-vite dev",
|
||||||
|
"build": "electron-vite build",
|
||||||
|
"start": "electron-vite preview",
|
||||||
|
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
|
||||||
|
"typecheck:web": "tsc --noEmit -p tsconfig.web.json",
|
||||||
|
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||||
|
"dist:mac": "electron-vite build && electron-builder --mac",
|
||||||
|
"dist:win": "electron-vite build && electron-builder --win",
|
||||||
|
"dist:linux": "electron-vite build && electron-builder --linux",
|
||||||
|
"postinstall": "electron-builder install-app-deps"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.30.1",
|
||||||
|
"@electron-toolkit/preload": "^3.0.1",
|
||||||
|
"@electron-toolkit/utils": "^3.0.0",
|
||||||
|
"better-sqlite3": "^11.3.0",
|
||||||
|
"imapflow": "^1.0.164",
|
||||||
|
"mailparser": "^3.7.1",
|
||||||
|
"nodemailer": "^6.9.15",
|
||||||
|
"openai": "^4.67.3",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"telegraf": "^4.16.3",
|
||||||
|
"telegram": "^2.26.22",
|
||||||
|
"whatsapp-web.js": "^1.26.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||||
|
"@types/better-sqlite3": "^7.6.11",
|
||||||
|
"@types/mailparser": "^3.4.5",
|
||||||
|
"@types/node": "^22.7.4",
|
||||||
|
"@types/nodemailer": "^6.4.16",
|
||||||
|
"@types/qrcode": "^1.5.5",
|
||||||
|
"@types/react": "^18.3.11",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"electron": "^32.1.2",
|
||||||
|
"electron-builder": "^25.0.5",
|
||||||
|
"electron-vite": "^2.3.0",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"tailwindcss": "^3.4.13",
|
||||||
|
"typescript": "^5.6.2",
|
||||||
|
"vite": "^5.4.8"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.luciano.emailtochat",
|
||||||
|
"productName": "EmailToChat",
|
||||||
|
"directories": {
|
||||||
|
"output": "release"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"out/**/*",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"mac": {
|
||||||
|
"category": "public.app-category.productivity"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import Database from 'better-sqlite3'
|
||||||
|
import { app } from 'electron'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { mkdirSync } from 'fs'
|
||||||
|
|
||||||
|
let db: Database.Database | null = null
|
||||||
|
|
||||||
|
export function getDb(): Database.Database {
|
||||||
|
if (db) return db
|
||||||
|
const dataDir = join(app.getPath('userData'), 'data')
|
||||||
|
mkdirSync(dataDir, { recursive: true })
|
||||||
|
const dbPath = join(dataDir, 'emailtochat.db')
|
||||||
|
db = new Database(dbPath)
|
||||||
|
db.pragma('journal_mode = WAL')
|
||||||
|
db.pragma('foreign_keys = ON')
|
||||||
|
migrate(db)
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrate(d: Database.Database): void {
|
||||||
|
d.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS app_settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS email_accounts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
imap_host TEXT NOT NULL,
|
||||||
|
imap_port INTEGER NOT NULL,
|
||||||
|
imap_secure INTEGER NOT NULL DEFAULT 1,
|
||||||
|
imap_user TEXT NOT NULL,
|
||||||
|
imap_pass TEXT NOT NULL,
|
||||||
|
smtp_host TEXT NOT NULL,
|
||||||
|
smtp_port INTEGER NOT NULL,
|
||||||
|
smtp_secure INTEGER NOT NULL DEFAULT 1,
|
||||||
|
smtp_user TEXT NOT NULL,
|
||||||
|
smtp_pass TEXT NOT NULL,
|
||||||
|
smtp_from_name TEXT,
|
||||||
|
smtp_from_address TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
poll_interval INTEGER NOT NULL DEFAULT 60,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS telegram_user_accounts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
phone TEXT NOT NULL,
|
||||||
|
session_string TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'disconnected',
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS telegram_bots (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
default_chat_id TEXT,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS whatsapp_sessions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'disconnected',
|
||||||
|
phone TEXT,
|
||||||
|
default_recipient TEXT,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
account_id INTEGER,
|
||||||
|
direction TEXT NOT NULL,
|
||||||
|
channel TEXT NOT NULL,
|
||||||
|
external_id TEXT,
|
||||||
|
from_addr TEXT,
|
||||||
|
to_addr TEXT,
|
||||||
|
cc TEXT,
|
||||||
|
bcc TEXT,
|
||||||
|
subject TEXT,
|
||||||
|
body_text TEXT,
|
||||||
|
body_html TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'received',
|
||||||
|
error_message TEXT,
|
||||||
|
received_at TEXT,
|
||||||
|
sent_at TEXT,
|
||||||
|
FOREIGN KEY (account_id) REFERENCES email_accounts(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_direction ON messages(direction);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_account ON messages(account_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_external ON messages(external_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS attachments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
message_id INTEGER NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
content_type TEXT,
|
||||||
|
size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
path TEXT,
|
||||||
|
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rules (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
priority INTEGER NOT NULL DEFAULT 100,
|
||||||
|
match_from_contains TEXT,
|
||||||
|
match_to_contains TEXT,
|
||||||
|
match_subject_contains TEXT,
|
||||||
|
match_body_contains TEXT,
|
||||||
|
match_has_attachment INTEGER,
|
||||||
|
match_ai_category TEXT,
|
||||||
|
target_channel TEXT NOT NULL,
|
||||||
|
target_identifier TEXT NOT NULL,
|
||||||
|
include_body INTEGER NOT NULL DEFAULT 1,
|
||||||
|
include_attachments INTEGER NOT NULL DEFAULT 0,
|
||||||
|
template TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS contacts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
emails TEXT NOT NULL DEFAULT '[]',
|
||||||
|
telegram_chat_id TEXT,
|
||||||
|
whatsapp_number TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_providers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
api_key TEXT,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
base_url TEXT,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_default INTEGER NOT NULL DEFAULT 0,
|
||||||
|
auto_classify INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_analysis (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
message_id INTEGER NOT NULL,
|
||||||
|
summary TEXT,
|
||||||
|
category TEXT,
|
||||||
|
labels TEXT,
|
||||||
|
extracted TEXT,
|
||||||
|
suggested_reply TEXT,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ai_analysis_message ON ai_analysis(message_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ai_analysis_category ON ai_analysis(category);
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Idempotent column additions for OAuth
|
||||||
|
addColumn(d, 'email_accounts', 'auth_type', "TEXT NOT NULL DEFAULT 'password'")
|
||||||
|
addColumn(d, 'email_accounts', 'oauth_provider', 'TEXT')
|
||||||
|
addColumn(d, 'email_accounts', 'oauth_refresh_token', 'TEXT')
|
||||||
|
addColumn(d, 'email_accounts', 'oauth_access_token', 'TEXT')
|
||||||
|
addColumn(d, 'email_accounts', 'oauth_expires_at', 'INTEGER')
|
||||||
|
// Track last processed IMAP UID per mailbox
|
||||||
|
addColumn(d, 'email_accounts', 'last_imap_uid', 'INTEGER')
|
||||||
|
}
|
||||||
|
|
||||||
|
function addColumn(d: Database.Database, table: string, column: string, definition: string): void {
|
||||||
|
const info = d.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]
|
||||||
|
if (info.some((c) => c.name === column)) return
|
||||||
|
d.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeDb(): void {
|
||||||
|
if (db) {
|
||||||
|
db.close()
|
||||||
|
db = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,742 @@
|
|||||||
|
import { getDb } from './database'
|
||||||
|
import type {
|
||||||
|
EmailAccount,
|
||||||
|
TelegramBot,
|
||||||
|
TelegramUserAccount,
|
||||||
|
WhatsappSession,
|
||||||
|
Message,
|
||||||
|
Attachment,
|
||||||
|
ForwardingRule,
|
||||||
|
Contact,
|
||||||
|
AiProviderConfig,
|
||||||
|
AiAnalysis
|
||||||
|
} from '@shared/types'
|
||||||
|
|
||||||
|
const b = (v: unknown): number => (v ? 1 : 0)
|
||||||
|
const fromB = (v: unknown): boolean => Number(v) === 1
|
||||||
|
|
||||||
|
// ---------------- Email Accounts ----------------
|
||||||
|
export const accountsRepo = {
|
||||||
|
list(): EmailAccount[] {
|
||||||
|
const rows = getDb().prepare('SELECT * FROM email_accounts ORDER BY id ASC').all() as any[]
|
||||||
|
return rows.map(mapAccount)
|
||||||
|
},
|
||||||
|
get(id: number): EmailAccount | undefined {
|
||||||
|
const row = getDb().prepare('SELECT * FROM email_accounts WHERE id = ?').get(id) as any
|
||||||
|
return row ? mapAccount(row) : undefined
|
||||||
|
},
|
||||||
|
create(a: Omit<EmailAccount, 'id' | 'createdAt'>): EmailAccount {
|
||||||
|
const stmt = getDb().prepare(`
|
||||||
|
INSERT INTO email_accounts
|
||||||
|
(name, imap_host, imap_port, imap_secure, imap_user, imap_pass,
|
||||||
|
smtp_host, smtp_port, smtp_secure, smtp_user, smtp_pass,
|
||||||
|
smtp_from_name, smtp_from_address, enabled, poll_interval,
|
||||||
|
auth_type, oauth_provider, oauth_refresh_token, oauth_access_token, oauth_expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`)
|
||||||
|
const info = stmt.run(
|
||||||
|
a.name,
|
||||||
|
a.imap.host,
|
||||||
|
a.imap.port,
|
||||||
|
b(a.imap.secure),
|
||||||
|
a.imap.user,
|
||||||
|
a.imap.pass,
|
||||||
|
a.smtp.host,
|
||||||
|
a.smtp.port,
|
||||||
|
b(a.smtp.secure),
|
||||||
|
a.smtp.user,
|
||||||
|
a.smtp.pass,
|
||||||
|
a.smtp.fromName ?? null,
|
||||||
|
a.smtp.fromAddress,
|
||||||
|
b(a.enabled),
|
||||||
|
a.pollInterval,
|
||||||
|
a.authType ?? 'password',
|
||||||
|
a.oauth?.provider ?? null,
|
||||||
|
a.oauth?.refreshToken ?? null,
|
||||||
|
a.oauth?.accessToken ?? null,
|
||||||
|
a.oauth?.expiresAt ?? null
|
||||||
|
)
|
||||||
|
return this.get(Number(info.lastInsertRowid))!
|
||||||
|
},
|
||||||
|
update(id: number, a: Omit<EmailAccount, 'id' | 'createdAt'>): EmailAccount {
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
`UPDATE email_accounts SET
|
||||||
|
name=?, imap_host=?, imap_port=?, imap_secure=?, imap_user=?, imap_pass=?,
|
||||||
|
smtp_host=?, smtp_port=?, smtp_secure=?, smtp_user=?, smtp_pass=?,
|
||||||
|
smtp_from_name=?, smtp_from_address=?, enabled=?, poll_interval=?,
|
||||||
|
auth_type=?, oauth_provider=?, oauth_refresh_token=?, oauth_access_token=?, oauth_expires_at=?
|
||||||
|
WHERE id = ?`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
a.name,
|
||||||
|
a.imap.host,
|
||||||
|
a.imap.port,
|
||||||
|
b(a.imap.secure),
|
||||||
|
a.imap.user,
|
||||||
|
a.imap.pass,
|
||||||
|
a.smtp.host,
|
||||||
|
a.smtp.port,
|
||||||
|
b(a.smtp.secure),
|
||||||
|
a.smtp.user,
|
||||||
|
a.smtp.pass,
|
||||||
|
a.smtp.fromName ?? null,
|
||||||
|
a.smtp.fromAddress,
|
||||||
|
b(a.enabled),
|
||||||
|
a.pollInterval,
|
||||||
|
a.authType ?? 'password',
|
||||||
|
a.oauth?.provider ?? null,
|
||||||
|
a.oauth?.refreshToken ?? null,
|
||||||
|
a.oauth?.accessToken ?? null,
|
||||||
|
a.oauth?.expiresAt ?? null,
|
||||||
|
id
|
||||||
|
)
|
||||||
|
return this.get(id)!
|
||||||
|
},
|
||||||
|
updateTokens(id: number, tokens: { accessToken: string; expiresAt: number; refreshToken?: string }): void {
|
||||||
|
if (tokens.refreshToken) {
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
'UPDATE email_accounts SET oauth_access_token=?, oauth_expires_at=?, oauth_refresh_token=? WHERE id=?'
|
||||||
|
)
|
||||||
|
.run(tokens.accessToken, tokens.expiresAt, tokens.refreshToken, id)
|
||||||
|
} else {
|
||||||
|
getDb()
|
||||||
|
.prepare('UPDATE email_accounts SET oauth_access_token=?, oauth_expires_at=? WHERE id=?')
|
||||||
|
.run(tokens.accessToken, tokens.expiresAt, id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getLastUid(id: number): number | null {
|
||||||
|
const row = getDb()
|
||||||
|
.prepare('SELECT last_imap_uid FROM email_accounts WHERE id = ?')
|
||||||
|
.get(id) as { last_imap_uid: number | null } | undefined
|
||||||
|
return row?.last_imap_uid ?? null
|
||||||
|
},
|
||||||
|
setLastUid(id: number, uid: number): void {
|
||||||
|
getDb().prepare('UPDATE email_accounts SET last_imap_uid = ? WHERE id = ?').run(uid, id)
|
||||||
|
},
|
||||||
|
delete(id: number): void {
|
||||||
|
getDb().prepare('DELETE FROM email_accounts WHERE id = ?').run(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapAccount(r: any): EmailAccount {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
authType: (r.auth_type as EmailAccount['authType']) ?? 'password',
|
||||||
|
imap: {
|
||||||
|
host: r.imap_host,
|
||||||
|
port: r.imap_port,
|
||||||
|
secure: fromB(r.imap_secure),
|
||||||
|
user: r.imap_user,
|
||||||
|
pass: r.imap_pass
|
||||||
|
},
|
||||||
|
smtp: {
|
||||||
|
host: r.smtp_host,
|
||||||
|
port: r.smtp_port,
|
||||||
|
secure: fromB(r.smtp_secure),
|
||||||
|
user: r.smtp_user,
|
||||||
|
pass: r.smtp_pass,
|
||||||
|
fromName: r.smtp_from_name ?? undefined,
|
||||||
|
fromAddress: r.smtp_from_address
|
||||||
|
},
|
||||||
|
oauth: r.oauth_refresh_token
|
||||||
|
? {
|
||||||
|
provider: (r.oauth_provider ?? 'google') as 'google',
|
||||||
|
refreshToken: r.oauth_refresh_token,
|
||||||
|
accessToken: r.oauth_access_token ?? undefined,
|
||||||
|
expiresAt: r.oauth_expires_at ?? undefined
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
enabled: fromB(r.enabled),
|
||||||
|
pollInterval: r.poll_interval,
|
||||||
|
createdAt: r.created_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Telegram ----------------
|
||||||
|
export const telegramRepo = {
|
||||||
|
list(): TelegramBot[] {
|
||||||
|
const rows = getDb().prepare('SELECT * FROM telegram_bots ORDER BY id ASC').all() as any[]
|
||||||
|
return rows.map(mapTelegram)
|
||||||
|
},
|
||||||
|
get(id: number): TelegramBot | undefined {
|
||||||
|
const row = getDb().prepare('SELECT * FROM telegram_bots WHERE id = ?').get(id) as any
|
||||||
|
return row ? mapTelegram(row) : undefined
|
||||||
|
},
|
||||||
|
create(t: Omit<TelegramBot, 'id' | 'createdAt'>): TelegramBot {
|
||||||
|
const info = getDb()
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO telegram_bots (name, token, default_chat_id, enabled) VALUES (?, ?, ?, ?)'
|
||||||
|
)
|
||||||
|
.run(t.name, t.token, t.defaultChatId ?? null, b(t.enabled))
|
||||||
|
return this.get(Number(info.lastInsertRowid))!
|
||||||
|
},
|
||||||
|
update(id: number, t: Omit<TelegramBot, 'id' | 'createdAt'>): TelegramBot {
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
'UPDATE telegram_bots SET name=?, token=?, default_chat_id=?, enabled=? WHERE id=?'
|
||||||
|
)
|
||||||
|
.run(t.name, t.token, t.defaultChatId ?? null, b(t.enabled), id)
|
||||||
|
return this.get(id)!
|
||||||
|
},
|
||||||
|
delete(id: number): void {
|
||||||
|
getDb().prepare('DELETE FROM telegram_bots WHERE id = ?').run(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapTelegram(r: any): TelegramBot {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
token: r.token,
|
||||||
|
defaultChatId: r.default_chat_id ?? undefined,
|
||||||
|
enabled: fromB(r.enabled),
|
||||||
|
createdAt: r.created_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Telegram User (MTProto) ----------------
|
||||||
|
export const telegramUserRepo = {
|
||||||
|
list(): TelegramUserAccount[] {
|
||||||
|
const rows = getDb()
|
||||||
|
.prepare('SELECT * FROM telegram_user_accounts ORDER BY id ASC')
|
||||||
|
.all() as any[]
|
||||||
|
return rows.map(mapTelegramUser)
|
||||||
|
},
|
||||||
|
get(id: number): TelegramUserAccount | undefined {
|
||||||
|
const row = getDb()
|
||||||
|
.prepare('SELECT * FROM telegram_user_accounts WHERE id = ?')
|
||||||
|
.get(id) as any
|
||||||
|
return row ? mapTelegramUser(row) : undefined
|
||||||
|
},
|
||||||
|
create(t: Omit<TelegramUserAccount, 'id' | 'createdAt'>): TelegramUserAccount {
|
||||||
|
const info = getDb()
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO telegram_user_accounts (name, phone, session_string, status, enabled) VALUES (?, ?, ?, ?, ?)'
|
||||||
|
)
|
||||||
|
.run(t.name, t.phone, t.sessionString ?? null, t.status, b(t.enabled))
|
||||||
|
return this.get(Number(info.lastInsertRowid))!
|
||||||
|
},
|
||||||
|
update(
|
||||||
|
id: number,
|
||||||
|
t: Partial<Omit<TelegramUserAccount, 'id' | 'createdAt'>>
|
||||||
|
): TelegramUserAccount {
|
||||||
|
const current = this.get(id)!
|
||||||
|
const merged = { ...current, ...t }
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
'UPDATE telegram_user_accounts SET name=?, phone=?, session_string=?, status=?, enabled=? WHERE id=?'
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
merged.name,
|
||||||
|
merged.phone,
|
||||||
|
merged.sessionString ?? null,
|
||||||
|
merged.status,
|
||||||
|
b(merged.enabled),
|
||||||
|
id
|
||||||
|
)
|
||||||
|
return this.get(id)!
|
||||||
|
},
|
||||||
|
delete(id: number): void {
|
||||||
|
getDb().prepare('DELETE FROM telegram_user_accounts WHERE id = ?').run(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapTelegramUser(r: any): TelegramUserAccount {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
phone: r.phone,
|
||||||
|
sessionString: r.session_string ?? undefined,
|
||||||
|
status: r.status as TelegramUserAccount['status'],
|
||||||
|
enabled: fromB(r.enabled),
|
||||||
|
createdAt: r.created_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- WhatsApp ----------------
|
||||||
|
export const whatsappRepo = {
|
||||||
|
list(): WhatsappSession[] {
|
||||||
|
const rows = getDb().prepare('SELECT * FROM whatsapp_sessions ORDER BY id ASC').all() as any[]
|
||||||
|
return rows.map(mapWhatsapp)
|
||||||
|
},
|
||||||
|
get(id: number): WhatsappSession | undefined {
|
||||||
|
const row = getDb().prepare('SELECT * FROM whatsapp_sessions WHERE id = ?').get(id) as any
|
||||||
|
return row ? mapWhatsapp(row) : undefined
|
||||||
|
},
|
||||||
|
create(w: Omit<WhatsappSession, 'id' | 'createdAt'>): WhatsappSession {
|
||||||
|
const info = getDb()
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO whatsapp_sessions (name, status, phone, default_recipient, enabled) VALUES (?, ?, ?, ?, ?)'
|
||||||
|
)
|
||||||
|
.run(w.name, w.status, w.phone ?? null, w.defaultRecipient ?? null, b(w.enabled))
|
||||||
|
return this.get(Number(info.lastInsertRowid))!
|
||||||
|
},
|
||||||
|
update(id: number, w: Partial<Omit<WhatsappSession, 'id' | 'createdAt'>>): WhatsappSession {
|
||||||
|
const current = this.get(id)!
|
||||||
|
const merged = { ...current, ...w }
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
'UPDATE whatsapp_sessions SET name=?, status=?, phone=?, default_recipient=?, enabled=? WHERE id=?'
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
merged.name,
|
||||||
|
merged.status,
|
||||||
|
merged.phone ?? null,
|
||||||
|
merged.defaultRecipient ?? null,
|
||||||
|
b(merged.enabled),
|
||||||
|
id
|
||||||
|
)
|
||||||
|
return this.get(id)!
|
||||||
|
},
|
||||||
|
delete(id: number): void {
|
||||||
|
getDb().prepare('DELETE FROM whatsapp_sessions WHERE id = ?').run(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapWhatsapp(r: any): WhatsappSession {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
status: r.status,
|
||||||
|
phone: r.phone ?? undefined,
|
||||||
|
defaultRecipient: r.default_recipient ?? undefined,
|
||||||
|
enabled: fromB(r.enabled),
|
||||||
|
createdAt: r.created_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Messages ----------------
|
||||||
|
export const messagesRepo = {
|
||||||
|
list(filter?: { direction?: string; channel?: string; limit?: number }): Message[] {
|
||||||
|
const parts: string[] = []
|
||||||
|
const args: any[] = []
|
||||||
|
if (filter?.direction) {
|
||||||
|
parts.push('direction = ?')
|
||||||
|
args.push(filter.direction)
|
||||||
|
}
|
||||||
|
if (filter?.channel) {
|
||||||
|
parts.push('channel = ?')
|
||||||
|
args.push(filter.channel)
|
||||||
|
}
|
||||||
|
const where = parts.length ? `WHERE ${parts.join(' AND ')}` : ''
|
||||||
|
const limit = filter?.limit ?? 200
|
||||||
|
const rows = getDb()
|
||||||
|
.prepare(`SELECT * FROM messages ${where} ORDER BY id DESC LIMIT ?`)
|
||||||
|
.all(...args, limit) as any[]
|
||||||
|
const msgs = rows.map(mapMessage)
|
||||||
|
for (const m of msgs) {
|
||||||
|
m.attachments = attachmentsRepo.listByMessage(m.id)
|
||||||
|
m.ai = aiRepo.getByMessage(m.id)
|
||||||
|
}
|
||||||
|
return msgs
|
||||||
|
},
|
||||||
|
get(id: number): Message | undefined {
|
||||||
|
const row = getDb().prepare('SELECT * FROM messages WHERE id = ?').get(id) as any
|
||||||
|
if (!row) return undefined
|
||||||
|
const m = mapMessage(row)
|
||||||
|
m.attachments = attachmentsRepo.listByMessage(m.id)
|
||||||
|
m.ai = aiRepo.getByMessage(m.id)
|
||||||
|
return m
|
||||||
|
},
|
||||||
|
findByExternalId(channel: string, externalId: string): Message | undefined {
|
||||||
|
const row = getDb()
|
||||||
|
.prepare('SELECT * FROM messages WHERE channel = ? AND external_id = ? LIMIT 1')
|
||||||
|
.get(channel, externalId) as any
|
||||||
|
return row ? mapMessage(row) : undefined
|
||||||
|
},
|
||||||
|
create(m: Omit<Message, 'id'>): Message {
|
||||||
|
const info = getDb()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO messages
|
||||||
|
(account_id, direction, channel, external_id, from_addr, to_addr, cc, bcc,
|
||||||
|
subject, body_text, body_html, status, error_message, received_at, sent_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
m.accountId ?? null,
|
||||||
|
m.direction,
|
||||||
|
m.channel,
|
||||||
|
m.externalId ?? null,
|
||||||
|
m.fromAddr ?? null,
|
||||||
|
m.toAddr ?? null,
|
||||||
|
m.cc ?? null,
|
||||||
|
m.bcc ?? null,
|
||||||
|
m.subject ?? null,
|
||||||
|
m.bodyText ?? null,
|
||||||
|
m.bodyHtml ?? null,
|
||||||
|
m.status,
|
||||||
|
m.errorMessage ?? null,
|
||||||
|
m.receivedAt ?? null,
|
||||||
|
m.sentAt ?? null
|
||||||
|
)
|
||||||
|
const id = Number(info.lastInsertRowid)
|
||||||
|
if (m.attachments) {
|
||||||
|
for (const att of m.attachments) attachmentsRepo.create(id, att)
|
||||||
|
}
|
||||||
|
return this.get(id)!
|
||||||
|
},
|
||||||
|
updateStatus(id: number, status: string, errorMessage?: string): void {
|
||||||
|
getDb()
|
||||||
|
.prepare('UPDATE messages SET status = ?, error_message = ? WHERE id = ?')
|
||||||
|
.run(status, errorMessage ?? null, id)
|
||||||
|
},
|
||||||
|
delete(id: number): void {
|
||||||
|
getDb().prepare('DELETE FROM messages WHERE id = ?').run(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapMessage(r: any): Message {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
accountId: r.account_id ?? undefined,
|
||||||
|
direction: r.direction,
|
||||||
|
channel: r.channel,
|
||||||
|
externalId: r.external_id ?? undefined,
|
||||||
|
fromAddr: r.from_addr ?? undefined,
|
||||||
|
toAddr: r.to_addr ?? undefined,
|
||||||
|
cc: r.cc ?? undefined,
|
||||||
|
bcc: r.bcc ?? undefined,
|
||||||
|
subject: r.subject ?? undefined,
|
||||||
|
bodyText: r.body_text ?? undefined,
|
||||||
|
bodyHtml: r.body_html ?? undefined,
|
||||||
|
status: r.status,
|
||||||
|
errorMessage: r.error_message ?? undefined,
|
||||||
|
receivedAt: r.received_at ?? undefined,
|
||||||
|
sentAt: r.sent_at ?? undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Attachments ----------------
|
||||||
|
export const attachmentsRepo = {
|
||||||
|
listByMessage(messageId: number): Attachment[] {
|
||||||
|
const rows = getDb()
|
||||||
|
.prepare('SELECT * FROM attachments WHERE message_id = ?')
|
||||||
|
.all(messageId) as any[]
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
messageId: r.message_id,
|
||||||
|
filename: r.filename,
|
||||||
|
contentType: r.content_type,
|
||||||
|
size: r.size,
|
||||||
|
path: r.path ?? undefined
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
create(messageId: number, a: Attachment): void {
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO attachments (message_id, filename, content_type, size, path) VALUES (?, ?, ?, ?, ?)'
|
||||||
|
)
|
||||||
|
.run(messageId, a.filename, a.contentType, a.size, a.path ?? null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Rules ----------------
|
||||||
|
export const rulesRepo = {
|
||||||
|
list(): ForwardingRule[] {
|
||||||
|
const rows = getDb()
|
||||||
|
.prepare('SELECT * FROM rules ORDER BY priority ASC, id ASC')
|
||||||
|
.all() as any[]
|
||||||
|
return rows.map(mapRule)
|
||||||
|
},
|
||||||
|
listEnabled(): ForwardingRule[] {
|
||||||
|
const rows = getDb()
|
||||||
|
.prepare('SELECT * FROM rules WHERE enabled = 1 ORDER BY priority ASC, id ASC')
|
||||||
|
.all() as any[]
|
||||||
|
return rows.map(mapRule)
|
||||||
|
},
|
||||||
|
get(id: number): ForwardingRule | undefined {
|
||||||
|
const row = getDb().prepare('SELECT * FROM rules WHERE id = ?').get(id) as any
|
||||||
|
return row ? mapRule(row) : undefined
|
||||||
|
},
|
||||||
|
create(r: Omit<ForwardingRule, 'id' | 'createdAt'>): ForwardingRule {
|
||||||
|
const info = getDb()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO rules
|
||||||
|
(name, enabled, priority, match_from_contains, match_to_contains, match_subject_contains,
|
||||||
|
match_body_contains, match_has_attachment, match_ai_category,
|
||||||
|
target_channel, target_identifier, include_body, include_attachments, template)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
r.name,
|
||||||
|
b(r.enabled),
|
||||||
|
r.priority,
|
||||||
|
r.matchFromContains ?? null,
|
||||||
|
r.matchToContains ?? null,
|
||||||
|
r.matchSubjectContains ?? null,
|
||||||
|
r.matchBodyContains ?? null,
|
||||||
|
r.matchHasAttachment == null ? null : b(r.matchHasAttachment),
|
||||||
|
r.matchAiCategory ?? null,
|
||||||
|
r.targetChannel,
|
||||||
|
r.targetIdentifier,
|
||||||
|
b(r.includeBody),
|
||||||
|
b(r.includeAttachments),
|
||||||
|
r.template ?? null
|
||||||
|
)
|
||||||
|
return this.get(Number(info.lastInsertRowid))!
|
||||||
|
},
|
||||||
|
update(id: number, r: Omit<ForwardingRule, 'id' | 'createdAt'>): ForwardingRule {
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
`UPDATE rules SET
|
||||||
|
name=?, enabled=?, priority=?, match_from_contains=?, match_to_contains=?,
|
||||||
|
match_subject_contains=?, match_body_contains=?, match_has_attachment=?, match_ai_category=?,
|
||||||
|
target_channel=?, target_identifier=?, include_body=?, include_attachments=?, template=?
|
||||||
|
WHERE id=?`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
r.name,
|
||||||
|
b(r.enabled),
|
||||||
|
r.priority,
|
||||||
|
r.matchFromContains ?? null,
|
||||||
|
r.matchToContains ?? null,
|
||||||
|
r.matchSubjectContains ?? null,
|
||||||
|
r.matchBodyContains ?? null,
|
||||||
|
r.matchHasAttachment == null ? null : b(r.matchHasAttachment),
|
||||||
|
r.matchAiCategory ?? null,
|
||||||
|
r.targetChannel,
|
||||||
|
r.targetIdentifier,
|
||||||
|
b(r.includeBody),
|
||||||
|
b(r.includeAttachments),
|
||||||
|
r.template ?? null,
|
||||||
|
id
|
||||||
|
)
|
||||||
|
return this.get(id)!
|
||||||
|
},
|
||||||
|
delete(id: number): void {
|
||||||
|
getDb().prepare('DELETE FROM rules WHERE id = ?').run(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRule(r: any): ForwardingRule {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
enabled: fromB(r.enabled),
|
||||||
|
priority: r.priority,
|
||||||
|
matchFromContains: r.match_from_contains ?? undefined,
|
||||||
|
matchToContains: r.match_to_contains ?? undefined,
|
||||||
|
matchSubjectContains: r.match_subject_contains ?? undefined,
|
||||||
|
matchBodyContains: r.match_body_contains ?? undefined,
|
||||||
|
matchHasAttachment:
|
||||||
|
r.match_has_attachment == null ? undefined : fromB(r.match_has_attachment),
|
||||||
|
matchAiCategory: r.match_ai_category ?? undefined,
|
||||||
|
targetChannel: r.target_channel,
|
||||||
|
targetIdentifier: r.target_identifier,
|
||||||
|
includeBody: fromB(r.include_body),
|
||||||
|
includeAttachments: fromB(r.include_attachments),
|
||||||
|
template: r.template ?? undefined,
|
||||||
|
createdAt: r.created_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Contacts ----------------
|
||||||
|
export const contactsRepo = {
|
||||||
|
list(): Contact[] {
|
||||||
|
const rows = getDb().prepare('SELECT * FROM contacts ORDER BY display_name ASC').all() as any[]
|
||||||
|
return rows.map(mapContact)
|
||||||
|
},
|
||||||
|
get(id: number): Contact | undefined {
|
||||||
|
const row = getDb().prepare('SELECT * FROM contacts WHERE id = ?').get(id) as any
|
||||||
|
return row ? mapContact(row) : undefined
|
||||||
|
},
|
||||||
|
create(c: Omit<Contact, 'id' | 'createdAt'>): Contact {
|
||||||
|
const info = getDb()
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO contacts (display_name, emails, telegram_chat_id, whatsapp_number, notes) VALUES (?, ?, ?, ?, ?)'
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
c.displayName,
|
||||||
|
JSON.stringify(c.emails ?? []),
|
||||||
|
c.telegramChatId ?? null,
|
||||||
|
c.whatsappNumber ?? null,
|
||||||
|
c.notes ?? null
|
||||||
|
)
|
||||||
|
return this.get(Number(info.lastInsertRowid))!
|
||||||
|
},
|
||||||
|
update(id: number, c: Omit<Contact, 'id' | 'createdAt'>): Contact {
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
'UPDATE contacts SET display_name=?, emails=?, telegram_chat_id=?, whatsapp_number=?, notes=? WHERE id=?'
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
c.displayName,
|
||||||
|
JSON.stringify(c.emails ?? []),
|
||||||
|
c.telegramChatId ?? null,
|
||||||
|
c.whatsappNumber ?? null,
|
||||||
|
c.notes ?? null,
|
||||||
|
id
|
||||||
|
)
|
||||||
|
return this.get(id)!
|
||||||
|
},
|
||||||
|
delete(id: number): void {
|
||||||
|
getDb().prepare('DELETE FROM contacts WHERE id = ?').run(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapContact(r: any): Contact {
|
||||||
|
let emails: string[] = []
|
||||||
|
try {
|
||||||
|
emails = JSON.parse(r.emails || '[]')
|
||||||
|
} catch {
|
||||||
|
emails = []
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
displayName: r.display_name,
|
||||||
|
emails,
|
||||||
|
telegramChatId: r.telegram_chat_id ?? undefined,
|
||||||
|
whatsappNumber: r.whatsapp_number ?? undefined,
|
||||||
|
notes: r.notes ?? undefined,
|
||||||
|
createdAt: r.created_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- AI Providers ----------------
|
||||||
|
export const aiProvidersRepo = {
|
||||||
|
list(): AiProviderConfig[] {
|
||||||
|
const rows = getDb().prepare('SELECT * FROM ai_providers ORDER BY id ASC').all() as any[]
|
||||||
|
return rows.map(mapAiProvider)
|
||||||
|
},
|
||||||
|
get(id: number): AiProviderConfig | undefined {
|
||||||
|
const row = getDb().prepare('SELECT * FROM ai_providers WHERE id = ?').get(id) as any
|
||||||
|
return row ? mapAiProvider(row) : undefined
|
||||||
|
},
|
||||||
|
getDefault(): AiProviderConfig | undefined {
|
||||||
|
const row = getDb()
|
||||||
|
.prepare('SELECT * FROM ai_providers WHERE is_default = 1 AND enabled = 1 LIMIT 1')
|
||||||
|
.get() as any
|
||||||
|
return row ? mapAiProvider(row) : undefined
|
||||||
|
},
|
||||||
|
getAutoClassify(): AiProviderConfig | undefined {
|
||||||
|
const row = getDb()
|
||||||
|
.prepare('SELECT * FROM ai_providers WHERE auto_classify = 1 AND enabled = 1 LIMIT 1')
|
||||||
|
.get() as any
|
||||||
|
return row ? mapAiProvider(row) : undefined
|
||||||
|
},
|
||||||
|
create(p: Omit<AiProviderConfig, 'id'>): AiProviderConfig {
|
||||||
|
if (p.isDefault) {
|
||||||
|
getDb().prepare('UPDATE ai_providers SET is_default = 0').run()
|
||||||
|
}
|
||||||
|
const info = getDb()
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO ai_providers (name, provider, api_key, model, base_url, enabled, is_default, auto_classify) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
p.name,
|
||||||
|
p.provider,
|
||||||
|
p.apiKey ?? null,
|
||||||
|
p.model,
|
||||||
|
p.baseUrl ?? null,
|
||||||
|
b(p.enabled),
|
||||||
|
b(p.isDefault),
|
||||||
|
b(p.autoClassify)
|
||||||
|
)
|
||||||
|
return this.get(Number(info.lastInsertRowid))!
|
||||||
|
},
|
||||||
|
update(id: number, p: Omit<AiProviderConfig, 'id'>): AiProviderConfig {
|
||||||
|
if (p.isDefault) {
|
||||||
|
getDb().prepare('UPDATE ai_providers SET is_default = 0 WHERE id != ?').run(id)
|
||||||
|
}
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
'UPDATE ai_providers SET name=?, provider=?, api_key=?, model=?, base_url=?, enabled=?, is_default=?, auto_classify=? WHERE id=?'
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
p.name,
|
||||||
|
p.provider,
|
||||||
|
p.apiKey ?? null,
|
||||||
|
p.model,
|
||||||
|
p.baseUrl ?? null,
|
||||||
|
b(p.enabled),
|
||||||
|
b(p.isDefault),
|
||||||
|
b(p.autoClassify),
|
||||||
|
id
|
||||||
|
)
|
||||||
|
return this.get(id)!
|
||||||
|
},
|
||||||
|
delete(id: number): void {
|
||||||
|
getDb().prepare('DELETE FROM ai_providers WHERE id = ?').run(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapAiProvider(r: any): AiProviderConfig {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
provider: r.provider,
|
||||||
|
apiKey: r.api_key ?? undefined,
|
||||||
|
model: r.model,
|
||||||
|
baseUrl: r.base_url ?? undefined,
|
||||||
|
enabled: fromB(r.enabled),
|
||||||
|
isDefault: fromB(r.is_default),
|
||||||
|
autoClassify: fromB(r.auto_classify)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- AI Analysis ----------------
|
||||||
|
export const aiRepo = {
|
||||||
|
getByMessage(messageId: number): AiAnalysis | undefined {
|
||||||
|
const row = getDb()
|
||||||
|
.prepare('SELECT * FROM ai_analysis WHERE message_id = ? ORDER BY id DESC LIMIT 1')
|
||||||
|
.get(messageId) as any
|
||||||
|
if (!row) return undefined
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
messageId: row.message_id,
|
||||||
|
summary: row.summary ?? undefined,
|
||||||
|
category: row.category ?? undefined,
|
||||||
|
labels: row.labels ? safeParse<string[]>(row.labels, []) : undefined,
|
||||||
|
extracted: row.extracted ? safeParse<Record<string, unknown>>(row.extracted, {}) : undefined,
|
||||||
|
suggestedReply: row.suggested_reply ?? undefined,
|
||||||
|
provider: row.provider,
|
||||||
|
model: row.model,
|
||||||
|
createdAt: row.created_at
|
||||||
|
}
|
||||||
|
},
|
||||||
|
upsert(messageId: number, partial: Partial<AiAnalysis> & { provider: string; model: string }): void {
|
||||||
|
const existing = this.getByMessage(messageId)
|
||||||
|
if (existing) {
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
'UPDATE ai_analysis SET summary=COALESCE(?, summary), category=COALESCE(?, category), labels=COALESCE(?, labels), extracted=COALESCE(?, extracted), suggested_reply=COALESCE(?, suggested_reply), provider=?, model=? WHERE id=?'
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
partial.summary ?? null,
|
||||||
|
partial.category ?? null,
|
||||||
|
partial.labels ? JSON.stringify(partial.labels) : null,
|
||||||
|
partial.extracted ? JSON.stringify(partial.extracted) : null,
|
||||||
|
partial.suggestedReply ?? null,
|
||||||
|
partial.provider,
|
||||||
|
partial.model,
|
||||||
|
existing.id
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO ai_analysis (message_id, summary, category, labels, extracted, suggested_reply, provider, model) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
messageId,
|
||||||
|
partial.summary ?? null,
|
||||||
|
partial.category ?? null,
|
||||||
|
partial.labels ? JSON.stringify(partial.labels) : null,
|
||||||
|
partial.extracted ? JSON.stringify(partial.extracted) : null,
|
||||||
|
partial.suggestedReply ?? null,
|
||||||
|
partial.provider,
|
||||||
|
partial.model
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeParse<T>(s: string, fallback: T): T {
|
||||||
|
try {
|
||||||
|
return JSON.parse(s) as T
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { getDb } from './database'
|
||||||
|
|
||||||
|
export const settingsRepo = {
|
||||||
|
get(key: string): string | undefined {
|
||||||
|
const row = getDb().prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as
|
||||||
|
| { value: string }
|
||||||
|
| undefined
|
||||||
|
return row?.value
|
||||||
|
},
|
||||||
|
set(key: string, value: string | null): void {
|
||||||
|
if (value == null || value === '') {
|
||||||
|
getDb().prepare('DELETE FROM app_settings WHERE key = ?').run(key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||||
|
)
|
||||||
|
.run(key, value)
|
||||||
|
},
|
||||||
|
all(): Record<string, string> {
|
||||||
|
const rows = getDb().prepare('SELECT key, value FROM app_settings').all() as {
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
}[]
|
||||||
|
return Object.fromEntries(rows.map((r) => [r.key, r.value]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SETTING_KEYS = {
|
||||||
|
gmailClientId: 'gmail.oauth.clientId',
|
||||||
|
gmailClientSecret: 'gmail.oauth.clientSecret',
|
||||||
|
telegramApiId: 'telegram.mtproto.apiId',
|
||||||
|
telegramApiHash: 'telegram.mtproto.apiHash'
|
||||||
|
} as const
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { app, BrowserWindow, shell } from 'electron'
|
||||||
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { getDb, closeDb } from './db/database'
|
||||||
|
import { accountsRepo, aiProvidersRepo } from './db/repositories'
|
||||||
|
import { registerIpcHandlers, broadcast } from './ipc'
|
||||||
|
import { imapService } from './services/imap'
|
||||||
|
import { whatsappService } from './services/whatsapp'
|
||||||
|
import { forwarderService } from './services/forwarder'
|
||||||
|
import { aiService } from './services/ai'
|
||||||
|
import { aiRepo } from './db/repositories'
|
||||||
|
import { IPC } from '@shared/ipc-channels'
|
||||||
|
|
||||||
|
let mainWindow: BrowserWindow | null = null
|
||||||
|
|
||||||
|
function createWindow(): void {
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1280,
|
||||||
|
height: 820,
|
||||||
|
minWidth: 960,
|
||||||
|
minHeight: 600,
|
||||||
|
show: false,
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
webPreferences: {
|
||||||
|
preload: join(__dirname, '../preload/index.js'),
|
||||||
|
sandbox: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow.on('ready-to-show', () => mainWindow?.show())
|
||||||
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
void shell.openExternal(url)
|
||||||
|
return { action: 'deny' }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||||
|
void mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||||
|
} else {
|
||||||
|
void mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireServices(): void {
|
||||||
|
imapService.on('message', async (message) => {
|
||||||
|
broadcast(IPC.eventNewMessage, message)
|
||||||
|
const autoProvider = aiProvidersRepo.getAutoClassify()
|
||||||
|
if (autoProvider) {
|
||||||
|
try {
|
||||||
|
await aiService.classify(message, autoProvider)
|
||||||
|
message.ai = aiRepo.getByMessage(message.id)
|
||||||
|
} catch (err) {
|
||||||
|
broadcast(IPC.eventLog, {
|
||||||
|
level: 'warn',
|
||||||
|
source: 'ai:autoClassify',
|
||||||
|
message: (err as Error).message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await forwarderService.processIncoming(message)
|
||||||
|
})
|
||||||
|
|
||||||
|
imapService.on('log', (entry) => broadcast(IPC.eventLog, entry))
|
||||||
|
forwarderService.on('log', (entry) => broadcast(IPC.eventLog, entry))
|
||||||
|
whatsappService.on('qr', (payload) => broadcast(IPC.eventWhatsappQr, payload))
|
||||||
|
whatsappService.on('status', (payload) => broadcast(IPC.eventWhatsappStatus, payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEnabledAccounts(): void {
|
||||||
|
const accounts = accountsRepo.list()
|
||||||
|
for (const a of accounts) {
|
||||||
|
if (a.enabled) {
|
||||||
|
try {
|
||||||
|
imapService.startPolling(a)
|
||||||
|
} catch (err) {
|
||||||
|
broadcast(IPC.eventLog, {
|
||||||
|
level: 'error',
|
||||||
|
source: `imap:${a.name}`,
|
||||||
|
message: (err as Error).message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
electronApp.setAppUserModelId('com.luciano.emailtochat')
|
||||||
|
|
||||||
|
app.on('browser-window-created', (_, window) => {
|
||||||
|
optimizer.watchWindowShortcuts(window)
|
||||||
|
})
|
||||||
|
|
||||||
|
getDb()
|
||||||
|
wireServices()
|
||||||
|
registerIpcHandlers()
|
||||||
|
createWindow()
|
||||||
|
startEnabledAccounts()
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') app.quit()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('before-quit', async () => {
|
||||||
|
imapService.stopAll()
|
||||||
|
whatsappService.stopAll()
|
||||||
|
await (await import('./services/telegram-mtproto')).telegramMtprotoService
|
||||||
|
.disconnectAll()
|
||||||
|
.catch(() => undefined)
|
||||||
|
closeDb()
|
||||||
|
})
|
||||||
+342
@@ -0,0 +1,342 @@
|
|||||||
|
import { ipcMain, BrowserWindow } from 'electron'
|
||||||
|
import { IPC } from '@shared/ipc-channels'
|
||||||
|
import {
|
||||||
|
accountsRepo,
|
||||||
|
telegramRepo,
|
||||||
|
telegramUserRepo,
|
||||||
|
whatsappRepo,
|
||||||
|
messagesRepo,
|
||||||
|
rulesRepo,
|
||||||
|
contactsRepo,
|
||||||
|
aiProvidersRepo
|
||||||
|
} from './db/repositories'
|
||||||
|
import { settingsRepo, SETTING_KEYS } from './db/settings'
|
||||||
|
import { imapService } from './services/imap'
|
||||||
|
import { smtpService } from './services/smtp'
|
||||||
|
import { telegramService } from './services/telegram'
|
||||||
|
import { telegramMtprotoService } from './services/telegram-mtproto'
|
||||||
|
import { whatsappService } from './services/whatsapp'
|
||||||
|
import { aiService } from './services/ai'
|
||||||
|
import { connectGmail } from './services/gmail-oauth'
|
||||||
|
import { importContactsDialog } from './services/contacts-import'
|
||||||
|
import type {
|
||||||
|
IpcResponse,
|
||||||
|
SendRequest,
|
||||||
|
AiProviderConfig,
|
||||||
|
AppSettings,
|
||||||
|
EmailAccount
|
||||||
|
} from '@shared/types'
|
||||||
|
|
||||||
|
function ok<T>(data: T): IpcResponse<T> {
|
||||||
|
return { ok: true, data }
|
||||||
|
}
|
||||||
|
function fail(err: unknown): IpcResponse<never> {
|
||||||
|
return { ok: false, error: err instanceof Error ? err.message : String(err) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function on<T>(channel: string, handler: (...args: any[]) => Promise<T> | T): void {
|
||||||
|
ipcMain.handle(channel, async (_event, ...args) => {
|
||||||
|
try {
|
||||||
|
return ok(await handler(...args))
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[ipc:${channel}]`, err)
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function broadcast(channel: string, payload: unknown): void {
|
||||||
|
for (const win of BrowserWindow.getAllWindows()) {
|
||||||
|
win.webContents.send(channel, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerIpcHandlers(): void {
|
||||||
|
// -------- Accounts --------
|
||||||
|
on(IPC.accountsList, () => accountsRepo.list())
|
||||||
|
on(IPC.accountsCreate, (payload) => {
|
||||||
|
const created = accountsRepo.create(payload)
|
||||||
|
if (created.enabled) imapService.startPolling(created)
|
||||||
|
return created
|
||||||
|
})
|
||||||
|
on(IPC.accountsUpdate, (id, payload) => {
|
||||||
|
const updated = accountsRepo.update(id, payload)
|
||||||
|
imapService.stopPolling(id)
|
||||||
|
smtpService.invalidate(id)
|
||||||
|
if (updated.enabled) imapService.startPolling(updated)
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
on(IPC.accountsDelete, (id) => {
|
||||||
|
imapService.stopPolling(id)
|
||||||
|
smtpService.invalidate(id)
|
||||||
|
accountsRepo.delete(id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
on(IPC.accountsTest, async (id) => {
|
||||||
|
const a = accountsRepo.get(id)
|
||||||
|
if (!a) throw new Error('Account non trovato')
|
||||||
|
await imapService.testConnection(a)
|
||||||
|
await smtpService.verify(a)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
on(IPC.accountsStartPolling, (id) => {
|
||||||
|
const a = accountsRepo.get(id)
|
||||||
|
if (!a) throw new Error('Account non trovato')
|
||||||
|
imapService.startPolling(a)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
on(IPC.accountsStopPolling, (id) => {
|
||||||
|
imapService.stopPolling(id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------- Telegram --------
|
||||||
|
on(IPC.telegramList, () => telegramRepo.list())
|
||||||
|
on(IPC.telegramCreate, (payload) => telegramRepo.create(payload))
|
||||||
|
on(IPC.telegramUpdate, (id, payload) => {
|
||||||
|
const bot = telegramRepo.update(id, payload)
|
||||||
|
telegramService.invalidate(id)
|
||||||
|
return bot
|
||||||
|
})
|
||||||
|
on(IPC.telegramDelete, (id) => {
|
||||||
|
telegramService.invalidate(id)
|
||||||
|
telegramRepo.delete(id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
on(IPC.telegramTest, async (id) => {
|
||||||
|
const bot = telegramRepo.get(id)
|
||||||
|
if (!bot) throw new Error('Bot non trovato')
|
||||||
|
return await telegramService.verify(bot)
|
||||||
|
})
|
||||||
|
on(IPC.telegramDiscover, async (id) => {
|
||||||
|
const bot = telegramRepo.get(id)
|
||||||
|
if (!bot) throw new Error('Bot non trovato')
|
||||||
|
return await telegramService.discoverChats(bot)
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------- Telegram User (MTProto) --------
|
||||||
|
on(IPC.tguList, () => telegramUserRepo.list())
|
||||||
|
on(IPC.tguCreate, (payload) => telegramUserRepo.create(payload))
|
||||||
|
on(IPC.tguDelete, async (id) => {
|
||||||
|
await telegramMtprotoService.logout(id).catch(() => undefined)
|
||||||
|
telegramUserRepo.delete(id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
on(IPC.tguStartLogin, async (id) => {
|
||||||
|
await telegramMtprotoService.startLogin(id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
on(IPC.tguSubmitCode, async (id, code: string) => {
|
||||||
|
return await telegramMtprotoService.submitCode(id, code)
|
||||||
|
})
|
||||||
|
on(IPC.tguSubmit2fa, async (id, password: string) => {
|
||||||
|
return await telegramMtprotoService.submit2fa(id, password)
|
||||||
|
})
|
||||||
|
on(IPC.tguLogout, async (id) => {
|
||||||
|
await telegramMtprotoService.logout(id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
on(IPC.tguResolve, async (id, recipient: string) => {
|
||||||
|
const account = telegramUserRepo.get(id)
|
||||||
|
if (!account) throw new Error('Account non trovato')
|
||||||
|
return await telegramMtprotoService.resolveContact(account, recipient)
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------- WhatsApp --------
|
||||||
|
on(IPC.whatsappList, () => whatsappRepo.list())
|
||||||
|
on(IPC.whatsappCreate, (payload) => whatsappRepo.create(payload))
|
||||||
|
on(IPC.whatsappUpdate, (id, payload) => whatsappRepo.update(id, payload))
|
||||||
|
on(IPC.whatsappDelete, async (id) => {
|
||||||
|
await whatsappService.stop(id)
|
||||||
|
whatsappRepo.delete(id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
on(IPC.whatsappStart, async (id) => {
|
||||||
|
const s = whatsappRepo.get(id)
|
||||||
|
if (!s) throw new Error('Sessione non trovata')
|
||||||
|
await whatsappService.start(s)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
on(IPC.whatsappStop, async (id) => {
|
||||||
|
await whatsappService.stop(id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
on(IPC.whatsappStatus, (id) => whatsappService.getStatus(id))
|
||||||
|
|
||||||
|
// -------- Messages --------
|
||||||
|
on(IPC.messagesList, (filter) => messagesRepo.list(filter))
|
||||||
|
on(IPC.messagesGet, (id) => messagesRepo.get(id))
|
||||||
|
on(IPC.messagesDelete, (id) => {
|
||||||
|
messagesRepo.delete(id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
on(IPC.messagesSend, async (req: SendRequest) => {
|
||||||
|
let result: { messageId: string | number }
|
||||||
|
if (req.channel === 'smtp') {
|
||||||
|
if (!req.accountId) throw new Error('accountId obbligatorio per SMTP')
|
||||||
|
const account = accountsRepo.get(req.accountId)
|
||||||
|
if (!account) throw new Error('Account non trovato')
|
||||||
|
result = await smtpService.send(account, {
|
||||||
|
to: req.to,
|
||||||
|
cc: req.cc,
|
||||||
|
bcc: req.bcc,
|
||||||
|
subject: req.subject,
|
||||||
|
text: req.body,
|
||||||
|
html: req.html,
|
||||||
|
attachments: req.attachments
|
||||||
|
})
|
||||||
|
} else if (req.channel === 'telegram') {
|
||||||
|
if (!req.accountId) throw new Error('Bot Telegram id obbligatorio (accountId)')
|
||||||
|
const bot = telegramRepo.get(req.accountId)
|
||||||
|
if (!bot) throw new Error('Bot non trovato')
|
||||||
|
const res = await telegramService.send(bot, req.to, req.body, req.attachments)
|
||||||
|
result = { messageId: res.messageId }
|
||||||
|
} else if (req.channel === 'telegram_user') {
|
||||||
|
if (!req.accountId) throw new Error('Account Telegram (MTProto) id obbligatorio')
|
||||||
|
const account = telegramUserRepo.get(req.accountId)
|
||||||
|
if (!account) throw new Error('Account Telegram non trovato')
|
||||||
|
const res = await telegramMtprotoService.send(account, req.to, req.body, req.attachments)
|
||||||
|
result = { messageId: res.messageId }
|
||||||
|
} else if (req.channel === 'whatsapp') {
|
||||||
|
if (!req.accountId) throw new Error('Sessione WhatsApp id obbligatorio (accountId)')
|
||||||
|
const res = await whatsappService.send(req.accountId, req.to, req.body, req.attachments)
|
||||||
|
result = res
|
||||||
|
} else {
|
||||||
|
throw new Error(`Canale non supportato: ${req.channel}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = messagesRepo.create({
|
||||||
|
accountId: req.channel === 'smtp' ? req.accountId : undefined,
|
||||||
|
direction: 'outbound',
|
||||||
|
channel: req.channel,
|
||||||
|
externalId: String(result.messageId),
|
||||||
|
toAddr: req.to,
|
||||||
|
cc: req.cc,
|
||||||
|
bcc: req.bcc,
|
||||||
|
subject: req.subject,
|
||||||
|
bodyText: req.body,
|
||||||
|
bodyHtml: req.html,
|
||||||
|
status: 'sent',
|
||||||
|
sentAt: new Date().toISOString()
|
||||||
|
} as any)
|
||||||
|
return stored
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------- Rules --------
|
||||||
|
on(IPC.rulesList, () => rulesRepo.list())
|
||||||
|
on(IPC.rulesCreate, (payload) => rulesRepo.create(payload))
|
||||||
|
on(IPC.rulesUpdate, (id, payload) => rulesRepo.update(id, payload))
|
||||||
|
on(IPC.rulesDelete, (id) => {
|
||||||
|
rulesRepo.delete(id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------- Contacts --------
|
||||||
|
on(IPC.contactsList, () => contactsRepo.list())
|
||||||
|
on(IPC.contactsCreate, (payload) => contactsRepo.create(payload))
|
||||||
|
on(IPC.contactsUpdate, (id, payload) => contactsRepo.update(id, payload))
|
||||||
|
on(IPC.contactsDelete, (id) => {
|
||||||
|
contactsRepo.delete(id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
on(IPC.contactsImport, async () => {
|
||||||
|
const result = await importContactsDialog()
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------- AI --------
|
||||||
|
on(IPC.aiList, () => aiProvidersRepo.list())
|
||||||
|
on(IPC.aiCreate, (payload: Omit<AiProviderConfig, 'id'>) => aiProvidersRepo.create(payload))
|
||||||
|
on(IPC.aiUpdate, (id, payload) => aiProvidersRepo.update(id, payload))
|
||||||
|
on(IPC.aiDelete, (id) => {
|
||||||
|
aiProvidersRepo.delete(id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
on(IPC.aiTest, async (id) => {
|
||||||
|
const p = aiProvidersRepo.get(id)
|
||||||
|
if (!p) throw new Error('Provider non trovato')
|
||||||
|
return await aiService.test(p)
|
||||||
|
})
|
||||||
|
on(IPC.aiSummarize, async (messageId, providerId) => {
|
||||||
|
const message = messagesRepo.get(messageId)
|
||||||
|
if (!message) throw new Error('Messaggio non trovato')
|
||||||
|
const provider = providerId ? aiProvidersRepo.get(providerId) : undefined
|
||||||
|
return await aiService.summarize(message, provider)
|
||||||
|
})
|
||||||
|
on(IPC.aiClassify, async (messageId, providerId) => {
|
||||||
|
const message = messagesRepo.get(messageId)
|
||||||
|
if (!message) throw new Error('Messaggio non trovato')
|
||||||
|
const provider = providerId ? aiProvidersRepo.get(providerId) : undefined
|
||||||
|
return await aiService.classify(message, provider)
|
||||||
|
})
|
||||||
|
on(IPC.aiExtract, async (messageId, providerId) => {
|
||||||
|
const message = messagesRepo.get(messageId)
|
||||||
|
if (!message) throw new Error('Messaggio non trovato')
|
||||||
|
const provider = providerId ? aiProvidersRepo.get(providerId) : undefined
|
||||||
|
return await aiService.extract(message, provider)
|
||||||
|
})
|
||||||
|
on(IPC.aiSuggestReply, async (messageId, providerId) => {
|
||||||
|
const message = messagesRepo.get(messageId)
|
||||||
|
if (!message) throw new Error('Messaggio non trovato')
|
||||||
|
const provider = providerId ? aiProvidersRepo.get(providerId) : undefined
|
||||||
|
return await aiService.suggestReply(message, provider)
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------- Settings --------
|
||||||
|
on(IPC.settingsGet, (): AppSettings => ({
|
||||||
|
gmailClientId: settingsRepo.get(SETTING_KEYS.gmailClientId),
|
||||||
|
gmailClientSecret: settingsRepo.get(SETTING_KEYS.gmailClientSecret),
|
||||||
|
telegramApiId: settingsRepo.get(SETTING_KEYS.telegramApiId),
|
||||||
|
telegramApiHash: settingsRepo.get(SETTING_KEYS.telegramApiHash)
|
||||||
|
}))
|
||||||
|
on(IPC.settingsUpdate, (payload: AppSettings) => {
|
||||||
|
if ('gmailClientId' in payload) {
|
||||||
|
settingsRepo.set(SETTING_KEYS.gmailClientId, payload.gmailClientId ?? null)
|
||||||
|
}
|
||||||
|
if ('gmailClientSecret' in payload) {
|
||||||
|
settingsRepo.set(SETTING_KEYS.gmailClientSecret, payload.gmailClientSecret ?? null)
|
||||||
|
}
|
||||||
|
if ('telegramApiId' in payload) {
|
||||||
|
settingsRepo.set(SETTING_KEYS.telegramApiId, payload.telegramApiId ?? null)
|
||||||
|
}
|
||||||
|
if ('telegramApiHash' in payload) {
|
||||||
|
settingsRepo.set(SETTING_KEYS.telegramApiHash, payload.telegramApiHash ?? null)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------- Gmail OAuth connect --------
|
||||||
|
on(IPC.gmailConnect, async (): Promise<EmailAccount> => {
|
||||||
|
const result = await connectGmail()
|
||||||
|
const account = accountsRepo.create({
|
||||||
|
name: result.name ? `${result.name} (Gmail)` : `Gmail: ${result.email}`,
|
||||||
|
authType: 'oauth_google',
|
||||||
|
imap: {
|
||||||
|
host: 'imap.gmail.com',
|
||||||
|
port: 993,
|
||||||
|
secure: true,
|
||||||
|
user: result.email,
|
||||||
|
pass: ''
|
||||||
|
},
|
||||||
|
smtp: {
|
||||||
|
host: 'smtp.gmail.com',
|
||||||
|
port: 465,
|
||||||
|
secure: true,
|
||||||
|
user: result.email,
|
||||||
|
pass: '',
|
||||||
|
fromName: result.name,
|
||||||
|
fromAddress: result.email
|
||||||
|
},
|
||||||
|
oauth: {
|
||||||
|
provider: 'google',
|
||||||
|
refreshToken: result.refreshToken,
|
||||||
|
accessToken: result.accessToken,
|
||||||
|
expiresAt: result.expiresAt
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
pollInterval: 60
|
||||||
|
})
|
||||||
|
imapService.startPolling(account)
|
||||||
|
return account
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import Anthropic from '@anthropic-ai/sdk'
|
||||||
|
import OpenAI from 'openai'
|
||||||
|
import type { AiProviderConfig, Message, AiAnalysis } from '@shared/types'
|
||||||
|
import { aiRepo, aiProvidersRepo } from '../db/repositories'
|
||||||
|
|
||||||
|
export type AiTask = 'summarize' | 'classify' | 'extract' | 'reply'
|
||||||
|
|
||||||
|
const DEFAULT_CATEGORIES = [
|
||||||
|
'fattura',
|
||||||
|
'spam',
|
||||||
|
'newsletter',
|
||||||
|
'lavoro',
|
||||||
|
'personale',
|
||||||
|
'notifica',
|
||||||
|
'importante',
|
||||||
|
'altro'
|
||||||
|
]
|
||||||
|
|
||||||
|
interface CompletionInput {
|
||||||
|
system: string
|
||||||
|
user: string
|
||||||
|
jsonMode?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompletionOutput {
|
||||||
|
text: string
|
||||||
|
provider: string
|
||||||
|
model: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AiService {
|
||||||
|
async complete(provider: AiProviderConfig, input: CompletionInput): Promise<CompletionOutput> {
|
||||||
|
switch (provider.provider) {
|
||||||
|
case 'anthropic':
|
||||||
|
return this.completeAnthropic(provider, input)
|
||||||
|
case 'openai':
|
||||||
|
return this.completeOpenAI(provider, input)
|
||||||
|
case 'ollama':
|
||||||
|
return this.completeOllama(provider, input)
|
||||||
|
default:
|
||||||
|
throw new Error(`Provider AI non supportato: ${provider.provider}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async completeAnthropic(
|
||||||
|
p: AiProviderConfig,
|
||||||
|
input: CompletionInput
|
||||||
|
): Promise<CompletionOutput> {
|
||||||
|
if (!p.apiKey) throw new Error('API key Anthropic mancante')
|
||||||
|
const client = new Anthropic({ apiKey: p.apiKey, baseURL: p.baseUrl })
|
||||||
|
const resp = await client.messages.create({
|
||||||
|
model: p.model,
|
||||||
|
max_tokens: 2048,
|
||||||
|
system: input.system,
|
||||||
|
messages: [{ role: 'user', content: input.user }]
|
||||||
|
})
|
||||||
|
const block = resp.content.find((b) => b.type === 'text')
|
||||||
|
const text = block && block.type === 'text' ? block.text : ''
|
||||||
|
return { text, provider: p.provider, model: p.model }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async completeOpenAI(
|
||||||
|
p: AiProviderConfig,
|
||||||
|
input: CompletionInput
|
||||||
|
): Promise<CompletionOutput> {
|
||||||
|
if (!p.apiKey) throw new Error('API key OpenAI mancante')
|
||||||
|
const client = new OpenAI({ apiKey: p.apiKey, baseURL: p.baseUrl })
|
||||||
|
const resp = await client.chat.completions.create({
|
||||||
|
model: p.model,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: input.system },
|
||||||
|
{ role: 'user', content: input.user }
|
||||||
|
],
|
||||||
|
response_format: input.jsonMode ? { type: 'json_object' } : undefined
|
||||||
|
})
|
||||||
|
const text = resp.choices[0]?.message?.content ?? ''
|
||||||
|
return { text, provider: p.provider, model: p.model }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async completeOllama(
|
||||||
|
p: AiProviderConfig,
|
||||||
|
input: CompletionInput
|
||||||
|
): Promise<CompletionOutput> {
|
||||||
|
const baseUrl = p.baseUrl || 'http://localhost:11434'
|
||||||
|
const resp = await fetch(`${baseUrl}/api/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: p.model,
|
||||||
|
stream: false,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: input.system },
|
||||||
|
{ role: 'user', content: input.user }
|
||||||
|
],
|
||||||
|
format: input.jsonMode ? 'json' : undefined
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`Ollama HTTP ${resp.status}: ${await resp.text()}`)
|
||||||
|
}
|
||||||
|
const json = (await resp.json()) as { message?: { content?: string } }
|
||||||
|
return { text: json.message?.content ?? '', provider: p.provider, model: p.model }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- High-level tasks ----------
|
||||||
|
|
||||||
|
async summarize(message: Message, provider?: AiProviderConfig): Promise<string> {
|
||||||
|
const p = provider ?? this.requireDefault()
|
||||||
|
const body = truncate(message.bodyText ?? stripHtml(message.bodyHtml ?? ''), 8000)
|
||||||
|
const out = await this.complete(p, {
|
||||||
|
system:
|
||||||
|
'Sei un assistente che riassume email in italiano. Riassumi in massimo 3 frasi, focalizzandoti sui punti chiave e azioni richieste.',
|
||||||
|
user: `Da: ${message.fromAddr ?? '?'}\nOggetto: ${message.subject ?? '(senza oggetto)'}\n\n${body}`
|
||||||
|
})
|
||||||
|
aiRepo.upsert(message.id, { provider: out.provider, model: out.model, summary: out.text.trim() })
|
||||||
|
return out.text.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
async classify(message: Message, provider?: AiProviderConfig): Promise<{ category: string; labels: string[] }> {
|
||||||
|
const p = provider ?? this.requireDefault()
|
||||||
|
const body = truncate(message.bodyText ?? stripHtml(message.bodyHtml ?? ''), 4000)
|
||||||
|
const out = await this.complete(p, {
|
||||||
|
system: `Classifica email. Rispondi SOLO con JSON valido nel formato {"category": "...", "labels": ["..."]}. Le categorie ammesse sono: ${DEFAULT_CATEGORIES.join(', ')}. Le labels sono parole chiave libere (max 5).`,
|
||||||
|
user: `Da: ${message.fromAddr ?? '?'}\nOggetto: ${message.subject ?? '(senza oggetto)'}\n\n${body}`,
|
||||||
|
jsonMode: true
|
||||||
|
})
|
||||||
|
const parsed = parseJson<{ category: string; labels: string[] }>(out.text, { category: 'altro', labels: [] })
|
||||||
|
aiRepo.upsert(message.id, {
|
||||||
|
provider: out.provider,
|
||||||
|
model: out.model,
|
||||||
|
category: parsed.category,
|
||||||
|
labels: parsed.labels ?? []
|
||||||
|
})
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
async extract(message: Message, provider?: AiProviderConfig): Promise<Record<string, unknown>> {
|
||||||
|
const p = provider ?? this.requireDefault()
|
||||||
|
const body = truncate(message.bodyText ?? stripHtml(message.bodyHtml ?? ''), 8000)
|
||||||
|
const out = await this.complete(p, {
|
||||||
|
system:
|
||||||
|
'Estrai informazioni strutturate da questa email. Rispondi SOLO con JSON valido. Includi quando presenti: dates (array ISO 8601), amounts (array con currency e value), action_items (array stringhe), key_entities (array nomi/aziende), deadlines (array ISO), priority ("low"|"medium"|"high"). Ometti chiavi non rilevanti.',
|
||||||
|
user: `Oggetto: ${message.subject ?? '(senza oggetto)'}\n\n${body}`,
|
||||||
|
jsonMode: true
|
||||||
|
})
|
||||||
|
const extracted = parseJson<Record<string, unknown>>(out.text, {})
|
||||||
|
aiRepo.upsert(message.id, { provider: out.provider, model: out.model, extracted })
|
||||||
|
return extracted
|
||||||
|
}
|
||||||
|
|
||||||
|
async suggestReply(message: Message, provider?: AiProviderConfig): Promise<string> {
|
||||||
|
const p = provider ?? this.requireDefault()
|
||||||
|
const body = truncate(message.bodyText ?? stripHtml(message.bodyHtml ?? ''), 8000)
|
||||||
|
const out = await this.complete(p, {
|
||||||
|
system:
|
||||||
|
'Sei un assistente che scrive bozze di risposta professionali in italiano. Scrivi una bozza concisa, cortese e diretta. Non includere intestazioni, firma o "Caro/Gentile" se l\'email originale non li usa.',
|
||||||
|
user: `Email ricevuta:\nDa: ${message.fromAddr ?? '?'}\nOggetto: ${message.subject ?? '(senza oggetto)'}\n\n${body}\n\nScrivi una bozza di risposta.`
|
||||||
|
})
|
||||||
|
aiRepo.upsert(message.id, {
|
||||||
|
provider: out.provider,
|
||||||
|
model: out.model,
|
||||||
|
suggestedReply: out.text.trim()
|
||||||
|
})
|
||||||
|
return out.text.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
async test(provider: AiProviderConfig): Promise<string> {
|
||||||
|
const out = await this.complete(provider, {
|
||||||
|
system: 'Rispondi brevemente in italiano.',
|
||||||
|
user: 'Di\' "OK" se ricevi questo messaggio.'
|
||||||
|
})
|
||||||
|
return out.text
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireDefault(): AiProviderConfig {
|
||||||
|
const p = aiProvidersRepo.getDefault()
|
||||||
|
if (!p) throw new Error('Nessun provider AI predefinito configurato')
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(s: string, max: number): string {
|
||||||
|
if (s.length <= max) return s
|
||||||
|
return s.slice(0, max) + '\n[...troncato...]'
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHtml(html: string): string {
|
||||||
|
return html
|
||||||
|
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson<T>(text: string, fallback: T): T {
|
||||||
|
const trimmed = text.trim()
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed) as T
|
||||||
|
} catch {
|
||||||
|
const match = trimmed.match(/\{[\s\S]*\}/)
|
||||||
|
if (match) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(match[0]) as T
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const aiService = new AiService()
|
||||||
|
export { DEFAULT_CATEGORIES }
|
||||||
|
export type { AiAnalysis }
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import { dialog, BrowserWindow } from 'electron'
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
import { extname } from 'path'
|
||||||
|
import { contactsRepo } from '../db/repositories'
|
||||||
|
import type { Contact } from '@shared/types'
|
||||||
|
|
||||||
|
export interface ImportResult {
|
||||||
|
added: number
|
||||||
|
skipped: number
|
||||||
|
total: number
|
||||||
|
errors: string[]
|
||||||
|
source: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Candidate = Omit<Contact, 'id' | 'createdAt'>
|
||||||
|
|
||||||
|
export async function importContactsDialog(parent?: BrowserWindow): Promise<ImportResult | null> {
|
||||||
|
const win = parent ?? BrowserWindow.getFocusedWindow() ?? undefined
|
||||||
|
const result = await dialog.showOpenDialog(win!, {
|
||||||
|
title: 'Importa rubrica',
|
||||||
|
properties: ['openFile'],
|
||||||
|
filters: [
|
||||||
|
{ name: 'Rubrica', extensions: ['csv', 'vcf', 'vcard', 'ics'] },
|
||||||
|
{ name: 'CSV', extensions: ['csv'] },
|
||||||
|
{ name: 'vCard', extensions: ['vcf', 'vcard'] },
|
||||||
|
{ name: 'iCalendar (con vCard)', extensions: ['ics'] }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
if (result.canceled || result.filePaths.length === 0) return null
|
||||||
|
return importFromFile(result.filePaths[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importFromFile(filePath: string): ImportResult {
|
||||||
|
const content = readFileSync(filePath, 'utf8')
|
||||||
|
const ext = extname(filePath).toLowerCase()
|
||||||
|
let candidates: Candidate[] = []
|
||||||
|
const errors: string[] = []
|
||||||
|
try {
|
||||||
|
if (ext === '.csv') {
|
||||||
|
candidates = parseCsvContacts(content)
|
||||||
|
} else if (ext === '.vcf' || ext === '.vcard') {
|
||||||
|
candidates = parseVCards(content)
|
||||||
|
} else if (ext === '.ics') {
|
||||||
|
candidates = parseVCards(content)
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
errors.push('Il file .ics non contiene blocchi VCARD (è un calendario, non una rubrica).')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Try to detect by content
|
||||||
|
if (/BEGIN:VCARD/i.test(content)) candidates = parseVCards(content)
|
||||||
|
else candidates = parseCsvContacts(content)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errors.push((err as Error).message)
|
||||||
|
}
|
||||||
|
|
||||||
|
let added = 0
|
||||||
|
let skipped = 0
|
||||||
|
const existing = contactsRepo.list()
|
||||||
|
|
||||||
|
for (const cand of candidates) {
|
||||||
|
const duplicate = existing.find((c) => isDuplicate(c, cand))
|
||||||
|
if (duplicate) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
contactsRepo.create(cand)
|
||||||
|
existing.push({ ...cand, id: -1, createdAt: '' })
|
||||||
|
added++
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`Errore su "${cand.displayName}": ${(err as Error).message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { added, skipped, total: candidates.length, errors, source: filePath }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDuplicate(a: Contact, b: Candidate): boolean {
|
||||||
|
if (a.displayName.trim().toLowerCase() === b.displayName.trim().toLowerCase()) return true
|
||||||
|
if (b.emails.length && a.emails.some((e) => b.emails.includes(e))) return true
|
||||||
|
if (b.whatsappNumber && a.whatsappNumber && a.whatsappNumber === b.whatsappNumber) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------- vCard parser -----------------
|
||||||
|
|
||||||
|
export function parseVCards(content: string): Candidate[] {
|
||||||
|
// Unfold lines (vCard 3.0/4.0 line continuation: CRLF + space/tab)
|
||||||
|
const unfolded = content.replace(/\r?\n[ \t]/g, '')
|
||||||
|
const lines = unfolded.split(/\r?\n/)
|
||||||
|
const out: Candidate[] = []
|
||||||
|
let current: Partial<Candidate> | null = null
|
||||||
|
|
||||||
|
for (const raw of lines) {
|
||||||
|
const line = raw.trim()
|
||||||
|
if (!line) continue
|
||||||
|
const upper = line.toUpperCase()
|
||||||
|
if (upper === 'BEGIN:VCARD') {
|
||||||
|
current = { displayName: '', emails: [] }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (upper === 'END:VCARD') {
|
||||||
|
if (current && current.displayName) {
|
||||||
|
out.push({
|
||||||
|
displayName: current.displayName,
|
||||||
|
emails: current.emails ?? [],
|
||||||
|
telegramChatId: current.telegramChatId,
|
||||||
|
whatsappNumber: current.whatsappNumber,
|
||||||
|
notes: current.notes
|
||||||
|
})
|
||||||
|
}
|
||||||
|
current = null
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!current) continue
|
||||||
|
|
||||||
|
const sepIdx = line.indexOf(':')
|
||||||
|
if (sepIdx === -1) continue
|
||||||
|
const meta = line.slice(0, sepIdx)
|
||||||
|
const value = decodeVCardValue(line.slice(sepIdx + 1), meta)
|
||||||
|
const tag = meta.split(';')[0].toUpperCase()
|
||||||
|
|
||||||
|
if (tag === 'FN' && value) {
|
||||||
|
current.displayName = value
|
||||||
|
} else if (tag === 'N' && !current.displayName) {
|
||||||
|
// Family;Given;Additional;Prefix;Suffix
|
||||||
|
const parts = value.split(';').map((p) => p.trim())
|
||||||
|
current.displayName = [parts[1], parts[0]].filter(Boolean).join(' ')
|
||||||
|
} else if (tag === 'EMAIL' && value) {
|
||||||
|
const email = value.replace(/^mailto:/i, '').trim()
|
||||||
|
if (email.includes('@')) current.emails!.push(email)
|
||||||
|
} else if (tag === 'TEL' && value) {
|
||||||
|
const digits = value.replace(/\D/g, '')
|
||||||
|
if (digits && !current.whatsappNumber) current.whatsappNumber = digits
|
||||||
|
} else if (tag === 'NOTE' && value) {
|
||||||
|
current.notes = (current.notes ? current.notes + '\n' : '') + value
|
||||||
|
} else if (tag.startsWith('X-TELEGRAM') && value) {
|
||||||
|
current.telegramChatId = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeVCardValue(value: string, meta: string): string {
|
||||||
|
const enc = /ENCODING=QUOTED-PRINTABLE/i.test(meta)
|
||||||
|
if (enc) {
|
||||||
|
return value.replace(/=([0-9A-F]{2})/gi, (_, h) => String.fromCharCode(parseInt(h, 16)))
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
.replace(/\\n/g, '\n')
|
||||||
|
.replace(/\\,/g, ',')
|
||||||
|
.replace(/\\;/g, ';')
|
||||||
|
.replace(/\\\\/g, '\\')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------- CSV parser -----------------
|
||||||
|
|
||||||
|
export function parseCsvContacts(content: string): Candidate[] {
|
||||||
|
const rows = parseCsv(content)
|
||||||
|
if (rows.length < 2) return []
|
||||||
|
const headers = rows[0].map((h) => h.trim().toLowerCase())
|
||||||
|
const out: Candidate[] = []
|
||||||
|
|
||||||
|
const findIdx = (...names: string[]): number => {
|
||||||
|
for (const n of names) {
|
||||||
|
const idx = headers.indexOf(n)
|
||||||
|
if (idx >= 0) return idx
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
const findAllIdx = (re: RegExp): number[] => {
|
||||||
|
const out: number[] = []
|
||||||
|
headers.forEach((h, i) => {
|
||||||
|
if (re.test(h)) out.push(i)
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameIdx = findIdx('name', 'display name', 'full name', 'fullname')
|
||||||
|
const firstIdx = findIdx('first name', 'given name', 'firstname')
|
||||||
|
const lastIdx = findIdx('last name', 'family name', 'surname', 'lastname')
|
||||||
|
// Match any column with "email" or "e-mail" that holds the address (value), skip type/label
|
||||||
|
const emailIdxs = findAllIdx(/(email|e-mail)/).filter((i) => {
|
||||||
|
const h = headers[i]
|
||||||
|
return !/type|label/.test(h)
|
||||||
|
})
|
||||||
|
const phoneIdxs = findAllIdx(/(phone|mobile|cell)/).filter((i) => !/type|label/.test(headers[i]))
|
||||||
|
const telegramIdx = findIdx('telegram', 'telegram chat id', 'telegram_chat_id')
|
||||||
|
const whatsappIdx = findIdx('whatsapp', 'whatsapp number', 'whatsapp_number')
|
||||||
|
const notesIdx = findIdx('notes', 'note')
|
||||||
|
|
||||||
|
for (let r = 1; r < rows.length; r++) {
|
||||||
|
const row = rows[r]
|
||||||
|
if (row.every((c) => !c?.trim())) continue
|
||||||
|
const get = (i: number): string => (i >= 0 && i < row.length ? (row[i] ?? '').trim() : '')
|
||||||
|
|
||||||
|
let name = get(nameIdx)
|
||||||
|
if (!name) name = [get(firstIdx), get(lastIdx)].filter(Boolean).join(' ').trim()
|
||||||
|
if (!name) continue
|
||||||
|
|
||||||
|
const emails: string[] = []
|
||||||
|
for (const i of emailIdxs) {
|
||||||
|
const v = get(i)
|
||||||
|
if (v) {
|
||||||
|
for (const part of v.split(/[,;]\s*/)) {
|
||||||
|
if (part.includes('@')) emails.push(part.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let phone = ''
|
||||||
|
for (const i of phoneIdxs) {
|
||||||
|
const v = get(i)
|
||||||
|
if (v) {
|
||||||
|
phone = v
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const whatsapp = get(whatsappIdx) || phone
|
||||||
|
out.push({
|
||||||
|
displayName: name,
|
||||||
|
emails: [...new Set(emails)],
|
||||||
|
telegramChatId: get(telegramIdx) || undefined,
|
||||||
|
whatsappNumber: whatsapp ? whatsapp.replace(/\D/g, '') : undefined,
|
||||||
|
notes: get(notesIdx) || undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCsv(content: string): string[][] {
|
||||||
|
// Strip BOM
|
||||||
|
if (content.charCodeAt(0) === 0xfeff) content = content.slice(1)
|
||||||
|
// Detect delimiter (comma vs semicolon)
|
||||||
|
const firstLine = content.split(/\r?\n/, 1)[0]
|
||||||
|
const delim = firstLine.split(';').length > firstLine.split(',').length ? ';' : ','
|
||||||
|
|
||||||
|
const rows: string[][] = []
|
||||||
|
let row: string[] = []
|
||||||
|
let cell = ''
|
||||||
|
let inQuote = false
|
||||||
|
for (let i = 0; i < content.length; i++) {
|
||||||
|
const c = content[i]
|
||||||
|
if (inQuote) {
|
||||||
|
if (c === '"' && content[i + 1] === '"') {
|
||||||
|
cell += '"'
|
||||||
|
i++
|
||||||
|
} else if (c === '"') {
|
||||||
|
inQuote = false
|
||||||
|
} else {
|
||||||
|
cell += c
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (c === '"') inQuote = true
|
||||||
|
else if (c === delim) {
|
||||||
|
row.push(cell)
|
||||||
|
cell = ''
|
||||||
|
} else if (c === '\n') {
|
||||||
|
row.push(cell)
|
||||||
|
rows.push(row)
|
||||||
|
row = []
|
||||||
|
cell = ''
|
||||||
|
} else if (c === '\r') {
|
||||||
|
// skip
|
||||||
|
} else cell += c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cell || row.length) {
|
||||||
|
row.push(cell)
|
||||||
|
rows.push(row)
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import type { Message, ForwardingRule } from '@shared/types'
|
||||||
|
import {
|
||||||
|
rulesRepo,
|
||||||
|
telegramRepo,
|
||||||
|
telegramUserRepo,
|
||||||
|
whatsappRepo,
|
||||||
|
messagesRepo,
|
||||||
|
aiRepo
|
||||||
|
} from '../db/repositories'
|
||||||
|
import { telegramService } from './telegram'
|
||||||
|
import { telegramMtprotoService } from './telegram-mtproto'
|
||||||
|
import { whatsappService } from './whatsapp'
|
||||||
|
import { EventEmitter } from 'events'
|
||||||
|
|
||||||
|
export class ForwarderService extends EventEmitter {
|
||||||
|
async processIncoming(message: Message): Promise<void> {
|
||||||
|
const rules = rulesRepo.listEnabled()
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (this.matches(rule, message)) {
|
||||||
|
try {
|
||||||
|
await this.applyRule(rule, message)
|
||||||
|
this.emit('log', {
|
||||||
|
level: 'info',
|
||||||
|
source: 'forwarder',
|
||||||
|
message: `Regola "${rule.name}" applicata al messaggio #${message.id}`
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
this.emit('log', {
|
||||||
|
level: 'error',
|
||||||
|
source: 'forwarder',
|
||||||
|
message: `Regola "${rule.name}" fallita: ${(err as Error).message}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private matches(rule: ForwardingRule, message: Message): boolean {
|
||||||
|
const subject = message.subject?.toLowerCase() ?? ''
|
||||||
|
const from = message.fromAddr?.toLowerCase() ?? ''
|
||||||
|
const to = message.toAddr?.toLowerCase() ?? ''
|
||||||
|
const body = (message.bodyText ?? '').toLowerCase()
|
||||||
|
|
||||||
|
if (rule.matchFromContains && !from.includes(rule.matchFromContains.toLowerCase())) return false
|
||||||
|
if (rule.matchToContains && !to.includes(rule.matchToContains.toLowerCase())) return false
|
||||||
|
if (rule.matchSubjectContains && !subject.includes(rule.matchSubjectContains.toLowerCase()))
|
||||||
|
return false
|
||||||
|
if (rule.matchBodyContains && !body.includes(rule.matchBodyContains.toLowerCase())) return false
|
||||||
|
if (rule.matchHasAttachment != null) {
|
||||||
|
const has = (message.attachments?.length ?? 0) > 0
|
||||||
|
if (rule.matchHasAttachment !== has) return false
|
||||||
|
}
|
||||||
|
if (rule.matchAiCategory) {
|
||||||
|
const ai = aiRepo.getByMessage(message.id)
|
||||||
|
if (!ai || ai.category !== rule.matchAiCategory) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyRule(rule: ForwardingRule, message: Message): Promise<void> {
|
||||||
|
const text = this.renderTemplate(rule, message)
|
||||||
|
const attachments = rule.includeAttachments ? message.attachments : undefined
|
||||||
|
|
||||||
|
if (rule.targetChannel === 'telegram') {
|
||||||
|
const bot = this.findTelegramBot(rule.targetIdentifier)
|
||||||
|
const chatId = this.extractChatId(rule.targetIdentifier) ?? bot.defaultChatId
|
||||||
|
if (!chatId) throw new Error('Telegram: chat_id mancante nella regola')
|
||||||
|
await telegramService.send(bot, chatId, text, attachments)
|
||||||
|
} else if (rule.targetChannel === 'telegram_user') {
|
||||||
|
const [accountPart, recipient] = rule.targetIdentifier.split('|')
|
||||||
|
const id = Number(accountPart)
|
||||||
|
const account = !isNaN(id) ? telegramUserRepo.get(id) : telegramUserRepo.list()[0]
|
||||||
|
if (!account) throw new Error(`Account Telegram (MTProto) non trovato: ${accountPart}`)
|
||||||
|
if (!recipient) throw new Error('Telegram MTProto: destinatario mancante nella regola')
|
||||||
|
await telegramMtprotoService.send(account, recipient, text, attachments)
|
||||||
|
} else if (rule.targetChannel === 'whatsapp') {
|
||||||
|
const [sessionPart, recipient] = rule.targetIdentifier.split('|')
|
||||||
|
const session = this.findWhatsappSession(sessionPart)
|
||||||
|
const to = recipient ?? session.defaultRecipient
|
||||||
|
if (!to) throw new Error('WhatsApp: destinatario mancante nella regola')
|
||||||
|
await whatsappService.send(session.id, to, text, attachments)
|
||||||
|
} else if (rule.targetChannel === 'smtp') {
|
||||||
|
throw new Error(
|
||||||
|
'Forwarding via SMTP non ancora supportato come target: compose una nuova email manualmente'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
messagesRepo.create({
|
||||||
|
direction: 'outbound',
|
||||||
|
channel: rule.targetChannel,
|
||||||
|
fromAddr: message.fromAddr,
|
||||||
|
toAddr: rule.targetIdentifier,
|
||||||
|
subject: message.subject,
|
||||||
|
bodyText: text,
|
||||||
|
status: 'forwarded',
|
||||||
|
sentAt: new Date().toISOString()
|
||||||
|
} as Omit<Message, 'id'>)
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderTemplate(rule: ForwardingRule, message: Message): string {
|
||||||
|
const fallback = this.defaultTemplate(rule, message)
|
||||||
|
if (!rule.template) return fallback
|
||||||
|
return rule.template
|
||||||
|
.replace(/\{\{from\}\}/g, message.fromAddr ?? '')
|
||||||
|
.replace(/\{\{to\}\}/g, message.toAddr ?? '')
|
||||||
|
.replace(/\{\{subject\}\}/g, message.subject ?? '')
|
||||||
|
.replace(/\{\{body\}\}/g, rule.includeBody ? message.bodyText ?? '' : '')
|
||||||
|
.replace(/\{\{summary\}\}/g, aiRepo.getByMessage(message.id)?.summary ?? '')
|
||||||
|
.replace(/\{\{category\}\}/g, aiRepo.getByMessage(message.id)?.category ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
private defaultTemplate(rule: ForwardingRule, message: Message): string {
|
||||||
|
const lines: string[] = []
|
||||||
|
if (rule.targetChannel === 'telegram') {
|
||||||
|
lines.push(`<b>Nuova email</b>`)
|
||||||
|
lines.push(`<b>Da:</b> ${escapeHtml(message.fromAddr ?? '?')}`)
|
||||||
|
if (message.subject) lines.push(`<b>Oggetto:</b> ${escapeHtml(message.subject)}`)
|
||||||
|
const ai = aiRepo.getByMessage(message.id)
|
||||||
|
if (ai?.summary) lines.push(`\n<i>${escapeHtml(ai.summary)}</i>`)
|
||||||
|
if (rule.includeBody && message.bodyText) {
|
||||||
|
lines.push('\n' + escapeHtml(truncate(message.bodyText, 2000)))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines.push('Nuova email')
|
||||||
|
lines.push(`Da: ${message.fromAddr ?? '?'}`)
|
||||||
|
if (message.subject) lines.push(`Oggetto: ${message.subject}`)
|
||||||
|
const ai = aiRepo.getByMessage(message.id)
|
||||||
|
if (ai?.summary) lines.push(`\n${ai.summary}`)
|
||||||
|
if (rule.includeBody && message.bodyText) {
|
||||||
|
lines.push('\n' + truncate(message.bodyText, 2000))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
private findTelegramBot(identifier: string): ReturnType<typeof telegramRepo.get> & object {
|
||||||
|
const [botPart] = identifier.split(':')
|
||||||
|
const id = Number(botPart)
|
||||||
|
const bot = !isNaN(id) ? telegramRepo.get(id) : telegramRepo.list()[0]
|
||||||
|
if (!bot) throw new Error(`Telegram bot non trovato: ${identifier}`)
|
||||||
|
return bot
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractChatId(identifier: string): string | undefined {
|
||||||
|
const idx = identifier.indexOf(':')
|
||||||
|
if (idx === -1) return undefined
|
||||||
|
return identifier.slice(idx + 1) || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private findWhatsappSession(sessionPart: string): NonNullable<ReturnType<typeof whatsappRepo.get>> {
|
||||||
|
const id = Number(sessionPart)
|
||||||
|
const session = !isNaN(id) ? whatsappRepo.get(id) : whatsappRepo.list()[0]
|
||||||
|
if (!session) throw new Error(`Sessione WhatsApp non trovata: ${sessionPart}`)
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(s: string, n: number): string {
|
||||||
|
return s.length <= n ? s : s.slice(0, n) + '…'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const forwarderService = new ForwarderService()
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import { BrowserWindow } from 'electron'
|
||||||
|
import * as http from 'node:http'
|
||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
|
import { settingsRepo, SETTING_KEYS } from '../db/settings'
|
||||||
|
import { accountsRepo } from '../db/repositories'
|
||||||
|
import type { EmailAccount, GmailConnectResult } from '@shared/types'
|
||||||
|
|
||||||
|
const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
|
||||||
|
const TOKEN_URL = 'https://oauth2.googleapis.com/token'
|
||||||
|
const USERINFO_URL = 'https://www.googleapis.com/oauth2/v2/userinfo'
|
||||||
|
const SCOPES = [
|
||||||
|
'https://mail.google.com/',
|
||||||
|
'https://www.googleapis.com/auth/userinfo.email',
|
||||||
|
'https://www.googleapis.com/auth/userinfo.profile'
|
||||||
|
]
|
||||||
|
|
||||||
|
interface ClientConfig {
|
||||||
|
clientId: string
|
||||||
|
clientSecret: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadClientConfig(): ClientConfig {
|
||||||
|
const clientId = settingsRepo.get(SETTING_KEYS.gmailClientId)
|
||||||
|
const clientSecret = settingsRepo.get(SETTING_KEYS.gmailClientSecret)
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
throw new Error(
|
||||||
|
'Google OAuth Client ID/Secret non configurati. Vai in Impostazioni → Gmail OAuth.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return { clientId, clientSecret }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startLoopbackServer(): Promise<{
|
||||||
|
port: number
|
||||||
|
redirectUri: string
|
||||||
|
waitForCode: (state: string) => Promise<string>
|
||||||
|
close: () => void
|
||||||
|
}> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = http.createServer()
|
||||||
|
server.on('error', reject)
|
||||||
|
server.listen(0, '127.0.0.1', () => {
|
||||||
|
const address = server.address()
|
||||||
|
if (!address || typeof address === 'string') {
|
||||||
|
server.close()
|
||||||
|
reject(new Error('Impossibile aprire server di callback'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const port = address.port
|
||||||
|
const redirectUri = `http://127.0.0.1:${port}/callback`
|
||||||
|
|
||||||
|
const waitForCode = (state: string): Promise<string> =>
|
||||||
|
new Promise((res, rej) => {
|
||||||
|
server.on('request', (req, response) => {
|
||||||
|
const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`)
|
||||||
|
if (url.pathname !== '/callback') {
|
||||||
|
response.writeHead(404).end('Not found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const code = url.searchParams.get('code')
|
||||||
|
const returnedState = url.searchParams.get('state')
|
||||||
|
const error = url.searchParams.get('error')
|
||||||
|
|
||||||
|
const html = (msg: string, color: string): string =>
|
||||||
|
`<!doctype html><html><head><meta charset="utf-8"><title>Gmail</title>
|
||||||
|
<style>body{font-family:system-ui;background:#f8fafc;color:#0f172a;display:grid;
|
||||||
|
place-items:center;min-height:100vh;margin:0;text-align:center}
|
||||||
|
.card{background:white;border-radius:12px;padding:32px 40px;
|
||||||
|
box-shadow:0 8px 30px rgba(0,0,0,0.08);max-width:420px}
|
||||||
|
h1{color:${color};margin:0 0 8px}</style></head>
|
||||||
|
<body><div class="card"><h1>${msg}</h1>
|
||||||
|
<p>Puoi chiudere questa finestra.</p></div></body></html>`
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
response.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' })
|
||||||
|
response.end(html(`Errore: ${error}`, '#dc2626'))
|
||||||
|
rej(new Error(`OAuth: ${error}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!code || returnedState !== state) {
|
||||||
|
response.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' })
|
||||||
|
response.end(html('Stato OAuth non valido', '#dc2626'))
|
||||||
|
rej(new Error('OAuth: stato non valido'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
||||||
|
response.end(html('Account collegato!', '#059669'))
|
||||||
|
res(code)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
port,
|
||||||
|
redirectUri,
|
||||||
|
waitForCode,
|
||||||
|
close: () => server.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectGmail(): Promise<GmailConnectResult> {
|
||||||
|
const config = loadClientConfig()
|
||||||
|
const server = await startLoopbackServer()
|
||||||
|
const state = randomBytes(16).toString('hex')
|
||||||
|
|
||||||
|
const authUrl = new URL(AUTH_URL)
|
||||||
|
authUrl.searchParams.set('client_id', config.clientId)
|
||||||
|
authUrl.searchParams.set('redirect_uri', server.redirectUri)
|
||||||
|
authUrl.searchParams.set('response_type', 'code')
|
||||||
|
authUrl.searchParams.set('scope', SCOPES.join(' '))
|
||||||
|
authUrl.searchParams.set('access_type', 'offline')
|
||||||
|
authUrl.searchParams.set('prompt', 'consent')
|
||||||
|
authUrl.searchParams.set('state', state)
|
||||||
|
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 520,
|
||||||
|
height: 720,
|
||||||
|
title: 'Accedi con Google',
|
||||||
|
webPreferences: {
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let resolved = false
|
||||||
|
const cleanup = (): void => {
|
||||||
|
server.close()
|
||||||
|
if (!win.isDestroyed()) win.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelOnClose = new Promise<never>((_, reject) => {
|
||||||
|
win.on('closed', () => {
|
||||||
|
if (!resolved) reject(new Error('Login Google annullato'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await win.loadURL(authUrl.toString())
|
||||||
|
const code = await Promise.race([server.waitForCode(state), cancelOnClose])
|
||||||
|
resolved = true
|
||||||
|
|
||||||
|
const tokens = await exchangeCode(code, config, server.redirectUri)
|
||||||
|
const profile = await fetchUserInfo(tokens.accessToken)
|
||||||
|
|
||||||
|
return {
|
||||||
|
email: profile.email,
|
||||||
|
name: profile.name,
|
||||||
|
refreshToken: tokens.refreshToken,
|
||||||
|
accessToken: tokens.accessToken,
|
||||||
|
expiresAt: tokens.expiresAt
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenResponse {
|
||||||
|
access_token: string
|
||||||
|
refresh_token?: string
|
||||||
|
expires_in: number
|
||||||
|
token_type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exchangeCode(
|
||||||
|
code: string,
|
||||||
|
config: ClientConfig,
|
||||||
|
redirectUri: string
|
||||||
|
): Promise<{ accessToken: string; refreshToken: string; expiresAt: number }> {
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
code,
|
||||||
|
client_id: config.clientId,
|
||||||
|
client_secret: config.clientSecret,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
grant_type: 'authorization_code'
|
||||||
|
})
|
||||||
|
const resp = await fetch(TOKEN_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: body.toString()
|
||||||
|
})
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`Token exchange fallito: ${resp.status} ${await resp.text()}`)
|
||||||
|
}
|
||||||
|
const json = (await resp.json()) as TokenResponse
|
||||||
|
if (!json.refresh_token) {
|
||||||
|
throw new Error('Refresh token non ricevuto. Revoca l\'app su Google e riprova.')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
accessToken: json.access_token,
|
||||||
|
refreshToken: json.refresh_token,
|
||||||
|
expiresAt: Date.now() + (json.expires_in - 60) * 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUserInfo(accessToken: string): Promise<{ email: string; name?: string }> {
|
||||||
|
const resp = await fetch(USERINFO_URL, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` }
|
||||||
|
})
|
||||||
|
if (!resp.ok) throw new Error(`Userinfo: ${resp.status} ${await resp.text()}`)
|
||||||
|
const json = (await resp.json()) as { email: string; name?: string }
|
||||||
|
return json
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshAccessToken(account: EmailAccount): Promise<string> {
|
||||||
|
if (!account.oauth?.refreshToken) {
|
||||||
|
throw new Error('Account senza refresh token')
|
||||||
|
}
|
||||||
|
if (account.oauth.accessToken && account.oauth.expiresAt && account.oauth.expiresAt > Date.now()) {
|
||||||
|
return account.oauth.accessToken
|
||||||
|
}
|
||||||
|
const config = loadClientConfig()
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
client_id: config.clientId,
|
||||||
|
client_secret: config.clientSecret,
|
||||||
|
refresh_token: account.oauth.refreshToken,
|
||||||
|
grant_type: 'refresh_token'
|
||||||
|
})
|
||||||
|
const resp = await fetch(TOKEN_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: body.toString()
|
||||||
|
})
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`Refresh fallito: ${resp.status} ${await resp.text()}`)
|
||||||
|
}
|
||||||
|
const json = (await resp.json()) as TokenResponse
|
||||||
|
const expiresAt = Date.now() + (json.expires_in - 60) * 1000
|
||||||
|
accountsRepo.updateTokens(account.id, {
|
||||||
|
accessToken: json.access_token,
|
||||||
|
expiresAt,
|
||||||
|
refreshToken: json.refresh_token
|
||||||
|
})
|
||||||
|
return json.access_token
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { ImapFlow } from 'imapflow'
|
||||||
|
import { simpleParser, type ParsedMail } from 'mailparser'
|
||||||
|
import { EventEmitter } from 'events'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { mkdirSync, writeFileSync } from 'fs'
|
||||||
|
import { app } from 'electron'
|
||||||
|
import type { EmailAccount, Message, Attachment } from '@shared/types'
|
||||||
|
import { messagesRepo, accountsRepo } from '../db/repositories'
|
||||||
|
import { refreshAccessToken } from './gmail-oauth'
|
||||||
|
|
||||||
|
async function buildImapClient(account: EmailAccount): Promise<ImapFlow> {
|
||||||
|
if (account.authType === 'oauth_google') {
|
||||||
|
const accessToken = await refreshAccessToken(account)
|
||||||
|
return new ImapFlow({
|
||||||
|
host: account.imap.host || 'imap.gmail.com',
|
||||||
|
port: account.imap.port || 993,
|
||||||
|
secure: account.imap.secure ?? true,
|
||||||
|
auth: { user: account.imap.user || account.smtp.fromAddress, accessToken },
|
||||||
|
logger: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return new ImapFlow({
|
||||||
|
host: account.imap.host,
|
||||||
|
port: account.imap.port,
|
||||||
|
secure: account.imap.secure,
|
||||||
|
auth: { user: account.imap.user, pass: account.imap.pass },
|
||||||
|
logger: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PollerState {
|
||||||
|
client?: ImapFlow
|
||||||
|
timer?: NodeJS.Timeout
|
||||||
|
running: boolean
|
||||||
|
account: EmailAccount
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ImapService extends EventEmitter {
|
||||||
|
private pollers = new Map<number, PollerState>()
|
||||||
|
|
||||||
|
async testConnection(account: EmailAccount): Promise<void> {
|
||||||
|
const client = await buildImapClient(account)
|
||||||
|
await client.connect()
|
||||||
|
await client.logout()
|
||||||
|
}
|
||||||
|
|
||||||
|
startPolling(account: EmailAccount): void {
|
||||||
|
this.stopPolling(account.id)
|
||||||
|
const state: PollerState = { running: false, account }
|
||||||
|
this.pollers.set(account.id, state)
|
||||||
|
const intervalMs = Math.max(15, account.pollInterval) * 1000
|
||||||
|
const tick = async (): Promise<void> => {
|
||||||
|
if (state.running) return
|
||||||
|
state.running = true
|
||||||
|
try {
|
||||||
|
await this.pollOnce(account)
|
||||||
|
} catch (err) {
|
||||||
|
this.emit('log', {
|
||||||
|
level: 'error',
|
||||||
|
source: `imap:${account.name}`,
|
||||||
|
message: (err as Error).message
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
state.running = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.timer = setInterval(tick, intervalMs)
|
||||||
|
void tick()
|
||||||
|
}
|
||||||
|
|
||||||
|
stopPolling(accountId: number): void {
|
||||||
|
const state = this.pollers.get(accountId)
|
||||||
|
if (!state) return
|
||||||
|
if (state.timer) clearInterval(state.timer)
|
||||||
|
if (state.client && state.client.usable) {
|
||||||
|
void state.client.logout().catch(() => undefined)
|
||||||
|
}
|
||||||
|
this.pollers.delete(accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
stopAll(): void {
|
||||||
|
for (const id of [...this.pollers.keys()]) this.stopPolling(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pollOnce(account: EmailAccount): Promise<void> {
|
||||||
|
const client = await buildImapClient(account)
|
||||||
|
await client.connect()
|
||||||
|
try {
|
||||||
|
const lock = await client.getMailboxLock('INBOX')
|
||||||
|
try {
|
||||||
|
const lastUid = accountsRepo.getLastUid(account.id)
|
||||||
|
let uids: number[]
|
||||||
|
if (lastUid == null) {
|
||||||
|
// First poll: bring in the last 2 days of mail
|
||||||
|
const since = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000)
|
||||||
|
const result = (await client.search({ since }, { uid: true })) as number[] | false
|
||||||
|
uids = result ? result : []
|
||||||
|
this.emit('log', {
|
||||||
|
level: 'info',
|
||||||
|
source: `imap:${account.name}`,
|
||||||
|
message: `Primo poll: trovate ${uids.length} email dalle ultime 48h`
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Subsequent polls: only UIDs newer than the last processed one
|
||||||
|
const result = (await client.search({ uid: `${lastUid + 1}:*` }, { uid: true })) as
|
||||||
|
| number[]
|
||||||
|
| false
|
||||||
|
uids = result ? result : []
|
||||||
|
}
|
||||||
|
|
||||||
|
let maxUid = lastUid ?? 0
|
||||||
|
for (const uid of uids) {
|
||||||
|
const msg = await client.fetchOne(
|
||||||
|
uid.toString(),
|
||||||
|
{ source: true, envelope: true, uid: true },
|
||||||
|
{ uid: true }
|
||||||
|
)
|
||||||
|
if (!msg || !msg.source) continue
|
||||||
|
const parsed = await simpleParser(msg.source as Buffer)
|
||||||
|
const externalId = parsed.messageId || `uid-${account.id}-${uid}`
|
||||||
|
if (uid > maxUid) maxUid = uid
|
||||||
|
if (messagesRepo.findByExternalId('smtp', externalId)) continue
|
||||||
|
|
||||||
|
const attachments = await this.persistAttachments(account.id, externalId, parsed)
|
||||||
|
const stored = messagesRepo.create({
|
||||||
|
accountId: account.id,
|
||||||
|
direction: 'inbound',
|
||||||
|
channel: 'smtp',
|
||||||
|
externalId,
|
||||||
|
fromAddr: parsed.from?.text,
|
||||||
|
toAddr: parsed.to
|
||||||
|
? Array.isArray(parsed.to)
|
||||||
|
? parsed.to.map((t) => t.text).join(', ')
|
||||||
|
: parsed.to.text
|
||||||
|
: undefined,
|
||||||
|
cc: parsed.cc
|
||||||
|
? Array.isArray(parsed.cc)
|
||||||
|
? parsed.cc.map((t) => t.text).join(', ')
|
||||||
|
: parsed.cc.text
|
||||||
|
: undefined,
|
||||||
|
subject: parsed.subject,
|
||||||
|
bodyText: parsed.text,
|
||||||
|
bodyHtml: typeof parsed.html === 'string' ? parsed.html : undefined,
|
||||||
|
status: 'received',
|
||||||
|
receivedAt: (parsed.date ?? new Date()).toISOString(),
|
||||||
|
attachments
|
||||||
|
} as Omit<Message, 'id'>)
|
||||||
|
this.emit('message', stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxUid > (lastUid ?? 0)) {
|
||||||
|
accountsRepo.setLastUid(account.id, maxUid)
|
||||||
|
} else if (lastUid == null) {
|
||||||
|
// No mail in last 2 days: anchor at current mailbox state
|
||||||
|
const status = await client.status('INBOX', { uidNext: true })
|
||||||
|
const anchor = (status.uidNext ?? 1) - 1
|
||||||
|
accountsRepo.setLastUid(account.id, anchor)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
lock.release()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await client.logout().catch(() => undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistAttachments(
|
||||||
|
accountId: number,
|
||||||
|
externalId: string,
|
||||||
|
parsed: ParsedMail
|
||||||
|
): Promise<Attachment[]> {
|
||||||
|
if (!parsed.attachments || parsed.attachments.length === 0) return []
|
||||||
|
const dir = join(app.getPath('userData'), 'attachments', String(accountId), externalId.replace(/[^a-z0-9._-]/gi, '_'))
|
||||||
|
mkdirSync(dir, { recursive: true })
|
||||||
|
const result: Attachment[] = []
|
||||||
|
for (const att of parsed.attachments) {
|
||||||
|
const filename = att.filename || `attachment-${Date.now()}`
|
||||||
|
const filePath = join(dir, filename)
|
||||||
|
writeFileSync(filePath, att.content)
|
||||||
|
result.push({
|
||||||
|
filename,
|
||||||
|
contentType: att.contentType,
|
||||||
|
size: att.size,
|
||||||
|
path: filePath
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const imapService = new ImapService()
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import nodemailer, { type Transporter } from 'nodemailer'
|
||||||
|
import type { EmailAccount, Attachment } from '@shared/types'
|
||||||
|
import { refreshAccessToken } from './gmail-oauth'
|
||||||
|
|
||||||
|
export class SmtpService {
|
||||||
|
async verify(account: EmailAccount): Promise<void> {
|
||||||
|
const t = await this.buildTransporter(account)
|
||||||
|
try {
|
||||||
|
await t.verify()
|
||||||
|
} finally {
|
||||||
|
t.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(
|
||||||
|
account: EmailAccount,
|
||||||
|
opts: {
|
||||||
|
to: string
|
||||||
|
cc?: string
|
||||||
|
bcc?: string
|
||||||
|
subject?: string
|
||||||
|
text?: string
|
||||||
|
html?: string
|
||||||
|
attachments?: Attachment[]
|
||||||
|
}
|
||||||
|
): Promise<{ messageId: string }> {
|
||||||
|
const t = await this.buildTransporter(account)
|
||||||
|
try {
|
||||||
|
const from = account.smtp.fromName
|
||||||
|
? `${account.smtp.fromName} <${account.smtp.fromAddress}>`
|
||||||
|
: account.smtp.fromAddress
|
||||||
|
const info = await t.sendMail({
|
||||||
|
from,
|
||||||
|
to: opts.to,
|
||||||
|
cc: opts.cc,
|
||||||
|
bcc: opts.bcc,
|
||||||
|
subject: opts.subject,
|
||||||
|
text: opts.text,
|
||||||
|
html: opts.html,
|
||||||
|
attachments: opts.attachments?.map((a) => ({
|
||||||
|
filename: a.filename,
|
||||||
|
path: a.path,
|
||||||
|
content: a.data ? Buffer.from(a.data, 'base64') : undefined,
|
||||||
|
contentType: a.contentType
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
return { messageId: info.messageId }
|
||||||
|
} finally {
|
||||||
|
t.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildTransporter(account: EmailAccount): Promise<Transporter> {
|
||||||
|
if (account.authType === 'oauth_google') {
|
||||||
|
const accessToken = await refreshAccessToken(account)
|
||||||
|
return nodemailer.createTransport({
|
||||||
|
host: account.smtp.host || 'smtp.gmail.com',
|
||||||
|
port: account.smtp.port || 465,
|
||||||
|
secure: account.smtp.secure ?? true,
|
||||||
|
auth: {
|
||||||
|
type: 'OAuth2',
|
||||||
|
user: account.smtp.user || account.smtp.fromAddress,
|
||||||
|
accessToken
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nodemailer.createTransport({
|
||||||
|
host: account.smtp.host,
|
||||||
|
port: account.smtp.port,
|
||||||
|
secure: account.smtp.secure,
|
||||||
|
auth: { user: account.smtp.user, pass: account.smtp.pass }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidate(_accountId: number): void {
|
||||||
|
// No-op: transporter is built per-send to support OAuth refresh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const smtpService = new SmtpService()
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { TelegramClient, Api } from 'telegram'
|
||||||
|
import { StringSession } from 'telegram/sessions/index.js'
|
||||||
|
import { computeCheck } from 'telegram/Password.js'
|
||||||
|
import type { TelegramUserAccount, Attachment } from '@shared/types'
|
||||||
|
import { telegramUserRepo } from '../db/repositories'
|
||||||
|
import { settingsRepo, SETTING_KEYS } from '../db/settings'
|
||||||
|
|
||||||
|
interface PendingLogin {
|
||||||
|
client: TelegramClient
|
||||||
|
phoneNumber: string
|
||||||
|
phoneCodeHash: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppCreds {
|
||||||
|
apiId: number
|
||||||
|
apiHash: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAppCreds(): AppCreds {
|
||||||
|
const apiIdRaw = settingsRepo.get(SETTING_KEYS.telegramApiId)
|
||||||
|
const apiHash = settingsRepo.get(SETTING_KEYS.telegramApiHash)
|
||||||
|
if (!apiIdRaw || !apiHash) {
|
||||||
|
throw new Error(
|
||||||
|
'Telegram api_id/api_hash non configurati. Vai in Impostazioni → Telegram MTProto.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const apiId = Number(apiIdRaw)
|
||||||
|
if (!Number.isFinite(apiId) || apiId <= 0) {
|
||||||
|
throw new Error('api_id non valido (deve essere un numero intero positivo).')
|
||||||
|
}
|
||||||
|
return { apiId, apiHash }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TelegramMtprotoService {
|
||||||
|
private pending = new Map<number, PendingLogin>()
|
||||||
|
private connected = new Map<number, TelegramClient>()
|
||||||
|
|
||||||
|
async startLogin(accountId: number): Promise<void> {
|
||||||
|
const account = telegramUserRepo.get(accountId)
|
||||||
|
if (!account) throw new Error('Account non trovato')
|
||||||
|
const { apiId, apiHash } = loadAppCreds()
|
||||||
|
|
||||||
|
// Cleanup previous pending login for this account
|
||||||
|
const prev = this.pending.get(accountId)
|
||||||
|
if (prev) {
|
||||||
|
try {
|
||||||
|
await prev.client.disconnect()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
this.pending.delete(accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = new StringSession('')
|
||||||
|
const client = new TelegramClient(session, apiId, apiHash, {
|
||||||
|
connectionRetries: 3,
|
||||||
|
useWSS: false,
|
||||||
|
autoReconnect: false
|
||||||
|
})
|
||||||
|
// Quiet down internal logger to avoid filling stdout
|
||||||
|
;(client as any).setLogLevel?.('error')
|
||||||
|
|
||||||
|
await client.connect()
|
||||||
|
const result = (await client.sendCode(
|
||||||
|
{ apiId, apiHash },
|
||||||
|
account.phone
|
||||||
|
)) as { phoneCodeHash: string; isCodeViaApp: boolean }
|
||||||
|
|
||||||
|
this.pending.set(accountId, {
|
||||||
|
client,
|
||||||
|
phoneNumber: account.phone,
|
||||||
|
phoneCodeHash: result.phoneCodeHash
|
||||||
|
})
|
||||||
|
telegramUserRepo.update(accountId, { status: 'pending_code' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitCode(accountId: number, code: string): Promise<{
|
||||||
|
ok: boolean
|
||||||
|
requires2fa: boolean
|
||||||
|
status: TelegramUserAccount['status']
|
||||||
|
}> {
|
||||||
|
const pending = this.pending.get(accountId)
|
||||||
|
if (!pending) throw new Error('Nessun login in corso. Premi "Avvia login" prima.')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pending.client.invoke(
|
||||||
|
new Api.auth.SignIn({
|
||||||
|
phoneNumber: pending.phoneNumber,
|
||||||
|
phoneCodeHash: pending.phoneCodeHash,
|
||||||
|
phoneCode: code
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
const message = (err as Error).message || ''
|
||||||
|
if (/SESSION_PASSWORD_NEEDED/i.test(message)) {
|
||||||
|
telegramUserRepo.update(accountId, { status: 'pending_2fa' })
|
||||||
|
return { ok: false, requires2fa: true, status: 'pending_2fa' }
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.finalizeLogin(accountId, pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
async submit2fa(
|
||||||
|
accountId: number,
|
||||||
|
password: string
|
||||||
|
): Promise<{ ok: boolean; status: TelegramUserAccount['status'] }> {
|
||||||
|
const pending = this.pending.get(accountId)
|
||||||
|
if (!pending) throw new Error('Nessun login in corso')
|
||||||
|
|
||||||
|
const passwordInfo = await pending.client.invoke(new Api.account.GetPassword())
|
||||||
|
const check = await computeCheck(passwordInfo, password)
|
||||||
|
await pending.client.invoke(new Api.auth.CheckPassword({ password: check }))
|
||||||
|
|
||||||
|
const result = await this.finalizeLogin(accountId, pending)
|
||||||
|
return { ok: result.ok, status: result.status }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async finalizeLogin(
|
||||||
|
accountId: number,
|
||||||
|
pending: PendingLogin
|
||||||
|
): Promise<{ ok: boolean; requires2fa: false; status: TelegramUserAccount['status'] }> {
|
||||||
|
const sessionString = (pending.client.session as StringSession).save() as unknown as string
|
||||||
|
telegramUserRepo.update(accountId, {
|
||||||
|
sessionString,
|
||||||
|
status: 'authorized'
|
||||||
|
})
|
||||||
|
// Keep the connected client cached for immediate sends
|
||||||
|
this.connected.set(accountId, pending.client)
|
||||||
|
this.pending.delete(accountId)
|
||||||
|
return { ok: true, requires2fa: false, status: 'authorized' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(accountId: number): Promise<void> {
|
||||||
|
const connected = this.connected.get(accountId)
|
||||||
|
if (connected) {
|
||||||
|
try {
|
||||||
|
await connected.invoke(new Api.auth.LogOut())
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await connected.disconnect()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
this.connected.delete(accountId)
|
||||||
|
}
|
||||||
|
const pending = this.pending.get(accountId)
|
||||||
|
if (pending) {
|
||||||
|
try {
|
||||||
|
await pending.client.disconnect()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
this.pending.delete(accountId)
|
||||||
|
}
|
||||||
|
telegramUserRepo.update(accountId, { sessionString: undefined, status: 'disconnected' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(
|
||||||
|
account: TelegramUserAccount,
|
||||||
|
recipient: string,
|
||||||
|
text: string,
|
||||||
|
_attachments?: Attachment[]
|
||||||
|
): Promise<{ messageId: string }> {
|
||||||
|
if (!account.sessionString) {
|
||||||
|
throw new Error('Account non autenticato. Esegui prima il login.')
|
||||||
|
}
|
||||||
|
const client = await this.ensureConnected(account)
|
||||||
|
const entity = await this.resolveRecipient(client, recipient)
|
||||||
|
const sent = await client.sendMessage(entity, { message: text })
|
||||||
|
return { messageId: String((sent as any).id ?? '0') }
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveContact(
|
||||||
|
account: TelegramUserAccount,
|
||||||
|
recipient: string
|
||||||
|
): Promise<{ id: string; firstName?: string; lastName?: string; username?: string; phone?: string }> {
|
||||||
|
const client = await this.ensureConnected(account)
|
||||||
|
const entity = await this.resolveRecipient(client, recipient)
|
||||||
|
return {
|
||||||
|
id: String((entity as any).id ?? ''),
|
||||||
|
firstName: (entity as any).firstName,
|
||||||
|
lastName: (entity as any).lastName,
|
||||||
|
username: (entity as any).username,
|
||||||
|
phone: (entity as any).phone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureConnected(account: TelegramUserAccount): Promise<TelegramClient> {
|
||||||
|
const existing = this.connected.get(account.id)
|
||||||
|
if (existing && existing.connected) return existing
|
||||||
|
const { apiId, apiHash } = loadAppCreds()
|
||||||
|
const session = new StringSession(account.sessionString)
|
||||||
|
const client = new TelegramClient(session, apiId, apiHash, {
|
||||||
|
connectionRetries: 3,
|
||||||
|
useWSS: false,
|
||||||
|
autoReconnect: false
|
||||||
|
})
|
||||||
|
;(client as any).setLogLevel?.('error')
|
||||||
|
await client.connect()
|
||||||
|
const authed = await client.isUserAuthorized()
|
||||||
|
if (!authed) {
|
||||||
|
await client.disconnect().catch(() => undefined)
|
||||||
|
telegramUserRepo.update(account.id, { status: 'disconnected', sessionString: undefined })
|
||||||
|
throw new Error('Sessione non valida: ripeti il login.')
|
||||||
|
}
|
||||||
|
this.connected.set(account.id, client)
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveRecipient(client: TelegramClient, recipient: string): Promise<any> {
|
||||||
|
const trimmed = recipient.trim()
|
||||||
|
if (!trimmed) throw new Error('Destinatario vuoto')
|
||||||
|
|
||||||
|
// username
|
||||||
|
if (trimmed.startsWith('@')) {
|
||||||
|
return await client.getEntity(trimmed)
|
||||||
|
}
|
||||||
|
// phone (international format)
|
||||||
|
const digits = trimmed.replace(/\D/g, '')
|
||||||
|
if (digits.length >= 6) {
|
||||||
|
const phone = trimmed.startsWith('+') ? trimmed : `+${digits}`
|
||||||
|
try {
|
||||||
|
return await client.getEntity(phone)
|
||||||
|
} catch (err) {
|
||||||
|
// Try ImportContacts fallback
|
||||||
|
const imported = await client.invoke(
|
||||||
|
new Api.contacts.ImportContacts({
|
||||||
|
contacts: [
|
||||||
|
new Api.InputPhoneContact({
|
||||||
|
clientId: BigInt(0) as any,
|
||||||
|
phone,
|
||||||
|
firstName: 'Imported',
|
||||||
|
lastName: ''
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
if (!imported.users.length) {
|
||||||
|
throw new Error(`Il numero ${phone} non risulta su Telegram o non è raggiungibile.`)
|
||||||
|
}
|
||||||
|
return imported.users[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// assume username without @
|
||||||
|
return await client.getEntity(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnectAll(): Promise<void> {
|
||||||
|
for (const [id, client] of [...this.connected]) {
|
||||||
|
try {
|
||||||
|
await client.disconnect()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
this.connected.delete(id)
|
||||||
|
}
|
||||||
|
for (const [id, p] of [...this.pending]) {
|
||||||
|
try {
|
||||||
|
await p.client.disconnect()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
this.pending.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const telegramMtprotoService = new TelegramMtprotoService()
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { Telegraf } from 'telegraf'
|
||||||
|
import type { TelegramBot, Attachment } from '@shared/types'
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
|
||||||
|
export interface DiscoveredChat {
|
||||||
|
chatId: string
|
||||||
|
type: string
|
||||||
|
title?: string
|
||||||
|
username?: string
|
||||||
|
firstName?: string
|
||||||
|
lastName?: string
|
||||||
|
lastMessage?: string
|
||||||
|
lastUpdateUnix?: number
|
||||||
|
lastUpdateAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TelegramService {
|
||||||
|
private bots = new Map<number, Telegraf>()
|
||||||
|
|
||||||
|
private getBot(bot: TelegramBot): Telegraf {
|
||||||
|
const existing = this.bots.get(bot.id)
|
||||||
|
if (existing) return existing
|
||||||
|
const tg = new Telegraf(bot.token)
|
||||||
|
this.bots.set(bot.id, tg)
|
||||||
|
return tg
|
||||||
|
}
|
||||||
|
|
||||||
|
async verify(bot: TelegramBot): Promise<{ username?: string; firstName?: string }> {
|
||||||
|
const tg = new Telegraf(bot.token)
|
||||||
|
const me = await tg.telegram.getMe()
|
||||||
|
return { username: me.username, firstName: me.first_name }
|
||||||
|
}
|
||||||
|
|
||||||
|
async discoverChats(bot: TelegramBot): Promise<DiscoveredChat[]> {
|
||||||
|
const tg = new Telegraf(bot.token)
|
||||||
|
const updates = await tg.telegram.getUpdates(0, 100, undefined as any, [])
|
||||||
|
const map = new Map<string, DiscoveredChat>()
|
||||||
|
for (const u of updates) {
|
||||||
|
const msg =
|
||||||
|
(u as any).message ||
|
||||||
|
(u as any).edited_message ||
|
||||||
|
(u as any).channel_post ||
|
||||||
|
(u as any).callback_query?.message
|
||||||
|
if (!msg?.chat) continue
|
||||||
|
const chat = msg.chat as {
|
||||||
|
id: number
|
||||||
|
type: string
|
||||||
|
title?: string
|
||||||
|
username?: string
|
||||||
|
first_name?: string
|
||||||
|
last_name?: string
|
||||||
|
}
|
||||||
|
const lastText: string = (msg as any).text ?? '(media)'
|
||||||
|
const lastDate: number | undefined = (msg as any).date
|
||||||
|
const key = String(chat.id)
|
||||||
|
const existing = map.get(key)
|
||||||
|
if (!existing || (lastDate ?? 0) >= (existing.lastUpdateUnix ?? 0)) {
|
||||||
|
map.set(key, {
|
||||||
|
chatId: key,
|
||||||
|
type: chat.type,
|
||||||
|
title: chat.title,
|
||||||
|
username: chat.username,
|
||||||
|
firstName: chat.first_name,
|
||||||
|
lastName: chat.last_name,
|
||||||
|
lastMessage: typeof lastText === 'string' ? lastText.slice(0, 200) : '',
|
||||||
|
lastUpdateUnix: lastDate,
|
||||||
|
lastUpdateAt: lastDate ? new Date(lastDate * 1000).toISOString() : undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...map.values()].sort(
|
||||||
|
(a, b) => (b.lastUpdateUnix ?? 0) - (a.lastUpdateUnix ?? 0)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(
|
||||||
|
bot: TelegramBot,
|
||||||
|
chatId: string,
|
||||||
|
text: string,
|
||||||
|
attachments?: Attachment[]
|
||||||
|
): Promise<{ messageId: number }> {
|
||||||
|
const tg = this.getBot(bot)
|
||||||
|
let last: number | undefined
|
||||||
|
const chunks = splitForTelegram(text)
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
const sent = await tg.telegram.sendMessage(chatId, chunk, { parse_mode: 'HTML' })
|
||||||
|
last = sent.message_id
|
||||||
|
}
|
||||||
|
if (attachments && attachments.length) {
|
||||||
|
for (const att of attachments) {
|
||||||
|
if (!att.path) continue
|
||||||
|
const buf = readFileSync(att.path)
|
||||||
|
const isImage = /^image\//i.test(att.contentType)
|
||||||
|
if (isImage) {
|
||||||
|
const sent = await tg.telegram.sendPhoto(chatId, { source: buf, filename: att.filename })
|
||||||
|
last = sent.message_id
|
||||||
|
} else {
|
||||||
|
const sent = await tg.telegram.sendDocument(chatId, {
|
||||||
|
source: buf,
|
||||||
|
filename: att.filename
|
||||||
|
})
|
||||||
|
last = sent.message_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { messageId: last ?? 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidate(botId: number): void {
|
||||||
|
this.bots.delete(botId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitForTelegram(text: string, max = 3900): string[] {
|
||||||
|
if (text.length <= max) return [text]
|
||||||
|
const out: string[] = []
|
||||||
|
let i = 0
|
||||||
|
while (i < text.length) {
|
||||||
|
out.push(text.slice(i, i + max))
|
||||||
|
i += max
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export const telegramService = new TelegramService()
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { Client, LocalAuth, MessageMedia } from 'whatsapp-web.js'
|
||||||
|
import { EventEmitter } from 'events'
|
||||||
|
import { app } from 'electron'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { mkdirSync } from 'fs'
|
||||||
|
import type { WhatsappSession, Attachment } from '@shared/types'
|
||||||
|
import { whatsappRepo } from '../db/repositories'
|
||||||
|
|
||||||
|
interface SessionState {
|
||||||
|
client: Client
|
||||||
|
status: WhatsappSession['status']
|
||||||
|
lastQr?: string
|
||||||
|
phone?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WhatsappService extends EventEmitter {
|
||||||
|
private sessions = new Map<number, SessionState>()
|
||||||
|
|
||||||
|
async start(session: WhatsappSession): Promise<void> {
|
||||||
|
if (this.sessions.has(session.id)) return
|
||||||
|
const sessionsRoot = join(app.getPath('userData'), 'whatsapp-sessions')
|
||||||
|
mkdirSync(sessionsRoot, { recursive: true })
|
||||||
|
|
||||||
|
const client = new Client({
|
||||||
|
authStrategy: new LocalAuth({
|
||||||
|
clientId: `wa-${session.id}`,
|
||||||
|
dataPath: sessionsRoot
|
||||||
|
}),
|
||||||
|
puppeteer: {
|
||||||
|
headless: true,
|
||||||
|
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const state: SessionState = { client, status: 'disconnected' }
|
||||||
|
this.sessions.set(session.id, state)
|
||||||
|
|
||||||
|
client.on('qr', (qr) => {
|
||||||
|
state.lastQr = qr
|
||||||
|
state.status = 'pending_qr'
|
||||||
|
whatsappRepo.update(session.id, { status: 'pending_qr' })
|
||||||
|
this.emit('qr', { sessionId: session.id, qr })
|
||||||
|
this.emit('status', { sessionId: session.id, status: 'pending_qr' })
|
||||||
|
})
|
||||||
|
|
||||||
|
client.on('ready', () => {
|
||||||
|
state.status = 'ready'
|
||||||
|
state.phone = client.info?.wid?.user
|
||||||
|
whatsappRepo.update(session.id, { status: 'ready', phone: state.phone })
|
||||||
|
this.emit('status', { sessionId: session.id, status: 'ready', phone: state.phone })
|
||||||
|
})
|
||||||
|
|
||||||
|
client.on('auth_failure', () => {
|
||||||
|
state.status = 'auth_failure'
|
||||||
|
whatsappRepo.update(session.id, { status: 'auth_failure' })
|
||||||
|
this.emit('status', { sessionId: session.id, status: 'auth_failure' })
|
||||||
|
})
|
||||||
|
|
||||||
|
client.on('disconnected', () => {
|
||||||
|
state.status = 'disconnected'
|
||||||
|
whatsappRepo.update(session.id, { status: 'disconnected' })
|
||||||
|
this.emit('status', { sessionId: session.id, status: 'disconnected' })
|
||||||
|
})
|
||||||
|
|
||||||
|
await client.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(sessionId: number): Promise<void> {
|
||||||
|
const state = this.sessions.get(sessionId)
|
||||||
|
if (!state) return
|
||||||
|
await state.client.destroy().catch(() => undefined)
|
||||||
|
this.sessions.delete(sessionId)
|
||||||
|
whatsappRepo.update(sessionId, { status: 'disconnected' })
|
||||||
|
this.emit('status', { sessionId, status: 'disconnected' })
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus(sessionId: number): { status: WhatsappSession['status']; qr?: string; phone?: string } {
|
||||||
|
const s = this.sessions.get(sessionId)
|
||||||
|
if (!s) return { status: 'disconnected' }
|
||||||
|
return { status: s.status, qr: s.lastQr, phone: s.phone }
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(
|
||||||
|
sessionId: number,
|
||||||
|
recipient: string,
|
||||||
|
text: string,
|
||||||
|
attachments?: Attachment[]
|
||||||
|
): Promise<{ messageId: string }> {
|
||||||
|
const s = this.sessions.get(sessionId)
|
||||||
|
if (!s) throw new Error('Sessione WhatsApp non avviata')
|
||||||
|
if (s.status !== 'ready') throw new Error(`Sessione WhatsApp non pronta (stato: ${s.status})`)
|
||||||
|
|
||||||
|
const chatId = normalizeRecipient(recipient)
|
||||||
|
const sent = await s.client.sendMessage(chatId, text)
|
||||||
|
|
||||||
|
if (attachments) {
|
||||||
|
for (const att of attachments) {
|
||||||
|
if (!att.path) continue
|
||||||
|
const media = MessageMedia.fromFilePath(att.path)
|
||||||
|
await s.client.sendMessage(chatId, media, { caption: att.filename })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { messageId: sent.id._serialized }
|
||||||
|
}
|
||||||
|
|
||||||
|
stopAll(): void {
|
||||||
|
for (const id of [...this.sessions.keys()]) {
|
||||||
|
void this.stop(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRecipient(recipient: string): string {
|
||||||
|
if (recipient.endsWith('@c.us') || recipient.endsWith('@g.us')) return recipient
|
||||||
|
const digits = recipient.replace(/\D/g, '')
|
||||||
|
return `${digits}@c.us`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const whatsappService = new WhatsappService()
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
import type { Api } from './index'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
api: Api
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
|
import { IPC } from '@shared/ipc-channels'
|
||||||
|
import type {
|
||||||
|
EmailAccount,
|
||||||
|
TelegramBot,
|
||||||
|
TelegramUserAccount,
|
||||||
|
WhatsappSession,
|
||||||
|
Message,
|
||||||
|
ForwardingRule,
|
||||||
|
Contact,
|
||||||
|
AiProviderConfig,
|
||||||
|
SendRequest,
|
||||||
|
IpcResponse,
|
||||||
|
AppSettings
|
||||||
|
} from '@shared/types'
|
||||||
|
|
||||||
|
function invoke<T>(channel: string, ...args: unknown[]): Promise<IpcResponse<T>> {
|
||||||
|
return ipcRenderer.invoke(channel, ...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
accounts: {
|
||||||
|
list: () => invoke<EmailAccount[]>(IPC.accountsList),
|
||||||
|
create: (a: Omit<EmailAccount, 'id' | 'createdAt'>) => invoke<EmailAccount>(IPC.accountsCreate, a),
|
||||||
|
update: (id: number, a: Omit<EmailAccount, 'id' | 'createdAt'>) =>
|
||||||
|
invoke<EmailAccount>(IPC.accountsUpdate, id, a),
|
||||||
|
delete: (id: number) => invoke<boolean>(IPC.accountsDelete, id),
|
||||||
|
test: (id: number) => invoke<boolean>(IPC.accountsTest, id),
|
||||||
|
startPolling: (id: number) => invoke<boolean>(IPC.accountsStartPolling, id),
|
||||||
|
stopPolling: (id: number) => invoke<boolean>(IPC.accountsStopPolling, id)
|
||||||
|
},
|
||||||
|
telegram: {
|
||||||
|
list: () => invoke<TelegramBot[]>(IPC.telegramList),
|
||||||
|
create: (b: Omit<TelegramBot, 'id' | 'createdAt'>) => invoke<TelegramBot>(IPC.telegramCreate, b),
|
||||||
|
update: (id: number, b: Omit<TelegramBot, 'id' | 'createdAt'>) =>
|
||||||
|
invoke<TelegramBot>(IPC.telegramUpdate, id, b),
|
||||||
|
delete: (id: number) => invoke<boolean>(IPC.telegramDelete, id),
|
||||||
|
test: (id: number) => invoke<{ username?: string; firstName?: string }>(IPC.telegramTest, id),
|
||||||
|
discover: (id: number) =>
|
||||||
|
invoke<
|
||||||
|
{
|
||||||
|
chatId: string
|
||||||
|
type: string
|
||||||
|
title?: string
|
||||||
|
username?: string
|
||||||
|
firstName?: string
|
||||||
|
lastName?: string
|
||||||
|
lastMessage?: string
|
||||||
|
lastUpdateAt?: string
|
||||||
|
}[]
|
||||||
|
>(IPC.telegramDiscover, id)
|
||||||
|
},
|
||||||
|
telegramUser: {
|
||||||
|
list: () => invoke<TelegramUserAccount[]>(IPC.tguList),
|
||||||
|
create: (t: Omit<TelegramUserAccount, 'id' | 'createdAt'>) =>
|
||||||
|
invoke<TelegramUserAccount>(IPC.tguCreate, t),
|
||||||
|
delete: (id: number) => invoke<boolean>(IPC.tguDelete, id),
|
||||||
|
startLogin: (id: number) => invoke<boolean>(IPC.tguStartLogin, id),
|
||||||
|
submitCode: (id: number, code: string) =>
|
||||||
|
invoke<{ ok: boolean; requires2fa: boolean; status: TelegramUserAccount['status'] }>(
|
||||||
|
IPC.tguSubmitCode,
|
||||||
|
id,
|
||||||
|
code
|
||||||
|
),
|
||||||
|
submit2fa: (id: number, password: string) =>
|
||||||
|
invoke<{ ok: boolean; status: TelegramUserAccount['status'] }>(IPC.tguSubmit2fa, id, password),
|
||||||
|
logout: (id: number) => invoke<boolean>(IPC.tguLogout, id),
|
||||||
|
resolve: (id: number, recipient: string) =>
|
||||||
|
invoke<{
|
||||||
|
id: string
|
||||||
|
firstName?: string
|
||||||
|
lastName?: string
|
||||||
|
username?: string
|
||||||
|
phone?: string
|
||||||
|
}>(IPC.tguResolve, id, recipient)
|
||||||
|
},
|
||||||
|
whatsapp: {
|
||||||
|
list: () => invoke<WhatsappSession[]>(IPC.whatsappList),
|
||||||
|
create: (s: Omit<WhatsappSession, 'id' | 'createdAt'>) =>
|
||||||
|
invoke<WhatsappSession>(IPC.whatsappCreate, s),
|
||||||
|
update: (id: number, s: Partial<Omit<WhatsappSession, 'id' | 'createdAt'>>) =>
|
||||||
|
invoke<WhatsappSession>(IPC.whatsappUpdate, id, s),
|
||||||
|
delete: (id: number) => invoke<boolean>(IPC.whatsappDelete, id),
|
||||||
|
start: (id: number) => invoke<boolean>(IPC.whatsappStart, id),
|
||||||
|
stop: (id: number) => invoke<boolean>(IPC.whatsappStop, id),
|
||||||
|
status: (id: number) =>
|
||||||
|
invoke<{ status: WhatsappSession['status']; qr?: string; phone?: string }>(
|
||||||
|
IPC.whatsappStatus,
|
||||||
|
id
|
||||||
|
)
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
list: (filter?: { direction?: string; channel?: string; limit?: number }) =>
|
||||||
|
invoke<Message[]>(IPC.messagesList, filter),
|
||||||
|
get: (id: number) => invoke<Message>(IPC.messagesGet, id),
|
||||||
|
send: (req: SendRequest) => invoke<Message>(IPC.messagesSend, req),
|
||||||
|
delete: (id: number) => invoke<boolean>(IPC.messagesDelete, id)
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
list: () => invoke<ForwardingRule[]>(IPC.rulesList),
|
||||||
|
create: (r: Omit<ForwardingRule, 'id' | 'createdAt'>) =>
|
||||||
|
invoke<ForwardingRule>(IPC.rulesCreate, r),
|
||||||
|
update: (id: number, r: Omit<ForwardingRule, 'id' | 'createdAt'>) =>
|
||||||
|
invoke<ForwardingRule>(IPC.rulesUpdate, id, r),
|
||||||
|
delete: (id: number) => invoke<boolean>(IPC.rulesDelete, id)
|
||||||
|
},
|
||||||
|
contacts: {
|
||||||
|
list: () => invoke<Contact[]>(IPC.contactsList),
|
||||||
|
create: (c: Omit<Contact, 'id' | 'createdAt'>) => invoke<Contact>(IPC.contactsCreate, c),
|
||||||
|
update: (id: number, c: Omit<Contact, 'id' | 'createdAt'>) =>
|
||||||
|
invoke<Contact>(IPC.contactsUpdate, id, c),
|
||||||
|
delete: (id: number) => invoke<boolean>(IPC.contactsDelete, id),
|
||||||
|
importFile: () =>
|
||||||
|
invoke<{ added: number; skipped: number; total: number; errors: string[]; source: string } | null>(
|
||||||
|
IPC.contactsImport
|
||||||
|
)
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
get: () => invoke<AppSettings>(IPC.settingsGet),
|
||||||
|
update: (s: AppSettings) => invoke<boolean>(IPC.settingsUpdate, s)
|
||||||
|
},
|
||||||
|
gmail: {
|
||||||
|
connect: () => invoke<EmailAccount>(IPC.gmailConnect)
|
||||||
|
},
|
||||||
|
ai: {
|
||||||
|
list: () => invoke<AiProviderConfig[]>(IPC.aiList),
|
||||||
|
create: (p: Omit<AiProviderConfig, 'id'>) => invoke<AiProviderConfig>(IPC.aiCreate, p),
|
||||||
|
update: (id: number, p: Omit<AiProviderConfig, 'id'>) =>
|
||||||
|
invoke<AiProviderConfig>(IPC.aiUpdate, id, p),
|
||||||
|
delete: (id: number) => invoke<boolean>(IPC.aiDelete, id),
|
||||||
|
test: (id: number) => invoke<string>(IPC.aiTest, id),
|
||||||
|
summarize: (messageId: number, providerId?: number) =>
|
||||||
|
invoke<string>(IPC.aiSummarize, messageId, providerId),
|
||||||
|
classify: (messageId: number, providerId?: number) =>
|
||||||
|
invoke<{ category: string; labels: string[] }>(IPC.aiClassify, messageId, providerId),
|
||||||
|
extract: (messageId: number, providerId?: number) =>
|
||||||
|
invoke<Record<string, unknown>>(IPC.aiExtract, messageId, providerId),
|
||||||
|
suggestReply: (messageId: number, providerId?: number) =>
|
||||||
|
invoke<string>(IPC.aiSuggestReply, messageId, providerId)
|
||||||
|
},
|
||||||
|
on: {
|
||||||
|
newMessage: (cb: (m: Message) => void) => {
|
||||||
|
const listener = (_e: unknown, m: Message): void => cb(m)
|
||||||
|
ipcRenderer.on(IPC.eventNewMessage, listener)
|
||||||
|
return () => ipcRenderer.removeListener(IPC.eventNewMessage, listener)
|
||||||
|
},
|
||||||
|
whatsappQr: (cb: (p: { sessionId: number; qr: string }) => void) => {
|
||||||
|
const listener = (_e: unknown, p: { sessionId: number; qr: string }): void => cb(p)
|
||||||
|
ipcRenderer.on(IPC.eventWhatsappQr, listener)
|
||||||
|
return () => ipcRenderer.removeListener(IPC.eventWhatsappQr, listener)
|
||||||
|
},
|
||||||
|
whatsappStatus: (
|
||||||
|
cb: (p: { sessionId: number; status: WhatsappSession['status']; phone?: string }) => void
|
||||||
|
) => {
|
||||||
|
const listener = (
|
||||||
|
_e: unknown,
|
||||||
|
p: { sessionId: number; status: WhatsappSession['status']; phone?: string }
|
||||||
|
): void => cb(p)
|
||||||
|
ipcRenderer.on(IPC.eventWhatsappStatus, listener)
|
||||||
|
return () => ipcRenderer.removeListener(IPC.eventWhatsappStatus, listener)
|
||||||
|
},
|
||||||
|
log: (cb: (entry: { level: string; source: string; message: string }) => void) => {
|
||||||
|
const listener = (
|
||||||
|
_e: unknown,
|
||||||
|
entry: { level: string; source: string; message: string }
|
||||||
|
): void => cb(entry)
|
||||||
|
ipcRenderer.on(IPC.eventLog, listener)
|
||||||
|
return () => ipcRenderer.removeListener(IPC.eventLog, listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('api', api)
|
||||||
|
|
||||||
|
export type Api = typeof api
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta
|
||||||
|
http-equiv="Content-Security-Policy"
|
||||||
|
content="default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob:; connect-src 'self'"
|
||||||
|
/>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<title>EmailToChat</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-ink-50 text-ink-900 dark:bg-ink-950 dark:text-ink-50">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Sidebar, type ViewKey } from './components/Sidebar'
|
||||||
|
import { Toaster, useToaster } from './components/Toaster'
|
||||||
|
import { InboxView } from './views/InboxView'
|
||||||
|
import { ComposeView } from './views/ComposeView'
|
||||||
|
import { RulesView } from './views/RulesView'
|
||||||
|
import { ContactsView } from './views/ContactsView'
|
||||||
|
import { SettingsView } from './views/SettingsView'
|
||||||
|
|
||||||
|
export function App(): JSX.Element {
|
||||||
|
const [view, setView] = useState<ViewKey>('inbox')
|
||||||
|
const toaster = useToaster()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const offMsg = window.api.on.newMessage((m) => {
|
||||||
|
toaster.push({
|
||||||
|
kind: 'info',
|
||||||
|
title: 'Nuovo messaggio',
|
||||||
|
body: m.subject || m.fromAddr || `Messaggio #${m.id}`
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const offLog = window.api.on.log((entry) => {
|
||||||
|
if (entry.level === 'error') {
|
||||||
|
toaster.push({ kind: 'error', title: entry.source, body: entry.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
offMsg()
|
||||||
|
offLog()
|
||||||
|
}
|
||||||
|
}, [toaster])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-full overflow-hidden">
|
||||||
|
{/* Background decorative glows */}
|
||||||
|
<div aria-hidden className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||||
|
<div className="absolute -left-32 -top-32 h-96 w-96 rounded-full bg-brand-500/10 blur-3xl dark:bg-brand-500/20" />
|
||||||
|
<div className="absolute -bottom-32 -right-32 h-96 w-96 rounded-full bg-neon-purple/10 blur-3xl dark:bg-neon-purple/20" />
|
||||||
|
<div className="absolute right-1/3 top-1/2 h-72 w-72 rounded-full bg-neon-cyan/10 blur-3xl dark:bg-neon-cyan/15" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Sidebar active={view} onSelect={setView} />
|
||||||
|
<main key={view} className="relative flex-1 overflow-y-auto animate-fade-in">
|
||||||
|
{view === 'inbox' && <InboxView />}
|
||||||
|
{view === 'compose' && <ComposeView />}
|
||||||
|
{view === 'rules' && <RulesView />}
|
||||||
|
{view === 'contacts' && <ContactsView />}
|
||||||
|
{view === 'settings' && <SettingsView />}
|
||||||
|
</main>
|
||||||
|
<Toaster items={toaster.items} dismiss={toaster.dismiss} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import type { ChannelType, Contact } from '@shared/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
channel: ChannelType
|
||||||
|
contacts: Contact[]
|
||||||
|
value: string
|
||||||
|
onChange: (v: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
autoFocus?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
contactId: number
|
||||||
|
contactName: string
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOptions(channel: ChannelType, contacts: Contact[]): Option[] {
|
||||||
|
const opts: Option[] = []
|
||||||
|
for (const c of contacts) {
|
||||||
|
if (channel === 'smtp') {
|
||||||
|
for (const email of c.emails) {
|
||||||
|
opts.push({
|
||||||
|
contactId: c.id,
|
||||||
|
contactName: c.displayName,
|
||||||
|
value: email,
|
||||||
|
label: `${c.displayName} <${email}>`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (channel === 'telegram' && c.telegramChatId) {
|
||||||
|
opts.push({
|
||||||
|
contactId: c.id,
|
||||||
|
contactName: c.displayName,
|
||||||
|
value: c.telegramChatId,
|
||||||
|
label: `${c.displayName} · ${c.telegramChatId}`
|
||||||
|
})
|
||||||
|
} else if (channel === 'telegram_user' && c.whatsappNumber) {
|
||||||
|
// Reuse phone number field: MTProto sends to numbers (with +)
|
||||||
|
opts.push({
|
||||||
|
contactId: c.id,
|
||||||
|
contactName: c.displayName,
|
||||||
|
value: `+${c.whatsappNumber}`,
|
||||||
|
label: `${c.displayName} · +${c.whatsappNumber}`
|
||||||
|
})
|
||||||
|
} else if (channel === 'whatsapp' && c.whatsappNumber) {
|
||||||
|
opts.push({
|
||||||
|
contactId: c.id,
|
||||||
|
contactName: c.displayName,
|
||||||
|
value: c.whatsappNumber,
|
||||||
|
label: `${c.displayName} · +${c.whatsappNumber}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactPicker({
|
||||||
|
channel,
|
||||||
|
contacts,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
autoFocus
|
||||||
|
}: Props): JSX.Element {
|
||||||
|
const [focused, setFocused] = useState(false)
|
||||||
|
const [highlight, setHighlight] = useState(0)
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const options = useMemo(() => buildOptions(channel, contacts), [channel, contacts])
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = value.trim().toLowerCase()
|
||||||
|
if (!q) return options.slice(0, 8)
|
||||||
|
return options
|
||||||
|
.filter(
|
||||||
|
(o) =>
|
||||||
|
o.label.toLowerCase().includes(q) ||
|
||||||
|
o.value.toLowerCase().includes(q) ||
|
||||||
|
o.contactName.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
.slice(0, 8)
|
||||||
|
}, [options, value])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHighlight(0)
|
||||||
|
}, [value, channel])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onDoc = (e: MouseEvent): void => {
|
||||||
|
if (!wrapperRef.current?.contains(e.target as Node)) setFocused(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDoc)
|
||||||
|
return () => document.removeEventListener('mousedown', onDoc)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const selectedContact = useMemo(() => {
|
||||||
|
const trimmed = value.trim().toLowerCase()
|
||||||
|
if (!trimmed) return undefined
|
||||||
|
return options.find((o) => o.value.toLowerCase() === trimmed)
|
||||||
|
}, [options, value])
|
||||||
|
|
||||||
|
const pick = (o: Option): void => {
|
||||||
|
onChange(o.value)
|
||||||
|
setFocused(false)
|
||||||
|
inputRef.current?.blur()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||||
|
if (!focused || filtered.length === 0) return
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
setHighlight((h) => Math.min(h + 1, filtered.length - 1))
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
setHighlight((h) => Math.max(h - 1, 0))
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
if (filtered[highlight]) {
|
||||||
|
e.preventDefault()
|
||||||
|
pick(filtered[highlight])
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setFocused(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showDropdown = focused && filtered.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapperRef} className="relative">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="input"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onFocus={() => setFocused(true)}
|
||||||
|
onKeyDown={handleKey}
|
||||||
|
placeholder={placeholder}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
{selectedContact && !focused && (
|
||||||
|
<div className="mt-1 text-xs text-slate-500">
|
||||||
|
☺ <span className="font-medium text-slate-700">{selectedContact.contactName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showDropdown && (
|
||||||
|
<ul className="absolute z-30 mt-1 max-h-64 w-full overflow-y-auto rounded-md border border-slate-200 bg-white shadow-lg">
|
||||||
|
{filtered.map((o, idx) => (
|
||||||
|
<li
|
||||||
|
key={`${o.contactId}-${o.value}`}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
pick(o)
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHighlight(idx)}
|
||||||
|
className={`flex cursor-pointer items-center justify-between px-3 py-2 text-sm ${
|
||||||
|
idx === highlight ? 'bg-brand-50 text-brand-700' : 'hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{o.contactName}</span>
|
||||||
|
<span className="ml-3 truncate text-xs text-slate-500">{o.value}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{value.trim() && (
|
||||||
|
<li className="border-t border-slate-100 px-3 py-2 text-xs text-slate-500">
|
||||||
|
Premi <kbd className="rounded bg-slate-100 px-1">Invio</kbd> per selezionare, o
|
||||||
|
continua a scrivere per inserire un valore libero
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
export function Modal({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
footer
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
title: string
|
||||||
|
onClose: () => void
|
||||||
|
children: React.ReactNode
|
||||||
|
footer?: React.ReactNode
|
||||||
|
}): JSX.Element | null {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const handler = (e: KeyboardEvent): void => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handler)
|
||||||
|
return () => window.removeEventListener('keydown', handler)
|
||||||
|
}, [open, onClose])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-40 flex items-center justify-center bg-ink-900/40 p-4 backdrop-blur-sm dark:bg-black/70 animate-fade-in">
|
||||||
|
<div className="glass-strong flex max-h-[90vh] w-full max-w-2xl flex-col rounded-2xl animate-slide-up">
|
||||||
|
<header className="flex items-center justify-between border-b border-ink-200/70 px-6 py-4 dark:border-white/[0.06]">
|
||||||
|
<h2 className="font-display text-lg font-semibold tracking-tight text-ink-900 dark:text-white">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-md text-ink-400 hover:bg-ink-200/60 hover:text-ink-700 dark:text-ink-500 dark:hover:bg-white/[0.06] dark:hover:text-white"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-5">{children}</div>
|
||||||
|
{footer && (
|
||||||
|
<footer className="flex items-center justify-end gap-2 border-t border-ink-200/70 px-6 py-4 dark:border-white/[0.06]">
|
||||||
|
{footer}
|
||||||
|
</footer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Modal } from './Modal'
|
||||||
|
import { unwrap } from '../lib/api'
|
||||||
|
import type { TelegramUserAccount } from '@shared/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
account: TelegramUserAccount
|
||||||
|
onClose: () => void
|
||||||
|
onDone: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type Step = 'start' | 'code' | '2fa' | 'success'
|
||||||
|
|
||||||
|
export function MtProtoLoginModal({ account, onClose, onDone }: Props): JSX.Element {
|
||||||
|
const [step, setStep] = useState<Step>('start')
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const startLogin = async (): Promise<void> => {
|
||||||
|
setError(null)
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await unwrap(window.api.telegramUser.startLogin(account.id))
|
||||||
|
setStep('code')
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitCode = async (): Promise<void> => {
|
||||||
|
setError(null)
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await unwrap(window.api.telegramUser.submitCode(account.id, code.trim()))
|
||||||
|
if (result.requires2fa) {
|
||||||
|
setStep('2fa')
|
||||||
|
} else if (result.ok) {
|
||||||
|
setStep('success')
|
||||||
|
setTimeout(() => {
|
||||||
|
onDone()
|
||||||
|
onClose()
|
||||||
|
}, 1000)
|
||||||
|
} else {
|
||||||
|
setError('Codice non accettato')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit2fa = async (): Promise<void> => {
|
||||||
|
setError(null)
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await unwrap(window.api.telegramUser.submit2fa(account.id, password))
|
||||||
|
if (result.ok) {
|
||||||
|
setStep('success')
|
||||||
|
setTimeout(() => {
|
||||||
|
onDone()
|
||||||
|
onClose()
|
||||||
|
}, 1000)
|
||||||
|
} else {
|
||||||
|
setError('Password 2FA non corretta')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
onClose={onClose}
|
||||||
|
title={`Login Telegram: ${account.name}`}
|
||||||
|
footer={
|
||||||
|
<button className="btn-ghost" onClick={onClose}>
|
||||||
|
{step === 'success' ? 'Chiudi' : 'Annulla'}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-md border border-slate-200 bg-slate-50 p-3 text-xs text-slate-700">
|
||||||
|
<strong>Numero:</strong> {account.phone} ·
|
||||||
|
<strong className="ml-2">Stato:</strong> {account.status}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step === 'start' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-slate-700">
|
||||||
|
Premi <strong>Invia codice</strong>: Telegram ti manderà un codice numerico
|
||||||
|
direttamente nell'app Telegram (sezione "Telegram") o via SMS.
|
||||||
|
</p>
|
||||||
|
<button className="btn-primary" disabled={loading} onClick={startLogin}>
|
||||||
|
{loading ? 'Invio codice…' : 'Invia codice'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'code' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-slate-700">
|
||||||
|
Inserisci il codice ricevuto (in genere 5 cifre).
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="input"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
||||||
|
placeholder="12345"
|
||||||
|
inputMode="numeric"
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && code && submitCode()}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="btn-primary" disabled={loading || !code} onClick={submitCode}>
|
||||||
|
{loading ? 'Verifica…' : 'Verifica codice'}
|
||||||
|
</button>
|
||||||
|
<button className="btn-ghost" disabled={loading} onClick={startLogin}>
|
||||||
|
Reinvia
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === '2fa' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-slate-700">
|
||||||
|
Il tuo account ha la verifica in 2 passaggi (cloud password). Inseriscila per
|
||||||
|
completare il login.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="password"
|
||||||
|
className="input"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Password 2FA"
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && password && submit2fa()}
|
||||||
|
/>
|
||||||
|
<button className="btn-primary" disabled={loading || !password} onClick={submit2fa}>
|
||||||
|
{loading ? 'Verifica…' : 'Verifica password'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'success' && (
|
||||||
|
<div className="rounded-md bg-emerald-50 p-3 text-sm text-emerald-700">
|
||||||
|
✓ Login completato! Sessione salvata.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700">{error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export function PageHeader({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
action
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
action?: React.ReactNode
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="sticky top-0 z-20 flex items-end justify-between gap-4 border-b border-ink-200/70 bg-white/60 px-6 py-5 backdrop-blur-xl dark:border-white/[0.06] dark:bg-ink-950/60">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="font-display text-2xl font-bold tracking-tight text-ink-900 dark:text-white">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{subtitle && (
|
||||||
|
<p className="mt-1 text-sm text-ink-500 dark:text-ink-400">{subtitle}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{action && <div className="flex shrink-0 items-center gap-2">{action}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Modal } from './Modal'
|
||||||
|
import { ContactPicker } from './ContactPicker'
|
||||||
|
import { unwrap } from '../lib/api'
|
||||||
|
import type {
|
||||||
|
ChannelType,
|
||||||
|
EmailAccount,
|
||||||
|
TelegramBot,
|
||||||
|
TelegramUserAccount,
|
||||||
|
WhatsappSession,
|
||||||
|
Contact,
|
||||||
|
Message
|
||||||
|
} from '@shared/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mode: 'reply' | 'forward'
|
||||||
|
message: Message
|
||||||
|
onClose: () => void
|
||||||
|
onSent: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractEmail(addr: string | undefined): string {
|
||||||
|
if (!addr) return ''
|
||||||
|
const m = addr.match(/<([^>]+)>/)
|
||||||
|
return (m ? m[1] : addr.split(',')[0]).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSubject(mode: 'reply' | 'forward', subject?: string): string {
|
||||||
|
const original = subject?.trim() ?? ''
|
||||||
|
const prefix = mode === 'reply' ? 'Re:' : 'Fwd:'
|
||||||
|
if (!original) return prefix + ' (senza oggetto)'
|
||||||
|
if (original.toLowerCase().startsWith(prefix.toLowerCase())) return original
|
||||||
|
return `${prefix} ${original}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(d?: string): string {
|
||||||
|
if (!d) return ''
|
||||||
|
try {
|
||||||
|
return new Date(d).toLocaleString('it-IT')
|
||||||
|
} catch {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQuote(message: Message): string {
|
||||||
|
const lines: string[] = ['', '', `Il ${formatDate(message.receivedAt)}, ${message.fromAddr ?? ''} ha scritto:`]
|
||||||
|
const body = message.bodyText ?? ''
|
||||||
|
for (const line of body.split('\n')) lines.push(`> ${line}`)
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildForwardBody(message: Message, channel: ChannelType): string {
|
||||||
|
const body = message.bodyText ?? ''
|
||||||
|
if (channel === 'smtp') {
|
||||||
|
return [
|
||||||
|
'',
|
||||||
|
'---------- Messaggio inoltrato ----------',
|
||||||
|
`Da: ${message.fromAddr ?? ''}`,
|
||||||
|
`Data: ${formatDate(message.receivedAt)}`,
|
||||||
|
`Oggetto: ${message.subject ?? ''}`,
|
||||||
|
`A: ${message.toAddr ?? ''}`,
|
||||||
|
'',
|
||||||
|
body
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
`📨 Inoltro da: ${message.fromAddr ?? '?'}`,
|
||||||
|
message.subject ? `Oggetto: ${message.subject}` : '',
|
||||||
|
formatDate(message.receivedAt),
|
||||||
|
'',
|
||||||
|
body
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReplyForwardModal({ mode, message, onClose, onSent }: Props): JSX.Element {
|
||||||
|
const [channel, setChannel] = useState<ChannelType>('smtp')
|
||||||
|
const [accounts, setAccounts] = useState<EmailAccount[]>([])
|
||||||
|
const [bots, setBots] = useState<TelegramBot[]>([])
|
||||||
|
const [users, setUsers] = useState<TelegramUserAccount[]>([])
|
||||||
|
const [sessions, setSessions] = useState<WhatsappSession[]>([])
|
||||||
|
const [contacts, setContacts] = useState<Contact[]>([])
|
||||||
|
const [accountId, setAccountId] = useState<number | undefined>()
|
||||||
|
const [to, setTo] = useState('')
|
||||||
|
const [subject, setSubject] = useState(buildSubject(mode, message.subject))
|
||||||
|
const [body, setBody] = useState('')
|
||||||
|
const [includeOriginal, setIncludeOriginal] = useState(true)
|
||||||
|
const [sending, setSending] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [result, setResult] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Initial data load
|
||||||
|
useEffect(() => {
|
||||||
|
void Promise.all([
|
||||||
|
unwrap(window.api.accounts.list()),
|
||||||
|
unwrap(window.api.telegram.list()),
|
||||||
|
unwrap(window.api.telegramUser.list()),
|
||||||
|
unwrap(window.api.whatsapp.list()),
|
||||||
|
unwrap(window.api.contacts.list())
|
||||||
|
]).then(([a, b, u, s, c]) => {
|
||||||
|
setAccounts(a)
|
||||||
|
setBots(b)
|
||||||
|
setUsers(u)
|
||||||
|
setSessions(s)
|
||||||
|
setContacts(c)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Whenever channel changes (or initial data arrives), reset account + recipient + suggested body
|
||||||
|
useEffect(() => {
|
||||||
|
if (channel === 'smtp') {
|
||||||
|
setAccountId(message.accountId ?? accounts[0]?.id)
|
||||||
|
setTo(mode === 'reply' ? extractEmail(message.fromAddr) : '')
|
||||||
|
} else if (channel === 'telegram') {
|
||||||
|
setAccountId(bots[0]?.id)
|
||||||
|
setTo(mode === 'reply' ? (bots[0]?.defaultChatId ?? '') : '')
|
||||||
|
} else if (channel === 'telegram_user') {
|
||||||
|
setAccountId(users[0]?.id)
|
||||||
|
setTo('')
|
||||||
|
} else if (channel === 'whatsapp') {
|
||||||
|
setAccountId(sessions[0]?.id)
|
||||||
|
setTo(mode === 'reply' ? (sessions[0]?.defaultRecipient ?? '') : '')
|
||||||
|
}
|
||||||
|
// For forward, prefill body with forwarded content per channel
|
||||||
|
if (mode === 'forward') setBody(buildForwardBody(message, channel))
|
||||||
|
else setBody('')
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [channel, accounts.length, bots.length, users.length, sessions.length])
|
||||||
|
|
||||||
|
const useAiDraft = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const text = await unwrap(window.api.ai.suggestReply(message.id))
|
||||||
|
setBody(text)
|
||||||
|
} catch (err) {
|
||||||
|
setError(`AI: ${(err as Error).message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildFinalBody = (): string => {
|
||||||
|
if (mode === 'reply' && channel === 'smtp' && includeOriginal) {
|
||||||
|
return body + buildQuote(message)
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = async (): Promise<void> => {
|
||||||
|
setSending(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await unwrap(
|
||||||
|
window.api.messages.send({
|
||||||
|
channel,
|
||||||
|
accountId,
|
||||||
|
to,
|
||||||
|
subject: channel === 'smtp' ? subject : undefined,
|
||||||
|
body: buildFinalBody()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
setResult('Inviato ✓')
|
||||||
|
onSent()
|
||||||
|
setTimeout(() => onClose(), 700)
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message)
|
||||||
|
} finally {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountLabel = (): string => {
|
||||||
|
switch (channel) {
|
||||||
|
case 'smtp':
|
||||||
|
return 'Account email'
|
||||||
|
case 'telegram':
|
||||||
|
return 'Bot Telegram'
|
||||||
|
case 'telegram_user':
|
||||||
|
return 'Account utente Telegram'
|
||||||
|
case 'whatsapp':
|
||||||
|
return 'Sessione WhatsApp'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
onClose={onClose}
|
||||||
|
title={mode === 'reply' ? 'Rispondi' : 'Inoltra'}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="btn-ghost" onClick={onClose}>
|
||||||
|
Annulla
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={sending || !to || !buildFinalBody().trim()}
|
||||||
|
>
|
||||||
|
{sending ? 'Invio…' : result ?? 'Invia'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<span className="label">Canale</span>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-2">
|
||||||
|
{(['smtp', 'telegram', 'telegram_user', 'whatsapp'] as ChannelType[]).map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
onClick={() => setChannel(c)}
|
||||||
|
className={`btn ${channel === c ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
|
>
|
||||||
|
{channelLabel(c)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<span className="label">{accountLabel()}</span>
|
||||||
|
<select
|
||||||
|
className="input mt-1"
|
||||||
|
value={accountId ?? ''}
|
||||||
|
onChange={(e) => setAccountId(Number(e.target.value) || undefined)}
|
||||||
|
>
|
||||||
|
<option value="">— seleziona —</option>
|
||||||
|
{channel === 'smtp' &&
|
||||||
|
accounts.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{a.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{channel === 'telegram' &&
|
||||||
|
bots.map((b) => (
|
||||||
|
<option key={b.id} value={b.id}>
|
||||||
|
{b.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{channel === 'telegram_user' &&
|
||||||
|
users.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.name} ({u.status})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{channel === 'whatsapp' &&
|
||||||
|
sessions.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name} ({s.status})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="label">
|
||||||
|
{channel === 'smtp'
|
||||||
|
? 'A (email)'
|
||||||
|
: channel === 'telegram'
|
||||||
|
? 'Chat ID'
|
||||||
|
: channel === 'telegram_user'
|
||||||
|
? 'Numero (+39…) o @username'
|
||||||
|
: 'Numero WhatsApp (con prefisso paese, senza +)'}
|
||||||
|
</span>
|
||||||
|
<div className="mt-1">
|
||||||
|
<ContactPicker
|
||||||
|
channel={channel}
|
||||||
|
contacts={contacts}
|
||||||
|
value={to}
|
||||||
|
onChange={setTo}
|
||||||
|
placeholder={
|
||||||
|
channel === 'smtp'
|
||||||
|
? 'Cerca un contatto o digita un\'email…'
|
||||||
|
: channel === 'telegram'
|
||||||
|
? 'Cerca un contatto o digita un chat_id…'
|
||||||
|
: channel === 'telegram_user'
|
||||||
|
? '+393331234567 o @username'
|
||||||
|
: 'es. 393331234567'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{channel === 'whatsapp' && (
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
Es. <code>393331234567</code> (39 = Italia). Senza prefisso paese WhatsApp
|
||||||
|
risponde "No LID for user".
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{channel === 'smtp' && (
|
||||||
|
<div>
|
||||||
|
<span className="label">Oggetto</span>
|
||||||
|
<input className="input mt-1" value={subject} onChange={(e) => setSubject(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="label">{channel === 'smtp' ? 'Corpo' : 'Messaggio'}</span>
|
||||||
|
{mode === 'reply' && (
|
||||||
|
<button type="button" className="text-xs text-brand-600 hover:underline" onClick={useAiDraft}>
|
||||||
|
★ Usa bozza AI
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
className="input mt-1 h-48 resize-y"
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
placeholder={mode === 'reply' ? 'Scrivi la tua risposta…' : ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'reply' && channel === 'smtp' && (
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={includeOriginal}
|
||||||
|
onChange={(e) => setIncludeOriginal(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Includi messaggio originale in quote
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded border border-slate-200 bg-slate-50 p-2 text-xs text-slate-600">
|
||||||
|
<strong>{mode === 'reply' ? 'In risposta a:' : 'Messaggio originale:'}</strong>{' '}
|
||||||
|
{message.fromAddr ?? '?'} — <em>{message.subject ?? '(senza oggetto)'}</em>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700">{error}</div>
|
||||||
|
)}
|
||||||
|
{result && (
|
||||||
|
<div className="rounded-md bg-emerald-50 p-3 text-sm text-emerald-700">{result}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelLabel(c: ChannelType): string {
|
||||||
|
switch (c) {
|
||||||
|
case 'smtp':
|
||||||
|
return 'EMAIL'
|
||||||
|
case 'telegram':
|
||||||
|
return 'TG (BOT)'
|
||||||
|
case 'telegram_user':
|
||||||
|
return 'TG (ACCOUNT)'
|
||||||
|
case 'whatsapp':
|
||||||
|
return 'WHATSAPP'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
export type ViewKey =
|
||||||
|
| 'inbox'
|
||||||
|
| 'compose'
|
||||||
|
| 'rules'
|
||||||
|
| 'contacts'
|
||||||
|
| 'settings'
|
||||||
|
|
||||||
|
const items: { key: ViewKey; label: string; icon: string; accent: string }[] = [
|
||||||
|
{ key: 'inbox', label: 'Inbox', icon: '✉', accent: 'from-brand-500 to-neon-cyan' },
|
||||||
|
{ key: 'compose', label: 'Componi', icon: '✎', accent: 'from-brand-500 to-neon-purple' },
|
||||||
|
{ key: 'rules', label: 'Regole', icon: '⇒', accent: 'from-neon-cyan to-emerald-500' },
|
||||||
|
{ key: 'contacts', label: 'Contatti', icon: '☺', accent: 'from-neon-purple to-neon-pink' },
|
||||||
|
{ key: 'settings', label: 'Impostazioni', icon: '⚙', accent: 'from-ink-400 to-ink-600' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export function Sidebar({
|
||||||
|
active,
|
||||||
|
onSelect
|
||||||
|
}: {
|
||||||
|
active: ViewKey
|
||||||
|
onSelect: (v: ViewKey) => void
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<aside className="relative flex w-60 flex-col gap-1 border-r border-ink-200/70 bg-white/70 px-3 py-5 backdrop-blur-xl dark:border-white/[0.06] dark:bg-ink-950/60">
|
||||||
|
{/* Decorative gradient glow */}
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute -top-32 left-1/2 h-64 w-64 -translate-x-1/2 rounded-full bg-gradient-to-br from-brand-500/30 to-neon-purple/30 blur-3xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative px-2 pb-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-brand-500 via-neon-purple to-neon-cyan shadow-glow-sm">
|
||||||
|
<span className="font-display text-lg font-bold text-white">E</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-display text-base font-bold leading-none gradient-text">
|
||||||
|
EmailToChat
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[10px] font-medium uppercase tracking-[0.15em] text-ink-500 dark:text-ink-400">
|
||||||
|
Multi-canale · AI
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="relative flex flex-col gap-1">
|
||||||
|
{items.map((item) => {
|
||||||
|
const isActive = active === item.key
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
onClick={() => onSelect(item.key)}
|
||||||
|
className={`group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm font-medium transition-all duration-200 ${
|
||||||
|
isActive
|
||||||
|
? 'bg-gradient-to-r from-brand-500/15 via-neon-purple/10 to-transparent text-ink-900 ring-1 ring-brand-500/25 shadow-glow-sm dark:from-brand-400/15 dark:via-neon-purple/15 dark:text-white dark:ring-brand-400/30'
|
||||||
|
: 'text-ink-600 hover:bg-white/60 hover:text-ink-900 dark:text-ink-300 dark:hover:bg-white/[0.04] dark:hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isActive && (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={`absolute inset-y-1.5 left-0 w-0.5 rounded-full bg-gradient-to-b ${item.accent}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`flex h-7 w-7 items-center justify-center rounded-md text-base transition-all ${
|
||||||
|
isActive
|
||||||
|
? `bg-gradient-to-br ${item.accent} text-white shadow-glow-sm`
|
||||||
|
: 'bg-ink-200/40 text-ink-500 group-hover:bg-ink-200/70 dark:bg-white/[0.04] dark:text-ink-400 dark:group-hover:bg-white/[0.08]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
<span className="tracking-tight">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="mt-auto px-2 pt-6">
|
||||||
|
<div className="rounded-lg border border-ink-200/70 bg-gradient-to-br from-brand-500/5 via-neon-purple/5 to-neon-cyan/5 p-3 text-[11px] text-ink-500 backdrop-blur dark:border-white/[0.06] dark:from-brand-500/10 dark:via-neon-purple/10 dark:to-neon-cyan/10 dark:text-ink-400">
|
||||||
|
<div className="font-semibold uppercase tracking-wider text-ink-700 dark:text-ink-200">
|
||||||
|
v0.1.0
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 leading-relaxed">
|
||||||
|
Inoltro email → SMTP · Telegram · WhatsApp, con AI integrata.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
|
||||||
|
export interface ToastItem {
|
||||||
|
id: number
|
||||||
|
kind: 'info' | 'success' | 'error' | 'warn'
|
||||||
|
title: string
|
||||||
|
body?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToaster(): {
|
||||||
|
items: ToastItem[]
|
||||||
|
push: (t: Omit<ToastItem, 'id'>) => void
|
||||||
|
dismiss: (id: number) => void
|
||||||
|
} {
|
||||||
|
const [items, setItems] = useState<ToastItem[]>([])
|
||||||
|
const push = useCallback((t: Omit<ToastItem, 'id'>) => {
|
||||||
|
const id = Date.now() + Math.random()
|
||||||
|
setItems((prev) => [...prev, { ...t, id }])
|
||||||
|
setTimeout(() => setItems((prev) => prev.filter((p) => p.id !== id)), 5000)
|
||||||
|
}, [])
|
||||||
|
const dismiss = useCallback((id: number) => {
|
||||||
|
setItems((prev) => prev.filter((p) => p.id !== id))
|
||||||
|
}, [])
|
||||||
|
return { items, push, dismiss }
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACCENTS: Record<ToastItem['kind'], string> = {
|
||||||
|
info: 'from-brand-500 to-neon-cyan',
|
||||||
|
success: 'from-emerald-500 to-teal-500',
|
||||||
|
error: 'from-red-500 to-pink-500',
|
||||||
|
warn: 'from-amber-500 to-orange-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICONS: Record<ToastItem['kind'], string> = {
|
||||||
|
info: 'ⓘ',
|
||||||
|
success: '✓',
|
||||||
|
error: '⚠',
|
||||||
|
warn: '!'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Toaster({
|
||||||
|
items,
|
||||||
|
dismiss
|
||||||
|
}: {
|
||||||
|
items: ToastItem[]
|
||||||
|
dismiss: (id: number) => void
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed bottom-5 right-5 z-50 flex w-96 flex-col gap-2">
|
||||||
|
{items.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className="pointer-events-auto animate-slide-up overflow-hidden rounded-xl border border-ink-200/70 bg-white/90 shadow-depth-lg backdrop-blur-xl dark:border-white/[0.06] dark:bg-ink-900/80"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3 p-3.5 pr-2">
|
||||||
|
<div
|
||||||
|
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br ${ACCENTS[t.kind]} text-white shadow-glow-sm`}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-bold">{ICONS[t.kind]}</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-semibold tracking-tight text-ink-900 dark:text-white">
|
||||||
|
{t.title}
|
||||||
|
</div>
|
||||||
|
{t.body && (
|
||||||
|
<div className="mt-0.5 text-xs text-ink-500 dark:text-ink-400">{t.body}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => dismiss(t.id)}
|
||||||
|
className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-ink-400 hover:bg-ink-200/60 hover:text-ink-700 dark:text-ink-500 dark:hover:bg-white/[0.06]"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Progress line */}
|
||||||
|
<div
|
||||||
|
className={`h-0.5 origin-left bg-gradient-to-r ${ACCENTS[t.kind]}`}
|
||||||
|
style={{
|
||||||
|
animation: 'shrink 5s linear forwards'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<style>{`
|
||||||
|
@keyframes shrink {
|
||||||
|
from { transform: scaleX(1); }
|
||||||
|
to { transform: scaleX(0); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { IpcResponse } from '@shared/types'
|
||||||
|
|
||||||
|
export async function unwrap<T>(p: Promise<IpcResponse<T>>): Promise<T> {
|
||||||
|
const r = await p
|
||||||
|
if (!r.ok) throw new Error(r.error || 'Errore sconosciuto')
|
||||||
|
return r.data as T
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
export type ThemeMode = 'light' | 'dark' | 'system'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'theme'
|
||||||
|
const LISTENERS = new Set<(t: ThemeMode) => void>()
|
||||||
|
let mediaQueryListener: ((e: MediaQueryListEvent) => void) | null = null
|
||||||
|
|
||||||
|
export function getTheme(): ThemeMode {
|
||||||
|
const v = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (v === 'light' || v === 'dark' || v === 'system') return v
|
||||||
|
return 'system'
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(mode: ThemeMode): void {
|
||||||
|
const isDark =
|
||||||
|
mode === 'dark' ||
|
||||||
|
(mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
|
document.documentElement.classList.toggle('dark', isDark)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindSystemListener(mode: ThemeMode): void {
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
if (mediaQueryListener) {
|
||||||
|
mq.removeEventListener('change', mediaQueryListener)
|
||||||
|
mediaQueryListener = null
|
||||||
|
}
|
||||||
|
if (mode === 'system') {
|
||||||
|
mediaQueryListener = (): void => applyTheme('system')
|
||||||
|
mq.addEventListener('change', mediaQueryListener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initTheme(): void {
|
||||||
|
const t = getTheme()
|
||||||
|
applyTheme(t)
|
||||||
|
bindSystemListener(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTheme(mode: ThemeMode): void {
|
||||||
|
localStorage.setItem(STORAGE_KEY, mode)
|
||||||
|
applyTheme(mode)
|
||||||
|
bindSystemListener(mode)
|
||||||
|
for (const fn of LISTENERS) fn(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeTheme(cb: (t: ThemeMode) => void): () => void {
|
||||||
|
LISTENERS.add(cb)
|
||||||
|
return () => LISTENERS.delete(cb)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { App } from './App'
|
||||||
|
import { initTheme } from './lib/theme'
|
||||||
|
import './styles.css'
|
||||||
|
|
||||||
|
initTheme()
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html, body, #root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply font-sans antialiased text-ink-900 dark:text-ink-50;
|
||||||
|
background-color: theme('colors.ink.50');
|
||||||
|
background-image: theme('backgroundImage.mesh-light');
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
.dark body {
|
||||||
|
background-color: theme('colors.ink.950');
|
||||||
|
background-image: theme('backgroundImage.mesh-dark');
|
||||||
|
}
|
||||||
|
h1, h2, h3 {
|
||||||
|
@apply font-display tracking-tight;
|
||||||
|
}
|
||||||
|
details > summary {
|
||||||
|
@apply cursor-pointer select-none;
|
||||||
|
}
|
||||||
|
details > summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
@apply rounded-full bg-ink-300/60 dark:bg-ink-700/60;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
@apply bg-ink-400/80 dark:bg-ink-600/80;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* ------- Glass surfaces ------- */
|
||||||
|
.glass {
|
||||||
|
@apply relative border border-ink-200/70 bg-white/70 backdrop-blur-xl
|
||||||
|
shadow-depth
|
||||||
|
dark:border-white/[0.06] dark:bg-white/[0.03];
|
||||||
|
}
|
||||||
|
.glass-strong {
|
||||||
|
@apply relative border border-ink-200 bg-white/90 backdrop-blur-2xl
|
||||||
|
shadow-depth-lg
|
||||||
|
dark:border-white/[0.08] dark:bg-ink-900/70;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------- Card (used everywhere) ------- */
|
||||||
|
.card {
|
||||||
|
@apply glass rounded-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------- Buttons ------- */
|
||||||
|
.btn {
|
||||||
|
@apply relative inline-flex items-center justify-center gap-1.5 rounded-lg px-3.5 py-1.5
|
||||||
|
text-sm font-medium tracking-tight transition-all duration-150
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-offset-2
|
||||||
|
focus:ring-offset-ink-50 dark:focus:ring-offset-ink-950
|
||||||
|
disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none
|
||||||
|
active:scale-[0.98];
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
@apply btn text-white focus:ring-brand-500/70
|
||||||
|
bg-gradient-to-br from-brand-500 via-brand-600 to-brand-700
|
||||||
|
hover:from-brand-400 hover:via-brand-500 hover:to-brand-600
|
||||||
|
shadow-glow-sm hover:shadow-glow
|
||||||
|
dark:from-brand-400 dark:via-brand-500 dark:to-brand-600;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
@apply btn bg-white/70 text-ink-800 hover:bg-white
|
||||||
|
border border-ink-200 focus:ring-ink-400
|
||||||
|
shadow-sm hover:shadow
|
||||||
|
dark:bg-white/[0.04] dark:text-ink-100 dark:border-white/[0.08]
|
||||||
|
dark:hover:bg-white/[0.08];
|
||||||
|
}
|
||||||
|
.btn-ghost {
|
||||||
|
@apply btn text-ink-700 hover:bg-ink-200/60 focus:ring-ink-400
|
||||||
|
dark:text-ink-200 dark:hover:bg-white/[0.06];
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
@apply btn text-white focus:ring-red-500/70
|
||||||
|
bg-gradient-to-br from-red-500 to-red-700
|
||||||
|
hover:from-red-400 hover:to-red-600
|
||||||
|
shadow-glow-danger;
|
||||||
|
}
|
||||||
|
.btn-accent {
|
||||||
|
@apply btn text-white focus:ring-neon-cyan/70
|
||||||
|
bg-gradient-to-br from-neon-cyan via-brand-500 to-neon-purple
|
||||||
|
hover:brightness-110
|
||||||
|
shadow-glow-cyan;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------- Inputs ------- */
|
||||||
|
.input {
|
||||||
|
@apply w-full rounded-lg border border-ink-200 bg-white/80 px-3 py-2 text-sm shadow-sm
|
||||||
|
placeholder:text-ink-400
|
||||||
|
focus:border-brand-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand-500/30
|
||||||
|
transition-all duration-150
|
||||||
|
dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-ink-100
|
||||||
|
dark:placeholder:text-ink-500 dark:focus:bg-white/[0.06]
|
||||||
|
dark:focus:ring-brand-400/40;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------- Misc ------- */
|
||||||
|
.label {
|
||||||
|
@apply text-[10px] font-semibold uppercase tracking-[0.12em] text-ink-500 dark:text-ink-400;
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
@apply inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium
|
||||||
|
border border-transparent;
|
||||||
|
}
|
||||||
|
.pill-success {
|
||||||
|
@apply pill bg-emerald-500/10 text-emerald-600 border-emerald-500/20
|
||||||
|
dark:bg-emerald-400/10 dark:text-emerald-300;
|
||||||
|
}
|
||||||
|
.pill-warn {
|
||||||
|
@apply pill bg-amber-500/10 text-amber-700 border-amber-500/20
|
||||||
|
dark:bg-amber-400/10 dark:text-amber-300;
|
||||||
|
}
|
||||||
|
.pill-danger {
|
||||||
|
@apply pill bg-red-500/10 text-red-700 border-red-500/20
|
||||||
|
dark:bg-red-400/10 dark:text-red-300;
|
||||||
|
}
|
||||||
|
.pill-info {
|
||||||
|
@apply pill bg-brand-500/10 text-brand-700 border-brand-500/20
|
||||||
|
dark:bg-brand-400/10 dark:text-brand-300;
|
||||||
|
}
|
||||||
|
.pill-muted {
|
||||||
|
@apply pill bg-ink-200/60 text-ink-700 border-ink-200
|
||||||
|
dark:bg-white/[0.06] dark:text-ink-300 dark:border-white/[0.08];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------- Logo / Brand gradient text ------- */
|
||||||
|
.gradient-text {
|
||||||
|
@apply bg-clip-text text-transparent
|
||||||
|
bg-gradient-to-br from-brand-500 via-neon-purple to-neon-cyan;
|
||||||
|
}
|
||||||
|
.gradient-text-glow {
|
||||||
|
@apply gradient-text;
|
||||||
|
filter: drop-shadow(0 0 18px rgba(99, 102, 241, 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animated subtle border for active items */
|
||||||
|
.ring-glow {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(99, 102, 241, 0.35),
|
||||||
|
0 0 20px -2px rgba(99, 102, 241, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.text-balance {
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
.text-pretty {
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
.scrollbar-thin::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { PageHeader } from '../components/PageHeader'
|
||||||
|
import { Modal } from '../components/Modal'
|
||||||
|
import { unwrap } from '../lib/api'
|
||||||
|
import type { EmailAccount } from '@shared/types'
|
||||||
|
|
||||||
|
const empty = (): Omit<EmailAccount, 'id' | 'createdAt'> => ({
|
||||||
|
name: '',
|
||||||
|
authType: 'password',
|
||||||
|
imap: { host: '', port: 993, secure: true, user: '', pass: '' },
|
||||||
|
smtp: { host: '', port: 465, secure: true, user: '', pass: '', fromName: '', fromAddress: '' },
|
||||||
|
enabled: true,
|
||||||
|
pollInterval: 60
|
||||||
|
})
|
||||||
|
|
||||||
|
const gmailPreset = (): Omit<EmailAccount, 'id' | 'createdAt'> => ({
|
||||||
|
name: 'Gmail',
|
||||||
|
authType: 'password',
|
||||||
|
imap: { host: 'imap.gmail.com', port: 993, secure: true, user: '', pass: '' },
|
||||||
|
smtp: {
|
||||||
|
host: 'smtp.gmail.com',
|
||||||
|
port: 465,
|
||||||
|
secure: true,
|
||||||
|
user: '',
|
||||||
|
pass: '',
|
||||||
|
fromName: '',
|
||||||
|
fromAddress: ''
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
pollInterval: 60
|
||||||
|
})
|
||||||
|
|
||||||
|
export function AccountsView(): JSX.Element {
|
||||||
|
const [accounts, setAccounts] = useState<EmailAccount[]>([])
|
||||||
|
const [editing, setEditing] = useState<EmailAccount | null>(null)
|
||||||
|
const [draft, setDraft] = useState<Omit<EmailAccount, 'id' | 'createdAt'>>(empty())
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [presetMode, setPresetMode] = useState<'none' | 'gmail'>('none')
|
||||||
|
const [testing, setTesting] = useState<number | null>(null)
|
||||||
|
const [testResult, setTestResult] = useState<{ id: number; ok: boolean; msg: string } | null>(null)
|
||||||
|
const [connectingGmail, setConnectingGmail] = useState(false)
|
||||||
|
const [gmailError, setGmailError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const load = async (): Promise<void> => {
|
||||||
|
setAccounts(await unwrap(window.api.accounts.list()))
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const openNew = (): void => {
|
||||||
|
setEditing(null)
|
||||||
|
setDraft(empty())
|
||||||
|
setPresetMode('none')
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
const openGmailPreset = (): void => {
|
||||||
|
setEditing(null)
|
||||||
|
setDraft(gmailPreset())
|
||||||
|
setPresetMode('gmail')
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
const openEdit = (a: EmailAccount): void => {
|
||||||
|
setEditing(a)
|
||||||
|
setDraft({
|
||||||
|
name: a.name,
|
||||||
|
authType: a.authType ?? 'password',
|
||||||
|
imap: { ...a.imap },
|
||||||
|
smtp: { ...a.smtp },
|
||||||
|
oauth: a.oauth,
|
||||||
|
enabled: a.enabled,
|
||||||
|
pollInterval: a.pollInterval
|
||||||
|
})
|
||||||
|
setPresetMode('none')
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectGmail = async (): Promise<void> => {
|
||||||
|
setConnectingGmail(true)
|
||||||
|
setGmailError(null)
|
||||||
|
try {
|
||||||
|
await unwrap(window.api.gmail.connect())
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setGmailError((err as Error).message)
|
||||||
|
} finally {
|
||||||
|
setConnectingGmail(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = async (): Promise<void> => {
|
||||||
|
if (editing) await unwrap(window.api.accounts.update(editing.id, draft))
|
||||||
|
else await unwrap(window.api.accounts.create(draft))
|
||||||
|
setOpen(false)
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = async (id: number): Promise<void> => {
|
||||||
|
if (!confirm('Eliminare questo account?')) return
|
||||||
|
await unwrap(window.api.accounts.delete(id))
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const test = async (id: number): Promise<void> => {
|
||||||
|
setTesting(id)
|
||||||
|
setTestResult(null)
|
||||||
|
try {
|
||||||
|
await unwrap(window.api.accounts.test(id))
|
||||||
|
setTestResult({ id, ok: true, msg: 'IMAP e SMTP connessi correttamente' })
|
||||||
|
} catch (err) {
|
||||||
|
setTestResult({ id, ok: false, msg: (err as Error).message })
|
||||||
|
} finally {
|
||||||
|
setTesting(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="Account Email"
|
||||||
|
subtitle="Configura caselle IMAP/SMTP"
|
||||||
|
action={
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={openGmailPreset}
|
||||||
|
title="Compila i campi per Gmail, usa una App Password"
|
||||||
|
>
|
||||||
|
+ Gmail (App Password)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
disabled={connectingGmail}
|
||||||
|
onClick={connectGmail}
|
||||||
|
title="Login OAuth Google (richiede Client ID in Impostazioni)"
|
||||||
|
>
|
||||||
|
{connectingGmail ? 'Connessione…' : 'G Collega Gmail (OAuth)'}
|
||||||
|
</button>
|
||||||
|
<button className="btn-primary" onClick={openNew}>
|
||||||
|
+ Altro
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="space-y-3 p-6">
|
||||||
|
{gmailError && (
|
||||||
|
<div className="card border-red-200 bg-red-50 p-3 text-sm text-red-800">
|
||||||
|
<strong>Errore Gmail:</strong> {gmailError}
|
||||||
|
{gmailError.includes('non configurati') && (
|
||||||
|
<span> Vai in <strong>Impostazioni</strong> per inserire Client ID/Secret.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{accounts.length === 0 && (
|
||||||
|
<div className="card p-6 text-center text-sm text-slate-500">
|
||||||
|
Nessun account configurato. Aggiungine uno o usa <em>Collega Gmail</em>.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<div key={a.id} className="card p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-base font-semibold text-slate-900">{a.name}</h3>
|
||||||
|
{a.authType === 'oauth_google' && (
|
||||||
|
<span className="pill bg-blue-100 text-blue-700">OAuth Google</span>
|
||||||
|
)}
|
||||||
|
{a.enabled ? (
|
||||||
|
<span className="pill bg-emerald-100 text-emerald-700">attivo</span>
|
||||||
|
) : (
|
||||||
|
<span className="pill bg-slate-100 text-slate-600">disattivato</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-1 text-sm text-slate-600">
|
||||||
|
<div>
|
||||||
|
IMAP: {a.imap.host}:{a.imap.port} ({a.imap.user})
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
SMTP: {a.smtp.host}:{a.smtp.port} ({a.smtp.fromAddress})
|
||||||
|
</div>
|
||||||
|
<div>Poll: ogni {a.pollInterval}s</div>
|
||||||
|
</div>
|
||||||
|
{testResult?.id === a.id && (
|
||||||
|
<div
|
||||||
|
className={`mt-2 rounded p-2 text-xs ${
|
||||||
|
testResult.ok ? 'bg-emerald-50 text-emerald-700' : 'bg-red-50 text-red-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{testResult.msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="btn-secondary" disabled={testing === a.id} onClick={() => test(a.id)}>
|
||||||
|
{testing === a.id ? 'Test…' : 'Test'}
|
||||||
|
</button>
|
||||||
|
<button className="btn-secondary" onClick={() => openEdit(a)}>
|
||||||
|
Modifica
|
||||||
|
</button>
|
||||||
|
<button className="btn-danger" onClick={() => remove(a.id)}>
|
||||||
|
Elimina
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
title={
|
||||||
|
editing
|
||||||
|
? `Modifica: ${editing.name}`
|
||||||
|
: presetMode === 'gmail'
|
||||||
|
? 'Nuovo account Gmail (App Password)'
|
||||||
|
: 'Nuovo account email'
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="btn-ghost" onClick={() => setOpen(false)}>
|
||||||
|
Annulla
|
||||||
|
</button>
|
||||||
|
<button className="btn-primary" onClick={save}>
|
||||||
|
Salva
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{presetMode === 'gmail' && !editing && (
|
||||||
|
<div className="mb-4 rounded-md border border-red-200 bg-red-50/60 p-4 text-sm text-slate-800">
|
||||||
|
<div className="font-semibold text-red-700">
|
||||||
|
Come ottenere una App Password per Gmail
|
||||||
|
</div>
|
||||||
|
<ol className="mt-2 list-decimal space-y-1 pl-5">
|
||||||
|
<li>
|
||||||
|
Abilita la verifica in 2 passaggi su{' '}
|
||||||
|
<a
|
||||||
|
href="https://myaccount.google.com/security"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-brand-600 underline"
|
||||||
|
>
|
||||||
|
myaccount.google.com/security
|
||||||
|
</a>{' '}
|
||||||
|
(se non è già attiva).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Vai su{' '}
|
||||||
|
<a
|
||||||
|
href="https://myaccount.google.com/apppasswords"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-brand-600 underline"
|
||||||
|
>
|
||||||
|
myaccount.google.com/apppasswords
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Crea una nuova App Password (nome es. "EmailToChat") → Google mostra una stringa di
|
||||||
|
16 caratteri.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Inserisci la tua email Gmail nei campi <strong>User</strong> e{' '}
|
||||||
|
<strong>Indirizzo From</strong>, e incolla la App Password (senza spazi) nei
|
||||||
|
campi <strong>Password</strong> di IMAP e SMTP.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<p className="mt-2 text-xs text-slate-600">
|
||||||
|
Host, porte e SSL sono già pre-compilati per Gmail.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Nome" full>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Indirizzo From">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={draft.smtp.fromAddress}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({ ...draft, smtp: { ...draft.smtp, fromAddress: e.target.value } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="From Name (opzionale)">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={draft.smtp.fromName ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({ ...draft, smtp: { ...draft.smtp, fromName: e.target.value } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<h3 className="col-span-2 mt-2 border-b border-slate-200 pb-1 text-xs font-bold uppercase text-slate-500">
|
||||||
|
IMAP (ricezione)
|
||||||
|
</h3>
|
||||||
|
<Field label="Host">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={draft.imap.host}
|
||||||
|
onChange={(e) => setDraft({ ...draft, imap: { ...draft.imap, host: e.target.value } })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Porta">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input"
|
||||||
|
value={draft.imap.port}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({ ...draft, imap: { ...draft.imap, port: Number(e.target.value) } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="User">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={draft.imap.user}
|
||||||
|
onChange={(e) => setDraft({ ...draft, imap: { ...draft.imap, user: e.target.value } })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Password">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input"
|
||||||
|
value={draft.imap.pass}
|
||||||
|
onChange={(e) => setDraft({ ...draft, imap: { ...draft.imap, pass: e.target.value } })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="SSL/TLS">
|
||||||
|
<label className="mt-1 inline-flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.imap.secure}
|
||||||
|
onChange={(e) => setDraft({ ...draft, imap: { ...draft.imap, secure: e.target.checked } })}
|
||||||
|
/>
|
||||||
|
Usa connessione sicura
|
||||||
|
</label>
|
||||||
|
</Field>
|
||||||
|
<Field label="Poll interval (s)">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={15}
|
||||||
|
className="input"
|
||||||
|
value={draft.pollInterval}
|
||||||
|
onChange={(e) => setDraft({ ...draft, pollInterval: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<h3 className="col-span-2 mt-2 border-b border-slate-200 pb-1 text-xs font-bold uppercase text-slate-500">
|
||||||
|
SMTP (invio)
|
||||||
|
</h3>
|
||||||
|
<Field label="Host">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={draft.smtp.host}
|
||||||
|
onChange={(e) => setDraft({ ...draft, smtp: { ...draft.smtp, host: e.target.value } })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Porta">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input"
|
||||||
|
value={draft.smtp.port}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({ ...draft, smtp: { ...draft.smtp, port: Number(e.target.value) } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="User">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={draft.smtp.user}
|
||||||
|
onChange={(e) => setDraft({ ...draft, smtp: { ...draft.smtp, user: e.target.value } })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Password">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input"
|
||||||
|
value={draft.smtp.pass}
|
||||||
|
onChange={(e) => setDraft({ ...draft, smtp: { ...draft.smtp, pass: e.target.value } })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="SSL/TLS">
|
||||||
|
<label className="mt-1 inline-flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.smtp.secure}
|
||||||
|
onChange={(e) => setDraft({ ...draft, smtp: { ...draft.smtp, secure: e.target.checked } })}
|
||||||
|
/>
|
||||||
|
Usa connessione sicura
|
||||||
|
</label>
|
||||||
|
</Field>
|
||||||
|
<Field label="Attivo" full>
|
||||||
|
<label className="mt-1 inline-flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.enabled}
|
||||||
|
onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Avvia polling IMAP automaticamente
|
||||||
|
</label>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
full,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
full?: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className={full ? 'col-span-2' : ''}>
|
||||||
|
<span className="label">{label}</span>
|
||||||
|
<div className="mt-1">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { PageHeader } from '../components/PageHeader'
|
||||||
|
import { Modal } from '../components/Modal'
|
||||||
|
import { unwrap } from '../lib/api'
|
||||||
|
import type { AiProviderConfig } from '@shared/types'
|
||||||
|
|
||||||
|
const DEFAULT_MODELS: Record<AiProviderConfig['provider'], string> = {
|
||||||
|
anthropic: 'claude-haiku-4-5-20251001',
|
||||||
|
openai: 'gpt-4o-mini',
|
||||||
|
ollama: 'llama3.2'
|
||||||
|
}
|
||||||
|
|
||||||
|
const empty = (): Omit<AiProviderConfig, 'id'> => ({
|
||||||
|
name: '',
|
||||||
|
provider: 'anthropic',
|
||||||
|
apiKey: '',
|
||||||
|
model: DEFAULT_MODELS.anthropic,
|
||||||
|
baseUrl: '',
|
||||||
|
enabled: true,
|
||||||
|
isDefault: false,
|
||||||
|
autoClassify: false
|
||||||
|
})
|
||||||
|
|
||||||
|
export function AiView(): JSX.Element {
|
||||||
|
const [providers, setProviders] = useState<AiProviderConfig[]>([])
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<AiProviderConfig | null>(null)
|
||||||
|
const [draft, setDraft] = useState<Omit<AiProviderConfig, 'id'>>(empty())
|
||||||
|
const [test, setTest] = useState<{ id: number; ok: boolean; msg: string } | null>(null)
|
||||||
|
|
||||||
|
const load = async (): Promise<void> => {
|
||||||
|
setProviders(await unwrap(window.api.ai.list()))
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const save = async (): Promise<void> => {
|
||||||
|
if (editing) await unwrap(window.api.ai.update(editing.id, draft))
|
||||||
|
else await unwrap(window.api.ai.create(draft))
|
||||||
|
setOpen(false)
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = async (id: number): Promise<void> => {
|
||||||
|
if (!confirm('Eliminare questo provider AI?')) return
|
||||||
|
await unwrap(window.api.ai.delete(id))
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const runTest = async (id: number): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const r = await unwrap(window.api.ai.test(id))
|
||||||
|
setTest({ id, ok: true, msg: `Risposta: ${r}` })
|
||||||
|
} catch (err) {
|
||||||
|
setTest({ id, ok: false, msg: (err as Error).message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="Provider AI"
|
||||||
|
subtitle="Anthropic Claude, OpenAI, Ollama. Imposta un provider predefinito e attiva la classificazione automatica."
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(null)
|
||||||
|
setDraft(empty())
|
||||||
|
setOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Nuovo provider
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="space-y-3 p-6">
|
||||||
|
<div className="card border-brand-200 bg-brand-50/40 p-4 text-sm text-brand-900">
|
||||||
|
<strong>Come funziona:</strong> il provider <em>predefinito</em> viene usato per le azioni
|
||||||
|
on-demand (riassumi, estrai, suggerisci risposta). Se attivi{' '}
|
||||||
|
<em>classificazione automatica</em> su un provider, ogni email in entrata viene
|
||||||
|
classificata subito (utile per regole basate su categoria).
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{providers.length === 0 && (
|
||||||
|
<div className="card p-6 text-center text-sm text-slate-500">
|
||||||
|
Nessun provider AI configurato.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{providers.map((p) => (
|
||||||
|
<div key={p.id} className="card p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-base font-semibold">{p.name}</h3>
|
||||||
|
<span className="pill bg-slate-100 text-slate-700">{p.provider}</span>
|
||||||
|
{p.isDefault && <span className="pill bg-brand-100 text-brand-800">predefinito</span>}
|
||||||
|
{p.autoClassify && (
|
||||||
|
<span className="pill bg-emerald-100 text-emerald-700">auto-classify</span>
|
||||||
|
)}
|
||||||
|
{!p.enabled && <span className="pill bg-slate-200 text-slate-600">disattivo</span>}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-600">Modello: {p.model}</div>
|
||||||
|
{p.baseUrl && <div className="text-sm text-slate-600">Base URL: {p.baseUrl}</div>}
|
||||||
|
{test?.id === p.id && (
|
||||||
|
<div
|
||||||
|
className={`mt-2 rounded p-2 text-xs ${
|
||||||
|
test.ok ? 'bg-emerald-50 text-emerald-700' : 'bg-red-50 text-red-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{test.msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="btn-secondary" onClick={() => runTest(p.id)}>
|
||||||
|
Test
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(p)
|
||||||
|
setDraft({
|
||||||
|
name: p.name,
|
||||||
|
provider: p.provider,
|
||||||
|
apiKey: p.apiKey ?? '',
|
||||||
|
model: p.model,
|
||||||
|
baseUrl: p.baseUrl ?? '',
|
||||||
|
enabled: p.enabled,
|
||||||
|
isDefault: p.isDefault,
|
||||||
|
autoClassify: p.autoClassify
|
||||||
|
})
|
||||||
|
setOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Modifica
|
||||||
|
</button>
|
||||||
|
<button className="btn-danger" onClick={() => remove(p.id)}>
|
||||||
|
Elimina
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={editing ? 'Modifica provider' : 'Nuovo provider AI'}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="btn-ghost" onClick={() => setOpen(false)}>
|
||||||
|
Annulla
|
||||||
|
</button>
|
||||||
|
<button className="btn-primary" onClick={save}>
|
||||||
|
Salva
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<span className="label">Nome</span>
|
||||||
|
<input
|
||||||
|
className="input mt-1"
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="label">Provider</span>
|
||||||
|
<select
|
||||||
|
className="input mt-1"
|
||||||
|
value={draft.provider}
|
||||||
|
onChange={(e) => {
|
||||||
|
const provider = e.target.value as AiProviderConfig['provider']
|
||||||
|
setDraft({ ...draft, provider, model: DEFAULT_MODELS[provider] })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="anthropic">Anthropic (Claude)</option>
|
||||||
|
<option value="openai">OpenAI</option>
|
||||||
|
<option value="ollama">Ollama (locale)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="label">Modello</span>
|
||||||
|
<input
|
||||||
|
className="input mt-1"
|
||||||
|
value={draft.model}
|
||||||
|
onChange={(e) => setDraft({ ...draft, model: e.target.value })}
|
||||||
|
/>
|
||||||
|
<div className="mt-1 text-xs text-slate-500">
|
||||||
|
Suggerimenti: <code>claude-haiku-4-5-20251001</code>, <code>claude-sonnet-4-6</code>,{' '}
|
||||||
|
<code>gpt-4o-mini</code>, <code>gpt-4o</code>, <code>llama3.2</code>,{' '}
|
||||||
|
<code>mistral</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{draft.provider !== 'ollama' && (
|
||||||
|
<div>
|
||||||
|
<span className="label">API Key</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input mt-1 font-mono"
|
||||||
|
value={draft.apiKey ?? ''}
|
||||||
|
onChange={(e) => setDraft({ ...draft, apiKey: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<span className="label">
|
||||||
|
Base URL (opzionale — Ollama: <code>http://localhost:11434</code>)
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="input mt-1"
|
||||||
|
value={draft.baseUrl ?? ''}
|
||||||
|
onChange={(e) => setDraft({ ...draft, baseUrl: e.target.value })}
|
||||||
|
placeholder={
|
||||||
|
draft.provider === 'ollama'
|
||||||
|
? 'http://localhost:11434'
|
||||||
|
: 'lasciare vuoto per default'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 border-t border-slate-200 pt-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.enabled}
|
||||||
|
onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Attivo
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.isDefault}
|
||||||
|
onChange={(e) => setDraft({ ...draft, isDefault: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Provider predefinito (usato per azioni on-demand)
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.autoClassify}
|
||||||
|
onChange={(e) => setDraft({ ...draft, autoClassify: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Classificazione automatica al ricevimento (un solo provider può avere questo flag)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { PageHeader } from '../components/PageHeader'
|
||||||
|
import { ContactPicker } from '../components/ContactPicker'
|
||||||
|
import { unwrap } from '../lib/api'
|
||||||
|
import type {
|
||||||
|
ChannelType,
|
||||||
|
EmailAccount,
|
||||||
|
TelegramBot,
|
||||||
|
TelegramUserAccount,
|
||||||
|
WhatsappSession,
|
||||||
|
Contact
|
||||||
|
} from '@shared/types'
|
||||||
|
|
||||||
|
export function ComposeView(): JSX.Element {
|
||||||
|
const [channel, setChannel] = useState<ChannelType>('smtp')
|
||||||
|
const [emailAccounts, setEmailAccounts] = useState<EmailAccount[]>([])
|
||||||
|
const [bots, setBots] = useState<TelegramBot[]>([])
|
||||||
|
const [users, setUsers] = useState<TelegramUserAccount[]>([])
|
||||||
|
const [sessions, setSessions] = useState<WhatsappSession[]>([])
|
||||||
|
const [contacts, setContacts] = useState<Contact[]>([])
|
||||||
|
const [accountId, setAccountId] = useState<number | undefined>()
|
||||||
|
const [to, setTo] = useState('')
|
||||||
|
const [cc, setCc] = useState('')
|
||||||
|
const [subject, setSubject] = useState('')
|
||||||
|
const [body, setBody] = useState('')
|
||||||
|
const [sending, setSending] = useState(false)
|
||||||
|
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void Promise.all([
|
||||||
|
unwrap(window.api.accounts.list()),
|
||||||
|
unwrap(window.api.telegram.list()),
|
||||||
|
unwrap(window.api.telegramUser.list()),
|
||||||
|
unwrap(window.api.whatsapp.list()),
|
||||||
|
unwrap(window.api.contacts.list())
|
||||||
|
]).then(([a, b, u, s, c]) => {
|
||||||
|
setEmailAccounts(a)
|
||||||
|
setBots(b)
|
||||||
|
setUsers(u)
|
||||||
|
setSessions(s)
|
||||||
|
setContacts(c)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (channel === 'smtp' && emailAccounts[0]) setAccountId(emailAccounts[0].id)
|
||||||
|
else if (channel === 'telegram' && bots[0]) setAccountId(bots[0].id)
|
||||||
|
else if (channel === 'telegram_user' && users[0]) setAccountId(users[0].id)
|
||||||
|
else if (channel === 'whatsapp' && sessions[0]) setAccountId(sessions[0].id)
|
||||||
|
}, [channel, emailAccounts, bots, users, sessions])
|
||||||
|
|
||||||
|
const onSend = async (): Promise<void> => {
|
||||||
|
setSending(true)
|
||||||
|
setResult(null)
|
||||||
|
try {
|
||||||
|
const msg = await unwrap(
|
||||||
|
window.api.messages.send({
|
||||||
|
channel,
|
||||||
|
accountId,
|
||||||
|
to,
|
||||||
|
cc: channel === 'smtp' ? cc : undefined,
|
||||||
|
subject: channel === 'smtp' ? subject : undefined,
|
||||||
|
body
|
||||||
|
})
|
||||||
|
)
|
||||||
|
setResult({ ok: true, msg: `Inviato! ID #${msg.id}` })
|
||||||
|
setTo('')
|
||||||
|
setCc('')
|
||||||
|
setSubject('')
|
||||||
|
setBody('')
|
||||||
|
} catch (err) {
|
||||||
|
setResult({ ok: false, msg: (err as Error).message })
|
||||||
|
} finally {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader title="Componi messaggio" subtitle="Invia su SMTP, Telegram o WhatsApp" />
|
||||||
|
<div className="mx-auto max-w-3xl space-y-4 p-6">
|
||||||
|
<div>
|
||||||
|
<span className="label">Canale</span>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{(['smtp', 'telegram', 'telegram_user', 'whatsapp'] as ChannelType[]).map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
onClick={() => setChannel(c)}
|
||||||
|
className={`btn ${channel === c ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
|
>
|
||||||
|
{channelLabel(c)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="label">
|
||||||
|
{channel === 'smtp'
|
||||||
|
? 'Account email'
|
||||||
|
: channel === 'telegram'
|
||||||
|
? 'Bot Telegram'
|
||||||
|
: channel === 'telegram_user'
|
||||||
|
? 'Account utente Telegram'
|
||||||
|
: 'Sessione WhatsApp'}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
className="input mt-1"
|
||||||
|
value={accountId ?? ''}
|
||||||
|
onChange={(e) => setAccountId(Number(e.target.value) || undefined)}
|
||||||
|
>
|
||||||
|
<option value="">— seleziona —</option>
|
||||||
|
{channel === 'smtp' &&
|
||||||
|
emailAccounts.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{a.name} ({a.smtp.fromAddress})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{channel === 'telegram' &&
|
||||||
|
bots.map((b) => (
|
||||||
|
<option key={b.id} value={b.id}>
|
||||||
|
{b.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{channel === 'telegram_user' &&
|
||||||
|
users.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.name} ({u.status})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{channel === 'whatsapp' &&
|
||||||
|
sessions.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name} ({s.status})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="label">
|
||||||
|
{channel === 'smtp'
|
||||||
|
? 'A (email)'
|
||||||
|
: channel === 'telegram'
|
||||||
|
? 'Chat ID'
|
||||||
|
: channel === 'telegram_user'
|
||||||
|
? 'Numero (+39…) o @username'
|
||||||
|
: 'Numero WhatsApp (con prefisso paese, senza +)'}
|
||||||
|
</span>
|
||||||
|
<div className="mt-1">
|
||||||
|
<ContactPicker
|
||||||
|
channel={channel}
|
||||||
|
contacts={contacts}
|
||||||
|
value={to}
|
||||||
|
onChange={setTo}
|
||||||
|
placeholder={
|
||||||
|
channel === 'smtp'
|
||||||
|
? 'Cerca un contatto o digita un\'email…'
|
||||||
|
: channel === 'telegram'
|
||||||
|
? 'Cerca un contatto o digita un chat_id…'
|
||||||
|
: channel === 'telegram_user'
|
||||||
|
? '+393331234567 oppure @username'
|
||||||
|
: 'es. 393331234567'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{channel === 'whatsapp' && (
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
Il numero deve includere il prefisso internazionale (es. <code>39</code> per
|
||||||
|
l'Italia) e niente <code>+</code>: <code>393331234567</code>. Senza prefisso, WhatsApp
|
||||||
|
risponde "No LID for user".
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{channel === 'telegram_user' && (
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
Usa il formato internazionale con <code>+</code> (es. <code>+393331234567</code>) o
|
||||||
|
un username Telegram (es. <code>@mario</code>). Funziona solo per persone che
|
||||||
|
risultano registrate su Telegram.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{channel === 'smtp' && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<span className="label">CC</span>
|
||||||
|
<input className="input mt-1" value={cc} onChange={(e) => setCc(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="label">Oggetto</span>
|
||||||
|
<input
|
||||||
|
className="input mt-1"
|
||||||
|
value={subject}
|
||||||
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="label">{channel === 'smtp' ? 'Corpo' : 'Messaggio'}</span>
|
||||||
|
<textarea
|
||||||
|
className="input mt-1 h-48 resize-y"
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div
|
||||||
|
className={`rounded-md p-3 text-sm ${
|
||||||
|
result.ok ? 'bg-emerald-50 text-emerald-700' : 'bg-red-50 text-red-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{result.msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button className="btn-primary" onClick={onSend} disabled={sending || !to || !body}>
|
||||||
|
{sending ? 'Invio…' : 'Invia'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelLabel(c: ChannelType): string {
|
||||||
|
switch (c) {
|
||||||
|
case 'smtp':
|
||||||
|
return 'EMAIL'
|
||||||
|
case 'telegram':
|
||||||
|
return 'TELEGRAM (BOT)'
|
||||||
|
case 'telegram_user':
|
||||||
|
return 'TELEGRAM (ACCOUNT)'
|
||||||
|
case 'whatsapp':
|
||||||
|
return 'WHATSAPP'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { PageHeader } from '../components/PageHeader'
|
||||||
|
import { Modal } from '../components/Modal'
|
||||||
|
import { unwrap } from '../lib/api'
|
||||||
|
import type { Contact } from '@shared/types'
|
||||||
|
|
||||||
|
const empty = (): Omit<Contact, 'id' | 'createdAt'> => ({
|
||||||
|
displayName: '',
|
||||||
|
emails: [],
|
||||||
|
telegramChatId: '',
|
||||||
|
whatsappNumber: '',
|
||||||
|
notes: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
export function ContactsView(): JSX.Element {
|
||||||
|
const [contacts, setContacts] = useState<Contact[]>([])
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<Contact | null>(null)
|
||||||
|
const [draft, setDraft] = useState<Omit<Contact, 'id' | 'createdAt'>>(empty())
|
||||||
|
const [emailsText, setEmailsText] = useState('')
|
||||||
|
const [importing, setImporting] = useState(false)
|
||||||
|
const [importResult, setImportResult] = useState<{
|
||||||
|
added: number
|
||||||
|
skipped: number
|
||||||
|
total: number
|
||||||
|
errors: string[]
|
||||||
|
source: string
|
||||||
|
} | null>(null)
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
if (!q) return contacts
|
||||||
|
return contacts.filter((c) => {
|
||||||
|
if (c.displayName.toLowerCase().includes(q)) return true
|
||||||
|
if (c.emails.some((e) => e.toLowerCase().includes(q))) return true
|
||||||
|
if (c.telegramChatId?.toLowerCase().includes(q)) return true
|
||||||
|
if (c.whatsappNumber?.toLowerCase().includes(q)) return true
|
||||||
|
if (c.notes?.toLowerCase().includes(q)) return true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}, [contacts, query])
|
||||||
|
|
||||||
|
const load = async (): Promise<void> => {
|
||||||
|
setContacts(await unwrap(window.api.contacts.list()))
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const save = async (): Promise<void> => {
|
||||||
|
const emails = emailsText
|
||||||
|
.split(/[,;\n]/)
|
||||||
|
.map((e) => e.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
const payload = { ...draft, emails }
|
||||||
|
if (editing) await unwrap(window.api.contacts.update(editing.id, payload))
|
||||||
|
else await unwrap(window.api.contacts.create(payload))
|
||||||
|
setOpen(false)
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = async (id: number): Promise<void> => {
|
||||||
|
if (!confirm('Eliminare questo contatto?')) return
|
||||||
|
await unwrap(window.api.contacts.delete(id))
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const importFile = async (): Promise<void> => {
|
||||||
|
setImporting(true)
|
||||||
|
setImportResult(null)
|
||||||
|
try {
|
||||||
|
const result = await unwrap(window.api.contacts.importFile())
|
||||||
|
if (result) {
|
||||||
|
setImportResult(result)
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setImportResult({
|
||||||
|
added: 0,
|
||||||
|
skipped: 0,
|
||||||
|
total: 0,
|
||||||
|
errors: [(err as Error).message],
|
||||||
|
source: ''
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setImporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="Contatti"
|
||||||
|
subtitle={
|
||||||
|
query
|
||||||
|
? `${filtered.length} risultati su ${contacts.length} contatti`
|
||||||
|
: `${contacts.length} contatti in rubrica`
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="btn-secondary" disabled={importing} onClick={importFile}>
|
||||||
|
{importing ? 'Importazione…' : '📥 Importa CSV/vCard'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(null)
|
||||||
|
setDraft(empty())
|
||||||
|
setEmailsText('')
|
||||||
|
setOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Nuovo contatto
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="sticky top-0 z-10 border-b border-slate-200 bg-white/80 px-6 py-3 backdrop-blur">
|
||||||
|
<div className="relative max-w-md">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Cerca per nome, email, chat_id, numero, note…"
|
||||||
|
className="input pl-9"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">
|
||||||
|
🔍
|
||||||
|
</span>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
onClick={() => setQuery('')}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-700"
|
||||||
|
title="Pulisci"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{importResult && (
|
||||||
|
<div className="border-b border-slate-200 bg-emerald-50/60 px-6 py-3 text-sm text-slate-800">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<strong>Importazione completata:</strong> {importResult.added} aggiunti,{' '}
|
||||||
|
{importResult.skipped} duplicati saltati, {importResult.total} trovati in totale.
|
||||||
|
{importResult.errors.length > 0 && (
|
||||||
|
<ul className="mt-1 list-disc pl-5 text-xs text-red-700">
|
||||||
|
{importResult.errors.slice(0, 5).map((e, i) => (
|
||||||
|
<li key={i}>{e}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setImportResult(null)} className="text-slate-400 hover:text-slate-700">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-3 p-6 lg:grid-cols-3">
|
||||||
|
{contacts.length === 0 && (
|
||||||
|
<div className="col-span-full card p-6 text-center text-sm text-slate-500">
|
||||||
|
Nessun contatto in rubrica.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{contacts.length > 0 && filtered.length === 0 && (
|
||||||
|
<div className="col-span-full card p-6 text-center text-sm text-slate-500">
|
||||||
|
Nessun contatto corrisponde a "<strong>{query}</strong>".
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filtered.map((c) => (
|
||||||
|
<div key={c.id} className="card flex flex-col gap-2 p-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<h3 className="text-base font-semibold">{c.displayName}</h3>
|
||||||
|
</div>
|
||||||
|
{c.emails.length > 0 && (
|
||||||
|
<div className="text-xs text-slate-600">
|
||||||
|
<div className="label">Email</div>
|
||||||
|
{c.emails.map((e, i) => (
|
||||||
|
<div key={i}>{e}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{c.telegramChatId && (
|
||||||
|
<div className="text-xs text-slate-600">
|
||||||
|
<div className="label">Telegram</div>
|
||||||
|
{c.telegramChatId}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{c.whatsappNumber && (
|
||||||
|
<div className="text-xs text-slate-600">
|
||||||
|
<div className="label">WhatsApp</div>
|
||||||
|
{c.whatsappNumber}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{c.notes && <p className="text-xs text-slate-500">{c.notes}</p>}
|
||||||
|
<div className="mt-auto flex justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(c)
|
||||||
|
setDraft({
|
||||||
|
displayName: c.displayName,
|
||||||
|
emails: c.emails,
|
||||||
|
telegramChatId: c.telegramChatId ?? '',
|
||||||
|
whatsappNumber: c.whatsappNumber ?? '',
|
||||||
|
notes: c.notes ?? ''
|
||||||
|
})
|
||||||
|
setEmailsText(c.emails.join(', '))
|
||||||
|
setOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Modifica
|
||||||
|
</button>
|
||||||
|
<button className="btn-danger" onClick={() => remove(c.id)}>
|
||||||
|
Elimina
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={editing ? 'Modifica contatto' : 'Nuovo contatto'}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="btn-ghost" onClick={() => setOpen(false)}>
|
||||||
|
Annulla
|
||||||
|
</button>
|
||||||
|
<button className="btn-primary" onClick={save}>
|
||||||
|
Salva
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<span className="label">Nome</span>
|
||||||
|
<input
|
||||||
|
className="input mt-1"
|
||||||
|
value={draft.displayName}
|
||||||
|
onChange={(e) => setDraft({ ...draft, displayName: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="label">Email (separa con virgola o invio)</span>
|
||||||
|
<textarea
|
||||||
|
className="input mt-1 h-20 resize-y"
|
||||||
|
value={emailsText}
|
||||||
|
onChange={(e) => setEmailsText(e.target.value)}
|
||||||
|
placeholder="mario@example.com, mario.work@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="label">Telegram chat_id</span>
|
||||||
|
<input
|
||||||
|
className="input mt-1"
|
||||||
|
value={draft.telegramChatId ?? ''}
|
||||||
|
onChange={(e) => setDraft({ ...draft, telegramChatId: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="label">Numero WhatsApp (con prefisso, senza +)</span>
|
||||||
|
<input
|
||||||
|
className="input mt-1"
|
||||||
|
value={draft.whatsappNumber ?? ''}
|
||||||
|
onChange={(e) => setDraft({ ...draft, whatsappNumber: e.target.value })}
|
||||||
|
placeholder="393331234567"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="label">Note</span>
|
||||||
|
<textarea
|
||||||
|
className="input mt-1 h-20 resize-y"
|
||||||
|
value={draft.notes ?? ''}
|
||||||
|
onChange={(e) => setDraft({ ...draft, notes: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { PageHeader } from '../components/PageHeader'
|
||||||
|
import { unwrap } from '../lib/api'
|
||||||
|
import type { AppSettings } from '@shared/types'
|
||||||
|
|
||||||
|
export function CredentialsView(): JSX.Element {
|
||||||
|
const [settings, setSettings] = useState<AppSettings>({})
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void unwrap(window.api.settings.get()).then(setSettings)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const save = async (): Promise<void> => {
|
||||||
|
await unwrap(window.api.settings.update(settings))
|
||||||
|
setSaved(true)
|
||||||
|
setTimeout(() => setSaved(false), 2500)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="Credenziali OAuth / API"
|
||||||
|
subtitle="Chiavi necessarie per Gmail OAuth e Telegram MTProto"
|
||||||
|
action={
|
||||||
|
<button className="btn-primary" onClick={save}>
|
||||||
|
{saved ? 'Salvato ✓' : 'Salva'}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
||||||
|
<section className="card p-5">
|
||||||
|
<h2 className="text-base font-semibold text-slate-900">Gmail OAuth</h2>
|
||||||
|
<p className="mt-1 text-sm text-slate-600">
|
||||||
|
Per collegare account Gmail con OAuth (senza app password) serve un Client ID e Client
|
||||||
|
Secret di tipo <strong>Desktop application</strong> da Google Cloud Console.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<details className="mt-3 rounded-md border border-slate-200 bg-slate-50 p-3 text-sm">
|
||||||
|
<summary className="cursor-pointer font-medium text-slate-800">
|
||||||
|
▸ Come ottenere Client ID/Secret in 5 minuti
|
||||||
|
</summary>
|
||||||
|
<ol className="mt-3 list-decimal space-y-2 pl-5 text-slate-700">
|
||||||
|
<li>
|
||||||
|
Apri{' '}
|
||||||
|
<a
|
||||||
|
href="https://console.cloud.google.com/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-brand-600 underline"
|
||||||
|
>
|
||||||
|
console.cloud.google.com
|
||||||
|
</a>{' '}
|
||||||
|
e crea un nuovo progetto (es. "EmailToChat").
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>API & Services → Library</strong>: abilita "Gmail API".
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>API & Services → OAuth consent screen</strong>: scegli tipo "External",
|
||||||
|
compila i campi base (nome app, email tua), salva. Aggiungi te stesso come{' '}
|
||||||
|
<em>Test user</em>.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>API & Services → Credentials → Create Credentials → OAuth client ID</strong>
|
||||||
|
: application type <strong>Desktop app</strong>, dai un nome, crea.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Copia il <em>Client ID</em> e <em>Client Secret</em> qui sotto, salva.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Vai in <strong>Email Account</strong> e clicca <em>+ Collega Gmail</em>.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<span className="label">Client ID</span>
|
||||||
|
<input
|
||||||
|
className="input mt-1 font-mono text-xs"
|
||||||
|
value={settings.gmailClientId ?? ''}
|
||||||
|
onChange={(e) => setSettings({ ...settings, gmailClientId: e.target.value })}
|
||||||
|
placeholder="123456789-xxx.apps.googleusercontent.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="label">Client Secret</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input mt-1 font-mono text-xs"
|
||||||
|
value={settings.gmailClientSecret ?? ''}
|
||||||
|
onChange={(e) => setSettings({ ...settings, gmailClientSecret: e.target.value })}
|
||||||
|
placeholder="GOCSPX-..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card p-5">
|
||||||
|
<h2 className="text-base font-semibold text-slate-900">Telegram MTProto</h2>
|
||||||
|
<p className="mt-1 text-sm text-slate-600">
|
||||||
|
Per usare un account Telegram personale (invio a qualsiasi numero, come WhatsApp) servono
|
||||||
|
le credenziali dell'app da{' '}
|
||||||
|
<a
|
||||||
|
href="https://my.telegram.org"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-brand-600 underline"
|
||||||
|
>
|
||||||
|
my.telegram.org
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<details className="mt-3 rounded-md border border-slate-200 bg-slate-50 p-3 text-sm">
|
||||||
|
<summary className="cursor-pointer font-medium text-slate-800">
|
||||||
|
▸ Come ottenere api_id/api_hash
|
||||||
|
</summary>
|
||||||
|
<ol className="mt-3 list-decimal space-y-2 pl-5 text-slate-700">
|
||||||
|
<li>
|
||||||
|
Vai su{' '}
|
||||||
|
<a
|
||||||
|
href="https://my.telegram.org"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-brand-600 underline"
|
||||||
|
>
|
||||||
|
my.telegram.org
|
||||||
|
</a>{' '}
|
||||||
|
e accedi con il tuo numero di telefono (riceverai un codice via Telegram).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Sezione <strong>API development tools</strong> → crea una nuova "app" (nome
|
||||||
|
qualsiasi, es. "EmailToChat", platform "Other").
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Copia <code>api_id</code> e <code>api_hash</code> qui sotto.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Vai in <strong>Telegram → Account utente</strong> per fare il login con il tuo
|
||||||
|
numero.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<p className="mt-3 text-xs text-slate-600">
|
||||||
|
⚠️ <strong>Importante</strong>: l'uso del tuo account personale per inviare messaggi
|
||||||
|
comporta il rischio di <strong>ban da parte di Telegram</strong> se usato per spam o
|
||||||
|
invii massivi a sconosciuti. Tienilo per uso ragionevole.
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<span className="label">api_id</span>
|
||||||
|
<input
|
||||||
|
className="input mt-1 font-mono text-xs"
|
||||||
|
value={settings.telegramApiId ?? ''}
|
||||||
|
onChange={(e) => setSettings({ ...settings, telegramApiId: e.target.value })}
|
||||||
|
placeholder="12345678"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="label">api_hash</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input mt-1 font-mono text-xs"
|
||||||
|
value={settings.telegramApiHash ?? ''}
|
||||||
|
onChange={(e) => setSettings({ ...settings, telegramApiHash: e.target.value })}
|
||||||
|
placeholder="abcdef1234567890abcdef1234567890"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { PageHeader } from '../components/PageHeader'
|
||||||
|
import { ReplyForwardModal } from '../components/ReplyForwardModal'
|
||||||
|
import { unwrap } from '../lib/api'
|
||||||
|
import type { Message } from '@shared/types'
|
||||||
|
|
||||||
|
export function InboxView(): JSX.Element {
|
||||||
|
const [messages, setMessages] = useState<Message[]>([])
|
||||||
|
const [selected, setSelected] = useState<Message | null>(null)
|
||||||
|
const [aiBusy, setAiBusy] = useState<string | null>(null)
|
||||||
|
const [filter, setFilter] = useState<'all' | 'inbound' | 'outbound'>('all')
|
||||||
|
const [composeMode, setComposeMode] = useState<'reply' | 'forward' | null>(null)
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
const filterObj = filter === 'all' ? undefined : { direction: filter }
|
||||||
|
const list = await unwrap(window.api.messages.list(filterObj))
|
||||||
|
setMessages(list)
|
||||||
|
if (selected) {
|
||||||
|
const found = list.find((m) => m.id === selected.id)
|
||||||
|
if (found) setSelected(found)
|
||||||
|
}
|
||||||
|
}, [filter, selected])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reload()
|
||||||
|
}, [reload])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return window.api.on.newMessage(() => {
|
||||||
|
void reload()
|
||||||
|
})
|
||||||
|
}, [reload])
|
||||||
|
|
||||||
|
const runAi = async (task: 'summarize' | 'classify' | 'extract' | 'reply'): Promise<void> => {
|
||||||
|
if (!selected) return
|
||||||
|
setAiBusy(task)
|
||||||
|
try {
|
||||||
|
if (task === 'summarize') await unwrap(window.api.ai.summarize(selected.id))
|
||||||
|
else if (task === 'classify') await unwrap(window.api.ai.classify(selected.id))
|
||||||
|
else if (task === 'extract') await unwrap(window.api.ai.extract(selected.id))
|
||||||
|
else if (task === 'reply') await unwrap(window.api.ai.suggestReply(selected.id))
|
||||||
|
const updated = await unwrap(window.api.messages.get(selected.id))
|
||||||
|
setSelected(updated)
|
||||||
|
await reload()
|
||||||
|
} catch (err) {
|
||||||
|
alert(`AI: ${(err as Error).message}`)
|
||||||
|
} finally {
|
||||||
|
setAiBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<PageHeader
|
||||||
|
title="Inbox"
|
||||||
|
subtitle={`${messages.length} messaggi`}
|
||||||
|
action={
|
||||||
|
<div className="flex gap-1 rounded-md bg-slate-100 p-1">
|
||||||
|
{(['all', 'inbound', 'outbound'] as const).map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={() => setFilter(f)}
|
||||||
|
className={`rounded px-3 py-1 text-xs font-medium ${
|
||||||
|
filter === f ? 'bg-white shadow-sm text-slate-900' : 'text-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f === 'all' ? 'Tutti' : f === 'inbound' ? 'In entrata' : 'In uscita'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="grid flex-1 grid-cols-[360px_1fr] overflow-hidden">
|
||||||
|
<div className="overflow-y-auto border-r border-slate-200 bg-white">
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div className="p-6 text-sm text-slate-500">
|
||||||
|
Nessun messaggio. Configura un account email e attendi che il polling porti i primi
|
||||||
|
messaggi.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => setSelected(m)}
|
||||||
|
className={`flex w-full flex-col gap-1 border-b border-slate-100 px-4 py-3 text-left transition-colors ${
|
||||||
|
selected?.id === m.id ? 'bg-brand-50' : 'hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="truncate text-sm font-semibold text-slate-900">
|
||||||
|
{m.fromAddr || m.toAddr || '?'}
|
||||||
|
</span>
|
||||||
|
<ChannelBadge channel={m.channel} />
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-sm text-slate-700">
|
||||||
|
{m.subject || <em className="text-slate-400">(senza oggetto)</em>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||||
|
<span>{formatDate(m.receivedAt || m.sentAt)}</span>
|
||||||
|
{m.ai?.category && (
|
||||||
|
<span className="pill bg-slate-100 text-slate-700">{m.ai.category}</span>
|
||||||
|
)}
|
||||||
|
{m.status === 'forwarded' && (
|
||||||
|
<span className="pill bg-emerald-100 text-emerald-700">inoltrato</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto p-6">
|
||||||
|
{!selected && (
|
||||||
|
<div className="text-sm text-slate-500">Seleziona un messaggio per vederne il contenuto.</div>
|
||||||
|
)}
|
||||||
|
{selected && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs uppercase text-slate-500">Oggetto</div>
|
||||||
|
<div className="text-lg font-semibold">{selected.subject || '(senza oggetto)'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="label">Da</span>
|
||||||
|
<div className="text-slate-800">{selected.fromAddr || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="label">A</span>
|
||||||
|
<div className="text-slate-800">{selected.toAddr || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="label">Data</span>
|
||||||
|
<div className="text-slate-800">{formatDate(selected.receivedAt || selected.sentAt)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="label">Stato</span>
|
||||||
|
<div className="text-slate-800">{selected.status}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 border-t border-slate-200 pt-4">
|
||||||
|
{selected.direction === 'inbound' && (
|
||||||
|
<>
|
||||||
|
<button className="btn-primary" onClick={() => setComposeMode('reply')}>
|
||||||
|
↩ Rispondi
|
||||||
|
</button>
|
||||||
|
<button className="btn-primary" onClick={() => setComposeMode('forward')}>
|
||||||
|
➔ Inoltra
|
||||||
|
</button>
|
||||||
|
<div className="mx-1 self-stretch border-l border-slate-200" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
disabled={!!aiBusy}
|
||||||
|
onClick={() => runAi('summarize')}
|
||||||
|
>
|
||||||
|
{aiBusy === 'summarize' ? 'Riassumo…' : '★ Riassumi'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
disabled={!!aiBusy}
|
||||||
|
onClick={() => runAi('classify')}
|
||||||
|
>
|
||||||
|
{aiBusy === 'classify' ? 'Classifico…' : '★ Classifica'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
disabled={!!aiBusy}
|
||||||
|
onClick={() => runAi('extract')}
|
||||||
|
>
|
||||||
|
{aiBusy === 'extract' ? 'Estraggo…' : '★ Estrai info'}
|
||||||
|
</button>
|
||||||
|
<button className="btn-secondary" disabled={!!aiBusy} onClick={() => runAi('reply')}>
|
||||||
|
{aiBusy === 'reply' ? 'Genero…' : '★ Suggerisci risposta'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected.ai && (
|
||||||
|
<div className="card space-y-3 border-brand-200 bg-brand-50/40 p-4">
|
||||||
|
<div className="text-xs font-bold uppercase text-brand-700">
|
||||||
|
Analisi AI ({selected.ai.provider} · {selected.ai.model})
|
||||||
|
</div>
|
||||||
|
{selected.ai.summary && (
|
||||||
|
<div>
|
||||||
|
<div className="label">Riassunto</div>
|
||||||
|
<p className="text-sm text-slate-800">{selected.ai.summary}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selected.ai.category && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="label">Categoria</span>
|
||||||
|
<span className="pill bg-brand-100 text-brand-800">{selected.ai.category}</span>
|
||||||
|
{selected.ai.labels?.map((l) => (
|
||||||
|
<span key={l} className="pill bg-slate-100 text-slate-700">
|
||||||
|
{l}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selected.ai.extracted && Object.keys(selected.ai.extracted).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="label">Info estratte</div>
|
||||||
|
<pre className="mt-1 overflow-x-auto rounded bg-white p-2 text-xs text-slate-700">
|
||||||
|
{JSON.stringify(selected.ai.extracted, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selected.ai.suggestedReply && (
|
||||||
|
<div>
|
||||||
|
<div className="label">Bozza di risposta</div>
|
||||||
|
<p className="mt-1 whitespace-pre-wrap rounded bg-white p-3 text-sm text-slate-800">
|
||||||
|
{selected.ai.suggestedReply}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="label">Corpo</div>
|
||||||
|
<div className="mt-1 whitespace-pre-wrap rounded-md border border-slate-200 bg-white p-4 text-sm text-slate-800">
|
||||||
|
{selected.bodyText || stripHtml(selected.bodyHtml) || '(vuoto)'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected.attachments && selected.attachments.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="label">Allegati ({selected.attachments.length})</div>
|
||||||
|
<ul className="mt-1 text-sm text-slate-700">
|
||||||
|
{selected.attachments.map((a, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
📎 {a.filename} ({Math.round(a.size / 1024)} KB)
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected && composeMode && (
|
||||||
|
<ReplyForwardModal
|
||||||
|
mode={composeMode}
|
||||||
|
message={selected}
|
||||||
|
onClose={() => setComposeMode(null)}
|
||||||
|
onSent={() => void reload()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChannelBadge({ channel }: { channel: string }): JSX.Element {
|
||||||
|
const styles: Record<string, string> = {
|
||||||
|
smtp: 'bg-slate-100 text-slate-700',
|
||||||
|
telegram: 'bg-sky-100 text-sky-700',
|
||||||
|
whatsapp: 'bg-emerald-100 text-emerald-700'
|
||||||
|
}
|
||||||
|
return <span className={`pill ${styles[channel] ?? styles.smtp}`}>{channel}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(d?: string): string {
|
||||||
|
if (!d) return ''
|
||||||
|
try {
|
||||||
|
return new Date(d).toLocaleString('it-IT', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHtml(html?: string): string {
|
||||||
|
if (!html) return ''
|
||||||
|
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { PageHeader } from '../components/PageHeader'
|
||||||
|
import { getTheme, setTheme, subscribeTheme, type ThemeMode } from '../lib/theme'
|
||||||
|
|
||||||
|
const OPTIONS: { value: ThemeMode; label: string; description: string; icon: string }[] = [
|
||||||
|
{
|
||||||
|
value: 'system',
|
||||||
|
label: 'Sistema',
|
||||||
|
description: 'Segue la preferenza di macOS (automatico).',
|
||||||
|
icon: '🖥'
|
||||||
|
},
|
||||||
|
{ value: 'light', label: 'Chiaro', description: 'Sfondo bianco, alta luminosità.', icon: '☀' },
|
||||||
|
{ value: 'dark', label: 'Scuro', description: 'Sfondo blu profondo, riposante.', icon: '🌙' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export function PreferencesView(): JSX.Element {
|
||||||
|
const [theme, setLocal] = useState<ThemeMode>(getTheme())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return subscribeTheme((t) => setLocal(t))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const pick = (mode: ThemeMode): void => {
|
||||||
|
setTheme(mode)
|
||||||
|
setLocal(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader title="Preferenze" subtitle="Aspetto e comportamento dell'app" />
|
||||||
|
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
||||||
|
<section className="card p-5">
|
||||||
|
<h2 className="text-base font-semibold">Tema</h2>
|
||||||
|
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Scegli il tema dell'interfaccia. La modalità <strong>Sistema</strong> segue
|
||||||
|
l'impostazione del Mac e cambia automaticamente in base all'orario o al setting di
|
||||||
|
sistema.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 grid grid-cols-3 gap-3">
|
||||||
|
{OPTIONS.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
onClick={() => pick(o.value)}
|
||||||
|
className={`rounded-lg border p-4 text-left transition-colors ${
|
||||||
|
theme === o.value
|
||||||
|
? 'border-brand-500 bg-brand-50 dark:border-brand-400 dark:bg-brand-900/40'
|
||||||
|
: 'border-slate-200 hover:border-slate-300 dark:border-slate-700 dark:hover:border-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-2xl">{o.icon}</div>
|
||||||
|
<div className="mt-2 font-semibold">
|
||||||
|
{o.label}
|
||||||
|
{theme === o.value && <span className="ml-1 text-brand-600 dark:text-brand-400">✓</span>}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">{o.description}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { PageHeader } from '../components/PageHeader'
|
||||||
|
import { Modal } from '../components/Modal'
|
||||||
|
import { unwrap } from '../lib/api'
|
||||||
|
import type {
|
||||||
|
ChannelType,
|
||||||
|
ForwardingRule,
|
||||||
|
TelegramBot,
|
||||||
|
TelegramUserAccount,
|
||||||
|
WhatsappSession
|
||||||
|
} from '@shared/types'
|
||||||
|
|
||||||
|
const empty = (): Omit<ForwardingRule, 'id' | 'createdAt'> => ({
|
||||||
|
name: '',
|
||||||
|
enabled: true,
|
||||||
|
priority: 100,
|
||||||
|
targetChannel: 'telegram',
|
||||||
|
targetIdentifier: '',
|
||||||
|
includeBody: true,
|
||||||
|
includeAttachments: false
|
||||||
|
})
|
||||||
|
|
||||||
|
export function RulesView(): JSX.Element {
|
||||||
|
const [rules, setRules] = useState<ForwardingRule[]>([])
|
||||||
|
const [bots, setBots] = useState<TelegramBot[]>([])
|
||||||
|
const [users, setUsers] = useState<TelegramUserAccount[]>([])
|
||||||
|
const [sessions, setSessions] = useState<WhatsappSession[]>([])
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<ForwardingRule | null>(null)
|
||||||
|
const [draft, setDraft] = useState<Omit<ForwardingRule, 'id' | 'createdAt'>>(empty())
|
||||||
|
|
||||||
|
const load = async (): Promise<void> => {
|
||||||
|
const [r, b, u, s] = await Promise.all([
|
||||||
|
unwrap(window.api.rules.list()),
|
||||||
|
unwrap(window.api.telegram.list()),
|
||||||
|
unwrap(window.api.telegramUser.list()),
|
||||||
|
unwrap(window.api.whatsapp.list())
|
||||||
|
])
|
||||||
|
setRules(r)
|
||||||
|
setBots(b)
|
||||||
|
setUsers(u)
|
||||||
|
setSessions(s)
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const save = async (): Promise<void> => {
|
||||||
|
if (editing) await unwrap(window.api.rules.update(editing.id, draft))
|
||||||
|
else await unwrap(window.api.rules.create(draft))
|
||||||
|
setOpen(false)
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = async (id: number): Promise<void> => {
|
||||||
|
if (!confirm('Eliminare questa regola?')) return
|
||||||
|
await unwrap(window.api.rules.delete(id))
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="Regole di inoltro"
|
||||||
|
subtitle="Email IMAP in entrata → Telegram / WhatsApp"
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(null)
|
||||||
|
setDraft(empty())
|
||||||
|
setOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Nuova regola
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="space-y-3 p-6">
|
||||||
|
<div className="card bg-slate-50 p-4 text-sm text-slate-700">
|
||||||
|
Le regole sono valutate per ogni email in entrata in ordine di <em>priorità</em>
|
||||||
|
(più basso = prima). Tutti i match con <em>AND</em> tra i campi compilati. Lascia un campo
|
||||||
|
vuoto per ignorarlo.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rules.length === 0 && (
|
||||||
|
<div className="card p-6 text-center text-sm text-slate-500">
|
||||||
|
Nessuna regola configurata.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{rules.map((r) => (
|
||||||
|
<div key={r.id} className="card p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-base font-semibold">{r.name}</h3>
|
||||||
|
<span className="pill bg-slate-100 text-slate-700">prio {r.priority}</span>
|
||||||
|
{!r.enabled && <span className="pill bg-slate-200 text-slate-600">disattiva</span>}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm text-slate-600">
|
||||||
|
Quando: {describeFilters(r) || '(qualsiasi email)'}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-600">
|
||||||
|
Inoltra a: <strong>{r.targetChannel}</strong> · {r.targetIdentifier}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(r)
|
||||||
|
setDraft({ ...r })
|
||||||
|
setOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Modifica
|
||||||
|
</button>
|
||||||
|
<button className="btn-danger" onClick={() => remove(r.id)}>
|
||||||
|
Elimina
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={editing ? 'Modifica regola' : 'Nuova regola di inoltro'}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="btn-ghost" onClick={() => setOpen(false)}>
|
||||||
|
Annulla
|
||||||
|
</button>
|
||||||
|
<button className="btn-primary" onClick={save}>
|
||||||
|
Salva
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<span className="label">Nome</span>
|
||||||
|
<input
|
||||||
|
className="input mt-1"
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="label">Priorità (basso = prima)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input mt-1"
|
||||||
|
value={draft.priority}
|
||||||
|
onChange={(e) => setDraft({ ...draft, priority: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.enabled}
|
||||||
|
onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Attiva
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="col-span-2 mt-2 border-b border-slate-200 pb-1 text-xs font-bold uppercase text-slate-500">
|
||||||
|
Filtri (lasciare vuoto = ignora)
|
||||||
|
</h3>
|
||||||
|
<div>
|
||||||
|
<span className="label">From contiene</span>
|
||||||
|
<input
|
||||||
|
className="input mt-1"
|
||||||
|
value={draft.matchFromContains ?? ''}
|
||||||
|
onChange={(e) => setDraft({ ...draft, matchFromContains: e.target.value || undefined })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="label">To contiene</span>
|
||||||
|
<input
|
||||||
|
className="input mt-1"
|
||||||
|
value={draft.matchToContains ?? ''}
|
||||||
|
onChange={(e) => setDraft({ ...draft, matchToContains: e.target.value || undefined })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="label">Oggetto contiene</span>
|
||||||
|
<input
|
||||||
|
className="input mt-1"
|
||||||
|
value={draft.matchSubjectContains ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({ ...draft, matchSubjectContains: e.target.value || undefined })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="label">Corpo contiene</span>
|
||||||
|
<input
|
||||||
|
className="input mt-1"
|
||||||
|
value={draft.matchBodyContains ?? ''}
|
||||||
|
onChange={(e) => setDraft({ ...draft, matchBodyContains: e.target.value || undefined })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="label">Categoria AI</span>
|
||||||
|
<input
|
||||||
|
className="input mt-1"
|
||||||
|
placeholder="es. fattura, importante"
|
||||||
|
value={draft.matchAiCategory ?? ''}
|
||||||
|
onChange={(e) => setDraft({ ...draft, matchAiCategory: e.target.value || undefined })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="label">Allegati</span>
|
||||||
|
<select
|
||||||
|
className="input mt-1"
|
||||||
|
value={
|
||||||
|
draft.matchHasAttachment === undefined
|
||||||
|
? ''
|
||||||
|
: draft.matchHasAttachment
|
||||||
|
? 'yes'
|
||||||
|
: 'no'
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
matchHasAttachment:
|
||||||
|
e.target.value === '' ? undefined : e.target.value === 'yes'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">Indifferente</option>
|
||||||
|
<option value="yes">Con allegati</option>
|
||||||
|
<option value="no">Senza allegati</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="col-span-2 mt-2 border-b border-slate-200 pb-1 text-xs font-bold uppercase text-slate-500">
|
||||||
|
Destinazione
|
||||||
|
</h3>
|
||||||
|
<div>
|
||||||
|
<span className="label">Canale</span>
|
||||||
|
<select
|
||||||
|
className="input mt-1"
|
||||||
|
value={draft.targetChannel}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
targetChannel: e.target.value as ChannelType,
|
||||||
|
targetIdentifier: ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="telegram">Telegram (Bot)</option>
|
||||||
|
<option value="telegram_user">Telegram (Account utente)</option>
|
||||||
|
<option value="whatsapp">WhatsApp</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="label">
|
||||||
|
{draft.targetChannel === 'telegram'
|
||||||
|
? 'Bot:chatId (es. 1:123456789)'
|
||||||
|
: draft.targetChannel === 'telegram_user'
|
||||||
|
? 'AccountId|destinatario (es. 1|+393331234567)'
|
||||||
|
: 'SessionId|numero (es. 1|393331234567)'}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="input mt-1"
|
||||||
|
value={draft.targetIdentifier}
|
||||||
|
onChange={(e) => setDraft({ ...draft, targetIdentifier: e.target.value })}
|
||||||
|
list="target-suggestions"
|
||||||
|
/>
|
||||||
|
<datalist id="target-suggestions">
|
||||||
|
{draft.targetChannel === 'telegram' &&
|
||||||
|
bots.map((b) => (
|
||||||
|
<option key={b.id} value={`${b.id}:${b.defaultChatId ?? ''}`}>
|
||||||
|
{b.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{draft.targetChannel === 'telegram_user' &&
|
||||||
|
users.map((u) => (
|
||||||
|
<option key={u.id} value={`${u.id}|`}>
|
||||||
|
{u.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{draft.targetChannel === 'whatsapp' &&
|
||||||
|
sessions.map((s) => (
|
||||||
|
<option key={s.id} value={`${s.id}|${s.defaultRecipient ?? ''}`}>
|
||||||
|
{s.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2 space-y-2 border-t border-slate-200 pt-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.includeBody}
|
||||||
|
onChange={(e) => setDraft({ ...draft, includeBody: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Includi corpo email nel messaggio inoltrato
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.includeAttachments}
|
||||||
|
onChange={(e) => setDraft({ ...draft, includeAttachments: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Includi allegati
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2">
|
||||||
|
<span className="label">
|
||||||
|
Template personalizzato (opzionale — placeholder: {'{{from}} {{subject}} {{body}} {{summary}} {{category}}'})
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
className="input mt-1 h-24 resize-y"
|
||||||
|
value={draft.template ?? ''}
|
||||||
|
onChange={(e) => setDraft({ ...draft, template: e.target.value || undefined })}
|
||||||
|
placeholder="Lascia vuoto per usare il template predefinito"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeFilters(r: ForwardingRule): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (r.matchFromContains) parts.push(`from~"${r.matchFromContains}"`)
|
||||||
|
if (r.matchToContains) parts.push(`to~"${r.matchToContains}"`)
|
||||||
|
if (r.matchSubjectContains) parts.push(`subject~"${r.matchSubjectContains}"`)
|
||||||
|
if (r.matchBodyContains) parts.push(`body~"${r.matchBodyContains}"`)
|
||||||
|
if (r.matchAiCategory) parts.push(`AI=${r.matchAiCategory}`)
|
||||||
|
if (r.matchHasAttachment === true) parts.push('has attachments')
|
||||||
|
if (r.matchHasAttachment === false) parts.push('no attachments')
|
||||||
|
return parts.join(' AND ')
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { AccountsView } from './AccountsView'
|
||||||
|
import { TelegramView } from './TelegramView'
|
||||||
|
import { WhatsappView } from './WhatsappView'
|
||||||
|
import { AiView } from './AiView'
|
||||||
|
import { CredentialsView } from './CredentialsView'
|
||||||
|
import { PreferencesView } from './PreferencesView'
|
||||||
|
|
||||||
|
type SubTab = 'accounts' | 'telegram' | 'whatsapp' | 'ai' | 'credentials' | 'preferences'
|
||||||
|
|
||||||
|
const tabs: { key: SubTab; label: string; icon: string }[] = [
|
||||||
|
{ key: 'accounts', label: 'Email Account', icon: '@' },
|
||||||
|
{ key: 'telegram', label: 'Telegram', icon: '◆' },
|
||||||
|
{ key: 'whatsapp', label: 'WhatsApp', icon: '◉' },
|
||||||
|
{ key: 'ai', label: 'AI', icon: '★' },
|
||||||
|
{ key: 'credentials', label: 'Credenziali OAuth/API', icon: '🔑' },
|
||||||
|
{ key: 'preferences', label: 'Preferenze', icon: '🎨' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export function SettingsView(): JSX.Element {
|
||||||
|
const [tab, setTab] = useState<SubTab>('accounts')
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="sticky top-0 z-30 border-b border-ink-200/70 bg-white/60 px-6 backdrop-blur-xl dark:border-white/[0.06] dark:bg-ink-950/60">
|
||||||
|
<div className="flex gap-1 overflow-x-auto">
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => setTab(t.key)}
|
||||||
|
className={`relative shrink-0 px-4 py-3.5 text-sm font-medium tracking-tight transition-colors ${
|
||||||
|
tab === t.key
|
||||||
|
? 'text-ink-900 dark:text-white'
|
||||||
|
: 'text-ink-500 hover:text-ink-800 dark:text-ink-400 dark:hover:text-ink-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="mr-1.5 opacity-70">{t.icon}</span>
|
||||||
|
{t.label}
|
||||||
|
{tab === t.key && (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute inset-x-3 -bottom-px h-0.5 rounded-full bg-gradient-to-r from-brand-500 via-neon-purple to-neon-cyan shadow-glow-sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{tab === 'accounts' && <AccountsView />}
|
||||||
|
{tab === 'telegram' && <TelegramView />}
|
||||||
|
{tab === 'whatsapp' && <WhatsappView />}
|
||||||
|
{tab === 'ai' && <AiView />}
|
||||||
|
{tab === 'credentials' && <CredentialsView />}
|
||||||
|
{tab === 'preferences' && <PreferencesView />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Modal } from '../components/Modal'
|
||||||
|
import { MtProtoLoginModal } from '../components/MtProtoLoginModal'
|
||||||
|
import { unwrap } from '../lib/api'
|
||||||
|
import type { TelegramUserAccount } from '@shared/types'
|
||||||
|
|
||||||
|
export function TelegramUserView(): JSX.Element {
|
||||||
|
const [accounts, setAccounts] = useState<TelegramUserAccount[]>([])
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [draft, setDraft] = useState({ name: '', phone: '' })
|
||||||
|
const [loginAccount, setLoginAccount] = useState<TelegramUserAccount | null>(null)
|
||||||
|
const [busy, setBusy] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const load = async (): Promise<void> => {
|
||||||
|
setAccounts(await unwrap(window.api.telegramUser.list()))
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const create = async (): Promise<void> => {
|
||||||
|
if (!draft.name || !draft.phone) return
|
||||||
|
const phone = draft.phone.trim().startsWith('+') ? draft.phone.trim() : `+${draft.phone.replace(/\D/g, '')}`
|
||||||
|
const created = await unwrap(
|
||||||
|
window.api.telegramUser.create({
|
||||||
|
name: draft.name,
|
||||||
|
phone,
|
||||||
|
status: 'disconnected',
|
||||||
|
enabled: true
|
||||||
|
})
|
||||||
|
)
|
||||||
|
setOpen(false)
|
||||||
|
setDraft({ name: '', phone: '' })
|
||||||
|
await load()
|
||||||
|
setLoginAccount(created)
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = async (id: number): Promise<void> => {
|
||||||
|
if (!confirm('Eliminare questo account? La sessione verrà chiusa su Telegram.')) return
|
||||||
|
setBusy(id)
|
||||||
|
try {
|
||||||
|
await unwrap(window.api.telegramUser.delete(id))
|
||||||
|
await load()
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = async (id: number): Promise<void> => {
|
||||||
|
if (!confirm('Disconnettere questo account?')) return
|
||||||
|
setBusy(id)
|
||||||
|
try {
|
||||||
|
await unwrap(window.api.telegramUser.logout(id))
|
||||||
|
await load()
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 p-6">
|
||||||
|
<div className="card border-amber-200 bg-amber-50/50 p-4 text-sm text-amber-900">
|
||||||
|
<strong>Attenzione</strong>: questa modalità usa il <em>tuo account personale</em>{' '}
|
||||||
|
Telegram (non un bot). Puoi scrivere a chiunque conoscendo il numero, ma rischi il{' '}
|
||||||
|
<strong>ban dell'account</strong> se ne abusi. Per setup serve configurare{' '}
|
||||||
|
<code>api_id/api_hash</code> in <strong>Impostazioni → Telegram MTProto</strong>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button className="btn-primary" onClick={() => setOpen(true)}>
|
||||||
|
+ Nuovo account utente
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{accounts.length === 0 && (
|
||||||
|
<div className="card p-6 text-center text-sm text-slate-500">
|
||||||
|
Nessun account utente configurato.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<div key={a.id} className="card p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-base font-semibold">{a.name}</h3>
|
||||||
|
<StatusPill status={a.status} />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-600">Numero: {a.phone}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{a.status === 'authorized' ? (
|
||||||
|
<button className="btn-secondary" disabled={busy === a.id} onClick={() => logout(a.id)}>
|
||||||
|
Disconnetti
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="btn-primary" onClick={() => setLoginAccount(a)}>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="btn-danger" disabled={busy === a.id} onClick={() => remove(a.id)}>
|
||||||
|
Elimina
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
title="Nuovo account Telegram (MTProto)"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="btn-ghost" onClick={() => setOpen(false)}>
|
||||||
|
Annulla
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={!draft.name || !draft.phone}
|
||||||
|
onClick={create}
|
||||||
|
>
|
||||||
|
Crea e avvia login
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<span className="label">Nome (etichetta locale)</span>
|
||||||
|
<input
|
||||||
|
className="input mt-1"
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||||
|
placeholder="Es. Mio account personale"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="label">Numero di telefono (formato internazionale)</span>
|
||||||
|
<input
|
||||||
|
className="input mt-1"
|
||||||
|
value={draft.phone}
|
||||||
|
onChange={(e) => setDraft({ ...draft, phone: e.target.value })}
|
||||||
|
placeholder="+393331234567"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
Riceverai un codice di verifica nell'app Telegram (o via SMS) per autenticarti.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{loginAccount && (
|
||||||
|
<MtProtoLoginModal
|
||||||
|
account={loginAccount}
|
||||||
|
onClose={() => setLoginAccount(null)}
|
||||||
|
onDone={() => void load()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ status }: { status: TelegramUserAccount['status'] }): JSX.Element {
|
||||||
|
const styles: Record<TelegramUserAccount['status'], string> = {
|
||||||
|
disconnected: 'bg-slate-100 text-slate-700',
|
||||||
|
pending_code: 'bg-amber-100 text-amber-700',
|
||||||
|
pending_2fa: 'bg-amber-100 text-amber-700',
|
||||||
|
authorized: 'bg-emerald-100 text-emerald-700',
|
||||||
|
error: 'bg-red-100 text-red-700'
|
||||||
|
}
|
||||||
|
return <span className={`pill ${styles[status]}`}>{status}</span>
|
||||||
|
}
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { PageHeader } from '../components/PageHeader'
|
||||||
|
import { Modal } from '../components/Modal'
|
||||||
|
import { TelegramUserView } from './TelegramUserView'
|
||||||
|
import { unwrap } from '../lib/api'
|
||||||
|
import type { TelegramBot } from '@shared/types'
|
||||||
|
|
||||||
|
interface DiscoveredChat {
|
||||||
|
chatId: string
|
||||||
|
type: string
|
||||||
|
title?: string
|
||||||
|
username?: string
|
||||||
|
firstName?: string
|
||||||
|
lastName?: string
|
||||||
|
lastMessage?: string
|
||||||
|
lastUpdateAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const empty = (): Omit<TelegramBot, 'id' | 'createdAt'> => ({
|
||||||
|
name: '',
|
||||||
|
token: '',
|
||||||
|
defaultChatId: '',
|
||||||
|
enabled: true
|
||||||
|
})
|
||||||
|
|
||||||
|
export function TelegramView(): JSX.Element {
|
||||||
|
const [tab, setTab] = useState<'bot' | 'user'>('bot')
|
||||||
|
const [bots, setBots] = useState<TelegramBot[]>([])
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<TelegramBot | null>(null)
|
||||||
|
const [draft, setDraft] = useState<Omit<TelegramBot, 'id' | 'createdAt'>>(empty())
|
||||||
|
const [test, setTest] = useState<{ id: number; msg: string; ok: boolean } | null>(null)
|
||||||
|
const [discoverBot, setDiscoverBot] = useState<TelegramBot | null>(null)
|
||||||
|
const [discoverChats, setDiscoverChats] = useState<DiscoveredChat[]>([])
|
||||||
|
const [discoverLoading, setDiscoverLoading] = useState(false)
|
||||||
|
const [discoverError, setDiscoverError] = useState<string | null>(null)
|
||||||
|
const [savedContacts, setSavedContacts] = useState<Set<string>>(new Set())
|
||||||
|
const [copiedChat, setCopiedChat] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const load = async (): Promise<void> => {
|
||||||
|
setBots(await unwrap(window.api.telegram.list()))
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const save = async (): Promise<void> => {
|
||||||
|
if (editing) await unwrap(window.api.telegram.update(editing.id, draft))
|
||||||
|
else await unwrap(window.api.telegram.create(draft))
|
||||||
|
setOpen(false)
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = async (id: number): Promise<void> => {
|
||||||
|
if (!confirm('Eliminare questo bot?')) return
|
||||||
|
await unwrap(window.api.telegram.delete(id))
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const runTest = async (id: number): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const r = await unwrap(window.api.telegram.test(id))
|
||||||
|
setTest({ id, ok: true, msg: `Bot OK: @${r.username || r.firstName}` })
|
||||||
|
} catch (err) {
|
||||||
|
setTest({ id, ok: false, msg: (err as Error).message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runDiscover = async (bot: TelegramBot): Promise<void> => {
|
||||||
|
setDiscoverBot(bot)
|
||||||
|
setDiscoverChats([])
|
||||||
|
setDiscoverError(null)
|
||||||
|
setDiscoverLoading(true)
|
||||||
|
setSavedContacts(new Set())
|
||||||
|
try {
|
||||||
|
const chats = await unwrap(window.api.telegram.discover(bot.id))
|
||||||
|
setDiscoverChats(chats)
|
||||||
|
} catch (err) {
|
||||||
|
setDiscoverError((err as Error).message)
|
||||||
|
} finally {
|
||||||
|
setDiscoverLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatLabel = (c: DiscoveredChat): string => {
|
||||||
|
if (c.title) return c.title
|
||||||
|
const name = [c.firstName, c.lastName].filter(Boolean).join(' ')
|
||||||
|
if (name) return c.username ? `${name} (@${c.username})` : name
|
||||||
|
if (c.username) return `@${c.username}`
|
||||||
|
return `Chat ${c.chatId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyChatId = async (chatId: string): Promise<void> => {
|
||||||
|
await navigator.clipboard.writeText(chatId)
|
||||||
|
setCopiedChat(chatId)
|
||||||
|
setTimeout(() => setCopiedChat(null), 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveAsContact = async (c: DiscoveredChat): Promise<void> => {
|
||||||
|
await unwrap(
|
||||||
|
window.api.contacts.create({
|
||||||
|
displayName: chatLabel(c),
|
||||||
|
emails: [],
|
||||||
|
telegramChatId: c.chatId,
|
||||||
|
whatsappNumber: undefined,
|
||||||
|
notes: c.username ? `Telegram @${c.username}` : `Telegram chat ${c.chatId}`
|
||||||
|
})
|
||||||
|
)
|
||||||
|
setSavedContacts((prev) => new Set(prev).add(c.chatId))
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAsDefaultChatId = async (chatId: string): Promise<void> => {
|
||||||
|
if (!discoverBot) return
|
||||||
|
await unwrap(
|
||||||
|
window.api.telegram.update(discoverBot.id, {
|
||||||
|
name: discoverBot.name,
|
||||||
|
token: discoverBot.token,
|
||||||
|
defaultChatId: chatId,
|
||||||
|
enabled: discoverBot.enabled
|
||||||
|
})
|
||||||
|
)
|
||||||
|
setDiscoverBot({ ...discoverBot, defaultChatId: chatId })
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="Telegram"
|
||||||
|
subtitle={
|
||||||
|
tab === 'bot' ? 'Bot tramite chat_id (limitato)' : 'Account utente via MTProto (invio a numero)'
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
tab === 'bot' ? (
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(null)
|
||||||
|
setDraft(empty())
|
||||||
|
setOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Nuovo bot
|
||||||
|
</button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="border-b border-slate-200 bg-white px-6">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(['bot', 'user'] as const).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
className={`relative -mb-px border-b-2 px-4 py-2 text-sm font-medium ${
|
||||||
|
tab === t
|
||||||
|
? 'border-brand-600 text-brand-700'
|
||||||
|
: 'border-transparent text-slate-600 hover:text-slate-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t === 'bot' ? '🤖 Bot' : '👤 Account utente (MTProto)'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'user' && <TelegramUserView />}
|
||||||
|
{tab === 'bot' && (
|
||||||
|
<div className="space-y-3 p-6">
|
||||||
|
<div className="card border-sky-200 bg-sky-50/40 p-4 text-sm text-sky-900">
|
||||||
|
<strong>Come ottenere un token:</strong> scrivi a{' '}
|
||||||
|
<code className="rounded bg-white px-1">@BotFather</code> su Telegram, comando{' '}
|
||||||
|
<code className="rounded bg-white px-1">/newbot</code>. Per ottenere il chat_id, scrivi
|
||||||
|
al tuo bot e poi visita{' '}
|
||||||
|
<code className="rounded bg-white px-1">
|
||||||
|
https://api.telegram.org/bot<TOKEN>/getUpdates
|
||||||
|
</code>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bots.length === 0 && (
|
||||||
|
<div className="card p-6 text-center text-sm text-slate-500">
|
||||||
|
Nessun bot configurato.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{bots.map((b) => (
|
||||||
|
<div key={b.id} className="card p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold">{b.name}</h3>
|
||||||
|
<div className="text-sm text-slate-600">
|
||||||
|
Token: <code>{b.token.slice(0, 10)}…{b.token.slice(-4)}</code>
|
||||||
|
</div>
|
||||||
|
{b.defaultChatId && (
|
||||||
|
<div className="text-sm text-slate-600">Chat ID predefinito: {b.defaultChatId}</div>
|
||||||
|
)}
|
||||||
|
{test?.id === b.id && (
|
||||||
|
<div
|
||||||
|
className={`mt-2 rounded p-2 text-xs ${
|
||||||
|
test.ok ? 'bg-emerald-50 text-emerald-700' : 'bg-red-50 text-red-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{test.msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="btn-secondary" onClick={() => runTest(b.id)}>
|
||||||
|
Test
|
||||||
|
</button>
|
||||||
|
<button className="btn-secondary" onClick={() => runDiscover(b)}>
|
||||||
|
🔍 Scopri chat
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(b)
|
||||||
|
setDraft({
|
||||||
|
name: b.name,
|
||||||
|
token: b.token,
|
||||||
|
defaultChatId: b.defaultChatId ?? '',
|
||||||
|
enabled: b.enabled
|
||||||
|
})
|
||||||
|
setOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Modifica
|
||||||
|
</button>
|
||||||
|
<button className="btn-danger" onClick={() => remove(b.id)}>
|
||||||
|
Elimina
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Modal
|
||||||
|
open={!!discoverBot}
|
||||||
|
onClose={() => setDiscoverBot(null)}
|
||||||
|
title={discoverBot ? `Chat di ${discoverBot.name}` : 'Scopri chat'}
|
||||||
|
footer={
|
||||||
|
<button className="btn-primary" onClick={() => setDiscoverBot(null)}>
|
||||||
|
Chiudi
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs text-slate-600">
|
||||||
|
Mostra le ultime conversazioni che hanno scritto al bot. Limite Telegram: ultimi 100
|
||||||
|
messaggi non confermati.{' '}
|
||||||
|
<strong>L'utente deve scriverti almeno un messaggio</strong> (es. <code>/start</code>)
|
||||||
|
perché il bot lo veda.
|
||||||
|
</p>
|
||||||
|
{discoverLoading && <div className="text-sm text-slate-500">Carico chat…</div>}
|
||||||
|
{discoverError && (
|
||||||
|
<div className="rounded bg-red-50 p-3 text-sm text-red-700">{discoverError}</div>
|
||||||
|
)}
|
||||||
|
{!discoverLoading && discoverChats.length === 0 && !discoverError && (
|
||||||
|
<div className="rounded bg-amber-50 p-3 text-sm text-amber-800">
|
||||||
|
Nessuna chat trovata. Chiedi a chi vuoi contattare di scrivere{' '}
|
||||||
|
<code>/start</code> al bot, poi riprova.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{discoverChats.map((c) => (
|
||||||
|
<div key={c.chatId} className="rounded-md border border-slate-200 p-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{chatLabel(c)}</span>
|
||||||
|
<span className="pill bg-slate-100 text-slate-600">{c.type}</span>
|
||||||
|
{discoverBot?.defaultChatId === c.chatId && (
|
||||||
|
<span className="pill bg-brand-100 text-brand-700">predefinito</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-slate-500">
|
||||||
|
chat_id: <code className="font-mono">{c.chatId}</code>
|
||||||
|
{c.lastUpdateAt && (
|
||||||
|
<span className="ml-2">
|
||||||
|
· ultimo: {new Date(c.lastUpdateAt).toLocaleString('it-IT')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{c.lastMessage && (
|
||||||
|
<div className="mt-1 truncate text-xs italic text-slate-600">
|
||||||
|
"{c.lastMessage}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col gap-1">
|
||||||
|
<button
|
||||||
|
className="btn-secondary px-2 py-1 text-xs"
|
||||||
|
onClick={() => copyChatId(c.chatId)}
|
||||||
|
>
|
||||||
|
{copiedChat === c.chatId ? '✓ Copiato' : 'Copia ID'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-secondary px-2 py-1 text-xs"
|
||||||
|
disabled={savedContacts.has(c.chatId)}
|
||||||
|
onClick={() => saveAsContact(c)}
|
||||||
|
>
|
||||||
|
{savedContacts.has(c.chatId) ? '✓ Salvato' : 'Salva contatto'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-secondary px-2 py-1 text-xs"
|
||||||
|
disabled={discoverBot?.defaultChatId === c.chatId}
|
||||||
|
onClick={() => setAsDefaultChatId(c.chatId)}
|
||||||
|
>
|
||||||
|
Predefinito
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={editing ? 'Modifica bot' : 'Nuovo bot Telegram'}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="btn-ghost" onClick={() => setOpen(false)}>
|
||||||
|
Annulla
|
||||||
|
</button>
|
||||||
|
<button className="btn-primary" onClick={save}>
|
||||||
|
Salva
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<span className="label">Nome</span>
|
||||||
|
<input
|
||||||
|
className="input mt-1"
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="label">Token bot</span>
|
||||||
|
<input
|
||||||
|
className="input mt-1 font-mono"
|
||||||
|
value={draft.token}
|
||||||
|
onChange={(e) => setDraft({ ...draft, token: e.target.value })}
|
||||||
|
placeholder="123456789:ABC..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="label">Chat ID predefinito (opzionale)</span>
|
||||||
|
<input
|
||||||
|
className="input mt-1"
|
||||||
|
value={draft.defaultChatId ?? ''}
|
||||||
|
onChange={(e) => setDraft({ ...draft, defaultChatId: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.enabled}
|
||||||
|
onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Attivo
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import QRCode from 'qrcode'
|
||||||
|
import { PageHeader } from '../components/PageHeader'
|
||||||
|
import { Modal } from '../components/Modal'
|
||||||
|
import { unwrap } from '../lib/api'
|
||||||
|
import type { WhatsappSession } from '@shared/types'
|
||||||
|
|
||||||
|
const empty = (): Omit<WhatsappSession, 'id' | 'createdAt'> => ({
|
||||||
|
name: '',
|
||||||
|
status: 'disconnected',
|
||||||
|
enabled: true,
|
||||||
|
defaultRecipient: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
export function WhatsappView(): JSX.Element {
|
||||||
|
const [sessions, setSessions] = useState<WhatsappSession[]>([])
|
||||||
|
const [qrs, setQrs] = useState<Record<number, string>>({})
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<WhatsappSession | null>(null)
|
||||||
|
const [draft, setDraft] = useState<Omit<WhatsappSession, 'id' | 'createdAt'>>(empty())
|
||||||
|
const [busy, setBusy] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const load = async (): Promise<void> => {
|
||||||
|
setSessions(await unwrap(window.api.whatsapp.list()))
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
const offQr = window.api.on.whatsappQr(({ sessionId, qr }) => {
|
||||||
|
setQrs((prev) => ({ ...prev, [sessionId]: qr }))
|
||||||
|
})
|
||||||
|
const offStatus = window.api.on.whatsappStatus(({ sessionId, status, phone }) => {
|
||||||
|
setSessions((prev) =>
|
||||||
|
prev.map((s) => (s.id === sessionId ? { ...s, status, phone: phone ?? s.phone } : s))
|
||||||
|
)
|
||||||
|
if (status === 'ready') {
|
||||||
|
setQrs((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[sessionId]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
offQr()
|
||||||
|
offStatus()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const save = async (): Promise<void> => {
|
||||||
|
if (editing) await unwrap(window.api.whatsapp.update(editing.id, draft))
|
||||||
|
else await unwrap(window.api.whatsapp.create(draft))
|
||||||
|
setOpen(false)
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = async (id: number): Promise<void> => {
|
||||||
|
if (!confirm('Eliminare questa sessione? La sessione di autenticazione verrà rimossa.')) return
|
||||||
|
await unwrap(window.api.whatsapp.delete(id))
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = async (id: number): Promise<void> => {
|
||||||
|
setBusy(id)
|
||||||
|
try {
|
||||||
|
await unwrap(window.api.whatsapp.start(id))
|
||||||
|
} catch (err) {
|
||||||
|
alert((err as Error).message)
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = async (id: number): Promise<void> => {
|
||||||
|
setBusy(id)
|
||||||
|
try {
|
||||||
|
await unwrap(window.api.whatsapp.stop(id))
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="Sessioni WhatsApp"
|
||||||
|
subtitle="Autenticazione via QR-code con whatsapp-web.js"
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(null)
|
||||||
|
setDraft(empty())
|
||||||
|
setOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Nuova sessione
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="space-y-3 p-6">
|
||||||
|
<div className="card border-emerald-200 bg-emerald-50/40 p-4 text-sm text-emerald-900">
|
||||||
|
<strong>Come funziona:</strong> crea una sessione, clicca <em>Avvia</em>, scansiona il QR
|
||||||
|
code con WhatsApp dal tuo telefono (Impostazioni → Dispositivi collegati). La sessione
|
||||||
|
rimane salvata localmente — al riavvio dell'app si riconnette automaticamente.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sessions.length === 0 && (
|
||||||
|
<div className="card p-6 text-center text-sm text-slate-500">
|
||||||
|
Nessuna sessione configurata.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sessions.map((s) => (
|
||||||
|
<div key={s.id} className="card p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-base font-semibold">{s.name}</h3>
|
||||||
|
<StatusPill status={s.status} />
|
||||||
|
{s.phone && <span className="text-sm text-slate-600">+{s.phone}</span>}
|
||||||
|
</div>
|
||||||
|
{s.defaultRecipient && (
|
||||||
|
<div className="text-sm text-slate-600">
|
||||||
|
Destinatario predefinito: {s.defaultRecipient}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{qrs[s.id] && (
|
||||||
|
<div className="mt-3 rounded-md bg-white p-3">
|
||||||
|
<div className="label">Scansiona QR con WhatsApp</div>
|
||||||
|
<QrCodeDisplay text={qrs[s.id]} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{s.status === 'ready' || s.status === 'pending_qr' ? (
|
||||||
|
<button className="btn-secondary" disabled={busy === s.id} onClick={() => stop(s.id)}>
|
||||||
|
Ferma
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="btn-primary" disabled={busy === s.id} onClick={() => start(s.id)}>
|
||||||
|
{busy === s.id ? 'Avvio…' : 'Avvia'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(s)
|
||||||
|
setDraft({
|
||||||
|
name: s.name,
|
||||||
|
status: s.status,
|
||||||
|
enabled: s.enabled,
|
||||||
|
phone: s.phone,
|
||||||
|
defaultRecipient: s.defaultRecipient ?? ''
|
||||||
|
})
|
||||||
|
setOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Modifica
|
||||||
|
</button>
|
||||||
|
<button className="btn-danger" onClick={() => remove(s.id)}>
|
||||||
|
Elimina
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={editing ? 'Modifica sessione' : 'Nuova sessione WhatsApp'}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="btn-ghost" onClick={() => setOpen(false)}>
|
||||||
|
Annulla
|
||||||
|
</button>
|
||||||
|
<button className="btn-primary" onClick={save}>
|
||||||
|
Salva
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<span className="label">Nome sessione</span>
|
||||||
|
<input
|
||||||
|
className="input mt-1"
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="label">Destinatario predefinito (opzionale)</span>
|
||||||
|
<input
|
||||||
|
className="input mt-1"
|
||||||
|
placeholder="es. 393331234567"
|
||||||
|
value={draft.defaultRecipient ?? ''}
|
||||||
|
onChange={(e) => setDraft({ ...draft, defaultRecipient: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.enabled}
|
||||||
|
onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Attivo
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ status }: { status: WhatsappSession['status'] }): JSX.Element {
|
||||||
|
const styles: Record<WhatsappSession['status'], string> = {
|
||||||
|
disconnected: 'bg-slate-100 text-slate-700',
|
||||||
|
pending_qr: 'bg-amber-100 text-amber-700',
|
||||||
|
ready: 'bg-emerald-100 text-emerald-700',
|
||||||
|
auth_failure: 'bg-red-100 text-red-700'
|
||||||
|
}
|
||||||
|
return <span className={`pill ${styles[status]}`}>{status}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function QrCodeDisplay({ text }: { text: string }): JSX.Element {
|
||||||
|
const [dataUrl, setDataUrl] = useState<string>('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
QRCode.toDataURL(text, {
|
||||||
|
width: 256,
|
||||||
|
margin: 2,
|
||||||
|
errorCorrectionLevel: 'M'
|
||||||
|
})
|
||||||
|
.then((url) => {
|
||||||
|
if (!cancelled) setDataUrl(url)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setError((err as Error).message)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [text])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{error && <div className="text-xs text-red-600">Errore generazione QR: {error}</div>}
|
||||||
|
{dataUrl ? (
|
||||||
|
<img alt="QR WhatsApp" src={dataUrl} width={256} height={256} className="rounded border border-slate-200" />
|
||||||
|
) : (
|
||||||
|
!error && <div className="text-xs text-slate-500">Generazione QR…</div>
|
||||||
|
)}
|
||||||
|
<details className="text-xs text-slate-500">
|
||||||
|
<summary className="cursor-pointer">Mostra stringa raw (fallback)</summary>
|
||||||
|
<textarea readOnly value={text} className="input mt-1 h-16 font-mono text-xs" />
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
export const IPC = {
|
||||||
|
// Email accounts
|
||||||
|
accountsList: 'accounts:list',
|
||||||
|
accountsCreate: 'accounts:create',
|
||||||
|
accountsUpdate: 'accounts:update',
|
||||||
|
accountsDelete: 'accounts:delete',
|
||||||
|
accountsTest: 'accounts:test',
|
||||||
|
accountsStartPolling: 'accounts:startPolling',
|
||||||
|
accountsStopPolling: 'accounts:stopPolling',
|
||||||
|
|
||||||
|
// Telegram
|
||||||
|
telegramList: 'telegram:list',
|
||||||
|
telegramCreate: 'telegram:create',
|
||||||
|
telegramUpdate: 'telegram:update',
|
||||||
|
telegramDelete: 'telegram:delete',
|
||||||
|
telegramTest: 'telegram:test',
|
||||||
|
telegramDiscover: 'telegram:discover',
|
||||||
|
|
||||||
|
// Telegram User (MTProto)
|
||||||
|
tguList: 'tgu:list',
|
||||||
|
tguCreate: 'tgu:create',
|
||||||
|
tguDelete: 'tgu:delete',
|
||||||
|
tguStartLogin: 'tgu:startLogin',
|
||||||
|
tguSubmitCode: 'tgu:submitCode',
|
||||||
|
tguSubmit2fa: 'tgu:submit2fa',
|
||||||
|
tguLogout: 'tgu:logout',
|
||||||
|
tguResolve: 'tgu:resolve',
|
||||||
|
|
||||||
|
// WhatsApp
|
||||||
|
whatsappList: 'whatsapp:list',
|
||||||
|
whatsappCreate: 'whatsapp:create',
|
||||||
|
whatsappUpdate: 'whatsapp:update',
|
||||||
|
whatsappDelete: 'whatsapp:delete',
|
||||||
|
whatsappStart: 'whatsapp:start',
|
||||||
|
whatsappStop: 'whatsapp:stop',
|
||||||
|
whatsappStatus: 'whatsapp:status',
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
messagesList: 'messages:list',
|
||||||
|
messagesGet: 'messages:get',
|
||||||
|
messagesSend: 'messages:send',
|
||||||
|
messagesDelete: 'messages:delete',
|
||||||
|
|
||||||
|
// Rules
|
||||||
|
rulesList: 'rules:list',
|
||||||
|
rulesCreate: 'rules:create',
|
||||||
|
rulesUpdate: 'rules:update',
|
||||||
|
rulesDelete: 'rules:delete',
|
||||||
|
|
||||||
|
// Contacts
|
||||||
|
contactsList: 'contacts:list',
|
||||||
|
contactsCreate: 'contacts:create',
|
||||||
|
contactsUpdate: 'contacts:update',
|
||||||
|
contactsDelete: 'contacts:delete',
|
||||||
|
contactsImport: 'contacts:import',
|
||||||
|
|
||||||
|
// AI
|
||||||
|
aiList: 'ai:list',
|
||||||
|
aiCreate: 'ai:create',
|
||||||
|
aiUpdate: 'ai:update',
|
||||||
|
aiDelete: 'ai:delete',
|
||||||
|
aiSummarize: 'ai:summarize',
|
||||||
|
aiClassify: 'ai:classify',
|
||||||
|
aiExtract: 'ai:extract',
|
||||||
|
aiSuggestReply: 'ai:suggestReply',
|
||||||
|
aiTest: 'ai:test',
|
||||||
|
|
||||||
|
// App settings
|
||||||
|
settingsGet: 'settings:get',
|
||||||
|
settingsUpdate: 'settings:update',
|
||||||
|
|
||||||
|
// Gmail OAuth
|
||||||
|
gmailConnect: 'gmail:connect',
|
||||||
|
|
||||||
|
// Events (main -> renderer)
|
||||||
|
eventNewMessage: 'event:newMessage',
|
||||||
|
eventWhatsappQr: 'event:whatsappQr',
|
||||||
|
eventWhatsappStatus: 'event:whatsappStatus',
|
||||||
|
eventLog: 'event:log'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type IpcChannel = (typeof IPC)[keyof typeof IPC]
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
export type ChannelType = 'smtp' | 'telegram' | 'telegram_user' | 'whatsapp'
|
||||||
|
|
||||||
|
export interface ImapConfig {
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
secure: boolean
|
||||||
|
user: string
|
||||||
|
pass: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmtpConfig {
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
secure: boolean
|
||||||
|
user: string
|
||||||
|
pass: string
|
||||||
|
fromName?: string
|
||||||
|
fromAddress: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthType = 'password' | 'oauth_google'
|
||||||
|
|
||||||
|
export interface OAuthTokens {
|
||||||
|
provider: 'google'
|
||||||
|
refreshToken: string
|
||||||
|
accessToken?: string
|
||||||
|
expiresAt?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailAccount {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
authType: AuthType
|
||||||
|
imap: ImapConfig
|
||||||
|
smtp: SmtpConfig
|
||||||
|
oauth?: OAuthTokens
|
||||||
|
enabled: boolean
|
||||||
|
pollInterval: number
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppSettings {
|
||||||
|
gmailClientId?: string
|
||||||
|
gmailClientSecret?: string
|
||||||
|
telegramApiId?: string
|
||||||
|
telegramApiHash?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TelegramUserAccount {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
phone: string
|
||||||
|
sessionString?: string
|
||||||
|
status: 'disconnected' | 'pending_code' | 'pending_2fa' | 'authorized' | 'error'
|
||||||
|
enabled: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MtProtoLoginStartResult {
|
||||||
|
accountId: number
|
||||||
|
requires2fa?: false
|
||||||
|
status: 'pending_code'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MtProtoCodeResult {
|
||||||
|
ok: boolean
|
||||||
|
requires2fa?: boolean
|
||||||
|
status: TelegramUserAccount['status']
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GmailConnectResult {
|
||||||
|
email: string
|
||||||
|
name?: string
|
||||||
|
refreshToken: string
|
||||||
|
accessToken: string
|
||||||
|
expiresAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TelegramBot {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
token: string
|
||||||
|
defaultChatId?: string
|
||||||
|
enabled: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WhatsappSession {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
status: 'disconnected' | 'pending_qr' | 'ready' | 'auth_failure'
|
||||||
|
phone?: string
|
||||||
|
defaultRecipient?: string
|
||||||
|
enabled: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiProviderConfig {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
provider: 'anthropic' | 'openai' | 'ollama'
|
||||||
|
apiKey?: string
|
||||||
|
model: string
|
||||||
|
baseUrl?: string
|
||||||
|
enabled: boolean
|
||||||
|
isDefault: boolean
|
||||||
|
autoClassify: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Attachment {
|
||||||
|
id?: number
|
||||||
|
messageId?: number
|
||||||
|
filename: string
|
||||||
|
contentType: string
|
||||||
|
size: number
|
||||||
|
path?: string
|
||||||
|
data?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: number
|
||||||
|
accountId?: number
|
||||||
|
direction: 'inbound' | 'outbound'
|
||||||
|
channel: ChannelType
|
||||||
|
externalId?: string
|
||||||
|
fromAddr?: string
|
||||||
|
toAddr?: string
|
||||||
|
cc?: string
|
||||||
|
bcc?: string
|
||||||
|
subject?: string
|
||||||
|
bodyText?: string
|
||||||
|
bodyHtml?: string
|
||||||
|
status: 'received' | 'queued' | 'sent' | 'failed' | 'forwarded'
|
||||||
|
errorMessage?: string
|
||||||
|
receivedAt?: string
|
||||||
|
sentAt?: string
|
||||||
|
attachments?: Attachment[]
|
||||||
|
ai?: AiAnalysis
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiAnalysis {
|
||||||
|
id?: number
|
||||||
|
messageId: number
|
||||||
|
summary?: string
|
||||||
|
category?: string
|
||||||
|
labels?: string[]
|
||||||
|
extracted?: Record<string, unknown>
|
||||||
|
suggestedReply?: string
|
||||||
|
provider: string
|
||||||
|
model: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForwardingRule {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
enabled: boolean
|
||||||
|
priority: number
|
||||||
|
matchFromContains?: string
|
||||||
|
matchToContains?: string
|
||||||
|
matchSubjectContains?: string
|
||||||
|
matchBodyContains?: string
|
||||||
|
matchHasAttachment?: boolean
|
||||||
|
matchAiCategory?: string
|
||||||
|
targetChannel: ChannelType
|
||||||
|
targetIdentifier: string
|
||||||
|
includeBody: boolean
|
||||||
|
includeAttachments: boolean
|
||||||
|
template?: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Contact {
|
||||||
|
id: number
|
||||||
|
displayName: string
|
||||||
|
emails: string[]
|
||||||
|
telegramChatId?: string
|
||||||
|
whatsappNumber?: string
|
||||||
|
notes?: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendRequest {
|
||||||
|
channel: ChannelType
|
||||||
|
accountId?: number
|
||||||
|
to: string
|
||||||
|
cc?: string
|
||||||
|
bcc?: string
|
||||||
|
subject?: string
|
||||||
|
body: string
|
||||||
|
html?: string
|
||||||
|
attachments?: Attachment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IpcResponse<T = unknown> {
|
||||||
|
ok: boolean
|
||||||
|
data?: T
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import defaultTheme from 'tailwindcss/defaultTheme'
|
||||||
|
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
darkMode: 'class',
|
||||||
|
content: ['./src/renderer/index.html', './src/renderer/src/**/*.{ts,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', ...defaultTheme.fontFamily.sans],
|
||||||
|
display: ['"Space Grotesk"', 'Inter', ...defaultTheme.fontFamily.sans],
|
||||||
|
mono: ['"JetBrains Mono"', ...defaultTheme.fontFamily.mono]
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
50: '#eef4ff',
|
||||||
|
100: '#d9e6ff',
|
||||||
|
200: '#bcd2ff',
|
||||||
|
300: '#8eb4ff',
|
||||||
|
400: '#598bff',
|
||||||
|
500: '#3563ff',
|
||||||
|
600: '#2046f5',
|
||||||
|
700: '#1a35d8',
|
||||||
|
800: '#1c2faf',
|
||||||
|
900: '#1c2c87'
|
||||||
|
},
|
||||||
|
neon: {
|
||||||
|
cyan: '#06b6d4',
|
||||||
|
blue: '#3b82f6',
|
||||||
|
purple: '#a855f7',
|
||||||
|
pink: '#ec4899',
|
||||||
|
green: '#10b981'
|
||||||
|
},
|
||||||
|
ink: {
|
||||||
|
50: '#f8fafc',
|
||||||
|
100: '#f1f5f9',
|
||||||
|
200: '#e2e8f0',
|
||||||
|
300: '#cbd5e1',
|
||||||
|
400: '#94a3b8',
|
||||||
|
500: '#64748b',
|
||||||
|
600: '#475569',
|
||||||
|
700: '#334155',
|
||||||
|
800: '#1e293b',
|
||||||
|
900: '#0f172a',
|
||||||
|
950: '#020617'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
glow: '0 0 30px -8px rgba(99, 102, 241, 0.45)',
|
||||||
|
'glow-sm': '0 0 15px -4px rgba(99, 102, 241, 0.35)',
|
||||||
|
'glow-cyan': '0 0 30px -8px rgba(6, 182, 212, 0.5)',
|
||||||
|
'glow-purple': '0 0 30px -8px rgba(168, 85, 247, 0.5)',
|
||||||
|
'glow-success': '0 0 20px -6px rgba(16, 185, 129, 0.6)',
|
||||||
|
'glow-danger': '0 0 20px -6px rgba(239, 68, 68, 0.6)',
|
||||||
|
'inset-glow': 'inset 0 1px 0 0 rgba(255, 255, 255, 0.08)',
|
||||||
|
'depth':
|
||||||
|
'0 10px 30px -10px rgba(15, 23, 42, 0.1), 0 4px 6px -2px rgba(15, 23, 42, 0.04)',
|
||||||
|
'depth-lg':
|
||||||
|
'0 25px 50px -12px rgba(15, 23, 42, 0.18), 0 8px 16px -6px rgba(15, 23, 42, 0.08)'
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'fade-in': 'fadeIn 0.25s ease-out',
|
||||||
|
'slide-up': 'slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||||
|
'glow-pulse': 'glowPulse 2.5s ease-in-out infinite',
|
||||||
|
'shimmer': 'shimmer 2s linear infinite',
|
||||||
|
'gradient-x': 'gradientX 6s ease infinite'
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
fadeIn: { '0%': { opacity: '0' }, '100%': { opacity: '1' } },
|
||||||
|
slideUp: {
|
||||||
|
'0%': { transform: 'translateY(12px)', opacity: '0' },
|
||||||
|
'100%': { transform: 'translateY(0)', opacity: '1' }
|
||||||
|
},
|
||||||
|
glowPulse: {
|
||||||
|
'0%, 100%': { opacity: '0.7', filter: 'brightness(1)' },
|
||||||
|
'50%': { opacity: '1', filter: 'brightness(1.15)' }
|
||||||
|
},
|
||||||
|
shimmer: {
|
||||||
|
'0%': { backgroundPosition: '-200% 0' },
|
||||||
|
'100%': { backgroundPosition: '200% 0' }
|
||||||
|
},
|
||||||
|
gradientX: {
|
||||||
|
'0%, 100%': { backgroundPosition: '0% 50%' },
|
||||||
|
'50%': { backgroundPosition: '100% 50%' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
'gradient-radial': 'radial-gradient(circle at top left, var(--tw-gradient-stops))',
|
||||||
|
'mesh-light':
|
||||||
|
'radial-gradient(at 0% 0%, rgba(99,102,241,0.10) 0, transparent 50%), radial-gradient(at 100% 0%, rgba(168,85,247,0.08) 0, transparent 50%), radial-gradient(at 100% 100%, rgba(6,182,212,0.08) 0, transparent 50%)',
|
||||||
|
'mesh-dark':
|
||||||
|
'radial-gradient(at 0% 0%, rgba(99,102,241,0.22) 0, transparent 50%), radial-gradient(at 100% 0%, rgba(168,85,247,0.18) 0, transparent 50%), radial-gradient(at 100% 100%, rgba(6,182,212,0.15) 0, transparent 50%)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: []
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.node.json" },
|
||||||
|
{ "path": "./tsconfig.web.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||||
|
"include": [
|
||||||
|
"electron.vite.config.*",
|
||||||
|
"src/main/**/*",
|
||||||
|
"src/preload/**/*",
|
||||||
|
"src/shared/**/*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"types": ["electron-vite/node"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@shared/*": ["src/shared/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
||||||
|
"include": [
|
||||||
|
"src/renderer/src/**/*",
|
||||||
|
"src/renderer/src/**/*.tsx",
|
||||||
|
"src/preload/index.d.ts",
|
||||||
|
"src/shared/**/*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@renderer/*": ["src/renderer/src/*"],
|
||||||
|
"@shared/*": ["src/shared/*"]
|
||||||
|
},
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user