SLOPSHOPPER

posta

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

newpanecommandtoastpromptmodel
v1.5.1MITupdated 2026-10-07andreabrugnoli/mods/posta
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · posta
│ ┃ Posta ✕ › fix the failing auth test and add an audit log call │ ┃ Posta │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ──────────── ⎿ Read 6 lines │ ┃ ──────────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ ──────────── ⎿ Added 2 lines, removed 1 line │ ┃ [ Bozza ] [ Label ] [ Bozza+Label ] [ Task ] ⏺ Bash(bun test) │ ┃ ──────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ ──────────── │ ┃ Inbox vuota ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /posta │ ⎿ posta: posta · aperta, 0 mail │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Posta
Posta ──────────────────────────────────────────────────────── ──────────────────────────────────────────────────────── [ Bozza ] [ Label ] [ Bozza+Label ] [ Task ] [ Elimina ] │ ──────────────────────────────────────────────────────── Inbox vuota
README

mods

License: MIT Claude Code Mods

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.


Le sei mod

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.


Prerequisiti

  • Claude Code 2.1.287 o successivo: le mod sono attive di default da questa versione. Verifica con claude --version.
  • Terminale o tab Code dell'app Desktop: le sessioni WSL nell'app Desktop non eseguono le mod.

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.


Installazione

  1. Aggiungi il marketplace a Claude Code:
   claude plugin marketplace add andreabrugnoli/mods
  1. Installa le mod che ti interessano, una per comando:
   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
  1. Avvia una nuova sessione con 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.


Uso in cloud e da iPad

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.


Comandi disponibili

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.

Personalizzazione

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.


Struttura del progetto

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.

Source 1 files
hooks/register.js 885 lines
1// 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