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:
luzadevandClaude Opus 4.7 committed 2026-05-20 14:42:12 +02:00
commit 620b8fca67
53 files changed
+18873

No files matched your search

+20
View File
@@ -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/
+216
View File
@@ -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
+31
View File
@@ -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()]
}
})
+10610
View File
File diff suppressed because it is too large. Load diff
+69
View File
@@ -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"
}
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}
+192
View File
@@ -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
}
}
+742
View File
@@ -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
}
}
+35
View File
@@ -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
+116
View File
@@ -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
View File
@@ -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
})
}
+215
View File
@@ -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 }
+274
View File
@@ -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
}
+166
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function truncate(s: string, n: number): string {
return s.length <= n ? s : s.slice(0, n) + '…'
}
export const forwarderService = new ForwarderService()
+236
View File
@@ -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
}
+191
View File
@@ -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()
+80
View File
@@ -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()
+272
View File
@@ -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()
+125
View File
@@ -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()
+120
View File
@@ -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()
+9
View File
@@ -0,0 +1,9 @@
import type { Api } from './index'
declare global {
interface Window {
api: Api
}
}
export {}
+175
View File
@@ -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
+21
View File
@@ -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>
+53
View File
@@ -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>
)
}
+49
View File
@@ -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'
}
}
+93
View File
@@ -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>
)
}
+93
View File
@@ -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>
)
}
+7
View File
@@ -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
}
+48
View File
@@ -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)
}
+13
View File
@@ -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>
)
+170
View File
@@ -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;
}
}
+433
View File
@@ -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>
)
}
+256
View File
@@ -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>
)
}
+240
View File
@@ -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'
}
}
+288
View File
@@ -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>
)
}
+177
View File
@@ -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>
)
}
+281
View File
@@ -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>
)
}
+345
View File
@@ -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 ')
}
+58
View File
@@ -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>
)
}
+172
View File
@@ -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>
}
+370
View File
@@ -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&lt;TOKEN&gt;/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>
)
}
+261
View File
@@ -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>
)
}
+82
View File
@@ -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]
+200
View File
@@ -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
}
+97
View File
@@ -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: []
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.web.json" }
]
}
+17
View File
@@ -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/*"]
}
}
}
+18
View File
@@ -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"
}
}