1package main
2
3import (
4 "archive/tar"
5 "archive/zip"
6 "compress/gzip"
7 "context"
8 "encoding/base64"
9 "encoding/json"
10 "errors"
11 "flag"
12 "fmt"
13 "io"
14 "log"
15 "net/mail"
16 "net/url"
17 "os"
18 "os/exec"
19 "path/filepath"
20 "regexp"
21 "runtime"
22 "slices"
23 "sort"
24 "strings"
25 "sync"
26 "time"
27 "unicode/utf8"
28
29 tea "charm.land/bubbletea/v2"
30 "charm.land/lipgloss/v2"
31 "github.com/floatpane/matcha/backend"
32 _ "github.com/floatpane/matcha/backend/imap"
33 _ "github.com/floatpane/matcha/backend/jmap"
34 _ "github.com/floatpane/matcha/backend/maildir"
35 _ "github.com/floatpane/matcha/backend/pop3"
36 "github.com/floatpane/matcha/calendar"
37 matchaCli "github.com/floatpane/matcha/cli"
38 "github.com/floatpane/matcha/clib"
39 "github.com/floatpane/matcha/clib/macos"
40 "github.com/floatpane/matcha/config"
41 matchaDaemon "github.com/floatpane/matcha/daemon"
42 "github.com/floatpane/matcha/daemonclient"
43 "github.com/floatpane/matcha/daemonrpc"
44 "github.com/floatpane/matcha/fetcher"
45 "github.com/floatpane/matcha/i18n"
46 _ "github.com/floatpane/matcha/i18n/languages"
47 "github.com/floatpane/matcha/internal/httpclient"
48 "github.com/floatpane/matcha/internal/logging"
49 "github.com/floatpane/matcha/internal/loglevel"
50 "github.com/floatpane/matcha/notify"
51 "github.com/floatpane/matcha/plugin"
52 "github.com/floatpane/matcha/sender"
53 "github.com/floatpane/matcha/theme"
54 "github.com/floatpane/matcha/tui"
55 "github.com/google/uuid"
56 lua "github.com/yuin/gopher-lua"
57)
58
59const (
60 initialEmailLimit = 50
61 paginationLimit = 50
62 maxCacheEmails = 100
63)
64
65// Version variables are injected by the build (GoReleaser ldflags).
66// They default to "dev" when not set by the build system.
67var (
68 version = "dev"
69 commit = ""
70 date = ""
71
72 // httpClient is used for all outbound HTTP requests (update checks, asset downloads).
73 httpClient = httpclient.NewWithRedirectCap(httpclient.UpdateCheckTimeout, 5)
74)
75
76// UpdateAvailableMsg is sent into the TUI when a newer release is detected.
77type UpdateAvailableMsg struct {
78 Latest string
79 Current string
80}
81
82// internal struct for parsing GitHub release JSON.
83type githubRelease struct {
84 TagName string `json:"tag_name"`
85 Assets []struct {
86 Name string `json:"name"`
87 BrowserDownloadURL string `json:"browser_download_url"`
88 } `json:"assets"`
89}
90
91type mainModel struct {
92 current tea.Model
93 previousModel tea.Model
94 config *config.Config
95 plugins *plugin.Manager
96 // Folder-based email storage
97 folderEmails map[string][]fetcher.Email // key: folderName
98 folderInbox *tui.FolderInbox
99 // Legacy fields kept for email actions
100 emails []fetcher.Email
101 emailsByAcct map[string][]fetcher.Email
102 width int
103 height int
104 err error
105 // IMAP IDLE
106 idleWatcher *fetcher.IdleWatcher
107 idleUpdates chan fetcher.IdleUpdate
108 // Multi-protocol backend providers (keyed by account ID)
109 providers map[string]backend.Provider
110 providersMu sync.RWMutex
111 // Daemon client service (daemon or direct fallback)
112 service daemonclient.Service
113 // Plugin prompt waiting for user input
114 pendingPrompt *plugin.PendingPrompt
115 // mailto: URL parsed from os.Args
116 mailtoURL *url.URL
117 // Optional in-app log panel.
118 showLogPanel bool
119 logCh <-chan logging.Entry
120 logPanel *tui.LogPanel
121}
122
123type logEntryMsg struct {
124 entry logging.Entry
125}
126
127func newInitialModel(cfg *config.Config, mailtoURL *url.URL) *mainModel {
128 idleUpdates := make(chan fetcher.IdleUpdate, 16)
129 initialModel := &mainModel{
130 emailsByAcct: make(map[string][]fetcher.Email),
131 folderEmails: make(map[string][]fetcher.Email),
132 idleUpdates: idleUpdates,
133 idleWatcher: fetcher.NewIdleWatcher(idleUpdates),
134 providers: make(map[string]backend.Provider),
135 mailtoURL: mailtoURL,
136 }
137
138 if cfg == nil || !cfg.HasAccounts() {
139 hideTips := false
140 if cfg != nil {
141 hideTips = cfg.HideTips
142 }
143 initialModel.current = tui.NewLogin(hideTips)
144 } else {
145 if mailtoURL != nil {
146 // mailto:addr@example.com?subject=test
147 to := mailtoURL.Opaque
148 if to == "" {
149 to = mailtoURL.Path
150 }
151 if to == "" {
152 to = mailtoURL.Query().Get("to")
153 }
154 subject := mailtoURL.Query().Get("subject")
155 body := mailtoURL.Query().Get("body")
156 initialModel.current = tui.NewComposerWithAccounts(cfg.Accounts, cfg.Accounts[0].ID, to, subject, body, cfg.HideTips)
157 } else {
158
159 initialModel.current = tui.NewChoice()
160 }
161 initialModel.config = cfg
162 }
163 return initialModel
164}
165
166// ensureProviders creates backend providers for all configured accounts.
167// newSettings constructs a settings model and wires it to the plugin manager
168// so the Plugins category can list and edit plugin-declared settings.
169func (m *mainModel) newSettings() *tui.Settings {
170 s := tui.NewSettings(m.config)
171 if m.plugins != nil {
172 s.SetPlugins(m.plugins)
173 }
174 return s
175}
176
177func (m *mainModel) ensureProviders() {
178 if m.config == nil {
179 return
180 }
181 for _, acct := range m.config.Accounts {
182 m.providersMu.RLock()
183 _, ok := m.providers[acct.ID]
184 m.providersMu.RUnlock()
185
186 if ok {
187 continue
188 }
189
190 p, err := backend.New(&acct)
191 if err != nil {
192 log.Printf("backend: failed to create provider for %s: %v", acct.Email, err)
193 continue
194 }
195
196 m.providersMu.Lock()
197 m.providers[acct.ID] = p
198 m.providersMu.Unlock()
199 }
200}
201
202// getProvider returns the backend provider for the given account.
203func (m *mainModel) getProvider(acct *config.Account) backend.Provider {
204 if acct == nil {
205 return nil
206 }
207
208 m.providersMu.RLock()
209 p := m.providers[acct.ID]
210 m.providersMu.RUnlock()
211
212 return p
213}
214
215func (m *mainModel) Init() tea.Cmd {
216 cmds := []tea.Cmd{m.current.Init(), checkForUpdatesCmd()}
217 if m.showLogPanel && m.logCh != nil {
218 cmds = append(cmds, waitForLogEntry(m.logCh))
219 }
220 return tea.Batch(cmds...)
221}
222
223func waitForLogEntry(ch <-chan logging.Entry) tea.Cmd {
224 return func() tea.Msg {
225 entry := <-ch
226 return logEntryMsg{entry: entry}
227 }
228}
229
230func (m *mainModel) syncUnreadBadge() {
231 if runtime.GOOS != "darwin" {
232 return
233 }
234 count := 0
235 // Count unread across all accounts (cached/loaded emails)
236 for _, emails := range m.emailsByAcct {
237 for _, e := range emails {
238 if !e.IsRead {
239 count++
240 }
241 }
242 }
243 // Also check folderEmails for unread status
244 for _, emails := range m.folderEmails {
245 for _, e := range emails {
246 if !e.IsRead {
247 count++
248 }
249 }
250 }
251 _ = macos.SetBadge(count)
252}
253
254func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
255 var cmd tea.Cmd
256 var cmds []tea.Cmd
257 searchWasActive := false
258 filterWasActive := false
259 splitWasOpen := false
260
261 if msg, ok := msg.(logEntryMsg); ok {
262 _ = msg.entry
263 return m, waitForLogEntry(m.logCh)
264 }
265
266 if msg, ok := msg.(tea.WindowSizeMsg); ok {
267 m.width = msg.Width
268 m.height = msg.Height
269 m.current, cmd = m.current.Update(m.currentWindowSize())
270 return m, cmd
271 }
272
273 if keyMsg, ok := msg.(tea.KeyPressMsg); ok && keyMsg.String() == config.Keybinds.Global.Cancel {
274 switch current := m.current.(type) {
275 case *tui.Inbox:
276 searchWasActive = current.IsSearchActive()
277 filterWasActive = current.IsFilterActive()
278 case *tui.FolderInbox:
279 if inbox := current.GetInbox(); inbox != nil {
280 searchWasActive = inbox.IsSearchActive()
281 filterWasActive = inbox.IsFilterActive()
282 }
283 splitWasOpen = current.HasSplitPreview()
284 }
285 }
286
287 m.current, cmd = m.current.Update(msg)
288 cmds = append(cmds, cmd)
289
290 // Fire composer_updated hook on key presses when the composer is active
291 if keyMsg, isKey := msg.(tea.KeyPressMsg); isKey {
292 if composer, ok := m.current.(*tui.Composer); ok && m.plugins != nil {
293 m.plugins.CallComposerHook(plugin.HookComposerUpdated, composer.GetBody(), composer.GetSubject(), composer.GetTo(), composer.GetCc(), composer.GetBcc())
294 m.syncPluginStatus()
295 m.applyPluginFields(composer)
296 }
297
298 // Check plugin key bindings for the current view
299 if m.plugins != nil {
300 if bindingCmd := m.handlePluginKeyBinding(keyMsg); bindingCmd != nil {
301 cmds = append(cmds, bindingCmd)
302 }
303 }
304 }
305
306 switch msg := msg.(type) {
307 case tea.KeyPressMsg:
308 if msg.String() == "ctrl+c" {
309 m.idleWatcher.StopAll()
310 if m.service != nil {
311 m.service.Close()
312 }
313 return m, tea.Quit
314 }
315 if msg.String() == "esc" {
316 switch m.current.(type) {
317 case *tui.FilePicker:
318 return m, func() tea.Msg { return tui.CancelFilePickerMsg{} }
319 case *tui.FolderInbox, *tui.Inbox, *tui.Login:
320 if searchWasActive || filterWasActive || splitWasOpen {
321 return m, tea.Batch(cmds...)
322 }
323 m.idleWatcher.StopAll()
324 m.current = tui.NewChoice()
325 m.current, _ = m.current.Update(m.currentWindowSize())
326 return m, m.current.Init()
327 }
328 }
329
330 case tui.BackToInboxMsg:
331 if m.folderInbox != nil {
332 m.current = m.folderInbox
333 } else {
334 m.current = tui.NewChoice()
335 m.current, _ = m.current.Update(m.currentWindowSize())
336 }
337 return m, nil
338
339 case tui.BackToMailboxMsg:
340 // Ensure kitty graphics are cleared when leaving email view
341 tui.ClearKittyGraphics()
342 if m.folderInbox != nil {
343 m.current = m.folderInbox
344 return m, nil
345 }
346 m.current = tui.NewChoice()
347 m.current, _ = m.current.Update(m.currentWindowSize())
348 return m, nil
349
350 case tui.DiscardDraftMsg:
351 // Save draft to disk
352 if msg.ComposerState != nil {
353 draft := msg.ComposerState.ToDraft()
354
355 if err := config.SaveDraft(draft); err != nil {
356 log.Printf("Error saving draft: %v", err)
357 }
358
359 }
360 m.current = tui.NewChoice()
361 m.current, _ = m.current.Update(m.currentWindowSize())
362 return m, m.current.Init()
363
364 case tui.OAuth2CompleteMsg:
365 if msg.Err != nil {
366 log.Printf("OAuth2 authorization failed: %v", msg.Err)
367 }
368 // After OAuth2 flow, go to the choice menu so user can proceed
369 m.current = tui.NewChoice()
370 m.current, _ = m.current.Update(m.currentWindowSize())
371 return m, m.current.Init()
372
373 case tui.Credentials:
374 // Split FetchEmail by commas to support multiple fetch addresses.
375 // Each address creates a separate account sharing the same login credentials.
376 fetchEmails := []string{""}
377 if msg.FetchEmail != "" {
378 fetchEmails = fetchEmails[:0]
379 for _, fe := range strings.Split(msg.FetchEmail, ",") {
380 if trimmed := strings.TrimSpace(fe); trimmed != "" {
381 fetchEmails = append(fetchEmails, trimmed)
382 }
383 }
384 if len(fetchEmails) == 0 {
385 fetchEmails = []string{""}
386 }
387 }
388
389 if m.config == nil {
390 m.config = &config.Config{}
391 }
392
393 // Check if we're editing an existing account
394 isEdit := false
395 var lastAccount config.Account
396 if login, ok := m.current.(*tui.Login); ok && login.IsEditMode() {
397 isEdit = true
398 existingID := login.GetAccountID()
399
400 account := config.Account{
401 ID: existingID,
402 Name: msg.Name,
403 Email: msg.Host,
404 Password: msg.Password,
405 ServiceProvider: msg.Provider,
406 FetchEmail: fetchEmails[0],
407 SendAsEmail: msg.SendAsEmail,
408 CatchAll: msg.CatchAll,
409 AuthMethod: msg.AuthMethod,
410 Protocol: msg.Protocol,
411 Insecure: msg.Insecure,
412 JMAPEndpoint: msg.JMAPEndpoint,
413 POP3Server: msg.POP3Server,
414 POP3Port: msg.POP3Port,
415 MaildirPath: msg.MaildirPath,
416 SC: &config.SessionCache{},
417 }
418
419 if msg.Provider == "custom" || msg.Protocol == "pop3" {
420 account.IMAPServer = msg.IMAPServer
421 account.IMAPPort = msg.IMAPPort
422 account.SMTPServer = msg.SMTPServer
423 account.SMTPPort = msg.SMTPPort
424 }
425
426 if account.FetchEmail == "" && account.Email != "" {
427 account.FetchEmail = account.Email
428 }
429
430 // Find and update the existing account, preserving S/MIME settings
431 for i, acc := range m.config.Accounts {
432 if acc.ID == existingID {
433 account.SMIMECert = acc.SMIMECert
434 account.SMIMEKey = acc.SMIMEKey
435 account.SMIMESignByDefault = acc.SMIMESignByDefault
436 if account.Password == "" {
437 account.Password = acc.Password
438 }
439 m.config.Accounts[i] = account
440 break
441 }
442 }
443 lastAccount = account
444 } else {
445 // New account: create one account per fetch email address
446 for _, fe := range fetchEmails {
447 account := config.Account{
448 ID: uuid.New().String(),
449 Name: msg.Name,
450 Email: msg.Host,
451 Password: msg.Password,
452 ServiceProvider: msg.Provider,
453 FetchEmail: fe,
454 SendAsEmail: msg.SendAsEmail,
455 CatchAll: msg.CatchAll,
456 AuthMethod: msg.AuthMethod,
457 Protocol: msg.Protocol,
458 JMAPEndpoint: msg.JMAPEndpoint,
459 POP3Server: msg.POP3Server,
460 POP3Port: msg.POP3Port,
461 MaildirPath: msg.MaildirPath,
462 SC: &config.SessionCache{},
463 }
464
465 if msg.Provider == "custom" || msg.Protocol == "pop3" {
466 account.IMAPServer = msg.IMAPServer
467 account.IMAPPort = msg.IMAPPort
468 account.SMTPServer = msg.SMTPServer
469 account.SMTPPort = msg.SMTPPort
470 }
471
472 if account.FetchEmail == "" && account.Email != "" {
473 account.FetchEmail = account.Email
474 }
475
476 m.config.AddAccount(account)
477 lastAccount = account
478 }
479 }
480
481 if err := config.SaveConfig(m.config); err != nil {
482 log.Printf("could not save config: %v", err)
483 return m, tea.Quit
484 }
485
486 // If OAuth2, launch the authorization flow after saving the account
487 if lastAccount.IsOAuth2() {
488 email := lastAccount.Email
489 provider := lastAccount.ServiceProvider
490 return m, func() tea.Msg {
491 err := config.RunOAuth2Flow(email, provider, "", "")
492 return tui.OAuth2CompleteMsg{Email: email, Err: err}
493 }
494 }
495
496 if isEdit {
497 m.current = m.newSettings()
498 } else {
499 m.current = tui.NewChoice()
500 }
501 m.current, _ = m.current.Update(m.currentWindowSize())
502 return m, m.current.Init()
503
504 case tui.GoToInboxMsg:
505 if m.config == nil || !m.config.HasAccounts() {
506 hideTips := false
507 if m.config != nil {
508 hideTips = m.config.HideTips
509 }
510 m.current = tui.NewLogin(hideTips)
511 return m, m.current.Init()
512 }
513 m.ensureProviders()
514 // Load cached folders from all accounts, merge unique names
515 seen := make(map[string]bool)
516 var cachedFolders []string
517 for _, acc := range m.config.Accounts {
518 for _, f := range config.GetCachedFolders(acc.ID) {
519 if !seen[f] {
520 seen[f] = true
521 cachedFolders = append(cachedFolders, f)
522 }
523 }
524 }
525 // Always ensure INBOX is present, even if cache is empty or stale
526 if !seen["INBOX"] {
527 cachedFolders = append([]string{"INBOX"}, cachedFolders...)
528 }
529 m.folderInbox = tui.NewFolderInbox(cachedFolders, m.config.Accounts)
530 m.folderInbox.SetDateFormat(m.config.GetDateFormat())
531 m.folderInbox.SetDetailedDates(m.config.EnableDetailedDates)
532 m.folderInbox.SetDefaultThreaded(m.config.EnableThreaded)
533 m.folderInbox.SetDisableImages(m.config.DisableImages)
534 // Use cached INBOX emails for instant display (memory first, then disk)
535 if cached, ok := m.folderEmails["INBOX"]; ok && len(cached) > 0 {
536 m.folderInbox.SetEmails(cached, m.config.Accounts)
537 } else if diskCached := loadFolderEmailsFromCache("INBOX"); len(diskCached) > 0 {
538 m.folderEmails["INBOX"] = diskCached
539 m.emails = diskCached
540 m.emailsByAcct = make(map[string][]fetcher.Email)
541 for _, email := range diskCached {
542 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
543 }
544 m.folderInbox.SetEmails(diskCached, m.config.Accounts)
545 }
546 m.current = m.folderInbox
547 m.current, _ = m.current.Update(m.currentWindowSize())
548 // Initialize daemon service if not already set.
549 if m.service == nil {
550 m.service = daemonclient.NewService(m.config)
551 }
552 if m.service.IsDaemon() {
553 // Subscribe to INBOX updates if using daemon.
554 for _, acct := range m.config.Accounts {
555 m.service.Subscribe(acct.ID, "INBOX")
556 }
557 } else {
558 // Start IDLE watchers for all accounts on INBOX
559 for i := range m.config.Accounts {
560 m.idleWatcher.Watch(&m.config.Accounts[i], "INBOX")
561 }
562 }
563 // Fetch folders and INBOX emails in parallel (background refresh)
564 batchCmds := []tea.Cmd{
565 m.current.Init(),
566 fetchFoldersCmd(m.config),
567 fetchFolderEmailsCmd(m.config, "INBOX"),
568 listenForIdleUpdates(m.idleUpdates),
569 }
570 if m.service.IsDaemon() {
571 batchCmds = append(batchCmds, listenForDaemonEvents(m.service.Events()))
572 }
573 return m, tea.Batch(batchCmds...)
574
575 case tui.FoldersFetchedMsg:
576 if m.folderInbox == nil {
577 return m, nil
578 }
579 var folderNames []string
580 for _, f := range msg.MergedFolders {
581 folderNames = append(folderNames, f.Name)
582 }
583 m.folderInbox.SetFolders(folderNames)
584 // Cache folder lists per account
585 for accID, folders := range msg.FoldersByAccount {
586 var names []string
587 for _, f := range folders {
588 names = append(names, f.Name)
589 }
590 go config.SaveAccountFolders(accID, names)
591 }
592 // Per-account fetch errors (e.g. broken IMAP login, unreachable
593 // server) are non-fatal: other accounts' folders are still shown.
594 // Surface them as a transient overlay so the user knows why an
595 // account's folders are missing instead of silently dropping them.
596 // Reuses the PluginNotifyMsg pattern (save current view, show
597 // status with a tea.Tick that fires RestoreViewMsg).
598 if len(msg.Errors) > 0 {
599 lookup := map[string]string{}
600 if m.config != nil {
601 for _, acc := range m.config.Accounts {
602 name := acc.Email
603 if name == "" {
604 name = acc.Name
605 }
606 if name == "" {
607 name = acc.ID
608 }
609 lookup[acc.ID] = name
610 }
611 }
612 parts := make([]string, 0, len(msg.Errors))
613 for accID, err := range msg.Errors {
614 name := lookup[accID]
615 if name == "" {
616 name = accID
617 }
618 parts = append(parts, fmt.Sprintf("%s: %v", name, err))
619 }
620 sort.Strings(parts)
621 m.previousModel = m.current
622 m.current = tui.NewStatus(fmt.Sprintf(
623 "Folder fetch failed for %d account(s): %s",
624 len(parts), strings.Join(parts, "; "),
625 ))
626 return m, tea.Tick(4*time.Second, func(t time.Time) tea.Msg {
627 return tui.RestoreViewMsg{}
628 })
629 }
630 return m, nil
631
632 case tui.SwitchFolderMsg:
633 if m.config == nil {
634 return m, nil
635 }
636 // Update IDLE watchers to monitor the new folder
637 for i := range m.config.Accounts {
638 // Only start IDLE for accounts that actually have this folder
639 folders := config.GetCachedFolders(m.config.Accounts[i].ID)
640 if !slices.Contains(folders, msg.FolderName) {
641 if m.service != nil && m.service.IsDaemon() {
642 m.service.Unsubscribe(m.config.Accounts[i].ID, msg.PreviousFolder)
643 } else {
644 m.idleWatcher.Stop(m.config.Accounts[i].ID)
645 }
646 continue
647 }
648 if m.service != nil && m.service.IsDaemon() {
649 // Unsubscribe from old, subscribe to new.
650 if msg.PreviousFolder != "" {
651 m.service.Unsubscribe(m.config.Accounts[i].ID, msg.PreviousFolder)
652 }
653 m.service.Subscribe(m.config.Accounts[i].ID, msg.FolderName)
654 } else {
655 m.idleWatcher.Watch(&m.config.Accounts[i], msg.FolderName)
656 }
657 }
658 if m.plugins != nil {
659 m.plugins.CallFolderHook(plugin.HookFolderChanged, msg.FolderName)
660 m.syncPluginStatus()
661 m.syncPluginKeyBindings()
662 }
663 // Use in-memory cache if available
664 if cached, ok := m.folderEmails[msg.FolderName]; ok {
665 m.emails = cached
666 m.emailsByAcct = make(map[string][]fetcher.Email)
667 for _, email := range cached {
668 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
669 }
670 if m.folderInbox != nil {
671 m.folderInbox.SetEmails(cached, m.config.Accounts)
672 m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
673 m.folderInbox.SetLoadingEmails(false)
674 }
675 return m, m.pluginNotifyCmd()
676 }
677 // Fall back to disk cache for instant display, then fetch fresh in background
678 if diskCached := loadFolderEmailsFromCache(msg.FolderName); len(diskCached) > 0 {
679 m.folderEmails[msg.FolderName] = diskCached
680 m.emails = diskCached
681 m.emailsByAcct = make(map[string][]fetcher.Email)
682 for _, email := range diskCached {
683 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
684 }
685 if m.folderInbox != nil {
686 m.folderInbox.SetEmails(diskCached, m.config.Accounts)
687 m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
688 m.folderInbox.SetLoadingEmails(false)
689 }
690 // Still fetch fresh emails in background
691 return m, tea.Batch(fetchFolderEmailsCmd(m.config, msg.FolderName), m.pluginNotifyCmd())
692 }
693 if m.folderInbox != nil {
694 m.folderInbox.SetLoadingEmails(true)
695 }
696 return m, tea.Batch(fetchFolderEmailsCmd(m.config, msg.FolderName), m.pluginNotifyCmd())
697
698 case tui.PluginNotifyMsg:
699 m.previousModel = m.current
700 m.current = tui.NewStatus(msg.Message)
701 dur := time.Duration(msg.Duration * float64(time.Second))
702 if dur <= 0 {
703 dur = 2 * time.Second
704 }
705 return m, tea.Tick(dur, func(t time.Time) tea.Msg {
706 return tui.RestoreViewMsg{}
707 })
708
709 case tui.PluginPromptSubmitMsg:
710 if m.pendingPrompt != nil {
711 if composer, ok := m.current.(*tui.Composer); ok {
712 composer.HidePluginPrompt()
713 m.plugins.ResolvePrompt(m.pendingPrompt, msg.Value)
714 m.applyPluginFields(composer)
715 m.syncPluginStatus()
716 }
717 m.pendingPrompt = nil
718 }
719 return m, nil
720
721 case tui.PluginPromptCancelMsg:
722 if composer, ok := m.current.(*tui.Composer); ok {
723 composer.HidePluginPrompt()
724 }
725 m.pendingPrompt = nil
726 return m, nil
727
728 case tui.FolderEmailsFetchedMsg:
729 if m.folderInbox == nil {
730 return m, nil
731 }
732 // Call plugin hooks for received emails
733 if m.plugins != nil {
734 for _, email := range msg.Emails {
735 t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, msg.FolderName)
736 m.plugins.CallHook(plugin.HookEmailReceived, t)
737 }
738 }
739 // Always cache in memory and to disk
740 m.folderEmails[msg.FolderName] = msg.Emails
741 go saveFolderEmailsToCache(msg.FolderName, msg.Emails)
742 // Prune stale body cache entries
743 go func() {
744 validUIDs := make(map[uint32]string, len(msg.Emails))
745 for _, e := range msg.Emails {
746 validUIDs[e.UID] = e.AccountID
747 }
748 _ = config.PruneEmailBodyCache(msg.FolderName, validUIDs, m.config.GetBodyCacheThreshold())
749 }()
750 // Only update the view if the user is still on this folder
751 if m.folderInbox.GetCurrentFolder() != msg.FolderName {
752 return m, nil
753 }
754 m.emails = msg.Emails
755 m.emailsByAcct = make(map[string][]fetcher.Email)
756 for _, email := range msg.Emails {
757 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
758 }
759 m.folderInbox.SetEmails(msg.Emails, m.config.Accounts)
760 m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
761 m.folderInbox.SetLoadingEmails(false)
762 m.syncPluginStatus()
763 m.syncPluginKeyBindings()
764 return m, tea.Batch(append(m.pluginFlagCmds(), m.pluginNotifyCmd())...)
765
766 case tui.FetchFolderMoreEmailsMsg:
767 if msg.AccountID == "" || m.config == nil {
768 return m, nil
769 }
770 account := m.config.GetAccountByID(msg.AccountID)
771 if account == nil {
772 return m, nil
773 }
774 limit := uint32(paginationLimit)
775 if msg.Limit > 0 {
776 limit = msg.Limit
777 }
778 return m, tea.Batch(
779 func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
780 fetchFolderEmailsPaginatedCmd(account, msg.FolderName, limit, msg.Offset),
781 )
782
783 case tui.FolderEmailsAppendedMsg:
784 // Ignore stale appends for a folder the user has moved away from
785 if m.folderInbox == nil || m.folderInbox.GetCurrentFolder() != msg.FolderName {
786 return m, nil
787 }
788 m.folderInbox.Update(msg)
789 // Update local stores and per-folder cache
790 for _, email := range msg.Emails {
791 m.emails = append(m.emails, email)
792 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
793 }
794 m.folderEmails[msg.FolderName] = append(m.folderEmails[msg.FolderName], msg.Emails...)
795 go saveFolderEmailsToCache(msg.FolderName, m.folderEmails[msg.FolderName])
796 return m, nil
797
798 case tui.MoveEmailToFolderMsg:
799 if m.config == nil {
800 return m, nil
801 }
802 account := m.config.GetAccountByID(msg.AccountID)
803 if account == nil {
804 return m, nil
805 }
806 m.previousModel = m.current
807 m.current = tui.NewStatus("Moving email...")
808 return m, tea.Batch(m.current.Init(), moveEmailToFolderCmd(account, msg.UID, msg.AccountID, msg.SourceFolder, msg.DestFolder))
809
810 case tui.UpdatePreviewMsg:
811 // Trigger preview body fetch
812 if m.folderInbox == nil {
813 return m, nil
814 }
815 folderName := m.folderInbox.GetCurrentFolder()
816 // Check cache first
817 if cached := config.GetCachedEmailBody(folderName, msg.UID, msg.AccountID, m.config.GetBodyCacheThreshold()); cached != nil {
818 var attachments []fetcher.Attachment
819 for _, ca := range cached.Attachments {
820 att := fetcher.Attachment{
821 Filename: ca.Filename,
822 PartID: ca.PartID,
823 Encoding: ca.Encoding,
824 MIMEType: ca.MIMEType,
825 ContentID: ca.ContentID,
826 Inline: ca.Inline,
827 IsSMIMESignature: ca.IsSMIMESignature,
828 SMIMEVerified: ca.SMIMEVerified,
829 IsSMIMEEncrypted: ca.IsSMIMEEncrypted,
830 IsCalendarInvite: ca.IsCalendarInvite,
831 }
832 if ca.IsCalendarInvite && len(ca.CalendarData) > 0 {
833 att.Data = ca.CalendarData
834 }
835 attachments = append(attachments, att)
836 }
837 return m, func() tea.Msg {
838 return tui.PreviewBodyFetchedMsg{
839 UID: msg.UID,
840 Body: cached.Body,
841 BodyMIMEType: cached.BodyMIMEType,
842 Attachments: attachments,
843 AccountID: msg.AccountID,
844 }
845 }
846 }
847 return m, fetchPreviewBodyCmd(m.config, msg.UID, msg.AccountID, folderName)
848
849 case tui.PreviewBodyFetchedMsg:
850 // Cache body and forward to FolderInbox
851 if msg.Err == nil && m.folderInbox != nil {
852 folderName := m.folderInbox.GetCurrentFolder()
853 var cachedAttachments []config.CachedAttachment
854 for _, a := range msg.Attachments {
855 cachedAttachments = append(cachedAttachments, config.CachedAttachment{
856 Filename: a.Filename,
857 PartID: a.PartID,
858 Encoding: a.Encoding,
859 MIMEType: a.MIMEType,
860 ContentID: a.ContentID,
861 Inline: a.Inline,
862 })
863 }
864 go func() {
865 err := config.SaveEmailBody(folderName, config.CachedEmailBody{
866 UID: msg.UID,
867 AccountID: msg.AccountID,
868 Body: msg.Body,
869 BodyMIMEType: msg.BodyMIMEType,
870 Attachments: cachedAttachments,
871 }, m.config.GetBodyCacheThreshold())
872 if err != nil {
873 loglevel.Debugf("error caching email body fails (disk full, permission denied) for UID: %d: %v", msg.UID, err)
874 }
875 }()
876 }
877 // Forward to FolderInbox for rendering
878 if m.folderInbox != nil {
879 m.current, cmd = m.current.Update(msg)
880 return m, cmd
881 }
882 return m, nil
883
884 case tui.EmailMovedMsg:
885 if msg.Err != nil {
886 log.Printf("Move failed: %v", msg.Err)
887 if m.folderInbox != nil {
888 m.previousModel = m.folderInbox
889 }
890 m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
891 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
892 return tui.RestoreViewMsg{}
893 })
894 }
895 // Remove email from current view
896 if m.folderInbox != nil {
897 m.folderInbox.RemoveEmail(msg.UID, msg.AccountID)
898 m.current = m.folderInbox
899 }
900 return m, nil
901
902 case tui.CachedEmailsLoadedMsg:
903 // Cache is no longer used for the folder-based inbox flow
904 // This handler is kept for backwards compatibility but simply fetches normally
905 if m.folderInbox == nil {
906 return m, nil
907 }
908 return m, fetchFolderEmailsCmd(m.config, m.folderInbox.GetCurrentFolder())
909
910 case tui.IdleNewMailMsg:
911 // Send desktop notification for new mail (if enabled)
912 if m.config == nil || !m.config.DisableNotifications {
913 accountName := msg.AccountID
914 if m.config != nil {
915 if acc := m.config.GetAccountByID(msg.AccountID); acc != nil {
916 accountName = acc.Email
917 }
918 }
919 go notify.Send("Matcha", fmt.Sprintf("New mail in %s (%s)", msg.FolderName, accountName))
920 }
921
922 // IDLE detected new mail — refetch the folder if we're viewing it
923 if m.folderInbox != nil && m.folderInbox.GetCurrentFolder() == msg.FolderName {
924 return m, tea.Batch(
925 fetchFolderEmailsCmd(m.config, msg.FolderName),
926 listenForIdleUpdates(m.idleUpdates),
927 )
928 }
929 // Re-subscribe even if not viewing the affected folder
930 return m, listenForIdleUpdates(m.idleUpdates)
931
932 case tui.DaemonEventMsg:
933 if msg.Event == nil {
934 return m, nil
935 }
936 var cmds []tea.Cmd
937 // Re-subscribe for next event.
938 if m.service != nil && m.service.IsDaemon() {
939 cmds = append(cmds, listenForDaemonEvents(m.service.Events()))
940 }
941 switch msg.Event.Type {
942 case daemonrpc.EventNewMail:
943 var ev daemonrpc.NewMailEvent
944 if err := json.Unmarshal(msg.Event.Data, &ev); err == nil {
945 if m.config == nil || !m.config.DisableNotifications {
946 accountName := ev.AccountID
947 if m.config != nil {
948 if acc := m.config.GetAccountByID(ev.AccountID); acc != nil {
949 accountName = acc.Email
950 }
951 }
952 go notify.Send("Matcha", fmt.Sprintf("New mail in %s (%s)", ev.Folder, accountName))
953 }
954
955 if m.folderInbox != nil && m.folderInbox.GetCurrentFolder() == ev.Folder {
956 cmds = append(cmds, fetchFolderEmailsCmd(m.config, ev.Folder))
957 }
958 }
959 case daemonrpc.EventSyncComplete:
960 var ev daemonrpc.SyncCompleteEvent
961 if err := json.Unmarshal(msg.Event.Data, &ev); err == nil {
962 if m.folderInbox != nil && m.folderInbox.GetCurrentFolder() == ev.Folder {
963 cmds = append(cmds, fetchFolderEmailsCmd(m.config, ev.Folder))
964 }
965 }
966 }
967 return m, tea.Batch(cmds...)
968
969 case tui.RequestRefreshMsg:
970 // Folder-based refresh: clear folder cache and refetch
971 if msg.FolderName != "" && m.config != nil {
972 delete(m.folderEmails, msg.FolderName)
973 if m.folderInbox != nil {
974 m.folderInbox.SetRefreshing(true)
975 }
976 return m, fetchFolderEmailsCmd(m.config, msg.FolderName)
977 }
978 return m, tea.Batch(
979 func() tea.Msg { return tui.RefreshingEmailsMsg{Mailbox: msg.Mailbox} },
980 refreshEmails(m.config, msg.Mailbox, msg.Counts),
981 )
982
983 case tui.EmailsRefreshedMsg:
984 // Merge refreshed emails with any paginated emails already loaded.
985 for accID, refreshed := range msg.EmailsByAccount {
986 refreshedUIDs := make(map[uint32]struct{}, len(refreshed))
987 for _, e := range refreshed {
988 refreshedUIDs[e.UID] = struct{}{}
989 }
990 if existing, ok := m.emailsByAcct[accID]; ok {
991 for _, e := range existing {
992 if _, found := refreshedUIDs[e.UID]; !found {
993 refreshed = append(refreshed, e)
994 }
995 }
996 }
997 m.emailsByAcct[accID] = refreshed
998 }
999 m.emails = flattenAndSort(m.emailsByAcct)
1000 m.syncUnreadBadge()
1001
1002 // Update folder inbox if it exists
1003 if m.folderInbox != nil {
1004 m.folderInbox.SetEmails(m.emails, m.config.Accounts)
1005 m.folderInbox.GetInbox().Update(msg)
1006 }
1007 return m, nil
1008
1009 case tui.AllEmailsFetchedMsg:
1010 m.emailsByAcct = msg.EmailsByAccount
1011 m.emails = flattenAndSort(msg.EmailsByAccount)
1012 m.syncUnreadBadge()
1013
1014 if m.folderInbox != nil {
1015 m.folderInbox.SetEmails(m.emails, m.config.Accounts)
1016 m.folderInbox.SetLoadingEmails(false)
1017 }
1018 return m, nil
1019
1020 case tui.EmailsFetchedMsg:
1021 if m.emailsByAcct == nil {
1022 m.emailsByAcct = make(map[string][]fetcher.Email)
1023 }
1024 m.emailsByAcct[msg.AccountID] = msg.Emails
1025 m.emails = flattenAndSort(m.emailsByAcct)
1026 m.syncUnreadBadge()
1027
1028 if m.folderInbox != nil {
1029 m.folderInbox.SetEmails(m.emails, m.config.Accounts)
1030 }
1031 return m, nil
1032
1033 case tui.FetchMoreEmailsMsg:
1034 if msg.AccountID == "" {
1035 return m, nil
1036 }
1037 account := m.config.GetAccountByID(msg.AccountID)
1038 if account == nil {
1039 return m, nil
1040 }
1041 limit := uint32(paginationLimit)
1042 if msg.Limit > 0 {
1043 limit = msg.Limit
1044 }
1045 folderName := "INBOX"
1046 if m.folderInbox != nil {
1047 folderName = m.folderInbox.GetCurrentFolder()
1048 }
1049 return m, tea.Batch(
1050 func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
1051 fetchFolderEmailsPaginatedCmd(account, folderName, limit, msg.Offset),
1052 )
1053
1054 case tui.SearchRequestedMsg:
1055 folderName := msg.FolderName
1056 if folderName == "" {
1057 folderName = "INBOX"
1058 }
1059 return m, m.searchEmailsCmd(msg.Query, folderName, msg.AccountID)
1060
1061 case tui.EmailsAppendedMsg:
1062 if m.emailsByAcct == nil {
1063 m.emailsByAcct = make(map[string][]fetcher.Email)
1064 }
1065 unique := filterUnique(m.emailsByAcct[msg.AccountID], msg.Emails)
1066 m.emailsByAcct[msg.AccountID] = append(m.emailsByAcct[msg.AccountID], unique...)
1067 m.emails = append(m.emails, unique...)
1068 m.syncUnreadBadge()
1069 return m, nil
1070
1071 case tui.GoToSendMsg:
1072 hideTips := false
1073 if m.config != nil {
1074 hideTips = m.config.HideTips
1075 }
1076 if m.config != nil && len(m.config.Accounts) > 0 {
1077 firstAccount := m.config.GetFirstAccount()
1078 composer := tui.NewComposerWithAccounts(m.config.Accounts, firstAccount.ID, msg.To, msg.Subject, msg.Body, hideTips)
1079 m.current = composer
1080 } else {
1081 m.current = tui.NewComposer("", msg.To, msg.Subject, msg.Body, hideTips)
1082 }
1083 m.current, _ = m.current.Update(m.currentWindowSize())
1084 m.syncPluginKeyBindings()
1085 return m, m.current.Init()
1086
1087 case tui.GoToDraftsMsg:
1088 drafts := config.GetAllDrafts()
1089 m.current = tui.NewDrafts(drafts)
1090 m.current, _ = m.current.Update(m.currentWindowSize())
1091 return m, m.current.Init()
1092
1093 case tui.OpenDraftMsg:
1094 var accounts []config.Account
1095 hideTips := false
1096 if m.config != nil {
1097 accounts = m.config.Accounts
1098 hideTips = m.config.HideTips
1099 }
1100 composer := tui.NewComposerFromDraft(msg.Draft, accounts, hideTips)
1101 m.current = composer
1102 m.current, _ = m.current.Update(m.currentWindowSize())
1103 m.syncPluginKeyBindings()
1104 return m, m.current.Init()
1105
1106 case tui.DeleteSavedDraftMsg:
1107 go func() {
1108 if err := config.DeleteDraft(msg.DraftID); err != nil {
1109 log.Printf("Error deleting draft: %v", err)
1110 }
1111 }()
1112 // Send message back to drafts view
1113 m.current, cmd = m.current.Update(tui.DraftDeletedMsg{DraftID: msg.DraftID})
1114 return m, cmd
1115
1116 case tui.GoToMarketplaceMsg:
1117 m.current = tui.NewMarketplace(false)
1118 m.current, _ = m.current.Update(m.currentWindowSize())
1119 return m, m.current.Init()
1120
1121 case tui.ConfigSavedMsg:
1122 if m.service != nil {
1123 if err := m.service.ReloadConfig(); err != nil {
1124 log.Printf("config reload: %v", err)
1125 }
1126 }
1127 if m.folderInbox != nil {
1128 m.folderInbox.SetDateFormat(m.config.GetDateFormat())
1129 m.folderInbox.SetDetailedDates(m.config.EnableDetailedDates)
1130 m.folderInbox.SetDefaultThreaded(m.config.EnableThreaded)
1131 m.folderInbox.SetDisableImages(m.config.DisableImages)
1132 }
1133 return m, nil
1134
1135 case tui.LanguageChangedMsg:
1136 // Rebuild all models with new translations
1137 // Keep current view type but recreate with fresh i18n
1138 switch curr := m.current.(type) {
1139 case *tui.Settings:
1140 // Preserve settings state when rebuilding
1141 newSettings := m.newSettings()
1142 newSettings.RestoreState(curr.GetState())
1143 m.current = newSettings
1144 case *tui.Composer:
1145 // Preserve composer state if possible, for now just refresh
1146 m.current = tui.NewChoice()
1147 case *tui.Inbox:
1148 m.current = tui.NewChoice()
1149 case *tui.FolderInbox:
1150 // Just rebuild settings view, folder inbox will be recreated on next navigation
1151 m.current = m.newSettings()
1152 default:
1153 // For other views, return to choice menu
1154 m.current = tui.NewChoice()
1155 }
1156 m.current, _ = m.current.Update(m.currentWindowSize())
1157 return m, m.current.Init()
1158
1159 case tui.GoToSettingsMsg:
1160 m.current = m.newSettings()
1161 m.current, _ = m.current.Update(m.currentWindowSize())
1162 return m, m.current.Init()
1163
1164 case tui.GoToAddAccountMsg:
1165 hideTips := false
1166 if m.config != nil {
1167 hideTips = m.config.HideTips
1168 }
1169 m.current = tui.NewLogin(hideTips)
1170 m.current, _ = m.current.Update(m.currentWindowSize())
1171 return m, m.current.Init()
1172
1173 case tui.GoToAddMailingListMsg:
1174 m.current = tui.NewMailingListEditor()
1175 m.current, _ = m.current.Update(m.currentWindowSize())
1176 return m, m.current.Init()
1177
1178 case tui.GoToEditAccountMsg:
1179 hideTips := false
1180 if m.config != nil {
1181 hideTips = m.config.HideTips
1182 }
1183 login := tui.NewLogin(hideTips)
1184 login.SetEditMode(msg.AccountID, msg.Protocol, msg.Provider, msg.Name, msg.Email, msg.FetchEmail, msg.SendAsEmail, msg.IMAPServer, msg.IMAPPort, msg.SMTPServer, msg.SMTPPort, msg.Insecure, msg.JMAPEndpoint, msg.POP3Server, msg.POP3Port, msg.CatchAll, msg.MaildirPath)
1185 m.current = login
1186 m.current, _ = m.current.Update(m.currentWindowSize())
1187 return m, m.current.Init()
1188
1189 case tui.GoToEditMailingListMsg:
1190 editor := tui.NewMailingListEditor()
1191 editor.SetEditMode(msg.Index, msg.Name, msg.Addresses)
1192 m.current = editor
1193 m.current, _ = m.current.Update(m.currentWindowSize())
1194 return m, m.current.Init()
1195
1196 case tui.SaveMailingListMsg:
1197 if m.config != nil {
1198 var addrs []string
1199 for _, part := range strings.Split(msg.Addresses, ",") {
1200 if trimmed := strings.TrimSpace(part); trimmed != "" {
1201 addrs = append(addrs, trimmed)
1202 }
1203 }
1204 if msg.EditIndex >= 0 && msg.EditIndex < len(m.config.MailingLists) {
1205 m.config.MailingLists[msg.EditIndex] = config.MailingList{
1206 Name: msg.Name,
1207 Addresses: addrs,
1208 }
1209 } else {
1210 m.config.MailingLists = append(m.config.MailingLists, config.MailingList{
1211 Name: msg.Name,
1212 Addresses: addrs,
1213 })
1214 }
1215 if err := config.SaveConfig(m.config); err != nil {
1216 log.Printf("could not save config: %v", err)
1217 }
1218 }
1219 // Return to settings
1220 m.current = m.newSettings()
1221 // Try to navigate to the mailing list view internally if possible, but NewSettings will go to SettingsMain by default.
1222 m.current, _ = m.current.Update(m.currentWindowSize())
1223 return m, m.current.Init()
1224
1225 case tui.GoToSignatureEditorMsg:
1226 m.current = tui.NewSignatureEditor(msg.AccountID)
1227 m.current, _ = m.current.Update(m.currentWindowSize())
1228 return m, m.current.Init()
1229
1230 case tui.PasswordVerifiedMsg:
1231 if msg.Err != nil {
1232 // Error is handled inside PasswordPrompt itself
1233 return m, nil
1234 }
1235 // Password verified — set session key and load config
1236 config.SetSessionKey(msg.Key)
1237 cfg, err := config.LoadConfig()
1238 if err == nil {
1239 if migrateErr := config.MigrateContactsCacheUsage(cfg.GetAccountIDs()); migrateErr != nil {
1240 log.Printf("warning: contacts migration failed: %v", migrateErr)
1241 }
1242 if cfg.Theme != "" {
1243 theme.SetTheme(cfg.Theme)
1244 tui.RebuildStyles()
1245 }
1246 // Set language from config
1247 lang := i18n.DetectLanguage(cfg)
1248 log.Printf("Detected language: %s", lang)
1249 if err := i18n.GetManager().SetLanguage(lang); err != nil {
1250 log.Printf("Failed to set language %s: %v", lang, err)
1251 } else {
1252 log.Printf("Language set to: %s", i18n.GetManager().GetLanguage())
1253 log.Printf("Test translation: %s", i18n.GetManager().T("composer.title"))
1254 }
1255 }
1256 _ = config.EnsurePGPDir()
1257 if err != nil {
1258 m.config = nil
1259 hideTips := false
1260 m.current = tui.NewLogin(hideTips)
1261 } else {
1262 m.config = cfg
1263 if m.mailtoURL != nil {
1264 to := m.mailtoURL.Opaque
1265 if to == "" {
1266 to = m.mailtoURL.Path
1267 }
1268 if to == "" {
1269 to = m.mailtoURL.Query().Get("to")
1270 }
1271 subject := m.mailtoURL.Query().Get("subject")
1272 body := m.mailtoURL.Query().Get("body")
1273 m.current = tui.NewComposerWithAccounts(cfg.Accounts, cfg.Accounts[0].ID, to, subject, body, cfg.HideTips)
1274 } else {
1275 m.current = tui.NewChoice()
1276 }
1277 }
1278 m.current, _ = m.current.Update(m.currentWindowSize())
1279 return m, m.current.Init()
1280
1281 case tui.SecureModeEnabledMsg:
1282 if msg.Err != nil {
1283 log.Printf("Failed to enable encryption: %v", msg.Err)
1284 }
1285 return m, nil
1286
1287 case tui.SecureModeDisabledMsg:
1288 if msg.Err != nil {
1289 log.Printf("Failed to disable encryption: %v", msg.Err)
1290 }
1291 return m, nil
1292
1293 case tui.GoToChoiceMenuMsg:
1294 m.current = tui.NewChoice()
1295 m.current, _ = m.current.Update(m.currentWindowSize())
1296 return m, m.current.Init()
1297
1298 case tui.DeleteAccountMsg:
1299 if m.config != nil {
1300 if m.config.RemoveAccount(msg.AccountID) {
1301 if err := config.CleanupAccountCache(msg.AccountID); err != nil {
1302 log.Printf("could not clean account cache: %v", err)
1303 }
1304 if err := config.SaveConfig(m.config); err != nil {
1305 log.Printf("could not save config: %v", err)
1306 }
1307 }
1308 // Remove emails for this account
1309 delete(m.emailsByAcct, msg.AccountID)
1310
1311 // Rebuild all emails
1312 var allEmails []fetcher.Email
1313 for _, emails := range m.emailsByAcct {
1314 allEmails = append(allEmails, emails...)
1315 }
1316 m.emails = allEmails
1317
1318 // Go back to settings
1319 m.current = m.newSettings()
1320 m.current, _ = m.current.Update(m.currentWindowSize())
1321 }
1322 return m, m.current.Init()
1323
1324 case tui.ViewEmailMsg:
1325 email := msg.Email
1326 if email == nil {
1327 email = m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox)
1328 } else {
1329 m.addEmailToStoresIfMissing(*email, msg.Mailbox)
1330 }
1331 if email == nil {
1332 return m, nil
1333 }
1334 folderName := "INBOX"
1335 if m.folderInbox != nil {
1336 folderName = m.folderInbox.GetCurrentFolder()
1337 }
1338 suppressRead := false
1339 if m.plugins != nil {
1340 t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, folderName)
1341 m.plugins.CallHook(plugin.HookEmailViewed, t)
1342 suppressRead = m.plugins.TakeAutoReadSuppressed()
1343 }
1344 // Split pane mode: open in split view instead of full screen
1345 if m.config.EnableSplitPane && m.folderInbox != nil {
1346 m.folderInbox.OpenSplitPreview(msg.UID, msg.AccountID, email)
1347 m.current = m.folderInbox
1348 // Mark as read
1349 if !email.IsRead && !suppressRead {
1350 m.markEmailAsReadInStores(msg.UID, msg.AccountID)
1351 account := m.config.GetAccountByID(msg.AccountID)
1352 if account != nil {
1353 cmd = markEmailAsReadCmd(account, msg.UID, msg.AccountID, folderName)
1354 }
1355 }
1356 // Fetch body
1357 return m, tea.Batch(append(m.pluginFlagCmds(), cmd, func() tea.Msg {
1358 return tui.UpdatePreviewMsg{UID: msg.UID, AccountID: msg.AccountID}
1359 })...)
1360 }
1361 // Check body cache first
1362 if cached := config.GetCachedEmailBody(folderName, msg.UID, msg.AccountID, m.config.GetBodyCacheThreshold()); cached != nil {
1363 // Convert cached attachments back to fetcher.Attachment
1364 var attachments []fetcher.Attachment
1365 for _, ca := range cached.Attachments {
1366 att := fetcher.Attachment{
1367 Filename: ca.Filename,
1368 PartID: ca.PartID,
1369 Encoding: ca.Encoding,
1370 MIMEType: ca.MIMEType,
1371 ContentID: ca.ContentID,
1372 Inline: ca.Inline,
1373 IsSMIMESignature: ca.IsSMIMESignature,
1374 SMIMEVerified: ca.SMIMEVerified,
1375 IsSMIMEEncrypted: ca.IsSMIMEEncrypted,
1376 IsCalendarInvite: ca.IsCalendarInvite,
1377 }
1378 if ca.IsCalendarInvite && len(ca.CalendarData) > 0 {
1379 att.Data = ca.CalendarData
1380 }
1381 attachments = append(attachments, att)
1382 }
1383 return m, func() tea.Msg {
1384 return tui.EmailBodyFetchedMsg{
1385 UID: msg.UID,
1386 Body: cached.Body,
1387 BodyMIMEType: cached.BodyMIMEType,
1388 Attachments: attachments,
1389 AccountID: msg.AccountID,
1390 Mailbox: msg.Mailbox,
1391 }
1392 }
1393 }
1394 m.current = tui.NewStatus("Fetching email content...")
1395 return m, tea.Batch(append(m.pluginFlagCmds(), m.current.Init(), fetchFolderEmailBodyCmd(m.config, msg.UID, msg.AccountID, folderName, msg.Mailbox), m.pluginNotifyCmd())...)
1396
1397 case tui.EmailBodyFetchedMsg:
1398 if msg.Err != nil {
1399 log.Printf("could not fetch email body: %v", msg.Err)
1400 if m.folderInbox != nil {
1401 m.current = m.folderInbox
1402 }
1403 return m, nil
1404 }
1405
1406 // Update the email in our stores
1407 m.updateEmailBodyByUID(msg.UID, msg.AccountID, msg.Mailbox, msg.Body, msg.BodyMIMEType, msg.Attachments)
1408
1409 // Cache the body to disk
1410 folderForCache := "INBOX"
1411 if m.folderInbox != nil {
1412 folderForCache = m.folderInbox.GetCurrentFolder()
1413 }
1414 var cachedAttachments []config.CachedAttachment
1415 for _, a := range msg.Attachments {
1416 ca := config.CachedAttachment{
1417 Filename: a.Filename,
1418 PartID: a.PartID,
1419 Encoding: a.Encoding,
1420 MIMEType: a.MIMEType,
1421 ContentID: a.ContentID,
1422 Inline: a.Inline,
1423 IsSMIMESignature: a.IsSMIMESignature,
1424 SMIMEVerified: a.SMIMEVerified,
1425 IsSMIMEEncrypted: a.IsSMIMEEncrypted,
1426 IsCalendarInvite: a.IsCalendarInvite,
1427 }
1428 if a.IsCalendarInvite && len(a.Data) > 0 {
1429 ca.CalendarData = a.Data
1430 }
1431 cachedAttachments = append(cachedAttachments, ca)
1432 }
1433 err := config.SaveEmailBody(folderForCache, config.CachedEmailBody{
1434 UID: msg.UID,
1435 AccountID: msg.AccountID,
1436 Body: msg.Body,
1437 BodyMIMEType: msg.BodyMIMEType,
1438 Attachments: cachedAttachments,
1439 }, m.config.GetBodyCacheThreshold())
1440
1441 if err != nil {
1442 loglevel.Debugf("error caching email body fails (disk full, permission denied) for UID: %d: %v", msg.UID, err)
1443 }
1444
1445 email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox)
1446 if email == nil {
1447 if m.folderInbox != nil {
1448 m.current = m.folderInbox
1449 }
1450 return m, nil
1451 }
1452
1453 // Mark as read in UI immediately and on the server (unless plugin suppressed it)
1454 var markReadCmd tea.Cmd
1455 pluginSuppressed := m.plugins != nil && m.plugins.TakeAutoReadSuppressed()
1456 if !email.IsRead && !pluginSuppressed {
1457 m.markEmailAsReadInStores(msg.UID, msg.AccountID)
1458
1459 folderName := "INBOX"
1460 if m.folderInbox != nil {
1461 folderName = m.folderInbox.GetCurrentFolder()
1462 }
1463 account := m.config.GetAccountByID(msg.AccountID)
1464 if account != nil {
1465 markReadCmd = markEmailAsReadCmd(account, msg.UID, msg.AccountID, folderName)
1466 }
1467 }
1468
1469 // Find the index for the email view (used for display purposes)
1470 emailIndex := m.getEmailIndex(msg.UID, msg.AccountID, msg.Mailbox)
1471 emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height, msg.Mailbox, m.config.DisableImages)
1472 m.current = emailView
1473 m.syncPluginStatus()
1474 m.syncPluginKeyBindings()
1475 cmds := []tea.Cmd{m.current.Init()}
1476 if markReadCmd != nil {
1477 cmds = append(cmds, markReadCmd)
1478 }
1479 cmds = append(cmds, m.pluginFlagCmds()...)
1480 return m, tea.Batch(cmds...)
1481
1482 case tui.ReplyToEmailMsg:
1483 var to string
1484 if len(msg.Email.ReplyTo) > 0 {
1485 to = strings.Join(msg.Email.ReplyTo, ", ")
1486 } else {
1487 to = msg.Email.From
1488 }
1489 subject := msg.Email.Subject
1490 normalizedSubject := strings.ToLower(strings.TrimSpace(subject))
1491 if !strings.HasPrefix(normalizedSubject, "re:") {
1492 subject = "Re: " + subject
1493 }
1494 quotedText := fmt.Sprintf("\n\nOn %s, %s wrote:\n> %s", msg.Email.Date.Local().Format("Jan 2, 2006 at 3:04 PM"), msg.Email.From, strings.ReplaceAll(msg.Email.Body, "\n", "\n> "))
1495
1496 var composer *tui.Composer
1497 hideTips := false
1498 if m.config != nil {
1499 hideTips = m.config.HideTips
1500 }
1501 if m.config != nil && len(m.config.Accounts) > 0 {
1502 // Use the account that received the email
1503 accountID := msg.Email.AccountID
1504 if accountID == "" {
1505 accountID = m.config.GetFirstAccount().ID
1506 }
1507 composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, to, subject, "", hideTips)
1508 // For catch-all accounts, pre-fill From with the specific address the email was delivered to.
1509 if len(msg.Email.To) > 0 {
1510 for i := range m.config.Accounts {
1511 if m.config.Accounts[i].ID == accountID && m.config.Accounts[i].CatchAll {
1512 acc := &m.config.Accounts[i]
1513 deliveryAddr := msg.Email.To[0]
1514 if addr, err := mail.ParseAddress(deliveryAddr); err == nil {
1515 deliveryAddr = addr.Address
1516 }
1517 fromVal := deliveryAddr
1518 if acc.Name != "" {
1519 fromVal = fmt.Sprintf("%s <%s>", acc.Name, deliveryAddr)
1520 }
1521 composer.SetFromOverride(fromVal)
1522 break
1523 }
1524 }
1525 }
1526 } else {
1527 composer = tui.NewComposer("", to, subject, "", hideTips)
1528 }
1529 composer.SetQuotedText(quotedText)
1530
1531 // Set reply headers
1532 inReplyTo := msg.Email.MessageID
1533 references := append(msg.Email.References, msg.Email.MessageID)
1534 composer.SetReplyContext(inReplyTo, references)
1535
1536 m.current = composer
1537 m.current, _ = m.current.Update(m.currentWindowSize())
1538 m.syncPluginKeyBindings()
1539 return m, m.current.Init()
1540
1541 case tui.ForwardEmailMsg:
1542 subject := msg.Email.Subject
1543 if !strings.HasPrefix(strings.ToLower(subject), "fwd:") {
1544 subject = "Fwd: " + subject
1545 }
1546
1547 forwardHeader := fmt.Sprintf("\n\n---------- Forwarded message ----------\nFrom: %s\nDate: %s\nSubject: %s\nTo: %s\n\n",
1548 msg.Email.From,
1549 msg.Email.Date.Local().Format("Mon, Jan 2, 2006 at 3:04 PM"),
1550 msg.Email.Subject,
1551 msg.Email.To,
1552 )
1553
1554 body := forwardHeader + msg.Email.Body
1555
1556 var composer *tui.Composer
1557 hideTips := false
1558 if m.config != nil {
1559 hideTips = m.config.HideTips
1560 }
1561 if m.config != nil && len(m.config.Accounts) > 0 {
1562 // Use the account that received the email
1563 accountID := msg.Email.AccountID
1564 if accountID == "" {
1565 accountID = m.config.GetFirstAccount().ID
1566 }
1567 composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, "", subject, body, hideTips)
1568 } else {
1569 composer = tui.NewComposer("", "", subject, body, hideTips)
1570 }
1571
1572 m.current = composer
1573 m.current, _ = m.current.Update(m.currentWindowSize())
1574 m.syncPluginKeyBindings()
1575 return m, m.current.Init()
1576
1577 case tui.OpenEditorMsg:
1578 composer, ok := m.current.(*tui.Composer)
1579 if !ok {
1580 return m, nil
1581 }
1582 return m, openExternalEditor(composer.GetBody())
1583
1584 case tui.EditorFinishedMsg:
1585 if msg.Err != nil {
1586 log.Printf("Editor error: %v", msg.Err)
1587 return m, nil
1588 }
1589 if composer, ok := m.current.(*tui.Composer); ok {
1590 composer.SetBody(msg.Body)
1591 }
1592 return m, nil
1593
1594 case tui.GoToFilePickerMsg:
1595 if runtime.GOOS == "darwin" {
1596 return m, func() tea.Msg {
1597 wd, _ := os.Getwd()
1598 paths, err := macos.OpenFilePicker(wd)
1599 if err != nil || len(paths) == 0 {
1600 return tui.CancelFilePickerMsg{}
1601 }
1602 return tui.FileSelectedMsg{Paths: paths}
1603 }
1604 }
1605 m.previousModel = m.current
1606 wd, _ := os.Getwd()
1607 m.current = tui.NewFilePicker(wd)
1608 m.current, _ = m.current.Update(m.currentWindowSize())
1609 return m, m.current.Init()
1610
1611 case tui.FileSelectedMsg, tui.CancelFilePickerMsg:
1612 if m.previousModel != nil {
1613 m.current = m.previousModel
1614 m.previousModel = nil
1615 }
1616 m.current, cmd = m.current.Update(msg)
1617 cmds = append(cmds, cmd)
1618
1619 case tui.SendEmailMsg:
1620 if m.plugins != nil {
1621 m.plugins.CallSendHook(plugin.HookEmailSendBefore, msg.To, msg.Cc, msg.Subject, msg.AccountID)
1622 }
1623 // Get draft ID before clearing composer (if it's a composer)
1624 var draftID string
1625 if composer, ok := m.current.(*tui.Composer); ok {
1626 draftID = composer.GetDraftID()
1627 }
1628 // Get the account to send from
1629 var account *config.Account
1630 if msg.AccountID != "" && m.config != nil {
1631 account = m.config.GetAccountByID(msg.AccountID)
1632 }
1633 if account == nil && m.config != nil {
1634 account = m.config.GetFirstAccount()
1635 }
1636
1637 statusText := "Sending email..."
1638 if msg.SignPGP && account != nil && account.PGPKeySource == "yubikey" {
1639 statusText = "Touch your YubiKey to sign..."
1640 }
1641 m.current = tui.NewStatus(statusText)
1642
1643 // Save contact and delete draft in background
1644 go func() {
1645 // Save the recipient as a contact
1646 if msg.To != "" {
1647 recipients := strings.Split(msg.To, ",")
1648 for _, r := range recipients {
1649 r = strings.TrimSpace(r)
1650 if r == "" {
1651 continue
1652 }
1653 name, email := parseEmailAddress(r)
1654 if err := config.AddContactForAccount(name, email, msg.AccountID); err != nil {
1655 log.Printf("Error saving contact: %v", err)
1656 }
1657 }
1658 }
1659 // Delete the draft since email is being sent
1660 if draftID != "" {
1661 if err := config.DeleteDraft(draftID); err != nil {
1662 log.Printf("Error deleting draft after send: %v", err)
1663 }
1664 }
1665 }()
1666
1667 return m, tea.Batch(m.current.Init(), sendEmail(account, msg))
1668
1669 case tui.SendRSVPMsg:
1670 account := m.config.GetAccountByID(msg.AccountID)
1671 if account == nil {
1672 m.current = tui.NewStatus("Error: account not found")
1673 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1674 return tui.RestoreViewMsg{}
1675 })
1676 }
1677
1678 m.current = tui.NewStatus("Sending RSVP...")
1679 return m, tea.Batch(m.current.Init(), sendRSVP(account, msg))
1680
1681 case tui.RSVPResultMsg:
1682 if msg.Err != nil {
1683 log.Printf("Failed to send RSVP: %v", msg.Err)
1684 m.previousModel = tui.NewChoice()
1685 m.previousModel, _ = m.previousModel.Update(m.currentWindowSize())
1686 m.current = tui.NewStatus(fmt.Sprintf("RSVP error: %v", msg.Err))
1687 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1688 return tui.RestoreViewMsg{}
1689 })
1690 }
1691 status := fmt.Sprintf("RSVP sent: %s", msg.Response)
1692 if strings.HasSuffix(strings.ToLower(msg.Organizer), "@gmail.com") || strings.HasSuffix(strings.ToLower(msg.Organizer), "@googlemail.com") {
1693 status += " (Google Calendar may not auto-update — use Gmail buttons for Google events)"
1694 }
1695 m.current = tui.NewStatus(status)
1696 return m, tea.Tick(3*time.Second, func(t time.Time) tea.Msg {
1697 return tui.RestoreViewMsg{}
1698 })
1699
1700 case tui.EmailResultMsg:
1701 if msg.Err != nil {
1702 log.Printf("Failed to send email: %v", msg.Err)
1703 m.previousModel = tui.NewChoice()
1704 m.previousModel, _ = m.previousModel.Update(m.currentWindowSize())
1705 m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
1706 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1707 return tui.RestoreViewMsg{}
1708 })
1709 }
1710 if m.plugins != nil {
1711 m.plugins.CallHook(plugin.HookEmailSendAfter)
1712 }
1713 m.current = tui.NewChoice()
1714 m.current, _ = m.current.Update(m.currentWindowSize())
1715 return m, m.current.Init()
1716
1717 case tui.DeleteEmailMsg:
1718 tui.ClearKittyGraphics()
1719 m.previousModel = m.current
1720 m.current = tui.NewStatus("Deleting email...")
1721
1722 account := m.config.GetAccountByID(msg.AccountID)
1723 if account == nil {
1724 if m.folderInbox != nil {
1725 m.current = m.folderInbox
1726 }
1727 return m, nil
1728 }
1729
1730 folderName := "INBOX"
1731 if m.folderInbox != nil {
1732 folderName = m.folderInbox.GetCurrentFolder()
1733 }
1734 return m, tea.Batch(m.current.Init(), deleteFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
1735
1736 case tui.ArchiveEmailMsg:
1737 tui.ClearKittyGraphics()
1738 m.previousModel = m.current
1739 m.current = tui.NewStatus("Archiving email...")
1740
1741 account := m.config.GetAccountByID(msg.AccountID)
1742 if account == nil {
1743 if m.folderInbox != nil {
1744 m.current = m.folderInbox
1745 }
1746 return m, nil
1747 }
1748
1749 folderName := "INBOX"
1750 if m.folderInbox != nil {
1751 folderName = m.folderInbox.GetCurrentFolder()
1752 }
1753 return m, tea.Batch(m.current.Init(), archiveFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
1754
1755 case tui.EmailMarkedReadMsg:
1756 if msg.Err != nil {
1757 log.Printf("Error marking email as read: %v", msg.Err)
1758 }
1759 m.syncUnreadBadge()
1760 return m, nil
1761
1762 case tui.EmailMarkedUnreadMsg:
1763 if msg.Err != nil {
1764 log.Printf("Error marking email as unread: %v", msg.Err)
1765 }
1766 m.syncUnreadBadge()
1767 return m, nil
1768
1769 case tui.EmailActionDoneMsg:
1770 if msg.Err != nil {
1771 log.Printf("Action failed: %v", msg.Err)
1772 if m.folderInbox != nil {
1773 m.previousModel = m.folderInbox
1774 }
1775 m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
1776 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1777 return tui.RestoreViewMsg{}
1778 })
1779 }
1780
1781 // Remove email from stores
1782 m.removeEmailFromStores(msg.UID, msg.AccountID)
1783
1784 if m.folderInbox != nil {
1785 m.folderInbox.RemoveEmail(msg.UID, msg.AccountID)
1786 m.current = m.folderInbox
1787 m.current, _ = m.current.Update(m.currentWindowSize())
1788 return m, m.current.Init()
1789 }
1790 m.current = tui.NewChoice()
1791 m.current, _ = m.current.Update(m.currentWindowSize())
1792 return m, m.current.Init()
1793
1794 case tui.BatchDeleteEmailsMsg:
1795 tui.ClearKittyGraphics()
1796 m.previousModel = m.current
1797 count := len(msg.UIDs)
1798 m.current = tui.NewStatus(fmt.Sprintf("Deleting %d emails...", count))
1799
1800 account := m.config.GetAccountByID(msg.AccountID)
1801 if account == nil {
1802 if m.folderInbox != nil {
1803 m.current = m.folderInbox
1804 }
1805 return m, nil
1806 }
1807
1808 folderName := "INBOX"
1809 if m.folderInbox != nil {
1810 folderName = m.folderInbox.GetCurrentFolder()
1811 }
1812
1813 return m, tea.Batch(
1814 m.current.Init(),
1815 m.batchDeleteEmailsCmd(account, msg.UIDs, msg.AccountID, folderName, msg.Mailbox, count),
1816 )
1817
1818 case tui.BatchArchiveEmailsMsg:
1819 tui.ClearKittyGraphics()
1820 m.previousModel = m.current
1821 count := len(msg.UIDs)
1822 m.current = tui.NewStatus(fmt.Sprintf("Archiving %d emails...", count))
1823
1824 account := m.config.GetAccountByID(msg.AccountID)
1825 if account == nil {
1826 if m.folderInbox != nil {
1827 m.current = m.folderInbox
1828 }
1829 return m, nil
1830 }
1831
1832 folderName := "INBOX"
1833 if m.folderInbox != nil {
1834 folderName = m.folderInbox.GetCurrentFolder()
1835 }
1836
1837 return m, tea.Batch(
1838 m.current.Init(),
1839 m.batchArchiveEmailsCmd(account, msg.UIDs, msg.AccountID, folderName, msg.Mailbox, count),
1840 )
1841
1842 case tui.BatchMoveEmailsMsg:
1843 if m.config == nil {
1844 return m, nil
1845 }
1846 account := m.config.GetAccountByID(msg.AccountID)
1847 if account == nil {
1848 return m, nil
1849 }
1850
1851 count := len(msg.UIDs)
1852 m.previousModel = m.current
1853 m.current = tui.NewStatus(fmt.Sprintf("Moving %d emails...", count))
1854
1855 return m, tea.Batch(
1856 m.current.Init(),
1857 m.batchMoveEmailsCmd(account, msg.UIDs, msg.AccountID, msg.SourceFolder, msg.DestFolder, count),
1858 )
1859
1860 case tui.BatchEmailActionDoneMsg:
1861 if msg.Err != nil {
1862 log.Printf("Batch %s failed: %v", msg.Action, msg.Err)
1863 m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
1864 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1865 return tui.RestoreViewMsg{}
1866 })
1867 }
1868
1869 // Success - show brief confirmation
1870 successMsg := fmt.Sprintf("%d emails %sd successfully", msg.SuccessCount, msg.Action)
1871 if msg.FailureCount > 0 {
1872 successMsg = fmt.Sprintf("%d of %d emails %sd (%d failed)",
1873 msg.SuccessCount, msg.Count, msg.Action, msg.FailureCount)
1874 }
1875
1876 m.current = tui.NewStatus(successMsg)
1877
1878 return m, tea.Tick(1500*time.Millisecond, func(t time.Time) tea.Msg {
1879 return tui.RestoreViewMsg{}
1880 })
1881
1882 case tui.DownloadAttachmentMsg:
1883 m.previousModel = m.current
1884 m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename))
1885
1886 account := m.config.GetAccountByID(msg.AccountID)
1887 if account == nil {
1888 m.current = m.previousModel
1889 return m, nil
1890 }
1891
1892 email := m.getEmailByIndex(msg.Index, msg.Mailbox)
1893 if email == nil {
1894 m.current = m.previousModel
1895 return m, nil
1896 }
1897
1898 // Find the correct attachment to get encoding
1899 var encoding string
1900 for _, att := range email.Attachments {
1901 if att.PartID == msg.PartID {
1902 encoding = att.Encoding
1903 break
1904 }
1905 }
1906 newMsg := tui.DownloadAttachmentMsg{
1907 Index: msg.Index,
1908 Filename: msg.Filename,
1909 PartID: msg.PartID,
1910 Data: msg.Data,
1911 AccountID: msg.AccountID,
1912 Encoding: encoding,
1913 Mailbox: msg.Mailbox,
1914 }
1915 return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(account, email.UID, newMsg))
1916
1917 case tui.AttachmentDownloadedMsg:
1918 var statusMsg string
1919 if msg.Err != nil {
1920 statusMsg = fmt.Sprintf("Error downloading: %v", msg.Err)
1921 } else {
1922 statusMsg = fmt.Sprintf("Saved to %s", msg.Path)
1923 }
1924 m.current = tui.NewStatus(statusMsg)
1925 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1926 return tui.RestoreViewMsg{}
1927 })
1928
1929 case tui.RestoreViewMsg:
1930 if m.previousModel != nil {
1931 m.current = m.previousModel
1932 m.previousModel = nil
1933 }
1934 return m, nil
1935 }
1936
1937 if cmd := m.pluginNotifyCmd(); cmd != nil {
1938 cmds = append(cmds, cmd)
1939 }
1940
1941 return m, tea.Batch(cmds...)
1942}
1943
1944func (m *mainModel) View() tea.View {
1945 v := m.current.View()
1946 if m.showLogPanel {
1947 v.Content = m.renderWithLogPanel(v.Content)
1948 }
1949 v.AltScreen = true
1950 return v
1951}
1952
1953func (m *mainModel) currentWindowSize() tea.WindowSizeMsg {
1954 return tea.WindowSizeMsg{
1955 Width: m.width,
1956 Height: m.contentHeight(),
1957 }
1958}
1959
1960func (m *mainModel) contentHeight() int {
1961 height := m.height - m.logPanelHeight()
1962 if height < 1 {
1963 return 1
1964 }
1965 return height
1966}
1967
1968func (m *mainModel) renderWithLogPanel(content string) string {
1969 panelHeight := m.logPanelHeight()
1970 if panelHeight == 0 {
1971 return content
1972 }
1973
1974 contentHeight := m.contentHeight()
1975
1976 mainContent := lipgloss.NewStyle().
1977 MaxHeight(contentHeight).
1978 Height(contentHeight).
1979 Render(content)
1980
1981 if m.logPanel == nil {
1982 return mainContent
1983 }
1984 m.logPanel.SetSize(m.width, panelHeight)
1985 return lipgloss.JoinVertical(lipgloss.Left, mainContent, m.logPanel.View())
1986}
1987
1988func (m *mainModel) logPanelHeight() int {
1989 if !m.showLogPanel || m.height < 12 || m.width < 20 {
1990 return 0
1991 }
1992 if m.height < 20 {
1993 return 4
1994 }
1995 return 7
1996}
1997
1998func (m *mainModel) getEmailByIndex(index int, mailbox tui.MailboxKind) *fetcher.Email {
1999 if index >= 0 && index < len(m.emails) {
2000 return &m.emails[index]
2001 }
2002 return nil
2003}
2004
2005func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string, mailbox tui.MailboxKind) *fetcher.Email {
2006 for i := range m.emails {
2007 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
2008 return &m.emails[i]
2009 }
2010 }
2011 return nil
2012}
2013
2014func (m *mainModel) getEmailIndex(uid uint32, accountID string, mailbox tui.MailboxKind) int {
2015 for i := range m.emails {
2016 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
2017 return i
2018 }
2019 }
2020 return -1
2021}
2022
2023func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, mailbox tui.MailboxKind, body, bodyMIMEType string, attachments []fetcher.Attachment) {
2024 for i := range m.emails {
2025 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
2026 m.emails[i].Body = body
2027 m.emails[i].BodyMIMEType = bodyMIMEType
2028 m.emails[i].Attachments = attachments
2029 break
2030 }
2031 }
2032 if emails, ok := m.emailsByAcct[accountID]; ok {
2033 for i := range emails {
2034 if emails[i].UID == uid {
2035 emails[i].Body = body
2036 emails[i].BodyMIMEType = bodyMIMEType
2037 emails[i].Attachments = attachments
2038 break
2039 }
2040 }
2041 }
2042}
2043
2044func (m *mainModel) addEmailToStoresIfMissing(email fetcher.Email, mailbox tui.MailboxKind) {
2045 if m.getEmailByUIDAndAccount(email.UID, email.AccountID, mailbox) != nil {
2046 return
2047 }
2048 if m.emailsByAcct == nil {
2049 m.emailsByAcct = make(map[string][]fetcher.Email)
2050 }
2051 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
2052 m.emails = flattenAndSort(m.emailsByAcct)
2053}
2054
2055func (m *mainModel) markEmailAsReadInStores(uid uint32, accountID string) {
2056 for i := range m.emails {
2057 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
2058 m.emails[i].IsRead = true
2059 break
2060 }
2061 }
2062 if emails, ok := m.emailsByAcct[accountID]; ok {
2063 for i := range emails {
2064 if emails[i].UID == uid {
2065 emails[i].IsRead = true
2066 break
2067 }
2068 }
2069 }
2070 // Update folder email cache
2071 for folderName, folderEmails := range m.folderEmails {
2072 for i := range folderEmails {
2073 if folderEmails[i].UID == uid && folderEmails[i].AccountID == accountID {
2074 folderEmails[i].IsRead = true
2075 m.folderEmails[folderName] = folderEmails
2076 go saveFolderEmailsToCache(folderName, folderEmails)
2077 break
2078 }
2079 }
2080 }
2081 // Update the inbox UI
2082 if m.folderInbox != nil {
2083 m.folderInbox.GetInbox().MarkEmailAsRead(uid, accountID)
2084 }
2085}
2086
2087func (m *mainModel) markEmailAsUnreadInStores(uid uint32, accountID string) {
2088 for i := range m.emails {
2089 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
2090 m.emails[i].IsRead = false
2091 break
2092 }
2093 }
2094 if emails, ok := m.emailsByAcct[accountID]; ok {
2095 for i := range emails {
2096 if emails[i].UID == uid {
2097 emails[i].IsRead = false
2098 break
2099 }
2100 }
2101 }
2102 for folderName, folderEmails := range m.folderEmails {
2103 for i := range folderEmails {
2104 if folderEmails[i].UID == uid && folderEmails[i].AccountID == accountID {
2105 folderEmails[i].IsRead = false
2106 m.folderEmails[folderName] = folderEmails
2107 go saveFolderEmailsToCache(folderName, folderEmails)
2108 break
2109 }
2110 }
2111 }
2112 if m.folderInbox != nil {
2113 m.folderInbox.GetInbox().MarkEmailAsUnread(uid, accountID)
2114 }
2115}
2116
2117func (m *mainModel) removeEmailFromStores(uid uint32, accountID string) {
2118 var filtered []fetcher.Email
2119 for _, e := range m.emails {
2120 if !(e.UID == uid && e.AccountID == accountID) {
2121 filtered = append(filtered, e)
2122 }
2123 }
2124 m.emails = filtered
2125 if emails, ok := m.emailsByAcct[accountID]; ok {
2126 var filteredAcct []fetcher.Email
2127 for _, e := range emails {
2128 if e.UID != uid {
2129 filteredAcct = append(filteredAcct, e)
2130 }
2131 }
2132 m.emailsByAcct[accountID] = filteredAcct
2133 }
2134}
2135
2136// pluginFlagCmds drains pending flag ops from plugins and returns the corresponding tea.Cmds.
2137func (m *mainModel) pluginFlagCmds() []tea.Cmd {
2138 if m.plugins == nil {
2139 return nil
2140 }
2141 ops := m.plugins.TakePendingFlagOps()
2142 if len(ops) == 0 {
2143 return nil
2144 }
2145 var cmds []tea.Cmd
2146 for _, op := range ops {
2147 op := op
2148 account := m.config.GetAccountByID(op.AccountID)
2149 if account == nil {
2150 continue
2151 }
2152 if op.Read {
2153 m.markEmailAsReadInStores(op.UID, op.AccountID)
2154 cmds = append(cmds, markEmailAsReadCmd(account, op.UID, op.AccountID, op.Folder))
2155 } else {
2156 m.markEmailAsUnreadInStores(op.UID, op.AccountID)
2157 cmds = append(cmds, markEmailAsUnreadCmd(account, op.UID, op.AccountID, op.Folder))
2158 }
2159 }
2160 return cmds
2161}
2162
2163// pluginNotifyCmd checks for a pending plugin notification and returns a command if one exists.
2164func (m *mainModel) pluginNotifyCmd() tea.Cmd {
2165 if m.plugins == nil {
2166 return nil
2167 }
2168 if n, ok := m.plugins.TakePendingNotification(); ok {
2169 return func() tea.Msg {
2170 return tui.PluginNotifyMsg{Message: n.Message, Duration: n.Duration}
2171 }
2172 }
2173 return nil
2174}
2175
2176func (m *mainModel) syncPluginStatus() {
2177 if m.plugins == nil {
2178 return
2179 }
2180 if m.folderInbox != nil {
2181 m.folderInbox.GetInbox().SetPluginStatus(m.plugins.StatusText(plugin.StatusInbox))
2182 }
2183 switch v := m.current.(type) {
2184 case *tui.Composer:
2185 v.SetPluginStatus(m.plugins.StatusText(plugin.StatusComposer))
2186 case *tui.EmailView:
2187 v.SetPluginStatus(m.plugins.StatusText(plugin.StatusEmailView))
2188 }
2189}
2190
2191func (m *mainModel) handlePluginKeyBinding(msg tea.KeyPressMsg) tea.Cmd {
2192 keyStr := msg.String()
2193
2194 var area string
2195 switch m.current.(type) {
2196 case *tui.Inbox:
2197 area = plugin.StatusInbox
2198 case *tui.FolderInbox:
2199 area = plugin.StatusInbox
2200 case *tui.EmailView:
2201 area = plugin.StatusEmailView
2202 case *tui.Composer:
2203 area = plugin.StatusComposer
2204 default:
2205 return nil
2206 }
2207
2208 bindings := m.plugins.Bindings(area)
2209 for _, binding := range bindings {
2210 if binding.Key != keyStr {
2211 continue
2212 }
2213
2214 // Build context table based on the current view
2215 switch v := m.current.(type) {
2216 case *tui.Inbox:
2217 if email := v.GetSelectedEmail(); email != nil {
2218 t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, "")
2219 m.plugins.CallKeyBinding(binding, t)
2220 } else {
2221 m.plugins.CallKeyBinding(binding)
2222 }
2223 case *tui.FolderInbox:
2224 if email := v.GetInbox().GetSelectedEmail(); email != nil {
2225 t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, v.GetCurrentFolder())
2226 m.plugins.CallKeyBinding(binding, t)
2227 } else {
2228 m.plugins.CallKeyBinding(binding)
2229 }
2230 case *tui.EmailView:
2231 email := v.GetEmail()
2232 t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, "")
2233 m.plugins.CallKeyBinding(binding, t)
2234 case *tui.Composer:
2235 L := m.plugins.LuaState()
2236 t := L.NewTable()
2237 t.RawSetString("body", lua.LString(v.GetBody()))
2238 t.RawSetString("body_len", lua.LNumber(len(v.GetBody())))
2239 t.RawSetString("subject", lua.LString(v.GetSubject()))
2240 t.RawSetString("to", lua.LString(v.GetTo()))
2241 t.RawSetString("cc", lua.LString(v.GetCc()))
2242 t.RawSetString("bcc", lua.LString(v.GetBcc()))
2243 m.plugins.CallKeyBinding(binding, t)
2244 m.applyPluginFields(v)
2245
2246 // Check if the plugin requested a prompt overlay
2247 if p, ok := m.plugins.TakePendingPrompt(); ok {
2248 m.pendingPrompt = p
2249 v.ShowPluginPrompt(p.Placeholder)
2250 }
2251 }
2252
2253 m.syncPluginStatus()
2254 return tea.Batch(m.pluginFlagCmds()...)
2255 }
2256 return nil
2257}
2258
2259func (m *mainModel) syncPluginKeyBindings() {
2260 if m.plugins == nil {
2261 return
2262 }
2263
2264 toPluginKeyBindings := func(bindings []plugin.KeyBinding) []tui.PluginKeyBinding {
2265 result := make([]tui.PluginKeyBinding, len(bindings))
2266 for i, b := range bindings {
2267 result[i] = tui.PluginKeyBinding{Key: b.Key, Description: b.Description}
2268 }
2269 return result
2270 }
2271
2272 if m.folderInbox != nil {
2273 m.folderInbox.GetInbox().SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusInbox)))
2274 }
2275 switch v := m.current.(type) {
2276 case *tui.Composer:
2277 v.SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusComposer)))
2278 case *tui.EmailView:
2279 v.SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusEmailView)))
2280 }
2281}
2282
2283func (m *mainModel) applyPluginFields(composer *tui.Composer) {
2284 fields := m.plugins.TakePendingFields()
2285 if fields == nil {
2286 return
2287 }
2288 for field, value := range fields {
2289 switch field {
2290 case "to":
2291 composer.SetTo(value)
2292 case "cc":
2293 composer.SetCc(value)
2294 case "bcc":
2295 composer.SetBcc(value)
2296 case "subject":
2297 composer.SetSubject(value)
2298 case "body":
2299 composer.SetBody(value)
2300 }
2301 }
2302}
2303
2304func flattenAndSort(emailsByAccount map[string][]fetcher.Email) []fetcher.Email {
2305 var allEmails []fetcher.Email
2306 for _, emails := range emailsByAccount {
2307 allEmails = append(allEmails, emails...)
2308 }
2309 for i := 0; i < len(allEmails); i++ {
2310 for j := i + 1; j < len(allEmails); j++ {
2311 if allEmails[j].Date.After(allEmails[i].Date) {
2312 allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
2313 }
2314 }
2315 }
2316 return allEmails
2317}
2318
2319func fetchAllAccountsEmails(cfg *config.Config, mailbox tui.MailboxKind) tea.Cmd {
2320 return func() tea.Msg {
2321 emailsByAccount := make(map[string][]fetcher.Email)
2322 var mu sync.Mutex
2323 var wg sync.WaitGroup
2324
2325 for _, account := range cfg.Accounts {
2326 wg.Add(1)
2327 go func(acc config.Account) {
2328 defer wg.Done()
2329 var emails []fetcher.Email
2330 var err error
2331 switch mailbox {
2332 case tui.MailboxSent:
2333 emails, err = fetcher.FetchSentEmails(&acc, initialEmailLimit, 0)
2334 case tui.MailboxTrash:
2335 emails, err = fetcher.FetchTrashEmails(&acc, initialEmailLimit, 0)
2336 case tui.MailboxArchive:
2337 emails, err = fetcher.FetchArchiveEmails(&acc, initialEmailLimit, 0)
2338 default:
2339 emails, err = fetcher.FetchEmails(&acc, initialEmailLimit, 0)
2340 }
2341 if err != nil {
2342 log.Printf("Error fetching from %s: %v", acc.Email, err)
2343 return
2344 }
2345 mu.Lock()
2346 emailsByAccount[acc.ID] = emails
2347 mu.Unlock()
2348 }(account)
2349 }
2350
2351 wg.Wait()
2352 return tui.AllEmailsFetchedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
2353 }
2354}
2355
2356func fetchEmails(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
2357 return func() tea.Msg {
2358 var emails []fetcher.Email
2359 var err error
2360 if mailbox == tui.MailboxSent {
2361 emails, err = fetcher.FetchSentEmails(account, limit, offset)
2362 } else {
2363 emails, err = fetcher.FetchEmails(account, limit, offset)
2364 }
2365 if err != nil {
2366 return tui.FetchErr(err)
2367 }
2368 if offset == 0 {
2369 return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
2370 }
2371 return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
2372 }
2373}
2374
2375func fetchEmailsForMailbox(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
2376 return func() tea.Msg {
2377 var emails []fetcher.Email
2378 var err error
2379 switch mailbox {
2380 case tui.MailboxSent:
2381 emails, err = fetcher.FetchSentEmails(account, limit, offset)
2382 case tui.MailboxTrash:
2383 emails, err = fetcher.FetchTrashEmails(account, limit, offset)
2384 case tui.MailboxArchive:
2385 emails, err = fetcher.FetchArchiveEmails(account, limit, offset)
2386 default:
2387 emails, err = fetcher.FetchEmails(account, limit, offset)
2388 }
2389 if err != nil {
2390 return tui.FetchErr(err)
2391 }
2392 if offset == 0 {
2393 return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
2394 }
2395 return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
2396 }
2397}
2398
2399func (m *mainModel) searchEmailsCmd(query backend.SearchQuery, folderName, accountID string) tea.Cmd {
2400 return func() tea.Msg {
2401 ctx, cancel := context.WithTimeout(context.Background(), httpclient.IMAPSearchTimeout)
2402 defer cancel()
2403
2404 var accounts []config.Account
2405 for _, acc := range m.config.Accounts {
2406 if accountID == "" || acc.ID == accountID {
2407 accounts = append(accounts, acc)
2408 }
2409 }
2410
2411 var results []fetcher.Email
2412 var firstErr error
2413 succeeded := false
2414 for i := range accounts {
2415 acc := &accounts[i]
2416 p := m.getProvider(acc)
2417 if p == nil {
2418 if firstErr == nil {
2419 firstErr = fmt.Errorf("provider not found for account %s", acc.ID)
2420 }
2421 continue
2422 }
2423 emails, err := p.Search(ctx, folderName, query)
2424 if err != nil {
2425 if errors.Is(err, backend.ErrNotSupported) {
2426 continue
2427 }
2428 if firstErr == nil {
2429 firstErr = err
2430 }
2431 continue
2432 }
2433 succeeded = true
2434 results = append(results, backendEmailsToFetcher(emails)...)
2435 }
2436 if !succeeded && firstErr != nil {
2437 return tui.SearchResultsMsg{Query: query, Err: firstErr}
2438 }
2439 sortFetcherEmails(results)
2440
2441 return tui.SearchResultsMsg{Query: query, Emails: results}
2442 }
2443}
2444
2445func backendEmailsToFetcher(emails []backend.Email) []fetcher.Email {
2446 result := make([]fetcher.Email, len(emails))
2447 for i, e := range emails {
2448 result[i] = fetcher.Email{
2449 UID: e.UID, From: e.From, To: e.To, ReplyTo: e.ReplyTo,
2450 Subject: e.Subject, Body: e.Body, Date: e.Date, IsRead: e.IsRead,
2451 MessageID: e.MessageID, References: e.References, AccountID: e.AccountID,
2452 }
2453 }
2454 return result
2455}
2456
2457func sortFetcherEmails(emails []fetcher.Email) {
2458 sort.Slice(emails, func(i, j int) bool {
2459 if emails[i].Date.Equal(emails[j].Date) {
2460 return emails[i].UID > emails[j].UID
2461 }
2462 return emails[i].Date.After(emails[j].Date)
2463 })
2464}
2465
2466func loadCachedEmails() tea.Cmd {
2467 return func() tea.Msg {
2468 cache, err := config.LoadEmailCache()
2469 if err != nil {
2470 return tui.CachedEmailsLoadedMsg{Cache: nil}
2471 }
2472 return tui.CachedEmailsLoadedMsg{Cache: cache}
2473 }
2474}
2475
2476func refreshEmails(cfg *config.Config, mailbox tui.MailboxKind, counts map[string]int) tea.Cmd {
2477 return func() tea.Msg {
2478 emailsByAccount := make(map[string][]fetcher.Email)
2479 var mu sync.Mutex
2480 var wg sync.WaitGroup
2481
2482 for _, account := range cfg.Accounts {
2483 wg.Add(1)
2484 go func(acc config.Account) {
2485 defer wg.Done()
2486 var emails []fetcher.Email
2487 var err error
2488
2489 limit := uint32(initialEmailLimit)
2490 if counts != nil {
2491 if c, ok := counts[acc.ID]; ok && c > 0 {
2492 limit = uint32(c)
2493 }
2494 }
2495
2496 if mailbox == tui.MailboxSent {
2497 emails, err = fetcher.FetchSentEmails(&acc, limit, 0)
2498 } else {
2499 emails, err = fetcher.FetchEmails(&acc, limit, 0)
2500 }
2501 if err != nil {
2502 log.Printf("Error fetching from %s: %v", acc.Email, err)
2503 return
2504 }
2505 mu.Lock()
2506 emailsByAccount[acc.ID] = emails
2507 mu.Unlock()
2508 }(account)
2509 }
2510
2511 wg.Wait()
2512 return tui.EmailsRefreshedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
2513 }
2514}
2515
2516func emailsToCache(emails []fetcher.Email) []config.CachedEmail {
2517 var cached []config.CachedEmail
2518 for _, email := range emails {
2519 cached = append(cached, config.CachedEmail{
2520 UID: email.UID,
2521 From: email.From,
2522 To: email.To,
2523 Subject: email.Subject,
2524 Date: email.Date,
2525 MessageID: email.MessageID,
2526 InReplyTo: email.InReplyTo,
2527 References: email.References,
2528 AccountID: email.AccountID,
2529 IsRead: email.IsRead,
2530 })
2531 }
2532 return cached
2533}
2534
2535func cacheToEmails(cached []config.CachedEmail) []fetcher.Email {
2536 var emails []fetcher.Email
2537 for _, c := range cached {
2538 emails = append(emails, fetcher.Email{
2539 UID: c.UID,
2540 From: c.From,
2541 To: c.To,
2542 Subject: c.Subject,
2543 Date: c.Date,
2544 MessageID: c.MessageID,
2545 InReplyTo: c.InReplyTo,
2546 References: c.References,
2547 AccountID: c.AccountID,
2548 IsRead: c.IsRead,
2549 })
2550 }
2551 return emails
2552}
2553
2554func saveFolderEmailsToCache(folderName string, emails []fetcher.Email) {
2555 cached := emailsToCache(emails)
2556 if err := config.SaveFolderEmailCache(folderName, cached); err != nil {
2557 log.Printf("Error saving folder email cache for %s: %v", folderName, err)
2558 }
2559}
2560
2561func loadFolderEmailsFromCache(folderName string) []fetcher.Email {
2562 cached, err := config.LoadFolderEmailCache(folderName)
2563 if err != nil {
2564 return nil
2565 }
2566 return cacheToEmails(cached)
2567}
2568
2569func saveEmailsToCache(emails []fetcher.Email) {
2570 if len(emails) > maxCacheEmails {
2571 emails = emails[:maxCacheEmails]
2572 }
2573 var cachedEmails []config.CachedEmail
2574 for _, email := range emails {
2575 cachedEmails = append(cachedEmails, config.CachedEmail{
2576 UID: email.UID,
2577 From: email.From,
2578 To: email.To,
2579 Subject: email.Subject,
2580 Date: email.Date,
2581 MessageID: email.MessageID,
2582 InReplyTo: email.InReplyTo,
2583 References: email.References,
2584 AccountID: email.AccountID,
2585 IsRead: email.IsRead,
2586 })
2587
2588 // Save sender as a contact
2589 if email.From != "" {
2590 name, emailAddr := parseEmailAddress(email.From)
2591 if err := config.AddContactForAccount(name, emailAddr, email.AccountID); err != nil {
2592 log.Printf("Error saving contact from email: %v", err)
2593 }
2594 }
2595 }
2596 cache := &config.EmailCache{Emails: cachedEmails}
2597 if err := config.SaveEmailCache(cache); err != nil {
2598 log.Printf("Error saving email cache: %v", err)
2599 }
2600}
2601
2602// parseEmailAddress parses "Name <email>" or just "email" format
2603func parseEmailAddress(addr string) (name, email string) {
2604 addr = strings.TrimSpace(addr)
2605 if idx := strings.Index(addr, "<"); idx != -1 {
2606 name = strings.TrimSpace(addr[:idx])
2607 endIdx := strings.Index(addr, ">")
2608 if endIdx > idx {
2609 email = strings.TrimSpace(addr[idx+1 : endIdx])
2610 } else {
2611 email = strings.TrimSpace(addr[idx+1:])
2612 }
2613 } else {
2614 email = addr
2615 }
2616 return name, email
2617}
2618
2619func fetchEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
2620 return func() tea.Msg {
2621 account := cfg.GetAccountByID(accountID)
2622 if account == nil {
2623 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
2624 }
2625
2626 var (
2627 body string
2628 bodyMIMEType string
2629 attachments []fetcher.Attachment
2630 err error
2631 )
2632 switch mailbox {
2633 case tui.MailboxSent:
2634 body, bodyMIMEType, attachments, err = fetcher.FetchSentEmailBody(account, uid)
2635 case tui.MailboxTrash:
2636 body, bodyMIMEType, attachments, err = fetcher.FetchTrashEmailBody(account, uid)
2637 case tui.MailboxArchive:
2638 body, bodyMIMEType, attachments, err = fetcher.FetchArchiveEmailBody(account, uid)
2639 default:
2640 body, bodyMIMEType, attachments, err = fetcher.FetchEmailBody(account, uid)
2641 }
2642 if err != nil {
2643 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2644 }
2645
2646 return tui.EmailBodyFetchedMsg{
2647 UID: uid,
2648 Body: body,
2649 BodyMIMEType: bodyMIMEType,
2650 Attachments: attachments,
2651 AccountID: accountID,
2652 Mailbox: mailbox,
2653 }
2654 }
2655}
2656
2657func markdownToHTML(md []byte) []byte {
2658 return clib.MarkdownToHTML(md)
2659}
2660
2661func splitEmails(s string) []string {
2662 if s == "" {
2663 return nil
2664 }
2665 parts := strings.Split(s, ",")
2666 var res []string
2667 for _, p := range parts {
2668 if trimmed := strings.TrimSpace(p); trimmed != "" {
2669 res = append(res, trimmed)
2670 }
2671 }
2672 return res
2673}
2674
2675func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
2676 return func() tea.Msg {
2677 if account == nil {
2678 return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
2679 }
2680
2681 // Apply custom From address for catch-all accounts.
2682 if msg.FromOverride != "" {
2683 acc := *account
2684 acc.SendAsEmail = msg.FromOverride
2685 account = &acc
2686 }
2687
2688 recipients := splitEmails(msg.To)
2689 cc := splitEmails(msg.Cc)
2690 bcc := splitEmails(msg.Bcc)
2691 body := msg.Body
2692 // Append signature if present
2693 if msg.Signature != "" {
2694 body = body + "\n\n" + msg.Signature
2695 }
2696 // Append quoted text if present (for replies)
2697 if msg.QuotedText != "" {
2698 body = body + msg.QuotedText
2699 }
2700 images := make(map[string][]byte)
2701 attachments := make(map[string][]byte)
2702
2703 re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
2704 matches := re.FindAllStringSubmatch(body, -1)
2705
2706 for _, match := range matches {
2707 imgPath := match[1]
2708 imgData, err := os.ReadFile(imgPath)
2709 if err != nil {
2710 log.Printf("Could not read image file %s: %v", imgPath, err)
2711 continue
2712 }
2713 cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
2714 images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
2715 body = strings.Replace(body, imgPath, "cid:"+cid, 1)
2716 }
2717
2718 htmlBody := markdownToHTML([]byte(body))
2719
2720 for _, attachPath := range msg.AttachmentPaths {
2721 fileData, err := os.ReadFile(attachPath)
2722 if err != nil {
2723 log.Printf("Could not read attachment file %s: %v", attachPath, err)
2724 continue
2725 }
2726 _, filename := filepath.Split(attachPath)
2727 attachments[filename] = fileData
2728 }
2729
2730 rawMsg, err := sender.SendEmail(account, recipients, cc, bcc, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References, msg.SignSMIME, msg.EncryptSMIME, msg.SignPGP, false)
2731 if err != nil {
2732 log.Printf("Failed to send email: %v", err)
2733 return tui.EmailResultMsg{Err: err}
2734 }
2735
2736 // Append to Sent folder via IMAP (Gmail auto-saves, so skip it)
2737 if account.ServiceProvider != "gmail" {
2738 if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
2739 log.Printf("Failed to append sent message to Sent folder: %v", err)
2740 }
2741 }
2742
2743 return tui.EmailResultMsg{}
2744 }
2745}
2746
2747func sendRSVP(account *config.Account, msg tui.SendRSVPMsg) tea.Cmd {
2748 return func() tea.Msg {
2749 if account == nil {
2750 return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
2751 }
2752
2753 // Generate RSVP .ics
2754 rsvpICS, err := calendar.GenerateRSVP(msg.OriginalICS, account.Email, msg.Response)
2755 if err != nil {
2756 return tui.EmailResultMsg{Err: fmt.Errorf("generate RSVP: %w", err)}
2757 }
2758
2759 // Compose reply email
2760 subject := fmt.Sprintf("Re: %s", msg.Event.Summary)
2761 bodyText := fmt.Sprintf("%s: %s\n\n%s",
2762 msg.Response,
2763 msg.Event.Summary,
2764 msg.Event.Start.Local().Format("Mon Jan 2, 2006 3:04 PM"))
2765 if msg.Event.Location != "" {
2766 bodyText += " at " + msg.Event.Location
2767 }
2768
2769 // Send as multipart/alternative with text/calendar; method=REPLY
2770 // This iMIP format is required for Google Calendar to recognize the RSVP
2771 references := append(msg.References, msg.InReplyTo)
2772 rawMsg, err := sender.SendCalendarReply(
2773 account,
2774 []string{msg.Event.Organizer},
2775 subject,
2776 bodyText,
2777 rsvpICS,
2778 msg.InReplyTo,
2779 references,
2780 )
2781
2782 if err != nil {
2783 return tui.RSVPResultMsg{Err: fmt.Errorf("send RSVP: %w", err), Response: msg.Response, Organizer: msg.Event.Organizer}
2784 }
2785
2786 // Append to Sent folder
2787 if account.ServiceProvider != "gmail" {
2788 if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
2789 log.Printf("Failed to append RSVP to Sent folder: %v", err)
2790 }
2791 }
2792
2793 return tui.RSVPResultMsg{Response: msg.Response, Organizer: msg.Event.Organizer}
2794 }
2795}
2796
2797func deleteEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
2798 return func() tea.Msg {
2799 var err error
2800 switch mailbox {
2801 case tui.MailboxSent:
2802 err = fetcher.DeleteSentEmail(account, uid)
2803 case tui.MailboxTrash:
2804 err = fetcher.DeleteTrashEmail(account, uid)
2805 case tui.MailboxArchive:
2806 err = fetcher.DeleteArchiveEmail(account, uid)
2807 default:
2808 err = fetcher.DeleteEmail(account, uid)
2809 }
2810 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2811 }
2812}
2813
2814func archiveEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
2815 return func() tea.Msg {
2816 var err error
2817 if mailbox == tui.MailboxSent {
2818 err = fetcher.ArchiveSentEmail(account, uid)
2819 } else {
2820 err = fetcher.ArchiveEmail(account, uid)
2821 }
2822 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2823 }
2824}
2825
2826// --- External editor command ---
2827
2828// openExternalEditor writes the body to a temp file, opens $EDITOR, and reads back the result.
2829func openExternalEditor(body string) tea.Cmd {
2830 editor := os.Getenv("EDITOR")
2831 if editor == "" {
2832 editor = os.Getenv("VISUAL")
2833 }
2834 if editor == "" {
2835 editor = "vi"
2836 }
2837
2838 tmpFile, err := os.CreateTemp("", "matcha-*.md")
2839 if err != nil {
2840 return func() tea.Msg {
2841 return tui.EditorFinishedMsg{Err: fmt.Errorf("creating temp file: %w", err)}
2842 }
2843 }
2844 tmpPath := tmpFile.Name()
2845
2846 if _, err := tmpFile.WriteString(body); err != nil {
2847 tmpFile.Close()
2848 os.Remove(tmpPath)
2849 return func() tea.Msg {
2850 return tui.EditorFinishedMsg{Err: fmt.Errorf("writing temp file: %w", err)}
2851 }
2852 }
2853 tmpFile.Close()
2854
2855 parts := strings.Fields(editor)
2856 args := append(parts[1:], tmpPath)
2857 c := exec.Command(parts[0], args...)
2858 return tea.ExecProcess(c, func(err error) tea.Msg {
2859 defer os.Remove(tmpPath)
2860 if err != nil {
2861 return tui.EditorFinishedMsg{Err: err}
2862 }
2863 content, readErr := os.ReadFile(tmpPath)
2864 if readErr != nil {
2865 return tui.EditorFinishedMsg{Err: readErr}
2866 }
2867 return tui.EditorFinishedMsg{Body: string(content)}
2868 })
2869}
2870
2871// --- IDLE command ---
2872
2873// listenForIdleUpdates blocks until an IDLE update arrives, then returns it as a tea.Msg.
2874func listenForIdleUpdates(ch <-chan fetcher.IdleUpdate) tea.Cmd {
2875 return func() tea.Msg {
2876 update, ok := <-ch
2877 if !ok {
2878 return nil
2879 }
2880 return tui.IdleNewMailMsg{
2881 AccountID: update.AccountID,
2882 FolderName: update.FolderName,
2883 }
2884 }
2885}
2886
2887// --- Daemon event listener ---
2888
2889// listenForDaemonEvents blocks until a daemon event arrives, then returns it as a tea.Msg.
2890func listenForDaemonEvents(ch <-chan *daemonrpc.Event) tea.Cmd {
2891 return func() tea.Msg {
2892 ev, ok := <-ch
2893 if !ok {
2894 return nil
2895 }
2896 return tui.DaemonEventMsg{Event: ev}
2897 }
2898}
2899
2900// --- Folder-based command functions ---
2901
2902func fetchFoldersCmd(cfg *config.Config) tea.Cmd {
2903 return func() tea.Msg {
2904 if !cfg.HasAccounts() {
2905 return nil
2906 }
2907 foldersByAccount := make(map[string][]fetcher.Folder)
2908 errsByAccount := make(map[string]error)
2909 seen := make(map[string]fetcher.Folder)
2910 var mu sync.Mutex
2911 var wg sync.WaitGroup
2912
2913 for _, account := range cfg.Accounts {
2914 wg.Add(1)
2915 go func(acc config.Account) {
2916 defer wg.Done()
2917 folders, err := fetcher.FetchFolders(&acc)
2918 if err != nil {
2919 mu.Lock()
2920 errsByAccount[acc.ID] = err
2921 mu.Unlock()
2922 return
2923 }
2924 mu.Lock()
2925 foldersByAccount[acc.ID] = folders
2926 for _, f := range folders {
2927 if _, ok := seen[f.Name]; !ok {
2928 seen[f.Name] = f
2929 }
2930 }
2931 mu.Unlock()
2932 }(account)
2933 }
2934 wg.Wait()
2935
2936 var merged []fetcher.Folder
2937 for _, f := range seen {
2938 merged = append(merged, f)
2939 }
2940
2941 return tui.FoldersFetchedMsg{
2942 FoldersByAccount: foldersByAccount,
2943 MergedFolders: merged,
2944 Errors: errsByAccount,
2945 }
2946 }
2947}
2948
2949func fetchFolderEmailsCmd(cfg *config.Config, folderName string) tea.Cmd {
2950 return func() tea.Msg {
2951 emailsByAccount := make(map[string][]fetcher.Email)
2952 var mu sync.Mutex
2953 var wg sync.WaitGroup
2954
2955 for _, account := range cfg.Accounts {
2956 wg.Add(1)
2957 go func(acc config.Account) {
2958 defer wg.Done()
2959 emails, err := fetcher.FetchFolderEmails(&acc, folderName, initialEmailLimit, 0)
2960 if err != nil {
2961 // Folder may not exist for this account — silently skip
2962 return
2963 }
2964 mu.Lock()
2965 emailsByAccount[acc.ID] = emails
2966 mu.Unlock()
2967 }(account)
2968 }
2969
2970 wg.Wait()
2971
2972 // Flatten all account emails
2973 var allEmails []fetcher.Email
2974 for _, emails := range emailsByAccount {
2975 allEmails = append(allEmails, emails...)
2976 }
2977 // Sort newest first
2978 for i := 0; i < len(allEmails); i++ {
2979 for j := i + 1; j < len(allEmails); j++ {
2980 if allEmails[j].Date.After(allEmails[i].Date) {
2981 allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
2982 }
2983 }
2984 }
2985
2986 return tui.FolderEmailsFetchedMsg{
2987 Emails: allEmails,
2988 FolderName: folderName,
2989 }
2990 }
2991}
2992
2993func fetchFolderEmailsPaginatedCmd(account *config.Account, folderName string, limit, offset uint32) tea.Cmd {
2994 return func() tea.Msg {
2995 emails, err := fetcher.FetchFolderEmails(account, folderName, limit, offset)
2996 if err != nil {
2997 return tui.FetchErr(err)
2998 }
2999 return tui.FolderEmailsAppendedMsg{
3000 Emails: emails,
3001 AccountID: account.ID,
3002 FolderName: folderName,
3003 }
3004 }
3005}
3006
3007func fetchFolderEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
3008 return func() tea.Msg {
3009 account := cfg.GetAccountByID(accountID)
3010 if account == nil {
3011 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
3012 }
3013
3014 body, bodyMIMEType, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid)
3015 if err != nil {
3016 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
3017 }
3018
3019 return tui.EmailBodyFetchedMsg{
3020 UID: uid,
3021 Body: body,
3022 BodyMIMEType: bodyMIMEType,
3023 Attachments: attachments,
3024 AccountID: accountID,
3025 Mailbox: mailbox,
3026 }
3027 }
3028}
3029
3030func fetchPreviewBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string) tea.Cmd {
3031 return func() tea.Msg {
3032 account := cfg.GetAccountByID(accountID)
3033 if account == nil {
3034 return tui.PreviewBodyFetchedMsg{UID: uid, AccountID: accountID, Err: fmt.Errorf("account not found")}
3035 }
3036
3037 body, bodyMIMEType, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid)
3038 if err != nil {
3039 return tui.PreviewBodyFetchedMsg{UID: uid, AccountID: accountID, Err: err}
3040 }
3041
3042 return tui.PreviewBodyFetchedMsg{
3043 UID: uid,
3044 Body: body,
3045 BodyMIMEType: bodyMIMEType,
3046 Attachments: attachments,
3047 AccountID: accountID,
3048 }
3049 }
3050}
3051
3052func markEmailAsReadCmd(account *config.Account, uid uint32, accountID string, folderName string) tea.Cmd {
3053 return func() tea.Msg {
3054 err := fetcher.MarkEmailAsReadInMailbox(account, folderName, uid)
3055 return tui.EmailMarkedReadMsg{UID: uid, AccountID: accountID, Err: err}
3056 }
3057}
3058
3059func markEmailAsUnreadCmd(account *config.Account, uid uint32, accountID string, folderName string) tea.Cmd {
3060 return func() tea.Msg {
3061 err := fetcher.MarkEmailAsUnreadInMailbox(account, folderName, uid)
3062 return tui.EmailMarkedUnreadMsg{UID: uid, AccountID: accountID, Err: err}
3063 }
3064}
3065
3066func deleteFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
3067 return func() tea.Msg {
3068 err := fetcher.DeleteFolderEmail(account, folderName, uid)
3069 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
3070 }
3071}
3072
3073func archiveFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
3074 return func() tea.Msg {
3075 err := fetcher.ArchiveFolderEmail(account, folderName, uid)
3076 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
3077 }
3078}
3079
3080func (m *mainModel) batchDeleteEmailsCmd(account *config.Account, uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd {
3081 return func() tea.Msg {
3082 ctx, cancel := context.WithTimeout(context.Background(), httpclient.IMAPBatchActionTimeout)
3083 defer cancel()
3084
3085 p := m.getProvider(account)
3086 if p == nil {
3087 return tui.BatchEmailActionDoneMsg{
3088 Count: count,
3089 Action: "delete",
3090 Err: fmt.Errorf("provider not found"),
3091 }
3092 }
3093
3094 err := p.DeleteEmails(ctx, folderName, uids)
3095
3096 // Remove emails from local state on success
3097 if err == nil && m.folderInbox != nil {
3098 m.folderInbox.GetInbox().RemoveEmails(uids, accountID)
3099 }
3100
3101 successCount := count
3102 failureCount := 0
3103 if err != nil {
3104 failureCount = count
3105 successCount = 0
3106 }
3107
3108 return tui.BatchEmailActionDoneMsg{
3109 Count: count,
3110 SuccessCount: successCount,
3111 FailureCount: failureCount,
3112 Action: "delete",
3113 Mailbox: mailbox,
3114 Err: err,
3115 }
3116 }
3117}
3118
3119func (m *mainModel) batchArchiveEmailsCmd(account *config.Account, uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd {
3120 return func() tea.Msg {
3121 ctx, cancel := context.WithTimeout(context.Background(), httpclient.IMAPBatchActionTimeout)
3122 defer cancel()
3123
3124 p := m.getProvider(account)
3125 if p == nil {
3126 return tui.BatchEmailActionDoneMsg{
3127 Count: count,
3128 Action: "archive",
3129 Err: fmt.Errorf("provider not found"),
3130 }
3131 }
3132
3133 err := p.ArchiveEmails(ctx, folderName, uids)
3134
3135 if err == nil && m.folderInbox != nil {
3136 m.folderInbox.GetInbox().RemoveEmails(uids, accountID)
3137 }
3138
3139 successCount := count
3140 failureCount := 0
3141 if err != nil {
3142 failureCount = count
3143 successCount = 0
3144 }
3145
3146 return tui.BatchEmailActionDoneMsg{
3147 Count: count,
3148 SuccessCount: successCount,
3149 FailureCount: failureCount,
3150 Action: "archive",
3151 Mailbox: mailbox,
3152 Err: err,
3153 }
3154 }
3155}
3156
3157func (m *mainModel) batchMoveEmailsCmd(account *config.Account, uids []uint32, accountID, sourceFolder, destFolder string, count int) tea.Cmd {
3158 return func() tea.Msg {
3159 ctx, cancel := context.WithTimeout(context.Background(), httpclient.IMAPBatchActionTimeout)
3160 defer cancel()
3161
3162 p := m.getProvider(account)
3163 if p == nil {
3164 return tui.BatchEmailActionDoneMsg{
3165 Count: count,
3166 Action: "move",
3167 Err: fmt.Errorf("provider not found"),
3168 }
3169 }
3170
3171 err := p.MoveEmails(ctx, uids, sourceFolder, destFolder)
3172
3173 if err == nil && m.folderInbox != nil {
3174 m.folderInbox.GetInbox().RemoveEmails(uids, accountID)
3175 }
3176
3177 successCount := count
3178 failureCount := 0
3179 if err != nil {
3180 failureCount = count
3181 successCount = 0
3182 }
3183
3184 return tui.BatchEmailActionDoneMsg{
3185 Count: count,
3186 SuccessCount: successCount,
3187 FailureCount: failureCount,
3188 Action: "move",
3189 Err: err,
3190 }
3191 }
3192}
3193
3194func moveEmailToFolderCmd(account *config.Account, uid uint32, accountID string, sourceFolder, destFolder string) tea.Cmd {
3195 return func() tea.Msg {
3196 err := fetcher.MoveEmailToFolder(account, uid, sourceFolder, destFolder)
3197 return tui.EmailMovedMsg{
3198 UID: uid,
3199 AccountID: accountID,
3200 SourceFolder: sourceFolder,
3201 DestFolder: destFolder,
3202 Err: err,
3203 }
3204 }
3205}
3206
3207// sanitizeFilename prevents path traversal attacks on attachment downloads.
3208// Email attachment filenames come from untrusted email headers and could
3209// contain path separators or ".." sequences to escape the Downloads directory.
3210func sanitizeFilename(name string) string {
3211 // Normalize backslashes to forward slashes so filepath.Base works
3212 // correctly on all platforms (Linux doesn't treat \ as a separator)
3213 name = strings.ReplaceAll(name, "\\", "/")
3214 // Strip any path components, keep only the base filename
3215 name = filepath.Base(name)
3216 // Replace any remaining path separators (defensive)
3217 name = strings.ReplaceAll(name, "/", "_")
3218 name = strings.ReplaceAll(name, "..", "_")
3219 // Reject hidden files and empty names
3220 if name == "" || name == "." || strings.HasPrefix(name, ".") {
3221 name = "attachment"
3222 }
3223 // Sanitize filename: enforce length limit to prevent filesystem errors
3224 // with extremely long names from untrusted email headers.
3225 const maxFilenameLen = 255
3226 if len(name) > maxFilenameLen {
3227 ext := filepath.Ext(name)
3228 if len(ext) > maxFilenameLen {
3229 ext = truncateUTF8(ext, maxFilenameLen)
3230 }
3231 base := strings.TrimSuffix(name, ext)
3232 name = truncateUTF8(base, maxFilenameLen-len(ext)) + ext
3233 }
3234 return name
3235}
3236
3237func truncateUTF8(s string, maxBytes int) string {
3238 if maxBytes <= 0 {
3239 return ""
3240 }
3241 if len(s) <= maxBytes {
3242 return s
3243 }
3244 s = s[:maxBytes]
3245 for !utf8.ValidString(s) {
3246 _, size := utf8.DecodeLastRuneInString(s)
3247 s = s[:len(s)-size]
3248 }
3249 return s
3250}
3251
3252func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
3253 return func() tea.Msg {
3254 // Download and decode the attachment using encoding provided in msg.Encoding.
3255 var data []byte
3256 var err error
3257 switch msg.Mailbox {
3258 case tui.MailboxSent:
3259 data, err = fetcher.FetchSentAttachment(account, uid, msg.PartID, msg.Encoding)
3260 case tui.MailboxTrash:
3261 data, err = fetcher.FetchTrashAttachment(account, uid, msg.PartID, msg.Encoding)
3262 case tui.MailboxArchive:
3263 data, err = fetcher.FetchArchiveAttachment(account, uid, msg.PartID, msg.Encoding)
3264 default:
3265 data, err = fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding)
3266 }
3267
3268 if err != nil {
3269 return tui.AttachmentDownloadedMsg{Err: err}
3270 }
3271
3272 homeDir, err := os.UserHomeDir()
3273 if err != nil {
3274 return tui.AttachmentDownloadedMsg{Err: err}
3275 }
3276 downloadsPath := filepath.Join(homeDir, "Downloads")
3277 if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
3278 if mkErr := os.MkdirAll(downloadsPath, 0755); mkErr != nil {
3279 return tui.AttachmentDownloadedMsg{Err: mkErr}
3280 }
3281 }
3282
3283 // Save the attachment using an exclusive create so we never overwrite an existing file.
3284 // If the filename already exists, append \" (n)\" before the extension.
3285 origName := sanitizeFilename(msg.Filename)
3286 ext := filepath.Ext(origName)
3287 base := strings.TrimSuffix(origName, ext)
3288 candidate := origName
3289 i := 1
3290 var filePath string
3291
3292 for {
3293 filePath = filepath.Join(downloadsPath, candidate)
3294
3295 // Try to create file exclusively. If it already exists, os.OpenFile will return an error
3296 // that satisfies os.IsExist(err), so we can increment the candidate.
3297 f, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
3298 if err != nil {
3299 if os.IsExist(err) {
3300 // file exists, try next candidate
3301 candidate = fmt.Sprintf("%s (%d)%s", base, i, ext)
3302 i++
3303 continue
3304 }
3305 // Some other error while attempting to create file
3306 log.Printf("error creating file %s: %v", filePath, err)
3307 return tui.AttachmentDownloadedMsg{Err: err}
3308 }
3309
3310 // Successfully created the file descriptor; write and close.
3311 if _, writeErr := f.Write(data); writeErr != nil {
3312 _ = f.Close()
3313 log.Printf("error writing to file %s: %v", filePath, writeErr)
3314 return tui.AttachmentDownloadedMsg{Err: writeErr}
3315 }
3316 if closeErr := f.Close(); closeErr != nil {
3317 log.Printf("warning: error closing file %s: %v", filePath, closeErr)
3318 }
3319
3320 // file saved successfully
3321 break
3322 }
3323
3324 log.Printf("attachment saved to %s", filePath)
3325
3326 // Try to open the file using a platform-specific opener asynchronously and log the outcome.
3327 go func(p string) {
3328 var cmd *exec.Cmd
3329 switch runtime.GOOS {
3330 case "darwin":
3331 cmd = exec.Command("open", p)
3332 case "linux":
3333 cmd = exec.Command("xdg-open", p)
3334 case "windows":
3335 // 'start' is a cmd builtin; provide an empty title argument to avoid interpreting the path as the title.
3336 cmd = exec.Command("cmd", "/c", "start", "", p)
3337 default:
3338 // Unsupported OS: nothing to do.
3339 return
3340 }
3341 if err := cmd.Start(); err != nil {
3342 log.Printf("failed to open file %s: %v", p, err)
3343 }
3344 }(filePath)
3345
3346 return tui.AttachmentDownloadedMsg{Path: filePath, Err: nil}
3347 }
3348}
3349
3350/*
3351detectInstalledVersion returns a best-effort installed version string.
3352Priority:
3353 1. If the build-in `version` variable is set to something other than "dev", return it.
3354 2. If Homebrew is present and reports a version for `matcha`, return that.
3355 3. If snap is present and lists `matcha`, return that.
3356 4. Fallback to the build `version` (likely "dev").
3357*/
3358func detectInstalledVersion() string {
3359 v := strings.TrimSpace(version)
3360 if v != "dev" && v != "" {
3361 return v
3362 }
3363
3364 // Try Homebrew (macOS)
3365 if runtime.GOOS == "darwin" {
3366 if _, err := exec.LookPath("brew"); err == nil {
3367 // `brew list --versions matcha` prints: matcha 1.2.3
3368 if out, err := exec.Command("brew", "list", "--versions", "matcha").Output(); err == nil {
3369 parts := strings.Fields(string(out))
3370 if len(parts) >= 2 {
3371 return parts[1]
3372 }
3373 }
3374 }
3375 }
3376
3377 // Try WinGet (Windows)
3378 if runtime.GOOS == "windows" {
3379 if _, err := exec.LookPath("winget"); err == nil {
3380 if out, err := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity").Output(); err == nil {
3381 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
3382 for _, line := range lines {
3383 if strings.Contains(strings.ToLower(line), "floatpane.matcha") {
3384 fields := strings.Fields(line)
3385 for _, f := range fields {
3386 if len(f) > 0 && f[0] >= '0' && f[0] <= '9' && strings.Contains(f, ".") {
3387 return f
3388 }
3389 }
3390 }
3391 }
3392 }
3393 }
3394 }
3395
3396 // Try snap (Linux)
3397 if runtime.GOOS == "linux" {
3398 if _, err := exec.LookPath("snap"); err == nil {
3399 if out, err := exec.Command("snap", "list", "matcha").Output(); err == nil {
3400 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
3401 if len(lines) >= 2 {
3402 fields := strings.Fields(lines[1])
3403 if len(fields) >= 2 {
3404 return fields[1]
3405 }
3406 }
3407 }
3408 }
3409
3410 if _, err := exec.LookPath("flatpak"); err == nil {
3411 if out, err := exec.Command("flatpak", "info", "com.floatpane.matcha").Output(); err == nil {
3412 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
3413 for _, line := range lines {
3414 line = strings.TrimSpace(line)
3415 if strings.HasPrefix(line, "Version:") {
3416 fields := strings.Fields(line)
3417 if len(fields) >= 2 {
3418 return fields[1]
3419 }
3420 }
3421 }
3422 }
3423 }
3424 }
3425
3426 return v
3427}
3428
3429/*
3430checkForUpdatesCmd queries GitHub for the latest release tag and returns a
3431tea.Msg (UpdateAvailableMsg) if the latest version differs from the current
3432installed version. This runs in the background when the TUI initializes.
3433*/
3434func checkForUpdatesCmd() tea.Cmd {
3435 return func() tea.Msg {
3436 // Non-fatal: if anything goes wrong we just don't show the update message.
3437 const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
3438 resp, err := httpClient.Get(api)
3439 if err != nil {
3440 return nil
3441 }
3442 defer resp.Body.Close()
3443
3444 var rel githubRelease
3445 if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
3446 return nil
3447 }
3448
3449 latest := strings.TrimPrefix(rel.TagName, "v")
3450 installed := strings.TrimPrefix(detectInstalledVersion(), "v")
3451 if latest != "" && installed != "" && latest != installed {
3452 return UpdateAvailableMsg{Latest: latest, Current: installed}
3453 }
3454 return nil
3455 }
3456}
3457
3458// runUpdateCLI implements the CLI entrypoint for `matcha update`.
3459// It detects the likely installation method and attempts the appropriate
3460// update path (Homebrew, Snap, or GitHub release binary extract).
3461// runOAuthCLI handles the "matcha oauth" subcommand for OAuth2 management.
3462// Usage:
3463//
3464// matcha oauth auth <email> [--provider gmail|outlook] [--client-id ID --client-secret SECRET]
3465// matcha oauth token <email>
3466// matcha oauth revoke <email>
3467func runOAuthCLI(args []string) {
3468 if len(args) < 1 {
3469 fmt.Fprintln(os.Stderr, "Usage: matcha oauth <auth|token|revoke> <email> [flags]")
3470 fmt.Fprintln(os.Stderr, "")
3471 fmt.Fprintln(os.Stderr, "Commands:")
3472 fmt.Fprintln(os.Stderr, " auth <email> Authorize an email account via OAuth2 (opens browser)")
3473 fmt.Fprintln(os.Stderr, " token <email> Print a fresh access token (refreshes automatically)")
3474 fmt.Fprintln(os.Stderr, " revoke <email> Revoke and delete stored OAuth2 tokens")
3475 fmt.Fprintln(os.Stderr, "")
3476 fmt.Fprintln(os.Stderr, "Flags for auth:")
3477 fmt.Fprintln(os.Stderr, " --provider gmail|outlook OAuth2 provider (auto-detected from email)")
3478 fmt.Fprintln(os.Stderr, " --client-id ID OAuth2 client ID")
3479 fmt.Fprintln(os.Stderr, " --client-secret SECRET OAuth2 client secret")
3480 fmt.Fprintln(os.Stderr, "")
3481 fmt.Fprintln(os.Stderr, "Credentials are stored per provider in:")
3482 fmt.Fprintln(os.Stderr, " Gmail: ~/.config/matcha/oauth_client.json")
3483 fmt.Fprintln(os.Stderr, " Outlook: ~/.config/matcha/oauth_client_outlook.json")
3484 os.Exit(1)
3485 }
3486
3487 // Find the Python script and pass through to it
3488 script, err := config.OAuthScriptPath()
3489 if err != nil {
3490 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
3491 os.Exit(1)
3492 }
3493
3494 cmdArgs := append([]string{script}, args...)
3495 cmd := exec.Command("python3", cmdArgs...)
3496 cmd.Stdin = os.Stdin
3497 cmd.Stdout = os.Stdout
3498 cmd.Stderr = os.Stderr
3499
3500 if err := cmd.Run(); err != nil {
3501 if exitErr, ok := err.(*exec.ExitError); ok {
3502 os.Exit(exitErr.ExitCode())
3503 }
3504 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
3505 os.Exit(1)
3506 }
3507}
3508
3509// stringSliceFlag implements flag.Value to allow repeated --attach flags.
3510type stringSliceFlag []string
3511
3512func (s *stringSliceFlag) String() string { return strings.Join(*s, ", ") }
3513func (s *stringSliceFlag) Set(val string) error {
3514 *s = append(*s, val)
3515 return nil
3516}
3517
3518// runSendCLI implements the CLI entrypoint for `matcha send`.
3519// It sends an email non-interactively using configured accounts.
3520func runSendCLI(args []string) {
3521 fs := flag.NewFlagSet("send", flag.ExitOnError)
3522
3523 to := fs.String("to", "", "Recipient(s), comma-separated (required)")
3524 cc := fs.String("cc", "", "CC recipient(s), comma-separated")
3525 bcc := fs.String("bcc", "", "BCC recipient(s), comma-separated")
3526 subject := fs.String("subject", "", "Email subject (required)")
3527 body := fs.String("body", "", `Email body (Markdown supported). Use "-" to read from stdin`)
3528 from := fs.String("from", "", "Sender account email (defaults to first configured account)")
3529 withSignature := fs.Bool("signature", true, "Append default signature")
3530 signSMIME := fs.Bool("sign-smime", false, "Sign with S/MIME")
3531 encryptSMIME := fs.Bool("encrypt-smime", false, "Encrypt with S/MIME")
3532 signPGP := fs.Bool("sign-pgp", false, "Sign with PGP")
3533
3534 var attachments stringSliceFlag
3535 fs.Var(&attachments, "attach", "Attachment file path (can be repeated)")
3536
3537 fs.Usage = func() {
3538 fmt.Fprintln(os.Stderr, "Usage: matcha send [flags]")
3539 fmt.Fprintln(os.Stderr, "")
3540 fmt.Fprintln(os.Stderr, "Send an email non-interactively using a configured account.")
3541 fmt.Fprintln(os.Stderr, "")
3542 fmt.Fprintln(os.Stderr, "Flags:")
3543 fs.PrintDefaults()
3544 fmt.Fprintln(os.Stderr, "")
3545 fmt.Fprintln(os.Stderr, "Examples:")
3546 fmt.Fprintln(os.Stderr, ` matcha send --to user@example.com --subject "Hello" --body "Hi there"`)
3547 fmt.Fprintln(os.Stderr, ` echo "Body text" | matcha send --to user@example.com --subject "Hello" --body -`)
3548 fmt.Fprintln(os.Stderr, ` matcha send --to user@example.com --subject "Report" --body "See attached" --attach report.pdf`)
3549 }
3550
3551 if err := fs.Parse(args); err != nil {
3552 os.Exit(1)
3553 }
3554
3555 if *to == "" || *subject == "" {
3556 fmt.Fprintln(os.Stderr, "Error: --to and --subject are required")
3557 fs.Usage()
3558 os.Exit(1)
3559 }
3560
3561 // Read body from stdin if "-"
3562 emailBody := *body
3563 if emailBody == "-" {
3564 data, err := io.ReadAll(os.Stdin)
3565 if err != nil {
3566 fmt.Fprintf(os.Stderr, "Error reading stdin: %v\n", err)
3567 os.Exit(1)
3568 }
3569 emailBody = string(data)
3570 }
3571
3572 // Load config
3573 cfg, err := config.LoadConfig()
3574 if err != nil {
3575 fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
3576 os.Exit(1)
3577 }
3578 if !cfg.HasAccounts() {
3579 fmt.Fprintln(os.Stderr, "Error: no accounts configured. Run matcha to set up an account first.")
3580 os.Exit(1)
3581 }
3582
3583 // Resolve account
3584 var account *config.Account
3585 if *from != "" {
3586 account = cfg.GetAccountByEmail(*from)
3587 if account == nil {
3588 // Also try matching against FetchEmail
3589 for i := range cfg.Accounts {
3590 if strings.EqualFold(cfg.Accounts[i].FetchEmail, *from) {
3591 account = &cfg.Accounts[i]
3592 break
3593 }
3594 }
3595 }
3596 if account == nil {
3597 fmt.Fprintf(os.Stderr, "Error: no account found matching %q\n", *from)
3598 os.Exit(1)
3599 }
3600 } else {
3601 account = cfg.GetFirstAccount()
3602 }
3603
3604 // Use account S/MIME/PGP defaults unless explicitly set
3605 if !isFlagSet(fs, "sign-smime") {
3606 *signSMIME = account.SMIMESignByDefault
3607 }
3608 if !isFlagSet(fs, "sign-pgp") {
3609 *signPGP = account.PGPSignByDefault
3610 }
3611
3612 // Append signature
3613 if *withSignature {
3614 if sig, err := config.LoadSignature(); err == nil && sig != "" {
3615 emailBody = emailBody + "\n\n" + sig
3616 }
3617 }
3618
3619 // Process inline images (same logic as TUI sendEmail)
3620 images := make(map[string][]byte)
3621 re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
3622 matches := re.FindAllStringSubmatch(emailBody, -1)
3623 for _, match := range matches {
3624 imgPath := match[1]
3625 imgData, err := os.ReadFile(imgPath)
3626 if err != nil {
3627 log.Printf("Could not read image file %s: %v", imgPath, err)
3628 continue
3629 }
3630 cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
3631 images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
3632 emailBody = strings.Replace(emailBody, imgPath, "cid:"+cid, 1)
3633 }
3634
3635 htmlBody := markdownToHTML([]byte(emailBody))
3636
3637 // Process attachments
3638 attachMap := make(map[string][]byte)
3639 for _, attachPath := range attachments {
3640 fileData, err := os.ReadFile(attachPath)
3641 if err != nil {
3642 fmt.Fprintf(os.Stderr, "Error reading attachment %s: %v\n", attachPath, err)
3643 os.Exit(1)
3644 }
3645 attachMap[filepath.Base(attachPath)] = fileData
3646 }
3647
3648 // Send
3649 recipients := splitEmails(*to)
3650 ccList := splitEmails(*cc)
3651 bccList := splitEmails(*bcc)
3652
3653 rawMsg, sendErr := sender.SendEmail(account, recipients, ccList, bccList, *subject, emailBody, string(htmlBody), images, attachMap, "", nil, *signSMIME, *encryptSMIME, *signPGP, false)
3654 if sendErr != nil {
3655 fmt.Fprintf(os.Stderr, "Error: %v\n", sendErr)
3656 os.Exit(1)
3657 }
3658
3659 // Append to Sent folder via IMAP (Gmail auto-saves, so skip it)
3660 if account.ServiceProvider != "gmail" {
3661 if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
3662 log.Printf("Failed to append sent message to Sent folder: %v", err)
3663 }
3664 }
3665
3666 fmt.Println("Email sent successfully.")
3667}
3668
3669// isFlagSet returns true if the named flag was explicitly provided on the command line.
3670func isFlagSet(fs *flag.FlagSet, name string) bool {
3671 found := false
3672 fs.Visit(func(f *flag.Flag) {
3673 if f.Name == name {
3674 found = true
3675 }
3676 })
3677 return found
3678}
3679
3680func runUpdateCLI() (err error) {
3681 const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
3682 resp, err := httpClient.Get(api)
3683 if err != nil {
3684 return fmt.Errorf("could not query releases: %w", err)
3685 }
3686 defer resp.Body.Close()
3687
3688 var rel githubRelease
3689 if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
3690 return fmt.Errorf("could not parse release info: %w", err)
3691 }
3692
3693 latestTag := rel.TagName
3694 if strings.HasPrefix(latestTag, "v") {
3695 latestTag = latestTag[1:]
3696 }
3697
3698 fmt.Printf("Current version: %s\n", version)
3699 fmt.Printf("Latest version: %s\n", latestTag)
3700
3701 // Quick check: if already up-to-date, exit
3702 cur := version
3703 if strings.HasPrefix(cur, "v") {
3704 cur = cur[1:]
3705 }
3706 if latestTag == "" || cur == latestTag {
3707 fmt.Println("Already up to date.")
3708 return nil
3709 }
3710
3711 // Detect Homebrew
3712 if _, err := exec.LookPath("brew"); err == nil {
3713 fmt.Println("Detected Homebrew — updating taps and attempting to upgrade via brew.")
3714
3715 updateCmd := exec.Command("brew", "update")
3716 updateCmd.Stdout = os.Stdout
3717 updateCmd.Stderr = os.Stderr
3718 if err := updateCmd.Run(); err != nil {
3719 fmt.Printf("Homebrew update failed: %v\n", err)
3720 // continue to attempt upgrade even if update failed
3721 }
3722
3723 upgradeCmd := exec.Command("brew", "upgrade", "floatpane/matcha/matcha")
3724 upgradeCmd.Stdout = os.Stdout
3725 upgradeCmd.Stderr = os.Stderr
3726 if err := upgradeCmd.Run(); err == nil {
3727 fmt.Println("Successfully upgraded via Homebrew.")
3728 return nil
3729 }
3730 fmt.Printf("Homebrew upgrade failed: %v\n", err)
3731 // fallthrough to other methods
3732 }
3733
3734 // Detect snap
3735 if _, err := exec.LookPath("snap"); err == nil {
3736 // Check if matcha is installed as a snap
3737 cmdCheck := exec.Command("snap", "list", "matcha")
3738 if err := cmdCheck.Run(); err == nil {
3739 fmt.Println("Detected Snap package — attempting to refresh.")
3740 cmd := exec.Command("snap", "refresh", "matcha")
3741 cmd.Stdout = os.Stdout
3742 cmd.Stderr = os.Stderr
3743 if err := cmd.Run(); err == nil {
3744 fmt.Println("Successfully refreshed snap.")
3745 return nil
3746 }
3747 fmt.Printf("Snap refresh failed: %v\n", err)
3748 // fallthrough
3749 }
3750 }
3751 // Detect flatpak
3752 if _, err := exec.LookPath("flatpak"); err == nil {
3753 // Check if matcha is installed as a flatpak
3754 cmdCheck := exec.Command("flatpak", "info", "com.floatpane.matcha")
3755 if err := cmdCheck.Run(); err == nil {
3756 fmt.Println("Detected Flatpak package — attempting to update.")
3757 cmd := exec.Command("flatpak", "update", "-y", "com.floatpane.matcha")
3758 cmd.Stdout = os.Stdout
3759 cmd.Stderr = os.Stderr
3760 if err := cmd.Run(); err == nil {
3761 fmt.Println("Successfully updated flatpak.")
3762 return nil
3763 }
3764 fmt.Printf("Flatpak update failed: %v\n", err)
3765 // fallthrough
3766 }
3767 }
3768
3769 // Detect WinGet
3770 if _, err := exec.LookPath("winget"); err == nil {
3771 cmdCheck := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity")
3772 if err := cmdCheck.Run(); err == nil {
3773 fmt.Println("Detected WinGet package — attempting to upgrade.")
3774 cmd := exec.Command("winget", "upgrade", "--id", "floatpane.matcha", "--disable-interactivity")
3775 cmd.Stdout = os.Stdout
3776 cmd.Stderr = os.Stderr
3777 if err := cmd.Run(); err == nil {
3778 fmt.Println("Successfully upgraded via WinGet.")
3779 return nil
3780 }
3781 fmt.Printf("WinGet upgrade failed: %v\n", err)
3782 // fallthrough
3783 }
3784 }
3785
3786 // Otherwise attempt to download the proper release asset and replace the binary.
3787 osName := runtime.GOOS
3788 arch := runtime.GOARCH
3789
3790 // Try to find a matching asset
3791 var assetURL, assetName string
3792 for _, a := range rel.Assets {
3793 n := strings.ToLower(a.Name)
3794 if strings.Contains(n, osName) && strings.Contains(n, arch) && (strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || strings.HasSuffix(n, ".zip")) {
3795 assetURL = a.BrowserDownloadURL
3796 assetName = a.Name
3797 break
3798 }
3799 }
3800 if assetURL == "" {
3801 // Try any asset that contains 'matcha' and os/arch as a fallback
3802 for _, a := range rel.Assets {
3803 n := strings.ToLower(a.Name)
3804 if strings.Contains(n, "matcha") && (strings.Contains(n, osName) || strings.Contains(n, arch)) {
3805 assetURL = a.BrowserDownloadURL
3806 assetName = a.Name
3807 break
3808 }
3809 }
3810 }
3811
3812 if assetURL == "" {
3813 return fmt.Errorf("no suitable release artifact found for %s/%s", osName, arch)
3814 }
3815
3816 fmt.Printf("Found release asset: %s\n", assetName)
3817 fmt.Println("Downloading...")
3818
3819 // Download asset
3820 respAsset, err := httpClient.Get(assetURL)
3821 if err != nil {
3822 return fmt.Errorf("download failed: %w", err)
3823 }
3824 defer respAsset.Body.Close()
3825
3826 // Create a temp file for the download
3827 tmpDir, err := os.MkdirTemp("", "matcha-update-*")
3828 if err != nil {
3829 return fmt.Errorf("could not create temp dir: %w", err)
3830 }
3831 defer os.RemoveAll(tmpDir)
3832
3833 assetPath := filepath.Join(tmpDir, assetName)
3834 outFile, err := os.Create(assetPath)
3835 if err != nil {
3836 return fmt.Errorf("could not create temp file: %w", err)
3837 }
3838 _, err = io.Copy(outFile, respAsset.Body)
3839 outFile.Close()
3840 if err != nil {
3841 return fmt.Errorf("could not write asset to disk: %w", err)
3842 }
3843
3844 // Determine the expected binary name based on the OS.
3845 binaryName := "matcha"
3846 if runtime.GOOS == "windows" {
3847 binaryName = "matcha.exe"
3848 }
3849
3850 // Extract the binary from the archive.
3851 var binPath string
3852 if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") {
3853 f, err := os.Open(assetPath)
3854 if err != nil {
3855 return fmt.Errorf("could not open archive: %w", err)
3856 }
3857 defer f.Close()
3858 gzr, err := gzip.NewReader(f)
3859 if err != nil {
3860 return fmt.Errorf("could not create gzip reader: %w", err)
3861 }
3862 tr := tar.NewReader(gzr)
3863 for {
3864 hdr, err := tr.Next()
3865 if err == io.EOF {
3866 break
3867 }
3868 if err != nil {
3869 return fmt.Errorf("error reading tar: %w", err)
3870 }
3871 name := filepath.Base(hdr.Name)
3872 if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) {
3873 binPath = filepath.Join(tmpDir, binaryName)
3874 out, err := os.Create(binPath)
3875 if err != nil {
3876 return fmt.Errorf("could not create binary file: %w", err)
3877 }
3878 if _, err := io.Copy(out, tr); err != nil {
3879 out.Close()
3880 return fmt.Errorf("could not extract binary: %w", err)
3881 }
3882 out.Close()
3883 if err := os.Chmod(binPath, 0755); err != nil {
3884 return fmt.Errorf("could not make binary executable: %w", err)
3885 }
3886 break
3887 }
3888 }
3889 } else if strings.HasSuffix(assetName, ".zip") {
3890 zr, err := zip.OpenReader(assetPath)
3891 if err != nil {
3892 return fmt.Errorf("could not open zip archive: %w", err)
3893 }
3894 defer zr.Close()
3895 for _, zf := range zr.File {
3896 name := filepath.Base(zf.Name)
3897 if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && !zf.FileInfo().IsDir() {
3898 rc, err := zf.Open()
3899 if err != nil {
3900 return fmt.Errorf("could not open file in zip: %w", err)
3901 }
3902 binPath = filepath.Join(tmpDir, binaryName)
3903 out, err := os.Create(binPath)
3904 if err != nil {
3905 rc.Close()
3906 return fmt.Errorf("could not create binary file: %w", err)
3907 }
3908 if _, err := io.Copy(out, rc); err != nil {
3909 out.Close()
3910 rc.Close()
3911 return fmt.Errorf("could not extract binary: %w", err)
3912 }
3913 out.Close()
3914 rc.Close()
3915 if err := os.Chmod(binPath, 0755); err != nil {
3916 return fmt.Errorf("could not make binary executable: %w", err)
3917 }
3918 break
3919 }
3920 }
3921 } else {
3922 // For non-archive assets, assume the asset is the binary itself.
3923 binPath = assetPath
3924 if err := os.Chmod(binPath, 0755); err != nil {
3925 // ignore chmod errors but warn
3926 fmt.Printf("warning: could not chmod downloaded binary: %v\n", err)
3927 }
3928 }
3929
3930 if binPath == "" {
3931 return fmt.Errorf("could not locate matcha binary inside the release artifact")
3932 }
3933
3934 // Replace the running executable with the new binary
3935 execPath, err := os.Executable()
3936 if err != nil {
3937 return fmt.Errorf("could not determine executable path: %w", err)
3938 }
3939
3940 // Write the new binary to a temp file in same dir, then rename for atomic replacement.
3941 execDir := filepath.Dir(execPath)
3942 tmpNew := filepath.Join(execDir, fmt.Sprintf("matcha.new.%d", time.Now().Unix()))
3943 in, err := os.Open(binPath)
3944 if err != nil {
3945 return fmt.Errorf("could not open new binary: %w", err)
3946 }
3947 defer in.Close()
3948 out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
3949 if err != nil {
3950 return fmt.Errorf("could not create temp binary in target dir: %w", err)
3951 }
3952
3953 defer func() {
3954 cerr := out.Close()
3955 if err == nil && cerr != nil {
3956 err = fmt.Errorf("could not flush new binary to disk: %w", cerr)
3957 }
3958 }()
3959
3960 if _, err = io.Copy(out, in); err != nil {
3961 return fmt.Errorf("could not write new binary to disk: %w", err)
3962 }
3963
3964 // On Windows, a running executable cannot be overwritten directly.
3965 // Move the old binary out of the way first, then rename the new one in.
3966 if runtime.GOOS == "windows" {
3967 oldPath := execPath + ".old"
3968 _ = os.Remove(oldPath) // clean up any previous leftover
3969 if err := os.Rename(execPath, oldPath); err != nil {
3970 return fmt.Errorf("could not move old executable out of the way: %w", err)
3971 }
3972 }
3973
3974 if err = os.Rename(tmpNew, execPath); err != nil {
3975 return fmt.Errorf("could not replace executable: %w", err)
3976 }
3977
3978 fmt.Println("Successfully updated matcha to", latestTag)
3979 return nil
3980}
3981
3982func filterUnique(existing, incoming []fetcher.Email) []fetcher.Email {
3983 seen := make(map[uint32]struct{})
3984 for _, e := range existing {
3985 seen[e.UID] = struct{}{}
3986 }
3987 var unique []fetcher.Email
3988 for _, e := range incoming {
3989 if _, ok := seen[e.UID]; !ok {
3990 unique = append(unique, e)
3991 }
3992 }
3993 return unique
3994}
3995
3996func parseGlobalFlags(args []string) ([]string, loglevel.Level, bool) {
3997 level := loglevel.LevelInfo
3998 showLogPanel := false
3999 if len(args) <= 1 {
4000 return args, level, showLogPanel
4001 }
4002
4003 filtered := make([]string, 0, len(args))
4004 filtered = append(filtered, args[0])
4005
4006 for i := 1; i < len(args); i++ {
4007 switch args[i] {
4008 case "--debug":
4009 level = loglevel.LevelDebug
4010 case "--verbose", "-V":
4011 if level < loglevel.LevelVerbose {
4012 level = loglevel.LevelVerbose
4013 }
4014 case "--logs":
4015 showLogPanel = true
4016 default:
4017 filtered = append(filtered, args[i:]...)
4018 return filtered, level, showLogPanel
4019 }
4020 }
4021
4022 return filtered, level, showLogPanel
4023}
4024
4025func main() {
4026 args, level, showLogPanel := parseGlobalFlags(os.Args)
4027 os.Args = args
4028 loglevel.Set(level)
4029
4030 // If invoked with version flag, print version and exit
4031 if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") {
4032 fmt.Printf("matcha version %s", version)
4033 if commit != "" {
4034 fmt.Printf(" (%s)", commit)
4035 }
4036 if date != "" {
4037 fmt.Printf(" built on %s", date)
4038 }
4039 fmt.Println()
4040 os.Exit(0)
4041 }
4042
4043 // If invoked as CLI update command, run updater and exit.
4044 if len(os.Args) > 1 && os.Args[1] == "update" {
4045 if err := runUpdateCLI(); err != nil {
4046 fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
4047 os.Exit(1)
4048 }
4049 os.Exit(0)
4050 }
4051
4052 // Daemon CLI subcommand: matcha daemon <start|stop|status|run>
4053 if len(os.Args) > 1 && os.Args[1] == "daemon" {
4054 runDaemonCLI(os.Args[2:])
4055 os.Exit(0)
4056 }
4057
4058 // OAuth2 CLI subcommand: matcha oauth <auth|token|revoke> <email> [flags]
4059 // "gmail" is kept as an alias for backwards compatibility.
4060 if len(os.Args) > 1 && (os.Args[1] == "oauth" || os.Args[1] == "gmail") {
4061 runOAuthCLI(os.Args[2:])
4062 os.Exit(0)
4063 }
4064
4065 // Send email CLI subcommand: matcha send --to <email> --subject <subject> [flags]
4066 if len(os.Args) > 1 && os.Args[1] == "send" {
4067 runSendCLI(os.Args[2:])
4068 os.Exit(0)
4069 }
4070
4071 // Install plugin CLI subcommand: matcha install <url_or_file>
4072 if len(os.Args) > 1 && os.Args[1] == "install" {
4073 if err := matchaCli.RunInstall(os.Args[2:]); err != nil {
4074 fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
4075 os.Exit(1)
4076 }
4077 os.Exit(0)
4078 }
4079
4080 // Config CLI subcommand: matcha config [plugin_name]
4081 if len(os.Args) > 1 && os.Args[1] == "config" {
4082 if err := matchaCli.RunConfig(os.Args[2:]); err != nil {
4083 fmt.Fprintf(os.Stderr, "config failed: %v\n", err)
4084 os.Exit(1)
4085 }
4086 os.Exit(0)
4087 }
4088
4089 // Contacts CLI subcommand: matcha contacts <export|sync> [flags]
4090 if len(os.Args) > 1 && os.Args[1] == "contacts" && len(os.Args) > 2 {
4091 switch os.Args[2] {
4092 case "export":
4093 if err := matchaCli.RunContactsExport(os.Args[3:]); err != nil {
4094 fmt.Fprintf(os.Stderr, "contacts export failed: %v\n", err)
4095 os.Exit(1)
4096 }
4097 os.Exit(0)
4098 case "sync":
4099 if err := matchaCli.RunContactsSync(os.Args[3:]); err != nil {
4100 fmt.Fprintf(os.Stderr, "contacts sync failed: %v\n", err)
4101 os.Exit(1)
4102 }
4103 os.Exit(0)
4104 }
4105 }
4106
4107 // setup-mailto CLI subcommand: matcha setup-mailto
4108 if len(os.Args) > 1 && os.Args[1] == "setup-mailto" {
4109 if err := matchaCli.SetupMailto(); err != nil {
4110 fmt.Fprintf(os.Stderr, "setup-mailto failed: %v\n", err)
4111 os.Exit(1)
4112 }
4113 os.Exit(0)
4114 }
4115
4116 // Marketplace TUI subcommand: matcha marketplace
4117 if len(os.Args) > 1 && os.Args[1] == "marketplace" {
4118 mp := tui.NewMarketplace(true)
4119 p := tea.NewProgram(mp)
4120 if _, err := p.Run(); err != nil {
4121 fmt.Fprintf(os.Stderr, "marketplace failed: %v\n", err)
4122 os.Exit(1)
4123 }
4124 os.Exit(0)
4125 }
4126
4127 // Migrate cache files from ~/.config/matcha/ to ~/.cache/matcha/ if needed
4128 if err := config.MigrateCacheFiles(); err != nil {
4129 log.Printf("warning: cache migration failed: %v", err)
4130 }
4131
4132 // Initialize i18n
4133 if err := i18n.Init("en"); err != nil {
4134 log.Printf("Failed to initialize i18n: %v", err)
4135 }
4136
4137 var mailtoURL *url.URL
4138 if len(os.Args) > 1 && strings.HasPrefix(strings.ToLower(os.Args[1]), "mailto:") {
4139 if u, err := url.Parse(os.Args[1]); err == nil {
4140 mailtoURL = u
4141 }
4142 }
4143
4144 var initialModel *mainModel
4145
4146 if config.IsSecureModeEnabled() {
4147 // Secure mode: show password prompt before loading config
4148 tui.RebuildStyles()
4149 initialModel = newInitialModel(nil, mailtoURL)
4150 initialModel.current = tui.NewPasswordPrompt()
4151 } else {
4152 cfg, err := config.LoadConfig()
4153 if err == nil {
4154 loglevel.Verbosef("matcha: loaded config with %d account(s)", len(cfg.GetAccountIDs()))
4155 if migrateErr := config.MigrateContactsCacheUsage(cfg.GetAccountIDs()); migrateErr != nil {
4156 log.Printf("warning: contacts migration failed: %v", migrateErr)
4157 }
4158 if cfg.Theme != "" {
4159 theme.SetTheme(cfg.Theme)
4160 }
4161 // Set language from config
4162 lang := i18n.DetectLanguage(cfg)
4163 if err := i18n.GetManager().SetLanguage(lang); err != nil {
4164 log.Printf("Failed to set language %s: %v", lang, err)
4165 }
4166 }
4167 tui.RebuildStyles()
4168
4169 // Ensure PGP keys directory exists
4170 _ = config.EnsurePGPDir()
4171
4172 if err != nil {
4173 initialModel = newInitialModel(nil, mailtoURL)
4174 } else {
4175 initialModel = newInitialModel(cfg, mailtoURL)
4176 }
4177 }
4178
4179 if showLogPanel {
4180 logger := logging.NewBuffer(logging.DefaultMaxEntries)
4181 log.SetOutput(logger)
4182 initialModel.showLogPanel = true
4183 initialModel.logCh = logger.Subscribe()
4184 initialModel.logPanel = tui.NewLogPanel(logger)
4185 }
4186
4187 // Initialize plugin system
4188 plugins := plugin.NewManager()
4189 plugins.LoadPlugins()
4190 if initialModel.config != nil {
4191 plugins.LoadSettingValues(initialModel.config.PluginSettings)
4192 }
4193 initialModel.plugins = plugins
4194 tui.BodyTransformer = func(body string, email fetcher.Email) string {
4195 folder := "INBOX"
4196 if initialModel.folderInbox != nil {
4197 folder = initialModel.folderInbox.GetCurrentFolder()
4198 }
4199 t := plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, folder)
4200 return plugins.CallBodyRenderHook(t, body, email.Body)
4201 }
4202 plugins.CallHook(plugin.HookStartup)
4203
4204 // Background sync macOS features
4205 if runtime.GOOS == "darwin" {
4206 disableNotifications := false
4207 if initialModel.config != nil {
4208 disableNotifications = initialModel.config.DisableNotifications
4209 }
4210 if !disableNotifications {
4211 go func() {
4212 defer func() {
4213 if r := recover(); r != nil {
4214 log.Printf("panic in macOS sync goroutine: %v", r)
4215 }
4216 }()
4217 _ = config.SyncMacOSContacts()
4218 _ = theme.SyncWithMacOS()
4219 }()
4220 }
4221 }
4222
4223 p := tea.NewProgram(initialModel)
4224
4225 if _, err := p.Run(); err != nil {
4226 plugins.Close()
4227 fmt.Printf("Alas, there's been an error: %v", err)
4228 os.Exit(1)
4229 }
4230
4231 plugins.CallHook(plugin.HookShutdown)
4232 plugins.Close()
4233}
4234
4235func runDaemonCLI(args []string) {
4236 if len(args) == 0 {
4237 fmt.Println("Usage: matcha daemon <start|stop|status|run>")
4238 fmt.Println()
4239 fmt.Println("Commands:")
4240 fmt.Println(" start Start the daemon in the background")
4241 fmt.Println(" stop Stop the running daemon")
4242 fmt.Println(" status Show daemon status")
4243 fmt.Println(" run Run the daemon in the foreground")
4244 os.Exit(1)
4245 }
4246
4247 switch args[0] {
4248 case "start":
4249 runDaemonStart()
4250 case "stop":
4251 runDaemonStop()
4252 case "status":
4253 runDaemonStatus()
4254 case "run":
4255 runDaemonRun()
4256 default:
4257 fmt.Fprintf(os.Stderr, "unknown daemon command: %s\n", args[0])
4258 os.Exit(1)
4259 }
4260}
4261
4262func runDaemonStart() {
4263 pidPath := daemonrpc.PIDPath()
4264 if pid, running := matchaDaemon.IsRunning(pidPath); running {
4265 fmt.Printf("Daemon already running (PID %d)\n", pid)
4266 return
4267 }
4268
4269 // Fork ourselves with "daemon run".
4270 exe, err := os.Executable()
4271 if err != nil {
4272 fmt.Fprintf(os.Stderr, "cannot find executable: %v\n", err)
4273 os.Exit(1)
4274 }
4275
4276 cmd := exec.Command(exe, "daemon", "run")
4277 cmd.Stdout = nil
4278 cmd.Stderr = nil
4279 cmd.Stdin = nil
4280
4281 // Detach from parent process.
4282 cmd.SysProcAttr = daemonclient.DaemonProcAttr()
4283
4284 if err := cmd.Start(); err != nil {
4285 fmt.Fprintf(os.Stderr, "failed to start daemon: %v\n", err)
4286 os.Exit(1)
4287 }
4288
4289 fmt.Printf("Daemon started (PID %d)\n", cmd.Process.Pid)
4290}
4291
4292func runDaemonStop() {
4293 pidPath := daemonrpc.PIDPath()
4294 pid, running := matchaDaemon.IsRunning(pidPath)
4295 if !running {
4296 fmt.Println("Daemon is not running")
4297 return
4298 }
4299
4300 process, err := os.FindProcess(pid)
4301 if err != nil {
4302 fmt.Fprintf(os.Stderr, "cannot find process %d: %v\n", pid, err)
4303 os.Exit(1)
4304 }
4305
4306 if err := process.Signal(os.Interrupt); err != nil {
4307 fmt.Fprintf(os.Stderr, "failed to stop daemon: %v\n", err)
4308 os.Exit(1)
4309 }
4310
4311 fmt.Printf("Daemon stopped (PID %d)\n", pid)
4312}
4313
4314func runDaemonStatus() {
4315 // Try connecting to daemon for live status.
4316 client, err := daemonclient.Dial()
4317 if err != nil {
4318 pidPath := daemonrpc.PIDPath()
4319 if pid, running := matchaDaemon.IsRunning(pidPath); running {
4320 fmt.Printf("Daemon running (PID %d) but not responding\n", pid)
4321 } else {
4322 fmt.Println("Daemon is not running")
4323 }
4324 return
4325 }
4326 defer client.Close()
4327
4328 status, err := client.Status()
4329 if err != nil {
4330 fmt.Fprintf(os.Stderr, "failed to get status: %v\n", err)
4331 os.Exit(1)
4332 }
4333
4334 fmt.Printf("Daemon running (PID %d)\n", status.PID)
4335 fmt.Printf("Uptime: %s\n", formatUptime(status.Uptime))
4336 fmt.Printf("Accounts: %d\n", len(status.Accounts))
4337 for _, acct := range status.Accounts {
4338 fmt.Printf(" - %s\n", acct)
4339 }
4340}
4341
4342func runDaemonRun() {
4343 cfg, err := config.LoadConfig()
4344 if err != nil {
4345 fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
4346 os.Exit(1)
4347 }
4348
4349 d := matchaDaemon.New(cfg)
4350 if err := d.Run(); err != nil {
4351 fmt.Fprintf(os.Stderr, "daemon error: %v\n", err)
4352 os.Exit(1)
4353 }
4354}
4355
4356func formatUptime(seconds int64) string {
4357 d := time.Duration(seconds) * time.Second
4358 if d < time.Minute {
4359 return fmt.Sprintf("%ds", int(d.Seconds()))
4360 }
4361 if d < time.Hour {
4362 return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60)
4363 }
4364 return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
4365}