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