Initial commit: EmailToChat — multi-channel email manager with AI
Desktop app (Electron + Vite + React + TypeScript) that receives email via IMAP, sends/forwards via SMTP, Telegram (Bot API + MTProto user account) and WhatsApp (whatsapp-web.js), with AI integration (Anthropic, OpenAI, Ollama) for auto-classification, summary, structured extraction and reply drafts. Features: - Multi-account IMAP/SMTP with Gmail OAuth2 (loopback flow) + App Password preset - Telegram Bot API via telegraf, with chat discovery from getUpdates - Telegram MTProto via gram.js for sending to phone numbers like WhatsApp - WhatsApp via whatsapp-web.js with QR-code session (locally generated PNG) - Forwarding engine with rule-based filters incl. AI category match - SQLite persistence (better-sqlite3) with idempotent migrations - Contact picker with autocomplete, CSV/vCard import, search - Dark mode (light/dark/system) with futuristic glass-morphism UI - Settings tabbed: Email Accounts / Telegram / WhatsApp / AI / OAuth creds / Preferences Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
620b8fca67
53 files changed
+18873
No files matched your search
+342
@@ -0,0 +1,342 @@
|
||||
import { ipcMain, BrowserWindow } from 'electron'
|
||||
import { IPC } from '@shared/ipc-channels'
|
||||
import {
|
||||
accountsRepo,
|
||||
telegramRepo,
|
||||
telegramUserRepo,
|
||||
whatsappRepo,
|
||||
messagesRepo,
|
||||
rulesRepo,
|
||||
contactsRepo,
|
||||
aiProvidersRepo
|
||||
} from './db/repositories'
|
||||
import { settingsRepo, SETTING_KEYS } from './db/settings'
|
||||
import { imapService } from './services/imap'
|
||||
import { smtpService } from './services/smtp'
|
||||
import { telegramService } from './services/telegram'
|
||||
import { telegramMtprotoService } from './services/telegram-mtproto'
|
||||
import { whatsappService } from './services/whatsapp'
|
||||
import { aiService } from './services/ai'
|
||||
import { connectGmail } from './services/gmail-oauth'
|
||||
import { importContactsDialog } from './services/contacts-import'
|
||||
import type {
|
||||
IpcResponse,
|
||||
SendRequest,
|
||||
AiProviderConfig,
|
||||
AppSettings,
|
||||
EmailAccount
|
||||
} from '@shared/types'
|
||||
|
||||
function ok<T>(data: T): IpcResponse<T> {
|
||||
return { ok: true, data }
|
||||
}
|
||||
function fail(err: unknown): IpcResponse<never> {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) }
|
||||
}
|
||||
|
||||
function on<T>(channel: string, handler: (...args: any[]) => Promise<T> | T): void {
|
||||
ipcMain.handle(channel, async (_event, ...args) => {
|
||||
try {
|
||||
return ok(await handler(...args))
|
||||
} catch (err) {
|
||||
console.error(`[ipc:${channel}]`, err)
|
||||
return fail(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function broadcast(channel: string, payload: unknown): void {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send(channel, payload)
|
||||
}
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(): void {
|
||||
// -------- Accounts --------
|
||||
on(IPC.accountsList, () => accountsRepo.list())
|
||||
on(IPC.accountsCreate, (payload) => {
|
||||
const created = accountsRepo.create(payload)
|
||||
if (created.enabled) imapService.startPolling(created)
|
||||
return created
|
||||
})
|
||||
on(IPC.accountsUpdate, (id, payload) => {
|
||||
const updated = accountsRepo.update(id, payload)
|
||||
imapService.stopPolling(id)
|
||||
smtpService.invalidate(id)
|
||||
if (updated.enabled) imapService.startPolling(updated)
|
||||
return updated
|
||||
})
|
||||
on(IPC.accountsDelete, (id) => {
|
||||
imapService.stopPolling(id)
|
||||
smtpService.invalidate(id)
|
||||
accountsRepo.delete(id)
|
||||
return true
|
||||
})
|
||||
on(IPC.accountsTest, async (id) => {
|
||||
const a = accountsRepo.get(id)
|
||||
if (!a) throw new Error('Account non trovato')
|
||||
await imapService.testConnection(a)
|
||||
await smtpService.verify(a)
|
||||
return true
|
||||
})
|
||||
on(IPC.accountsStartPolling, (id) => {
|
||||
const a = accountsRepo.get(id)
|
||||
if (!a) throw new Error('Account non trovato')
|
||||
imapService.startPolling(a)
|
||||
return true
|
||||
})
|
||||
on(IPC.accountsStopPolling, (id) => {
|
||||
imapService.stopPolling(id)
|
||||
return true
|
||||
})
|
||||
|
||||
// -------- Telegram --------
|
||||
on(IPC.telegramList, () => telegramRepo.list())
|
||||
on(IPC.telegramCreate, (payload) => telegramRepo.create(payload))
|
||||
on(IPC.telegramUpdate, (id, payload) => {
|
||||
const bot = telegramRepo.update(id, payload)
|
||||
telegramService.invalidate(id)
|
||||
return bot
|
||||
})
|
||||
on(IPC.telegramDelete, (id) => {
|
||||
telegramService.invalidate(id)
|
||||
telegramRepo.delete(id)
|
||||
return true
|
||||
})
|
||||
on(IPC.telegramTest, async (id) => {
|
||||
const bot = telegramRepo.get(id)
|
||||
if (!bot) throw new Error('Bot non trovato')
|
||||
return await telegramService.verify(bot)
|
||||
})
|
||||
on(IPC.telegramDiscover, async (id) => {
|
||||
const bot = telegramRepo.get(id)
|
||||
if (!bot) throw new Error('Bot non trovato')
|
||||
return await telegramService.discoverChats(bot)
|
||||
})
|
||||
|
||||
// -------- Telegram User (MTProto) --------
|
||||
on(IPC.tguList, () => telegramUserRepo.list())
|
||||
on(IPC.tguCreate, (payload) => telegramUserRepo.create(payload))
|
||||
on(IPC.tguDelete, async (id) => {
|
||||
await telegramMtprotoService.logout(id).catch(() => undefined)
|
||||
telegramUserRepo.delete(id)
|
||||
return true
|
||||
})
|
||||
on(IPC.tguStartLogin, async (id) => {
|
||||
await telegramMtprotoService.startLogin(id)
|
||||
return true
|
||||
})
|
||||
on(IPC.tguSubmitCode, async (id, code: string) => {
|
||||
return await telegramMtprotoService.submitCode(id, code)
|
||||
})
|
||||
on(IPC.tguSubmit2fa, async (id, password: string) => {
|
||||
return await telegramMtprotoService.submit2fa(id, password)
|
||||
})
|
||||
on(IPC.tguLogout, async (id) => {
|
||||
await telegramMtprotoService.logout(id)
|
||||
return true
|
||||
})
|
||||
on(IPC.tguResolve, async (id, recipient: string) => {
|
||||
const account = telegramUserRepo.get(id)
|
||||
if (!account) throw new Error('Account non trovato')
|
||||
return await telegramMtprotoService.resolveContact(account, recipient)
|
||||
})
|
||||
|
||||
// -------- WhatsApp --------
|
||||
on(IPC.whatsappList, () => whatsappRepo.list())
|
||||
on(IPC.whatsappCreate, (payload) => whatsappRepo.create(payload))
|
||||
on(IPC.whatsappUpdate, (id, payload) => whatsappRepo.update(id, payload))
|
||||
on(IPC.whatsappDelete, async (id) => {
|
||||
await whatsappService.stop(id)
|
||||
whatsappRepo.delete(id)
|
||||
return true
|
||||
})
|
||||
on(IPC.whatsappStart, async (id) => {
|
||||
const s = whatsappRepo.get(id)
|
||||
if (!s) throw new Error('Sessione non trovata')
|
||||
await whatsappService.start(s)
|
||||
return true
|
||||
})
|
||||
on(IPC.whatsappStop, async (id) => {
|
||||
await whatsappService.stop(id)
|
||||
return true
|
||||
})
|
||||
on(IPC.whatsappStatus, (id) => whatsappService.getStatus(id))
|
||||
|
||||
// -------- Messages --------
|
||||
on(IPC.messagesList, (filter) => messagesRepo.list(filter))
|
||||
on(IPC.messagesGet, (id) => messagesRepo.get(id))
|
||||
on(IPC.messagesDelete, (id) => {
|
||||
messagesRepo.delete(id)
|
||||
return true
|
||||
})
|
||||
on(IPC.messagesSend, async (req: SendRequest) => {
|
||||
let result: { messageId: string | number }
|
||||
if (req.channel === 'smtp') {
|
||||
if (!req.accountId) throw new Error('accountId obbligatorio per SMTP')
|
||||
const account = accountsRepo.get(req.accountId)
|
||||
if (!account) throw new Error('Account non trovato')
|
||||
result = await smtpService.send(account, {
|
||||
to: req.to,
|
||||
cc: req.cc,
|
||||
bcc: req.bcc,
|
||||
subject: req.subject,
|
||||
text: req.body,
|
||||
html: req.html,
|
||||
attachments: req.attachments
|
||||
})
|
||||
} else if (req.channel === 'telegram') {
|
||||
if (!req.accountId) throw new Error('Bot Telegram id obbligatorio (accountId)')
|
||||
const bot = telegramRepo.get(req.accountId)
|
||||
if (!bot) throw new Error('Bot non trovato')
|
||||
const res = await telegramService.send(bot, req.to, req.body, req.attachments)
|
||||
result = { messageId: res.messageId }
|
||||
} else if (req.channel === 'telegram_user') {
|
||||
if (!req.accountId) throw new Error('Account Telegram (MTProto) id obbligatorio')
|
||||
const account = telegramUserRepo.get(req.accountId)
|
||||
if (!account) throw new Error('Account Telegram non trovato')
|
||||
const res = await telegramMtprotoService.send(account, req.to, req.body, req.attachments)
|
||||
result = { messageId: res.messageId }
|
||||
} else if (req.channel === 'whatsapp') {
|
||||
if (!req.accountId) throw new Error('Sessione WhatsApp id obbligatorio (accountId)')
|
||||
const res = await whatsappService.send(req.accountId, req.to, req.body, req.attachments)
|
||||
result = res
|
||||
} else {
|
||||
throw new Error(`Canale non supportato: ${req.channel}`)
|
||||
}
|
||||
|
||||
const stored = messagesRepo.create({
|
||||
accountId: req.channel === 'smtp' ? req.accountId : undefined,
|
||||
direction: 'outbound',
|
||||
channel: req.channel,
|
||||
externalId: String(result.messageId),
|
||||
toAddr: req.to,
|
||||
cc: req.cc,
|
||||
bcc: req.bcc,
|
||||
subject: req.subject,
|
||||
bodyText: req.body,
|
||||
bodyHtml: req.html,
|
||||
status: 'sent',
|
||||
sentAt: new Date().toISOString()
|
||||
} as any)
|
||||
return stored
|
||||
})
|
||||
|
||||
// -------- Rules --------
|
||||
on(IPC.rulesList, () => rulesRepo.list())
|
||||
on(IPC.rulesCreate, (payload) => rulesRepo.create(payload))
|
||||
on(IPC.rulesUpdate, (id, payload) => rulesRepo.update(id, payload))
|
||||
on(IPC.rulesDelete, (id) => {
|
||||
rulesRepo.delete(id)
|
||||
return true
|
||||
})
|
||||
|
||||
// -------- Contacts --------
|
||||
on(IPC.contactsList, () => contactsRepo.list())
|
||||
on(IPC.contactsCreate, (payload) => contactsRepo.create(payload))
|
||||
on(IPC.contactsUpdate, (id, payload) => contactsRepo.update(id, payload))
|
||||
on(IPC.contactsDelete, (id) => {
|
||||
contactsRepo.delete(id)
|
||||
return true
|
||||
})
|
||||
on(IPC.contactsImport, async () => {
|
||||
const result = await importContactsDialog()
|
||||
return result
|
||||
})
|
||||
|
||||
// -------- AI --------
|
||||
on(IPC.aiList, () => aiProvidersRepo.list())
|
||||
on(IPC.aiCreate, (payload: Omit<AiProviderConfig, 'id'>) => aiProvidersRepo.create(payload))
|
||||
on(IPC.aiUpdate, (id, payload) => aiProvidersRepo.update(id, payload))
|
||||
on(IPC.aiDelete, (id) => {
|
||||
aiProvidersRepo.delete(id)
|
||||
return true
|
||||
})
|
||||
on(IPC.aiTest, async (id) => {
|
||||
const p = aiProvidersRepo.get(id)
|
||||
if (!p) throw new Error('Provider non trovato')
|
||||
return await aiService.test(p)
|
||||
})
|
||||
on(IPC.aiSummarize, async (messageId, providerId) => {
|
||||
const message = messagesRepo.get(messageId)
|
||||
if (!message) throw new Error('Messaggio non trovato')
|
||||
const provider = providerId ? aiProvidersRepo.get(providerId) : undefined
|
||||
return await aiService.summarize(message, provider)
|
||||
})
|
||||
on(IPC.aiClassify, async (messageId, providerId) => {
|
||||
const message = messagesRepo.get(messageId)
|
||||
if (!message) throw new Error('Messaggio non trovato')
|
||||
const provider = providerId ? aiProvidersRepo.get(providerId) : undefined
|
||||
return await aiService.classify(message, provider)
|
||||
})
|
||||
on(IPC.aiExtract, async (messageId, providerId) => {
|
||||
const message = messagesRepo.get(messageId)
|
||||
if (!message) throw new Error('Messaggio non trovato')
|
||||
const provider = providerId ? aiProvidersRepo.get(providerId) : undefined
|
||||
return await aiService.extract(message, provider)
|
||||
})
|
||||
on(IPC.aiSuggestReply, async (messageId, providerId) => {
|
||||
const message = messagesRepo.get(messageId)
|
||||
if (!message) throw new Error('Messaggio non trovato')
|
||||
const provider = providerId ? aiProvidersRepo.get(providerId) : undefined
|
||||
return await aiService.suggestReply(message, provider)
|
||||
})
|
||||
|
||||
// -------- Settings --------
|
||||
on(IPC.settingsGet, (): AppSettings => ({
|
||||
gmailClientId: settingsRepo.get(SETTING_KEYS.gmailClientId),
|
||||
gmailClientSecret: settingsRepo.get(SETTING_KEYS.gmailClientSecret),
|
||||
telegramApiId: settingsRepo.get(SETTING_KEYS.telegramApiId),
|
||||
telegramApiHash: settingsRepo.get(SETTING_KEYS.telegramApiHash)
|
||||
}))
|
||||
on(IPC.settingsUpdate, (payload: AppSettings) => {
|
||||
if ('gmailClientId' in payload) {
|
||||
settingsRepo.set(SETTING_KEYS.gmailClientId, payload.gmailClientId ?? null)
|
||||
}
|
||||
if ('gmailClientSecret' in payload) {
|
||||
settingsRepo.set(SETTING_KEYS.gmailClientSecret, payload.gmailClientSecret ?? null)
|
||||
}
|
||||
if ('telegramApiId' in payload) {
|
||||
settingsRepo.set(SETTING_KEYS.telegramApiId, payload.telegramApiId ?? null)
|
||||
}
|
||||
if ('telegramApiHash' in payload) {
|
||||
settingsRepo.set(SETTING_KEYS.telegramApiHash, payload.telegramApiHash ?? null)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// -------- Gmail OAuth connect --------
|
||||
on(IPC.gmailConnect, async (): Promise<EmailAccount> => {
|
||||
const result = await connectGmail()
|
||||
const account = accountsRepo.create({
|
||||
name: result.name ? `${result.name} (Gmail)` : `Gmail: ${result.email}`,
|
||||
authType: 'oauth_google',
|
||||
imap: {
|
||||
host: 'imap.gmail.com',
|
||||
port: 993,
|
||||
secure: true,
|
||||
user: result.email,
|
||||
pass: ''
|
||||
},
|
||||
smtp: {
|
||||
host: 'smtp.gmail.com',
|
||||
port: 465,
|
||||
secure: true,
|
||||
user: result.email,
|
||||
pass: '',
|
||||
fromName: result.name,
|
||||
fromAddress: result.email
|
||||
},
|
||||
oauth: {
|
||||
provider: 'google',
|
||||
refreshToken: result.refreshToken,
|
||||
accessToken: result.accessToken,
|
||||
expiresAt: result.expiresAt
|
||||
},
|
||||
enabled: true,
|
||||
pollInterval: 60
|
||||
})
|
||||
imapService.startPolling(account)
|
||||
return account
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user