Mostra la inbox degli account Gmail configurati e, per ogni mail, i bottoni Bozza, Label, Bozza+Label e Task. /posta la apre e la chiude

Un marketplace di mod per Claude Code, pensato per capire e controllare una sessione: barra della cache e dei consumi, registro di ciò che viene scritto fuori dalla repo, conferma delle proposte, raccolta degli inciampi, modalità rec per le registrazioni e inbox con bozze, etichette e task. Ogni mod è un plugin indipendente, installabile singolarmente con un comando.
Ogni mod si accende e si spegne con un comando /..., che funziona in terminale, nell'app Desktop e da iPad. Lo stato si ricorda tra una sessione e l'altra.
⏳ barra-cache (/barra) — tre righe sopra il prompt. La prima è la memoria della chat: una barra con i token del contesto occupato e quanto è costata la sessione a listino (verde fino al 60%, gialla fino all'80%, rossa oltre, con il suggerimento Nuova chat). La seconda mostra l'*uso delle 5 ore* e l'*uso della settimana* dell'abbonamento, solo dove il motore riporta le cifre. La terza dice da quanto è aperta la sessione e quanto resta di cache calda: la prompt cache dura un'ora dall'ultima richiesta della conversazione principale (le richieste dei subagent non la rinnovano), poi la richiesta successiva riscrive tutto il contesto a prezzo pieno. Con la cache scaduta e almeno 20mila token di contesto compare un avviso rosso. Sotto, una riga con branch, file modificati e commit da pubblicare (letta da git, solo dove $.process esiste: nel cloud non compare) e i bottoni: Commit e push (tasto g), Push (tasto p, compare solo quando ci sono commit da pubblicare e nessuna modifica da committare, senza token) e Handoff (tasto n), che fa scrivere al modello un riassunto di ripartenza in ~/.claude/handoffs/, svuota la chat con /clear e riparte da quel file. Dove la banda non viene disegnata (ad esempio l'app su iPad) restano /cache, che mostra minuti rimasti, contesto, costo dell'ultimo turno e quota di cache letta, /push e /handoff (alias /nuova). I comandi sono anche file statici in barra-cache/commands/, perché l'elenco dello slash dell'app non mostra quelli registrati a runtime; l'hook risponde prima del modello, quindi non costano token.
📒 scritture-esterne (/scritture) — a fine turno elenca tutto ciò che il turno ha scritto fuori dalla repo: Notion, Postpickr, Spreaker, Gmail, Calendar, Drive, git push, gh, curl -X POST e gli script con --applica o --elimina. Ogni riga ha servizio, azione, bersaglio, un link apri se la risposta ne contiene uno, e una croce rossa se la chiamata è fallita o negata. Le letture non compaiono. Il registro sparisce all'inizio del turno successivo. Riconosce dal nome i connettori più comuni; per quelli con id opaco serve l'opzione servizi del plugin.
✅ conferma-proposte (/conferma) — quando l'ultima risposta di Claude chiude con una proposta o una domanda di conferma (una domanda, oppure formule come "procedo", "vuoi che", "confermi"), mostra sopra il prompt il passaggio che propone e due bottoni: Sì, procedi (tasto s) e No, fermati (tasto x). Il bottone invia la risposta come se l'avessi scritta tu. Non usa il modello, quindi non consuma token. Funziona anche nel cloud e da iPad.
🛠 correggi (/correggi) — durante la sessione annota gli inciampi: tool che falliscono, azioni negate dal sistema di permessi e le tue correzioni ("non vedo", "hai sbagliato", "riprova"). Con almeno due inciampi compare una riga con il conteggio, le skill usate e il bottone Proponi correzione (tasto l), che chiede a Claude di individuare la causa e proporre la modifica esatta alla skill o al file di istruzioni, senza applicarla. /correggi elenco mostra gli inciampi raccolti. Consuma token solo quando premi il bottone.
🔴 rec (/rec) — la modalità per registrare un video o lavorare in una sessione live con ospiti. Maschera a schermo chiavi e valori dei .env, email, nomi, telefoni, indirizzi, codice fiscale, partita IVA, IBAN, importi in euro e cifre vicino a parole come fatturato, margine, compenso, preventivo. I risultati di posta, chat, task, file, calendario, Notion e strumenti di pagamento si disegnano nascosti. Claude continua a lavorare sui dati reali: cambia solo ciò che si vede (se modifica un file da 29 a 39 euro, il file cambia davvero, lo schermo no). Tiene chiusi i file privati (.env, credenziali, fatture, contratti, preventivi, buste paga) e gli strumenti di pagamento, e ogni prompt porta una nota nascosta che chiede a Claude di usare segnaposto al posto di nomi e cifre. Un ● REC rosso sopra il prompt e nel piè di pagina ricorda che è acceso. /rec alterna, /rec rigoroso maschera anche ogni cifra grande, /rec off spegne, /rec config crea ~/.claude/mods-data/rec/config.json per il tuo nome (nomiVisibili), le persone da nascondere (nomiNascosti), le cartelle private (percorsiPrivati) e gli strumenti extra (strumentiAffari, strumentiChiusi). Limiti: cambia ciò che è disegnato, non ciò che è memorizzato; il riconoscimento per pattern non prende tutto quello che è scritto a parole, quindi riguarda il girato prima di pubblicarlo; i titoli delle chat nella barra laterale dell'app Desktop non si possono mascherare. Adattata da recording-mode di Nate Herk (MIT, vedi rec/NOTICE.md).
📬 posta (posta o /posta) — apre un pannello con la inbox Gmail: l'elenco delle ultime 12 mail per account (una finestra di 5 righe che scorre con j e k), i bottoni e, sotto, il testo intero della mail selezionata (tutti i messaggi del thread, dal più recente, con allegati), letto da Gmail senza modello. I bottoni sono Bozza (b), Label (l), Bozza+Label (m), Task (t), più Aggiorna (r) e Chiudi (x). Le quattro azioni girano dentro la mod, senza subagent: il modello riceve il thread senza alcun tool e sceglie solo i contenuti, mentre le scritture su Gmail e Notion le fa il codice dopo averle validate (la chat resta pulita: l'esito compare nel pannello e in un avviso). Bozza scrive con le regole di ~/.claude/mods-data/posta/sistematore.md e crea una bozza di risposta (la mail non viene mai inviata). Label fa scegliere al modello economico un'etichetta tra quelle esistenti e la applica solo se il nome coincide (non ne crea). Task fa proporre al modello economico titolo, scadenza, urgenza, importanza, impegno e contesto, li riporta ai valori ammessi (scadenza non valida o passata: domani alle 09:00) e crea la pagina nel database Tasks di Notion con il link alla mail. Solo Label e Bozza+Label tolgono la mail dall'inbox (finisce nella cartella dell'etichetta); Bozza e Task la lasciano in inbox. I lavori in corso mostrano uno spinner, quelli riusciti spariscono dopo 8 secondi e quelli falliti si rilanciano con Riprova (y). Nel dettaglio compare l'ultimo messaggio del thread; i precedenti sono righe ripiegate e si aprono con Mostra precedenti (p). Scrivendo posta senza barra il pannello si apre senza avviso dell'app e senza token; posta off lo chiude. Gli account sono in DEFAULT_ACCOUNTS e si sostituiscono con la chiave accounts dello store. In modalità Auto anche le chiamate della mod passano dal classificatore, che sulle scritture non dà verdetto: vanno autorizzate in permissions.allow le letture Gmail (search_threads, get_thread, list_labels) e le quattro scritture (create_draft, label_thread, unlabel_thread di Gmail, notion-create-pages di Notion), con il prefisso mcp__<id connettore>__. Un errore resta visibile solo sulla mail a cui appartiene e sparisce quando la stessa azione riesce.
claude --version.Una mod è codice eseguito con i tuoi permessi: può leggere e scrivere file, avviare processi e vedere prompt e tool call della sessione. Il sorgente di ogni mod è in hooks/: leggilo prima dell'installazione.
claude plugin marketplace add andreabrugnoli/mods
claude plugin install barra-cache@andrea-mods
claude plugin install scritture-esterne@andrea-mods
claude plugin install conferma-proposte@andrea-mods
claude plugin install correggi@andrea-mods
claude plugin install rec@andrea-mods
claude plugin install posta@andrea-mods
claude.Verifica: apri /plugin e controlla che le mod risultino attive. Per provare una mod in una sola sessione senza installarla, clona la repo e avvia claude --plugin-dir ./mods/barra-cache. Per disattivarla, usa il tab Installed di /plugin.
Le sessioni cloud (claude.ai/code, app per iPad) non leggono le tue impostazioni locali: partono da un contenitore pulito e caricano i plugin dichiarati nella repo su cui lavori. Per attivare una mod in una repo, aggiungi a .claude/settings.json di quella repo:
{
"extraKnownMarketplaces": {
"andrea-mods": { "source": { "source": "github", "repo": "andreabrugnoli/mods" } }
},
"enabledPlugins": { "barra-cache@andrea-mods": true, "rec@andrea-mods": true }
}
Lo fa per te scripts/abilita-cloud.sh <percorso-repo> [mod ...], che unisce la voce alle impostazioni esistenti. Se il tuo gitignore (anche globale) esclude .claude/settings.json, aggiungilo con git add -f .claude/settings.json, poi commit e push. La sessione cloud legge il file dal branch.
Su iPad la banda sopra il prompt può non essere disegnata: restano /cache, /push e /nuova, e tutti i comandi on/off (/barra, /scritture, /conferma, /correggi, /rec) funzionano uguale. Lo stato ($.store) e i riassunti in ~/.claude/handoffs/ vivono nel contenitore della sessione e si perdono alla sua chiusura.
Accendere e spegnere (senza argomento alternano; accettano anche on e off):
/barra: la barra sopra il prompt./scritture: il registro delle scritture esterne./conferma: i bottoni Sì e No sulle proposte./correggi: la raccolta degli inciampi (/correggi elenco li mostra)./rec: la modalità registrazione (/rec rigoroso, /rec off, /rec config).posta o /posta: la inbox con i bottoni Bozza, Label e Task (posta aggiorna, posta off).Altri comandi di barra-cache:
/cache: minuti di cache rimasti, contesto occupato, costo dell'ultimo turno e quota di cache letta./push: pubblica i commit del branch corrente./nuova: riassume la sessione in un file e riparte da una chat pulita.Durata della cache in barra-cache: parte da un'ora (TTL_MS) e si corregge da sola. Dopo una pausa di almeno 6 minuti guarda la quota di cache letta: se la cache è stata riscritta, passa a 5 minuti e lo ricorda tra le sessioni; se è stata letta, conferma l'ora.
Soglie dei colori in barra-cache: CONTEXT_WARN, CONTEXT_BAD, COST_WARN, CACHE_OK e CACHE_BAD, in cima allo stesso file.
Nomi dei connettori in scritture-esterne: opzione servizi del plugin.
Per modificare una mod, clona la repo e caricala con claude --plugin-dir: ogni salvataggio ricarica il modulo nella sessione aperta.
mods/
├── .claude-plugin/marketplace.json # elenco delle mod installabili
├── barra-cache/
├── scritture-esterne/
├── conferma-proposte/
├── correggi/
├── rec/
├── posta/
└── scripts/abilita-cloud.sh
Ogni cartella di mod è un plugin completo: .claude-plugin/plugin.json, hooks/hooks.json, hooks/register.js e tests/ (eseguibili con claude plugin test ./<mod>). claude plugin validate ./<mod> elenca gli eventi intercettati e le chiamate al motore.
Licenza: MIT.
hooks/register.js 885 lines1// Quante mail leggere per pagina di risultati (il massimo del connettore) e quante pagine al massimo per account
2const PAGE_SIZE = 50
3const MAX_PAGES = 10
4
5// I colori del puntino di ogni account
6const DOTS = ['cyan', 'magenta', 'yellow', 'green']
7
8// Quante mail mostrare insieme nella lista: il resto scorre con j e k
9const WINDOW = 5
10
11// L'id del pannello e la parola che lo apre scrivendola nel prompt (senza barra, niente avviso dell'app)
12const PANE = 'posta'
13
14// I nomi con cui il connettore Gmail può comparire: cambiano da sessione a sessione
15// (nome del connettore, nome con prefisso, id), quindi si provano in ordine
16const GMAIL_SERVERS = ['Gmail', 'e14c09e8-d3bf-4f30-b839-ba795465d6b4', 'claude.ai Gmail', 'claude_ai_Gmail']
17
18// Gli account: il connettore MCP che li legge (un nome o un elenco di nomi da provare)
19// e un nome breve. Si sostituiscono scrivendo un elenco con la stessa forma nello
20// store (chiave "accounts")
21const DEFAULT_ACCOUNTS = [
22 { name: 'hello', email: 'hello@andreabrugnoli.it', server: GMAIL_SERVERS },
23]
24
25// I nomi di connettore da provare per un account, il primo funzionante per primo
26export function serverList(account) {
27 const list = [].concat(account.resolved ?? [], account.server ?? [])
28 return list.filter((name, i) => name && list.indexOf(name) === i)
29}
30
31// Chiama un tool provando i nomi di connettore del bersaglio (un account Gmail o Notion) uno dopo l'altro,
32// e ricorda quello che risponde. Se nessuno risponde, l'errore riporta il motivo di ciascun tentativo
33async function callServers($, account, tool, args) {
34 const failures = []
35 for (const server of serverList(account)) {
36 try {
37 const res = await $.mcp.call(server, tool, args)
38 if (!res.isError) {
39 account.resolved = server
40 return res
41 }
42 failures.push(server + ': ' + String(res.content?.[0]?.text ?? 'errore').replace(/\s+/g, ' '))
43 } catch (err) {
44 failures.push(server + ': ' + String(err?.message ?? err).replace(/\s+/g, ' '))
45 }
46 }
47 // I nomi di connettore inesistenti non dicono nulla: si mostra l'errore del connettore che ha risposto
48 const real = failures.filter((f) => !/no connected MCP tool/i.test(f))
49 return { isError: true, content: [{ type: 'text', text: (real.length ? real : failures).join(' | ') || 'nessun connettore trovato' }] }
50}
51
52// Il data source del database Tasks di Notion
53const TASKS_DB = '1ee13fe7-1a52-8195-9008-000b5e44714d'
54
55// Le regole di scrittura delle bozze, dentro la cartella home
56const RULES_FILE = '/.claude/mods-data/posta/sistematore.md'
57
58// Acceso o spento: lo decide /posta e resta nello store tra una sessione e l'altra
59let enabled = false
60
61// Lo stato della vista: gli account con le loro mail e la mail selezionata
62let accounts = DEFAULT_ACCOUNTS
63let boxes = []
64let cursor = 0
65
66// I lavori in background: ogni azione gira nella mod e riferisce qui, senza riempire la chat
67// { label, status: 'corso' | 'fatto' | 'errore', note, box, mail, archived, action }
68const jobs = []
69
70// Il pannello mostra solo l'ultimo messaggio del thread; i precedenti si aprono a richiesta
71let showOlder = false
72
73// Spinner dei lavori in corso e durata dei lavori riusciti prima che spariscano dal pannello
74const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
75const DONE_VISIBLE_MS = 8000
76let ticker = null
77
78// Fa girare lo spinner finché c'è un lavoro in corso e fa sparire i lavori riusciti dopo qualche secondo
79function tick($) {
80 if (ticker) return
81 ticker = setInterval(() => {
82 const running = jobs.some((j) => j.status === 'corso')
83 const fading = jobs.some((j) => j.status === 'fatto' && Date.now() - j.doneAt < DONE_VISIBLE_MS + 500)
84 if (!running && !fading) {
85 clearInterval(ticker)
86 ticker = null
87 }
88 $.ui.invalidate('ui.render')
89 }, 120)
90}
91
92// I testi già letti, per thread: undefined = non ancora chiesto, 'carico' = in arrivo
93const bodies = new Map()
94
95// Accorcia un testo su una riga
96function clip(text, n) {
97 const one = String(text ?? '').replace(/\s+/g, ' ').trim()
98 return one.length > n ? one.slice(0, n - 1) + '…' : one
99}
100
101// Il nuovo stato dopo un comando: "on" apre, "off" chiude, senza argomento alterna
102export function parseToggle(args, current) {
103 const arg = String(args ?? '').trim().toLowerCase()
104 if (/^(on|acceso|apri|attiva|si|sì)$/.test(arg)) return true
105 if (/^(off|spento|chiudi|disattiva|no)$/.test(arg)) return false
106 return !current
107}
108
109// L'ora locale di una data ISO in formato HH:MM, oppure il giorno se non è di oggi
110export function formatWhen(iso, now = new Date()) {
111 const d = new Date(iso)
112 if (Number.isNaN(d.getTime())) return ''
113 const two = (n) => String(n).padStart(2, '0')
114 if (d.toDateString() === now.toDateString()) return two(d.getHours()) + ':' + two(d.getMinutes())
115 return two(d.getDate()) + '/' + two(d.getMonth() + 1)
116}
117
118// La data per esteso: 7 ott 2026, 09:26
119export function formatLong(iso) {
120 const d = new Date(iso)
121 if (Number.isNaN(d.getTime())) return ''
122 const two = (n) => String(n).padStart(2, '0')
123 const months = ['gen', 'feb', 'mar', 'apr', 'mag', 'giu', 'lug', 'ago', 'set', 'ott', 'nov', 'dic']
124 return d.getDate() + ' ' + months[d.getMonth()] + ' ' + d.getFullYear() + ', ' + two(d.getHours()) + ':' + two(d.getMinutes())
125}
126
127// Pulisce il testo di una mail: righe di asterischi o trattini ridotte, righe vuote ripetute tolte
128export function cleanBody(text) {
129 return String(text ?? '')
130 .replace(/\r/g, '')
131 .replace(/([*=_#-])\1{7,}/g, '────────')
132 .replace(/[ \t]+\n/g, '\n')
133 .replace(/\n{3,}/g, '\n\n')
134 .trim()
135 .slice(0, 20000)
136}
137
138// Dalla risposta di get_thread ai messaggi da leggere, dal più recente
139export function parseThread(result) {
140 const text = (result?.content ?? []).map((b) => (b && b.type === 'text' ? b.text : '')).join('')
141 let data
142 try {
143 data = JSON.parse(text)
144 } catch {
145 return []
146 }
147 return (data.messages ?? [])
148 .map((m) => ({
149 from: m.sender,
150 to: [].concat(m.toRecipients ?? []).join(', '),
151 date: m.date,
152 subject: m.subject,
153 body: cleanBody(m.plaintextBody ?? m.snippet),
154 attachments: (m.attachments ?? []).map((a) => a.filename ?? a.name).filter(Boolean),
155 }))
156 .reverse()
157}
158
159// Dal nome del mittente "Nome <a@b.it>" al solo nome, o all'indirizzo
160export function senderName(sender) {
161 const s = String(sender ?? '').trim()
162 const named = s.match(/^"?([^"<]+?)"?\s*<[^>]+>$/)
163 return (named ? named[1] : s.replace(/[<>]/g, '')).trim()
164}
165
166// Il mittente per la lista: il nome se c'è, altrimenti il dominio (linkedin da em.linkedin.com),
167// oppure la parte prima della chiocciola per le caselle personali
168export function shortSender(sender) {
169 const s = String(sender ?? '').trim()
170 const at = s.indexOf('@')
171 if (at < 0) return s
172 const host = s.slice(at + 1).split('.')
173 const personal = ['gmail', 'outlook', 'hotmail', 'yahoo', 'icloud', 'libero', 'live', 'me']
174 const domain = host.length > 1 ? host[host.length - 2] : host[0]
175 return personal.includes(domain) || host.length < 2 ? s.slice(0, at) : domain
176}
177
178// Dalla risposta di search_threads alla lista di mail: una per thread, l'ultimo messaggio
179export function parseThreads(result) {
180 const text = (result?.content ?? []).map((b) => (b && b.type === 'text' ? b.text : '')).join('')
181 let data
182 try {
183 data = JSON.parse(text)
184 } catch {
185 return []
186 }
187 return (data.threads ?? []).map((t) => {
188 const m = (t.messages ?? []).at(-1) ?? {}
189 return {
190 threadId: t.id,
191 messageId: m.id,
192 sender: senderName(m.sender),
193 subject: m.subject || '(senza oggetto)',
194 date: m.date,
195 snippet: m.snippet,
196 url: t.viewUrl,
197 }
198 })
199}
200
201// Il token della pagina successiva nella risposta di search_threads, se c'è
202export function parseNextToken(result) {
203 const text = (result?.content ?? []).map((b) => (b && b.type === 'text' ? b.text : '')).join('')
204 try {
205 const token = JSON.parse(text).nextPageToken
206 return typeof token === 'string' && token ? token : ''
207 } catch {
208 return ''
209 }
210}
211
212// Il testo del thread già letto dalla mod, da dare al modello al posto di una nuova lettura
213export function threadText(body, limit = 6000) {
214 if (!Array.isArray(body) || !body.length) return ''
215 return body
216 .map((m) => ['Da: ' + m.from, 'Data: ' + formatLong(m.date), 'Oggetto: ' + m.subject, '', m.body].join('\n'))
217 .join('\n\n---\n\n')
218 .slice(0, limit)
219}
220
221// I nomi con cui il connettore Notion può comparire, provati in ordine come per Gmail
222const NOTION_SERVERS = ['Notion', '46dded4b-f2d2-4af7-9ae7-1db030709c49', 'claude.ai Notion', 'claude_ai_Notion']
223const notion = { server: NOTION_SERVERS }
224
225// I modelli: uno economico per scegliere campi ed etichette, uno migliore per scrivere la bozza
226const CHEAP_MODEL = 'haiku'
227const WRITER_MODEL = 'sonnet'
228
229// I valori ammessi dei campi del task: quello che il modello propone fuori elenco cade sul primo
230export const TASK_CHOICES = {
231 Urgenza: ['Non urgente', 'Urgente', 'Routine'],
232 Importanza: ['Importante', 'Non importante', 'Strategico'],
233 Impegno: ['Facile', 'Flusso', 'Veloce', 'Personale'],
234}
235
236// L'ora locale di adesso con il fuso, in ISO senza secondi
237export function nowLocal(d = new Date()) {
238 const two = (n) => String(n).padStart(2, '0')
239 const off = -d.getTimezoneOffset()
240 const sign = off >= 0 ? '+' : '-'
241 return d.getFullYear() + '-' + two(d.getMonth() + 1) + '-' + two(d.getDate()) + 'T' + two(d.getHours()) + ':' + two(d.getMinutes()) + sign + two(Math.floor(Math.abs(off) / 60)) + ':' + two(Math.abs(off) % 60)
242}
243
244// Domani alle 09:00 locali, in ISO con fuso
245export function tomorrowNine(d = new Date()) {
246 const next = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1, 9, 0, 0)
247 return nowLocal(next).replace('T09:00', 'T09:00:00')
248}
249
250// Il primo oggetto JSON in una risposta del modello, anche se racchiuso in un blocco di codice
251export function parseJson(text) {
252 const s = String(text ?? '')
253 const start = s.indexOf('{')
254 const end = s.lastIndexOf('}')
255 if (start < 0 || end <= start) return null
256 try {
257 return JSON.parse(s.slice(start, end + 1))
258 } catch {
259 return null
260 }
261}
262
263// I campi del task proposti dal modello, ricondotti a valori ammessi: titolo non vuoto e corto,
264// scelte negli elenchi, scadenza tra adesso e un anno, altrimenti domani alle 09:00
265export function taskFields(raw, mail, now = new Date()) {
266 const r = raw && typeof raw === 'object' ? raw : {}
267 const pick = (key) => (TASK_CHOICES[key].includes(r[key]) ? r[key] : TASK_CHOICES[key][0])
268 const due = new Date(r.scadenza)
269 const valid = typeof r.scadenza === 'string' && /T\d\d:\d\d/.test(r.scadenza) && due > now && due - now < 365 * 864e5
270 return {
271 title: clip(r.titolo, 120) || clip(mail.subject, 120) || 'Rispondere alla mail',
272 due: valid ? r.scadenza : tomorrowNine(now),
273 Urgenza: pick('Urgenza'),
274 Importanza: pick('Importanza'),
275 Impegno: pick('Impegno'),
276 context: clip(r.contesto, 400),
277 }
278}
279
280// Le etichette dell'utente dalla risposta di list_labels (le etichette di sistema no)
281export function parseLabels(result) {
282 const text = (result?.content ?? []).map((b) => (b && b.type === 'text' ? b.text : '')).join('')
283 try {
284 return (JSON.parse(text).labels ?? []).filter((l) => l.labelType === 'USER' && l.labelId && l.name).map((l) => ({ id: l.labelId, name: l.name }))
285 } catch {
286 return []
287 }
288}
289
290// L'etichetta indicata dal modello, solo se è una di quelle esistenti
291export function matchLabel(answer, labels) {
292 const a = String(answer ?? '').trim().replace(/^["'`]+|["'`.]+$/g, '').trim().toLowerCase()
293 if (!a) return null
294 return labels.find((l) => l.name.toLowerCase() === a) ?? null
295}
296
297// La mappatura delle etichette di un account: ordinate per nome, con il percorso a livelli
298// (le sottoetichette di Gmail sono nomi con la barra, "Lavoro/AI news")
299export function buildLabelDb(labels) {
300 return [...labels]
301 .sort((a, b) => a.name.localeCompare(b.name, 'it'))
302 .map((l) => {
303 const path = l.name.split('/')
304 return { id: l.id, name: l.name, path, depth: path.length - 1, leaf: path[path.length - 1] }
305 })
306}
307
308// La mappatura come albero di testo, da tenere in un file leggibile
309export function labelTree(db) {
310 return db.map((l) => ' '.repeat(l.depth) + l.leaf + ' [' + l.id + ']').join('\n') + '\n'
311}
312
313// Le etichette che corrispondono a quello che l'utente ha scritto, le più precise per prime:
314// nome intero, ultimo livello, fine del percorso, poi qualunque pezzo del nome
315export function findLabels(query, db, max = 6) {
316 const q = String(query ?? '').trim().replace(/^["'`]+|["'`.]+$/g, '').trim().toLowerCase()
317 if (!q) return []
318 const rank = (l) => {
319 const name = l.name.toLowerCase()
320 const leaf = l.leaf.toLowerCase()
321 if (name === q) return 0
322 if (leaf === q) return 1
323 if (name.endsWith('/' + q)) return 2
324 if (leaf.startsWith(q)) return 3
325 if (name.includes(q)) return 4
326 return -1
327 }
328 return db
329 .map((l) => ({ l, r: rank(l) }))
330 .filter((x) => x.r >= 0)
331 .sort((a, b) => a.r - b.r)
332 .slice(0, max)
333 .map((x) => x.l)
334}
335
336// Le etichette scelte dal modello: numeri dell'elenco (anche più d'uno), in ordine, senza doppioni
337export function pickByNumbers(answer, db, max = 3) {
338 const picked = []
339 for (const n of String(answer ?? '').match(/\d+/g) ?? []) {
340 const l = db[Number(n) - 1]
341 if (l && !picked.includes(l)) picked.push(l)
342 if (picked.length === max) break
343 }
344 return picked
345}
346
347// Una completion senza tool: il modello propone, il codice decide cosa scrivere
348async function ask($, request) {
349 const r = await $.model.complete({ timeoutMs: 90000, ...request })
350 if (!r.isAnswered) throw new Error('modello senza risposta (' + r.reason + (r.status ? ' ' + r.status : '') + ')')
351 return r.text
352}
353
354// Il testo del thread: quello già letto dal pannello, altrimenti lo legge ora
355async function ensureText($, account, mail) {
356 let body = bodies.get(mail.threadId)
357 if (!Array.isArray(body)) {
358 const res = await callServers($, account, 'get_thread', { threadId: mail.threadId, messageFormat: 'PLAIN_TEXT' })
359 if (res.isError) throw new Error('lettura del thread: ' + clip(res.content?.[0]?.text, 200))
360 body = parseThread(res)
361 if (body.length) bodies.set(mail.threadId, body)
362 }
363 return threadText(body) || 'Oggetto: ' + mail.subject + '\n\n' + (mail.snippet ?? '')
364}
365
366// Una chiamata che deve riuscire: altrimenti l'errore ferma l'azione
367async function must($, target, tool, args, what) {
368 const res = await callServers($, target, tool, args)
369 if (res.isError) throw new Error(what + ': ' + clip(res.content?.[0]?.text, 200))
370 return res
371}
372
373// Le regole della bozza, lette dal file dell'utente
374let rules = null
375async function draftRules($) {
376 if (rules) return rules
377 const home = (await $.env.get('HOME')) || ''
378 rules = await $.fs.read(home + RULES_FILE)
379 return rules
380}
381
382// BOZZA: il modello scrive solo il testo, il codice crea la bozza di risposta (mai inviata)
383async function doDraft($, account, mail, text) {
384 const system = await draftRules($)
385 const reply = await ask($, {
386 model: WRITER_MODEL,
387 system,
388 maxTokens: 2000,
389 prompt: 'Thread (dal messaggio più recente):\n<<<\n' + text + '\n>>>\n\nScrivi la risposta di Andrea all\'ultimo messaggio. Restituisci solo il testo della mail, senza titoli, commenti né markdown.',
390 })
391 const body = String(reply).replace(/^```[a-z]*\n?|\n?```$/g, '').trim()
392 if (!body) throw new Error('bozza vuota')
393 await must($, account, 'create_draft', { replyToMessageId: mail.messageId, body }, 'creazione bozza')
394 return 'bozza creata'
395}
396
397// Le etichette di ogni account, per nome dell'account: la mappatura si legge una volta,
398// si ricorda tra sessioni nello store e si scrive anche in un file leggibile
399const labelDbs = new Map()
400const LABELS_FILE = '/.claude/mods-data/posta/etichette.md'
401
402async function ensureLabels($, account, force = false) {
403 if (!force && labelDbs.get(account.name)?.length) return labelDbs.get(account.name)
404 const db = buildLabelDb(parseLabels(await must($, account, 'list_labels', {}, 'lettura etichette')))
405 if (!db.length) throw new Error('nessuna etichetta trovata')
406 labelDbs.set(account.name, db)
407 try {
408 await $.store.set('labels', Object.fromEntries(labelDbs))
409 const home = (await $.env.get('HOME')) || ''
410 await $.fs.write(home + LABELS_FILE, '# Etichette Gmail (' + account.email + ')\n\n' + labelTree(db))
411 } catch {}
412 return db
413}
414
415// LABEL, primo tempo: il modello propone fino a tre etichette dell'elenco (per numero),
416// il codice non scrive nulla finché l'utente non conferma
417async function proposeLabels($, account, text) {
418 const db = await ensureLabels($, account)
419 const answer = await ask($, {
420 model: CHEAP_MODEL,
421 effort: 'low',
422 maxTokens: 60,
423 system: 'Classifichi mail. Le etichette sono percorsi: scegli la più specifica che si adatta. Rispondi solo con i numeri delle tre etichette più pertinenti, separati da virgola, la migliore per prima.',
424 prompt: 'Etichette:\n' + db.map((l, i) => (i + 1) + '. ' + l.name).join('\n') + '\n\nMail:\n<<<\n' + text.slice(0, 3000) + '\n>>>\n\nQuali etichette?',
425 })
426 const picked = pickByNumbers(answer, db)
427 if (!picked.length) throw new Error('nessuna etichetta proposta: ' + clip(answer, 60))
428 return picked
429}
430
431// LABEL, secondo tempo: applica l'etichetta scelta e toglie la mail dalla inbox
432async function applyLabel($, account, mail, label) {
433 await must($, account, 'label_thread', { threadId: mail.threadId, labelIds: [label.id] }, 'etichetta')
434 await must($, account, 'unlabel_thread', { threadId: mail.threadId, labelIds: ['INBOX'] }, 'archiviazione')
435 return label.name
436}
437
438// TASK: il modello propone i campi, il codice li valida e crea la pagina nel database Tasks
439async function doTask($, mail, text) {
440 const answer = await ask($, {
441 model: CHEAP_MODEL,
442 effort: 'low',
443 maxTokens: 400,
444 system: 'Trasformi una mail in un task. Rispondi solo con un oggetto JSON, senza testo intorno.',
445 prompt:
446 'Ora locale: ' + nowLocal() + '.\nMail:\n<<<\n' + text.slice(0, 4000) + '\n>>>\n\n' +
447 'Campi: "titolo" (cosa fare, breve, verbo all\'infinito), "scadenza" (ISO con fuso, ad esempio ' + tomorrowNine() + '; se la mail non indica una data usa domani alle 09:00), ' +
448 Object.entries(TASK_CHOICES).map(([k, v]) => '"' + k + '" (uno tra ' + v.map((x) => '"' + x + '"').join(', ') + ')').join(', ') +
449 ', "contesto" (una o due frasi).',
450 })
451 const f = taskFields(parseJson(answer), mail)
452 await must($, notion, 'notion-create-pages', {
453 parent: { type: 'data_source_id', data_source_id: TASKS_DB },
454 pages: [{
455 properties: { Task: f.title, 'date:Data:start': f.due, 'date:Data:is_datetime': 1, Urgenza: f.Urgenza, Importanza: f.Importanza, Impegno: f.Impegno, ' ': 'Non iniziato' },
456 content: '[Apri la mail](' + (mail.url ?? '') + ') da ' + mail.sender + '\n\n' + f.context,
457 }],
458 }, 'creazione task')
459 return clip(f.title, 50) + ' · ' + formatLong(f.due)
460}
461
462// Esegue l'azione di un bottone: tutte le scritture partono dal codice, il modello sceglie solo i contenuti.
463// Label e Bozza+Label non applicano nulla: restituiscono le etichette proposte, da confermare nel pannello
464export async function runAction($, key, account, mail) {
465 // L'eliminazione non ha bisogno del testo: il thread va nel cestino di Gmail (recuperabile)
466 if (key === 'elimina') {
467 await must($, account, 'trash_thread', { threadId: mail.threadId }, 'eliminazione')
468 return { note: 'nel cestino' }
469 }
470 const text = await ensureText($, account, mail)
471 if (key === 'bozza') return { note: await doDraft($, account, mail, text) }
472 if (key === 'label' || key === 'misto') {
473 let note = ''
474 // Se la bozza c'è già (un tentativo precedente si è fermato all'etichetta) non la si ripete
475 if (key === 'misto') {
476 if (!mail.drafted) await doDraft($, account, mail, text)
477 mail.drafted = true
478 note = 'bozza creata, '
479 }
480 return { proposals: await proposeLabels($, account, text), note }
481 }
482 if (key === 'task') return { note: 'task: ' + (await doTask($, mail, text)) }
483 throw new Error('azione sconosciuta: ' + key)
484}
485
486// Le azioni dei bottoni: cosa fanno alla mail dopo l'invio. Esce dall'elenco subito solo l'eliminazione;
487// le etichette la spostano dopo la conferma, bozza e task la lasciano dov'è
488const ACTIONS = [
489 { key: 'bozza', label: 'Bozza', hotkey: 'b', archives: false },
490 { key: 'label', label: 'Label', hotkey: 'l', archives: false },
491 { key: 'misto', label: 'Bozza+Label', hotkey: 'm', archives: false },
492 { key: 'task', label: 'Task', hotkey: 't', archives: false },
493 { key: 'elimina', label: 'Elimina', hotkey: 'd', archives: true },
494]
495
496// La mail selezionata nell'elenco piatto di tutti gli account
497function flat() {
498 return boxes.flatMap((box) => box.mails.map((mail) => ({ box, mail })))
499}
500
501// Carica la inbox di ogni account; un errore resta nell'account e non ferma gli altri
502async function load($) {
503 boxes = await Promise.all(
504 accounts.map(async (account) => {
505 try {
506 // Si seguono le pagine finché ce ne sono, così l'inbox si vede tutta
507 const mails = []
508 let pageToken = ''
509 for (let page = 0; page < MAX_PAGES; page++) {
510 const args = { query: 'in:inbox', pageSize: PAGE_SIZE, view: 'THREAD_VIEW_MINIMAL' }
511 if (pageToken) args.pageToken = pageToken
512 const res = await callServers($, account, 'search_threads', args)
513 if (res.isError) {
514 if (mails.length) break
515 return { account, mails: [], error: clip((res.content?.[0]?.text) ?? 'errore', 600) }
516 }
517 mails.push(...parseThreads(res).filter((m) => !mails.some((x) => x.threadId === m.threadId)))
518 pageToken = parseNextToken(res)
519 if (!pageToken) break
520 }
521 return { account, mails, error: '' }
522 } catch (err) {
523 return { account, mails: [], error: clip(err?.message ?? err, 600) }
524 }
525 }),
526 )
527 cursor = 0
528}
529
530// Legge il testo del thread selezionato (una sola volta) e ridisegna il pannello
531async function loadBody($, item) {
532 if (!item || bodies.has(item.mail.threadId)) return
533 bodies.set(item.mail.threadId, 'carico')
534 try {
535 const res = await callServers($, item.box.account, 'get_thread', { threadId: item.mail.threadId, messageFormat: 'PLAIN_TEXT' })
536 const messages = res.isError ? [] : parseThread(res)
537 bodies.set(item.mail.threadId, messages.length ? messages : { error: res.isError ? clip(res.content?.[0]?.text, 300) : 'testo non disponibile' })
538 } catch (err) {
539 bodies.set(item.mail.threadId, { error: clip(err?.message ?? err, 300) })
540 }
541 $.ui.invalidate('ui.render')
542}
543
544// Un lavoro fallito: la mail archiviata torna in elenco e l'errore si vede nel pannello
545function failJob($, job, note) {
546 job.status = 'errore'
547 job.note = clip(String(note).replace(/posta: \$ ?mcp\.call: /g, ''), 400)
548 if (job.archived && !job.box.mails.includes(job.mail)) job.box.mails.unshift(job.mail)
549 $.ui.toast('posta: ' + job.label + ' non riuscito')
550 $.ui.invalidate('ui.render')
551}
552
553// Apre il pannello (carica la inbox) o lo chiude; risponde con la riga di esito
554async function setOpen($, want) {
555 enabled = want
556 if (want) {
557 await load($)
558 // La mappatura delle etichette si aggiorna in background a ogni apertura
559 for (const account of accounts) void ensureLabels($, account, true).catch(() => {})
560 await $.ui.open({ id: PANE, title: 'Posta', focus: true })
561 void loadBody($, flat()[0])
562 } else {
563 await $.ui.close({ id: PANE })
564 }
565 $.ui.invalidate('ui.render')
566 return 'posta · ' + (want ? 'aperta, ' + flat().length + ' mail' : 'chiusa')
567}
568
569async function runPosta($, e) {
570 const arg = String(e.args ?? '').trim().toLowerCase()
571 try {
572 return { text: await setOpen($, arg === 'aggiorna' ? true : parseToggle(e.args, enabled)) }
573 } catch (err) {
574 return { text: 'posta: ' + clip(err?.message ?? err, 200) }
575 }
576}
577
578export function register(on) {
579 on('session.start', async ($, e, next) => {
580 try {
581 const saved = await $.store.get('accounts')
582 if (Array.isArray(saved) && saved.length) accounts = saved
583 const savedLabels = await $.store.get('labels')
584 if (savedLabels && typeof savedLabels === 'object') for (const [name, db] of Object.entries(savedLabels)) if (Array.isArray(db) && db.length) labelDbs.set(name, db)
585 } catch {}
586 const spec = { name: 'posta', description: 'Apre o chiude la inbox con i bottoni Bozza, Label e Task', argumentHint: '[on|off|aggiorna]', immediate: true }
587 await $.command.register(spec)
588 // Nelle chat nuove l'elenco dei comandi può essere già chiuso a questo punto: si ripete dopo poco
589 for (const ms of [1500, 5000]) {
590 $.clock.after(ms, () => {
591 void $.command.register(spec).catch(() => {})
592 })
593 }
594 return next(e)
595 })
596
597 on('command.run', { command: 'posta' }, runPosta)
598 // Il comando statico del plugin si chiama anche posta:apri
599 on('command.run', { command: 'posta:apri' }, runPosta)
600
601 // Scrivere "posta" (senza barra) apre o chiude il pannello: non arriva al modello e non consuma token
602 on('prompt.submit', async ($, e, next) => {
603 const m = String(e.text ?? '').trim().toLowerCase().match(/^posta(?:\s+(on|off|aggiorna|chiudi))?$/)
604 if (!m) return next(e)
605 try {
606 const text = await setOpen($, m[1] === 'aggiorna' ? true : parseToggle(m[1], enabled))
607 return { drop: text }
608 } catch (err) {
609 // Se il pannello non si apre il prompt non deve restare bloccato
610 return { drop: 'posta: ' + clip(err?.message ?? err, 200) }
611 }
612 })
613
614 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
615 const { Box, Text, Button, Input } = $.ui.resolve(e)
616 const all = flat()
617 cursor = Math.min(cursor, Math.max(0, all.length - 1))
618 const current = all[cursor]
619 const rule = Text({ dimColor: true, children: ['─'.repeat(Math.max(10, Math.min(120, e.props.bodyColumns ?? 60)))] })
620
621 // Sposta la selezione e carica il testo della nuova mail
622 const move = (step) => () => {
623 if (!all.length) return
624 cursor = (cursor + step + all.length) % all.length
625 showOlder = false
626 void loadBody($, all[cursor])
627 $.ui.invalidate('ui.render')
628 }
629
630 const refresh = async () => {
631 showOlder = false
632 bodies.clear()
633 await load($)
634 void loadBody($, flat()[0])
635 $.ui.invalidate('ui.render')
636 }
637
638 // Il lavoro riuscito: gli errori precedenti della stessa azione su questa mail non servono più
639 const succeed = (job, note) => {
640 job.note = clip(note, 160)
641 job.status = 'fatto'
642 job.doneAt = Date.now()
643 for (let i = jobs.length - 1; i >= 0; i--) {
644 const j = jobs[i]
645 if (j.status === 'errore' && j.action === job.action && j.mail.threadId === job.mail.threadId) jobs.splice(i, 1)
646 }
647 $.ui.toast('posta: ' + job.label + ' fatto')
648 }
649
650 // Il bottone avvia l'azione in background e toglie la mail dall'elenco se l'azione la archivia;
651 // l'esito compare nel pannello e in un avviso, la chat resta pulita.
652 // Le azioni con etichetta si fermano a una proposta da confermare (stato 'attesa')
653 const act = (action, target = current) => async () => {
654 if (!target) return
655 const { box, mail } = target
656 // Un secondo tocco sulla stessa mail mentre l'azione gira non la ripete
657 if (jobs.some((j) => j.status === 'corso' && j.mail === mail && j.action === action)) return
658 // Una proposta ancora aperta su questa mail viene sostituita
659 for (let i = jobs.length - 1; i >= 0; i--) if (jobs[i].status === 'attesa' && jobs[i].mail === mail) jobs.splice(i, 1)
660 const job = { label: action.label + ' · ' + clip(mail.subject, 40), status: 'corso', note: '', box, mail, archived: action.archives, action }
661 jobs.push(job)
662 tick($)
663 if (action.archives) {
664 box.mails = box.mails.filter((m) => m !== mail)
665 cursor = Math.min(cursor, Math.max(0, flat().length - 1))
666 void loadBody($, flat()[cursor])
667 }
668 $.ui.invalidate('ui.render')
669 try {
670 const out = await runAction($, action.key, box.account, mail)
671 if (out.proposals) {
672 job.status = 'attesa'
673 job.proposals = out.proposals
674 job.prefix = out.note
675 job.note = out.note + 'conferma l\'etichetta'
676 } else {
677 succeed(job, out.note)
678 }
679 } catch (err) {
680 failJob($, job, String(err?.message ?? err))
681 }
682 $.ui.invalidate('ui.render')
683 }
684
685 // L'etichetta scelta (proposta o scritta): la mail esce dall'inbox e si applica in background
686 const choose = async (job, label) => {
687 job.status = 'corso'
688 job.archived = true
689 job.hint = ''
690 job.options = null
691 job.note = 'etichetta ' + label.name
692 job.box.mails = job.box.mails.filter((m) => m !== job.mail)
693 cursor = Math.min(cursor, Math.max(0, flat().length - 1))
694 void loadBody($, flat()[cursor])
695 tick($)
696 $.ui.invalidate('ui.render')
697 try {
698 await applyLabel($, job.box.account, job.mail, label)
699 succeed(job, (job.prefix ?? '') + 'etichetta ' + label.name)
700 } catch (err) {
701 failJob($, job, String(err?.message ?? err))
702 }
703 $.ui.invalidate('ui.render')
704 }
705
706 // Quello che l'utente scrive come etichetta: una corrispondenza sola si applica, più d'una si sceglie
707 const typed = (job) => (value) => {
708 const found = findLabels(value, labelDbs.get(job.box.account.name) ?? [])
709 if (found.length === 1) return choose(job, found[0])
710 job.options = found.length ? found : null
711 job.hint = found.length ? '' : String(value).trim() ? 'nessuna etichetta per "' + clip(value, 40) + '"' : ''
712 $.ui.invalidate('ui.render')
713 }
714
715 const cancel = (job) => () => {
716 jobs.splice(jobs.indexOf(job), 1)
717 $.ui.invalidate('ui.render')
718 }
719
720 // Gli errori si vedono solo sulla mail a cui appartengono; Riprova rilancia l'ultimo di quella mail
721 const mine = (j) => current && j.mail.threadId === current.mail.threadId
722 const failed = jobs.findLast((j) => j.status === 'errore' && mine(j))
723 const waiting = jobs.findLast((j) => j.status === 'attesa' && mine(j))
724 const retry = () => {
725 if (!failed) return
726 const idx = flat().findIndex((x) => x.mail.threadId === failed.mail.threadId)
727 if (idx < 0) return
728 jobs.splice(jobs.indexOf(failed), 1)
729 cursor = idx
730 return act(failed.action, flat()[idx])()
731 }
732
733 // La lista: una finestra di poche righe che scorre con la selezione, con l'intestazione di ogni account
734 const top = Math.min(Math.max(0, cursor - Math.floor(WINDOW / 2)), Math.max(0, all.length - WINDOW))
735 const shown = all.slice(top, top + WINDOW)
736 const list = []
737 let lastBox = null
738 shown.forEach((item, i) => {
739 const dot = DOTS[boxes.indexOf(item.box) % DOTS.length]
740 if (item.box !== lastBox) {
741 lastBox = item.box
742 list.push(
743 Box({
744 key: 'h-' + item.box.account.name + i,
745 flexDirection: 'row',
746 children: [
747 Text({ color: dot, bold: true, children: ['● '] }),
748 Text({ bold: true, children: [item.box.account.email] }),
749 Text({ dimColor: true, children: [' ' + item.box.mails.length + ' mail'] }),
750 ],
751 }),
752 )
753 }
754 const selected = top + i === cursor
755 list.push(
756 Box({
757 key: 'm-' + (top + i),
758 flexDirection: 'row',
759 children: [
760 Text({ color: selected ? dot : undefined, bold: true, children: [selected ? '▌' : ' '] }),
761 Text({ inverse: selected, dimColor: !selected, children: [' ' + formatWhen(item.mail.date).padEnd(5) + ' '] }),
762 Text({ inverse: selected, bold: true, children: [' ' + clip(shortSender(item.mail.sender), 16).padEnd(16) + ' '] }),
763 Text({ inverse: selected, bold: selected, dimColor: !selected, children: [' ' + clip(item.mail.subject, 90) + ' '] }),
764 ],
765 }),
766 )
767 })
768
769 // Il testo intero: tutti i messaggi del thread, dal più recente
770 const detail = []
771 const body = current ? bodies.get(current.mail.threadId) : null
772 if (!current) {
773 detail.push(Text({ key: 'vuota', dimColor: true, children: ['Inbox vuota'] }))
774 } else if (!body || body === 'carico') {
775 detail.push(Text({ key: 'carico', dimColor: true, children: ['Carico il testo…'] }))
776 } else if (body.error) {
777 detail.push(Text({ key: 'err', color: 'red', children: ['errore: ' + body.error] }))
778 } else {
779 const block = (m, i) => {
780 detail.push(
781 Box({
782 key: 'da-' + i,
783 flexDirection: 'row',
784 children: [Text({ bold: true, children: [m.from] }), Text({ dimColor: true, children: [' ' + formatLong(m.date)] })],
785 }),
786 )
787 detail.push(Text({ key: 'a-' + i, dimColor: true, children: ['A: ' + m.to] }))
788 detail.push(Text({ key: 'og-' + i, bold: true, children: [m.subject ?? current.mail.subject] }))
789 detail.push(Text({ key: 'sp-' + i, children: [' '] }))
790 detail.push(Text({ key: 'tx-' + i, children: [m.body || '(vuoto)'] }))
791 if (m.attachments.length) detail.push(Text({ key: 'al-' + i, dimColor: true, children: ['Allegati: ' + m.attachments.join(', ')] }))
792 }
793 block(body[0], 0)
794 if (body.length > 1) {
795 detail.push(
796 Button({
797 key: 'precedenti',
798 label: showOlder ? 'Nascondi precedenti' : 'Mostra ' + (body.length - 1) + (body.length === 2 ? ' precedente' : ' precedenti'),
799 hotkey: 'p',
800 onPress: () => {
801 showOlder = !showOlder
802 $.ui.invalidate('ui.render')
803 },
804 }),
805 )
806 body.slice(1).forEach((m, k) => {
807 const i = k + 1
808 detail.push(Text({ key: 'sep-' + i, dimColor: true, children: ['┄'.repeat(Math.max(10, Math.min(120, e.props.bodyColumns ?? 60)))] }))
809 if (showOlder) block(m, i)
810 else detail.push(Text({ key: 'riga-' + i, dimColor: true, children: ['▸ ' + clip(shortSender(m.from), 16) + ' · ' + formatWhen(m.date) + ' · ' + clip(String(m.body ?? '').replace(/\s+/g, ' '), 70)] }))
811 })
812 }
813 }
814
815 // La proposta di etichetta: i bottoni con quelle suggerite, o con quelle trovate dalla ricerca scritta
816 const proposal = []
817 if (waiting) {
818 const options = waiting.options ?? waiting.proposals
819 const hotkeys = waiting.options ? ['1', '2', '3', '4', '5', '6'] : ['s', '2', '3']
820 proposal.push(Text({ key: 'p-t', bold: true, children: [waiting.options ? 'Quale di queste?' : 'Etichetta proposta, va bene?'] }))
821 proposal.push(
822 Box({
823 key: 'p-b',
824 flexDirection: 'row',
825 children: [
826 ...options.flatMap((l, i) => [
827 Button({ key: 'p-' + l.id, label: (i === 0 && !waiting.options ? 'Sì: ' : '') + l.name, hotkey: hotkeys[i], variant: i === 0 ? 'primary' : undefined, onPress: () => choose(waiting, l) }),
828 Text({ key: 'p-s' + i, children: [' '] }),
829 ]),
830 Button({ key: 'p-annulla', label: 'Annulla', hotkey: 'a', onPress: cancel(waiting) }),
831 ],
832 }),
833 )
834 proposal.push(Input({ key: 'p-altra', label: 'Altra etichetta: ', placeholder: 'scrivi un nome, anche una parte (es. AI news)', submitLabel: 'cerca', onSubmit: typed(waiting) }))
835 if (waiting.hint) proposal.push(Text({ key: 'p-h', color: 'red', children: [waiting.hint] }))
836 proposal.push(rule)
837 }
838
839 const errors = boxes.filter((box) => box.error)
840 return Box({
841 flexDirection: 'column',
842 children: [
843 Text({ key: 'head', bold: true, children: ['Posta' + (all.length ? ' (' + (cursor + 1) + '/' + all.length + ')' : '')] }),
844 ...errors.map((box) => Text({ key: 'e-' + box.account.name, color: 'red', children: ['errore: ' + box.error] })),
845 rule,
846 ...list,
847 rule,
848 ...jobs
849 .filter((j) => (j.status === 'corso') || (j.status === 'attesa' && mine(j)) || (j.status === 'fatto' && Date.now() - j.doneAt < DONE_VISIBLE_MS) || (j.status === 'errore' && mine(j)))
850 .slice(-4)
851 .map((j, i) =>
852 Text({
853 key: 'job-' + i,
854 color: j.status === 'errore' ? 'red' : j.status === 'fatto' ? 'green' : j.status === 'attesa' ? 'yellow' : undefined,
855 dimColor: j.status === 'corso',
856 children: [
857 (j.status === 'corso' ? SPIN[Math.floor(Date.now() / 120) % SPIN.length] : j.status === 'fatto' ? '✓' : j.status === 'attesa' ? '?' : '✗') + ' ' + j.label +
858 (j.note ? ' · ' + j.note : '') + (j.status === 'errore' ? ' (premi y per riprovare)' : ''),
859 ],
860 }),
861 ),
862 ...proposal,
863 Box({
864 flexDirection: 'row',
865 children: [
866 ...ACTIONS.flatMap((a) => [Button({ key: a.key, label: a.label, hotkey: a.hotkey, variant: a.key === 'bozza' ? 'primary' : undefined, onPress: act(a) }), Text({ children: [' '] })]),
867 Text({ dimColor: true, children: [' │ '] }),
868 Button({ key: 'su', label: 'Su', hotkey: 'j', onPress: move(-1) }),
869 Text({ children: [' '] }),
870 Button({ key: 'giu', label: 'Giù', hotkey: 'k', onPress: move(1) }),
871 Text({ dimColor: true, children: [' │ '] }),
872 failed && Button({ key: 'riprova', label: 'Riprova', hotkey: 'y', onPress: retry }),
873 failed && Text({ children: [' '] }),
874 Button({ key: 'aggiorna', label: 'Aggiorna', hotkey: 'r', onPress: refresh }),
875 Text({ children: [' '] }),
876 Button({ key: 'chiudi', label: 'Chiudi', hotkey: 'x', onPress: () => setOpen($, false) }),
877 ].filter(Boolean),
878 }),
879 rule,
880 ...detail,
881 ].filter(Boolean),
882 })
883 })
884}
885