1package main
2
3import (
4 "archive/tar"
5 "archive/zip"
6 "compress/gzip"
7 "encoding/base64"
8 "encoding/json"
9 "fmt"
10 "io"
11 "log"
12 "net/http"
13 "os"
14 "os/exec"
15 "path/filepath"
16 "regexp"
17 "runtime"
18 "strings"
19 "sync"
20 "time"
21
22 tea "charm.land/bubbletea/v2"
23 "github.com/floatpane/matcha/backend"
24 _ "github.com/floatpane/matcha/backend/imap"
25 _ "github.com/floatpane/matcha/backend/jmap"
26 _ "github.com/floatpane/matcha/backend/pop3"
27 "github.com/floatpane/matcha/clib"
28 "github.com/floatpane/matcha/config"
29 "github.com/floatpane/matcha/fetcher"
30 "github.com/floatpane/matcha/notify"
31 "github.com/floatpane/matcha/plugin"
32 "github.com/floatpane/matcha/sender"
33 "github.com/floatpane/matcha/theme"
34 "github.com/floatpane/matcha/tui"
35 "github.com/google/uuid"
36)
37
38const (
39 initialEmailLimit = 50
40 paginationLimit = 50
41 maxCacheEmails = 100
42)
43
44// Version variables are injected by the build (GoReleaser ldflags).
45// They default to "dev" when not set by the build system.
46var (
47 version = "dev"
48 commit = ""
49 date = ""
50)
51
52// UpdateAvailableMsg is sent into the TUI when a newer release is detected.
53type UpdateAvailableMsg struct {
54 Latest string
55 Current string
56}
57
58// internal struct for parsing GitHub release JSON.
59type githubRelease struct {
60 TagName string `json:"tag_name"`
61 Assets []struct {
62 Name string `json:"name"`
63 BrowserDownloadURL string `json:"browser_download_url"`
64 } `json:"assets"`
65}
66
67type mainModel struct {
68 current tea.Model
69 previousModel tea.Model
70 config *config.Config
71 plugins *plugin.Manager
72 // Folder-based email storage
73 folderEmails map[string][]fetcher.Email // key: folderName
74 folderInbox *tui.FolderInbox
75 // Legacy fields kept for email actions
76 emails []fetcher.Email
77 emailsByAcct map[string][]fetcher.Email
78 width int
79 height int
80 err error
81 // IMAP IDLE
82 idleWatcher *fetcher.IdleWatcher
83 idleUpdates chan fetcher.IdleUpdate
84 // Multi-protocol backend providers (keyed by account ID)
85 providers map[string]backend.Provider
86}
87
88func newInitialModel(cfg *config.Config) *mainModel {
89 idleUpdates := make(chan fetcher.IdleUpdate, 16)
90 initialModel := &mainModel{
91 emailsByAcct: make(map[string][]fetcher.Email),
92 folderEmails: make(map[string][]fetcher.Email),
93 idleUpdates: idleUpdates,
94 idleWatcher: fetcher.NewIdleWatcher(idleUpdates),
95 providers: make(map[string]backend.Provider),
96 }
97
98 if cfg == nil || !cfg.HasAccounts() {
99 hideTips := false
100 if cfg != nil {
101 hideTips = cfg.HideTips
102 }
103 initialModel.current = tui.NewLogin(hideTips)
104 } else {
105 initialModel.current = tui.NewChoice()
106 initialModel.config = cfg
107 }
108 return initialModel
109}
110
111// ensureProviders creates backend providers for all configured accounts.
112func (m *mainModel) ensureProviders() {
113 if m.config == nil {
114 return
115 }
116 for _, acct := range m.config.Accounts {
117 if _, ok := m.providers[acct.ID]; ok {
118 continue
119 }
120 p, err := backend.New(&acct)
121 if err != nil {
122 log.Printf("backend: failed to create provider for %s: %v", acct.Email, err)
123 continue
124 }
125 m.providers[acct.ID] = p
126 }
127}
128
129// getProvider returns the backend provider for the given account.
130func (m *mainModel) getProvider(acct *config.Account) backend.Provider {
131 if acct == nil {
132 return nil
133 }
134 return m.providers[acct.ID]
135}
136
137func (m *mainModel) Init() tea.Cmd {
138 return tea.Batch(m.current.Init(), checkForUpdatesCmd())
139}
140
141func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
142 var cmd tea.Cmd
143 var cmds []tea.Cmd
144
145 m.current, cmd = m.current.Update(msg)
146 cmds = append(cmds, cmd)
147
148 // Fire composer_updated hook on key presses when the composer is active
149 if _, isKey := msg.(tea.KeyPressMsg); isKey {
150 if composer, ok := m.current.(*tui.Composer); ok && m.plugins != nil {
151 m.plugins.CallComposerHook(plugin.HookComposerUpdated, composer.GetBody(), composer.GetSubject(), composer.GetTo())
152 m.syncPluginStatus()
153 }
154 }
155
156 switch msg := msg.(type) {
157 case tea.WindowSizeMsg:
158 m.width = msg.Width
159 m.height = msg.Height
160 return m, nil
161
162 case tea.KeyPressMsg:
163 if msg.String() == "ctrl+c" {
164 m.idleWatcher.StopAll()
165 return m, tea.Quit
166 }
167 if msg.String() == "esc" {
168 switch m.current.(type) {
169 case *tui.FilePicker:
170 return m, func() tea.Msg { return tui.CancelFilePickerMsg{} }
171 case *tui.FolderInbox, *tui.Inbox, *tui.Login:
172 m.idleWatcher.StopAll()
173 m.current = tui.NewChoice()
174 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
175 return m, m.current.Init()
176 }
177 }
178
179 case tui.BackToInboxMsg:
180 if m.folderInbox != nil {
181 m.current = m.folderInbox
182 } else {
183 m.current = tui.NewChoice()
184 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
185 }
186 return m, nil
187
188 case tui.BackToMailboxMsg:
189 // Ensure kitty graphics are cleared when leaving email view
190 tui.ClearKittyGraphics()
191 if m.folderInbox != nil {
192 m.current = m.folderInbox
193 return m, nil
194 }
195 m.current = tui.NewChoice()
196 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
197 return m, nil
198
199 case tui.DiscardDraftMsg:
200 // Save draft to disk
201 if msg.ComposerState != nil {
202 draft := msg.ComposerState.ToDraft()
203
204 if err := config.SaveDraft(draft); err != nil {
205 log.Printf("Error saving draft: %v", err)
206 }
207
208 }
209 m.current = tui.NewChoice()
210 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
211 return m, m.current.Init()
212
213 case tui.OAuth2CompleteMsg:
214 if msg.Err != nil {
215 log.Printf("OAuth2 authorization failed: %v", msg.Err)
216 }
217 // After OAuth2 flow, go to the choice menu so user can proceed
218 m.current = tui.NewChoice()
219 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
220 return m, m.current.Init()
221
222 case tui.Credentials:
223 // Split FetchEmail by commas to support multiple fetch addresses.
224 // Each address creates a separate account sharing the same login credentials.
225 fetchEmails := []string{""}
226 if msg.FetchEmail != "" {
227 fetchEmails = fetchEmails[:0]
228 for _, fe := range strings.Split(msg.FetchEmail, ",") {
229 if trimmed := strings.TrimSpace(fe); trimmed != "" {
230 fetchEmails = append(fetchEmails, trimmed)
231 }
232 }
233 if len(fetchEmails) == 0 {
234 fetchEmails = []string{""}
235 }
236 }
237
238 if m.config == nil {
239 m.config = &config.Config{}
240 }
241
242 // Check if we're editing an existing account
243 isEdit := false
244 var lastAccount config.Account
245 if login, ok := m.current.(*tui.Login); ok && login.IsEditMode() {
246 isEdit = true
247 existingID := login.GetAccountID()
248
249 account := config.Account{
250 ID: existingID,
251 Name: msg.Name,
252 Email: msg.Host,
253 Password: msg.Password,
254 ServiceProvider: msg.Provider,
255 FetchEmail: fetchEmails[0],
256 AuthMethod: msg.AuthMethod,
257 Protocol: msg.Protocol,
258 JMAPEndpoint: msg.JMAPEndpoint,
259 POP3Server: msg.POP3Server,
260 POP3Port: msg.POP3Port,
261 }
262
263 if msg.Provider == "custom" || msg.Protocol == "pop3" {
264 account.IMAPServer = msg.IMAPServer
265 account.IMAPPort = msg.IMAPPort
266 account.SMTPServer = msg.SMTPServer
267 account.SMTPPort = msg.SMTPPort
268 }
269
270 if account.FetchEmail == "" && account.Email != "" {
271 account.FetchEmail = account.Email
272 }
273
274 // Find and update the existing account, preserving S/MIME settings
275 for i, acc := range m.config.Accounts {
276 if acc.ID == existingID {
277 account.SMIMECert = acc.SMIMECert
278 account.SMIMEKey = acc.SMIMEKey
279 account.SMIMESignByDefault = acc.SMIMESignByDefault
280 if account.Password == "" {
281 account.Password = acc.Password
282 }
283 m.config.Accounts[i] = account
284 break
285 }
286 }
287 lastAccount = account
288 } else {
289 // New account: create one account per fetch email address
290 for _, fe := range fetchEmails {
291 account := config.Account{
292 ID: uuid.New().String(),
293 Name: msg.Name,
294 Email: msg.Host,
295 Password: msg.Password,
296 ServiceProvider: msg.Provider,
297 FetchEmail: fe,
298 AuthMethod: msg.AuthMethod,
299 Protocol: msg.Protocol,
300 JMAPEndpoint: msg.JMAPEndpoint,
301 POP3Server: msg.POP3Server,
302 POP3Port: msg.POP3Port,
303 }
304
305 if msg.Provider == "custom" || msg.Protocol == "pop3" {
306 account.IMAPServer = msg.IMAPServer
307 account.IMAPPort = msg.IMAPPort
308 account.SMTPServer = msg.SMTPServer
309 account.SMTPPort = msg.SMTPPort
310 }
311
312 if account.FetchEmail == "" && account.Email != "" {
313 account.FetchEmail = account.Email
314 }
315
316 m.config.AddAccount(account)
317 lastAccount = account
318 }
319 }
320
321 if err := config.SaveConfig(m.config); err != nil {
322 log.Printf("could not save config: %v", err)
323 return m, tea.Quit
324 }
325
326 // If OAuth2, launch the authorization flow after saving the account
327 if lastAccount.IsOAuth2() {
328 email := lastAccount.Email
329 return m, func() tea.Msg {
330 err := config.RunOAuth2Flow(email, "", "")
331 return tui.OAuth2CompleteMsg{Email: email, Err: err}
332 }
333 }
334
335 if isEdit {
336 m.current = tui.NewSettings(m.config)
337 } else {
338 m.current = tui.NewChoice()
339 }
340 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
341 return m, m.current.Init()
342
343 case tui.GoToInboxMsg:
344 if m.config == nil || !m.config.HasAccounts() {
345 hideTips := false
346 if m.config != nil {
347 hideTips = m.config.HideTips
348 }
349 m.current = tui.NewLogin(hideTips)
350 return m, m.current.Init()
351 }
352 m.ensureProviders()
353 // Load cached folders from all accounts, merge unique names
354 seen := make(map[string]bool)
355 var cachedFolders []string
356 for _, acc := range m.config.Accounts {
357 for _, f := range config.GetCachedFolders(acc.ID) {
358 if !seen[f] {
359 seen[f] = true
360 cachedFolders = append(cachedFolders, f)
361 }
362 }
363 }
364 if len(cachedFolders) == 0 {
365 cachedFolders = []string{"INBOX"}
366 }
367 m.folderInbox = tui.NewFolderInbox(cachedFolders, m.config.Accounts)
368 // Use cached INBOX emails for instant display (memory first, then disk)
369 if cached, ok := m.folderEmails["INBOX"]; ok && len(cached) > 0 {
370 m.folderInbox.SetEmails(cached, m.config.Accounts)
371 } else if diskCached := loadFolderEmailsFromCache("INBOX"); len(diskCached) > 0 {
372 m.folderEmails["INBOX"] = diskCached
373 m.emails = diskCached
374 m.emailsByAcct = make(map[string][]fetcher.Email)
375 for _, email := range diskCached {
376 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
377 }
378 m.folderInbox.SetEmails(diskCached, m.config.Accounts)
379 }
380 m.current = m.folderInbox
381 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
382 // Start IDLE watchers for all accounts on INBOX
383 for i := range m.config.Accounts {
384 m.idleWatcher.Watch(&m.config.Accounts[i], "INBOX")
385 }
386 // Fetch folders and INBOX emails in parallel (background refresh)
387 return m, tea.Batch(
388 m.current.Init(),
389 fetchFoldersCmd(m.config),
390 fetchFolderEmailsCmd(m.config, "INBOX"),
391 listenForIdleUpdates(m.idleUpdates),
392 )
393
394 case tui.FoldersFetchedMsg:
395 if m.folderInbox == nil {
396 return m, nil
397 }
398 var folderNames []string
399 for _, f := range msg.MergedFolders {
400 folderNames = append(folderNames, f.Name)
401 }
402 m.folderInbox.SetFolders(folderNames)
403 // Cache folder lists per account
404 for accID, folders := range msg.FoldersByAccount {
405 var names []string
406 for _, f := range folders {
407 names = append(names, f.Name)
408 }
409 go config.SaveAccountFolders(accID, names)
410 }
411 return m, nil
412
413 case tui.SwitchFolderMsg:
414 if m.config == nil {
415 return m, nil
416 }
417 // Update IDLE watchers to monitor the new folder
418 for i := range m.config.Accounts {
419 m.idleWatcher.Watch(&m.config.Accounts[i], msg.FolderName)
420 }
421 if m.plugins != nil {
422 m.plugins.CallFolderHook(plugin.HookFolderChanged, msg.FolderName)
423 m.syncPluginStatus()
424 }
425 // Use in-memory cache if available
426 if cached, ok := m.folderEmails[msg.FolderName]; ok {
427 m.emails = cached
428 m.emailsByAcct = make(map[string][]fetcher.Email)
429 for _, email := range cached {
430 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
431 }
432 if m.folderInbox != nil {
433 m.folderInbox.SetEmails(cached, m.config.Accounts)
434 m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
435 m.folderInbox.SetLoadingEmails(false)
436 }
437 return m, m.pluginNotifyCmd()
438 }
439 // Fall back to disk cache for instant display, then fetch fresh in background
440 if diskCached := loadFolderEmailsFromCache(msg.FolderName); len(diskCached) > 0 {
441 m.folderEmails[msg.FolderName] = diskCached
442 m.emails = diskCached
443 m.emailsByAcct = make(map[string][]fetcher.Email)
444 for _, email := range diskCached {
445 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
446 }
447 if m.folderInbox != nil {
448 m.folderInbox.SetEmails(diskCached, m.config.Accounts)
449 m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
450 m.folderInbox.SetLoadingEmails(false)
451 }
452 // Still fetch fresh emails in background
453 return m, tea.Batch(fetchFolderEmailsCmd(m.config, msg.FolderName), m.pluginNotifyCmd())
454 }
455 if m.folderInbox != nil {
456 m.folderInbox.SetLoadingEmails(true)
457 }
458 return m, tea.Batch(fetchFolderEmailsCmd(m.config, msg.FolderName), m.pluginNotifyCmd())
459
460 case tui.PluginNotifyMsg:
461 m.previousModel = m.current
462 m.current = tui.NewStatus(msg.Message)
463 dur := time.Duration(msg.Duration * float64(time.Second))
464 if dur <= 0 {
465 dur = 2 * time.Second
466 }
467 return m, tea.Tick(dur, func(t time.Time) tea.Msg {
468 return tui.RestoreViewMsg{}
469 })
470
471 case tui.FolderEmailsFetchedMsg:
472 if m.folderInbox == nil {
473 return m, nil
474 }
475 // Call plugin hooks for received emails
476 if m.plugins != nil {
477 for _, email := range msg.Emails {
478 t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, msg.FolderName)
479 m.plugins.CallHook(plugin.HookEmailReceived, t)
480 }
481 }
482 // Always cache in memory and to disk
483 m.folderEmails[msg.FolderName] = msg.Emails
484 go saveFolderEmailsToCache(msg.FolderName, msg.Emails)
485 // Only update the view if the user is still on this folder
486 if m.folderInbox.GetCurrentFolder() != msg.FolderName {
487 return m, nil
488 }
489 m.emails = msg.Emails
490 m.emailsByAcct = make(map[string][]fetcher.Email)
491 for _, email := range msg.Emails {
492 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
493 }
494 m.folderInbox.SetEmails(msg.Emails, m.config.Accounts)
495 m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
496 m.folderInbox.SetLoadingEmails(false)
497 m.syncPluginStatus()
498 return m, m.pluginNotifyCmd()
499
500 case tui.FetchFolderMoreEmailsMsg:
501 if msg.AccountID == "" || m.config == nil {
502 return m, nil
503 }
504 account := m.config.GetAccountByID(msg.AccountID)
505 if account == nil {
506 return m, nil
507 }
508 limit := uint32(paginationLimit)
509 if msg.Limit > 0 {
510 limit = msg.Limit
511 }
512 return m, tea.Batch(
513 func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
514 fetchFolderEmailsPaginatedCmd(account, msg.FolderName, limit, msg.Offset),
515 )
516
517 case tui.FolderEmailsAppendedMsg:
518 // Ignore stale appends for a folder the user has moved away from
519 if m.folderInbox == nil || m.folderInbox.GetCurrentFolder() != msg.FolderName {
520 return m, nil
521 }
522 m.folderInbox.Update(msg)
523 // Update local stores and per-folder cache
524 for _, email := range msg.Emails {
525 m.emails = append(m.emails, email)
526 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
527 }
528 m.folderEmails[msg.FolderName] = append(m.folderEmails[msg.FolderName], msg.Emails...)
529 go saveFolderEmailsToCache(msg.FolderName, m.folderEmails[msg.FolderName])
530 return m, nil
531
532 case tui.MoveEmailToFolderMsg:
533 if m.config == nil {
534 return m, nil
535 }
536 account := m.config.GetAccountByID(msg.AccountID)
537 if account == nil {
538 return m, nil
539 }
540 m.previousModel = m.current
541 m.current = tui.NewStatus("Moving email...")
542 return m, tea.Batch(m.current.Init(), moveEmailToFolderCmd(account, msg.UID, msg.AccountID, msg.SourceFolder, msg.DestFolder))
543
544 case tui.EmailMovedMsg:
545 if msg.Err != nil {
546 log.Printf("Move failed: %v", msg.Err)
547 if m.folderInbox != nil {
548 m.previousModel = m.folderInbox
549 }
550 m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
551 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
552 return tui.RestoreViewMsg{}
553 })
554 }
555 // Remove email from current view
556 if m.folderInbox != nil {
557 m.folderInbox.RemoveEmail(msg.UID, msg.AccountID)
558 m.current = m.folderInbox
559 }
560 return m, nil
561
562 case tui.CachedEmailsLoadedMsg:
563 // Cache is no longer used for the folder-based inbox flow
564 // This handler is kept for backwards compatibility but simply fetches normally
565 if m.folderInbox == nil {
566 return m, nil
567 }
568 return m, fetchFolderEmailsCmd(m.config, m.folderInbox.GetCurrentFolder())
569
570 case tui.IdleNewMailMsg:
571 // Send desktop notification for new mail (if enabled)
572 if m.config == nil || !m.config.DisableNotifications {
573 accountName := msg.AccountID
574 if m.config != nil {
575 if acc := m.config.GetAccountByID(msg.AccountID); acc != nil {
576 accountName = acc.Email
577 }
578 }
579 go notify.Send("Matcha", fmt.Sprintf("New mail in %s (%s)", msg.FolderName, accountName))
580 }
581
582 // IDLE detected new mail — refetch the folder if we're viewing it
583 if m.folderInbox != nil && m.folderInbox.GetCurrentFolder() == msg.FolderName {
584 return m, tea.Batch(
585 fetchFolderEmailsCmd(m.config, msg.FolderName),
586 listenForIdleUpdates(m.idleUpdates),
587 )
588 }
589 // Re-subscribe even if not viewing the affected folder
590 return m, listenForIdleUpdates(m.idleUpdates)
591
592 case tui.RequestRefreshMsg:
593 // Folder-based refresh: clear folder cache and refetch
594 if msg.FolderName != "" && m.config != nil {
595 delete(m.folderEmails, msg.FolderName)
596 if m.folderInbox != nil {
597 m.folderInbox.SetRefreshing(true)
598 }
599 return m, fetchFolderEmailsCmd(m.config, msg.FolderName)
600 }
601 return m, tea.Batch(
602 func() tea.Msg { return tui.RefreshingEmailsMsg{Mailbox: msg.Mailbox} },
603 refreshEmails(m.config, msg.Mailbox, msg.Counts),
604 )
605
606 case tui.EmailsRefreshedMsg:
607 // Merge refreshed emails with any paginated emails already loaded.
608 for accID, refreshed := range msg.EmailsByAccount {
609 refreshedUIDs := make(map[uint32]struct{}, len(refreshed))
610 for _, e := range refreshed {
611 refreshedUIDs[e.UID] = struct{}{}
612 }
613 if existing, ok := m.emailsByAcct[accID]; ok {
614 for _, e := range existing {
615 if _, found := refreshedUIDs[e.UID]; !found {
616 refreshed = append(refreshed, e)
617 }
618 }
619 }
620 m.emailsByAcct[accID] = refreshed
621 }
622 m.emails = flattenAndSort(m.emailsByAcct)
623
624 // Update folder inbox if it exists
625 if m.folderInbox != nil {
626 m.folderInbox.SetEmails(m.emails, m.config.Accounts)
627 m.folderInbox.GetInbox().Update(msg)
628 }
629 return m, nil
630
631 case tui.AllEmailsFetchedMsg:
632 m.emailsByAcct = msg.EmailsByAccount
633 m.emails = flattenAndSort(msg.EmailsByAccount)
634
635 if m.folderInbox != nil {
636 m.folderInbox.SetEmails(m.emails, m.config.Accounts)
637 m.folderInbox.SetLoadingEmails(false)
638 }
639 return m, nil
640
641 case tui.EmailsFetchedMsg:
642 if m.emailsByAcct == nil {
643 m.emailsByAcct = make(map[string][]fetcher.Email)
644 }
645 m.emailsByAcct[msg.AccountID] = msg.Emails
646 m.emails = flattenAndSort(m.emailsByAcct)
647 if m.folderInbox != nil {
648 m.folderInbox.SetEmails(m.emails, m.config.Accounts)
649 }
650 return m, nil
651
652 case tui.FetchMoreEmailsMsg:
653 if msg.AccountID == "" {
654 return m, nil
655 }
656 account := m.config.GetAccountByID(msg.AccountID)
657 if account == nil {
658 return m, nil
659 }
660 limit := uint32(paginationLimit)
661 if msg.Limit > 0 {
662 limit = msg.Limit
663 }
664 folderName := "INBOX"
665 if m.folderInbox != nil {
666 folderName = m.folderInbox.GetCurrentFolder()
667 }
668 return m, tea.Batch(
669 func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
670 fetchFolderEmailsPaginatedCmd(account, folderName, limit, msg.Offset),
671 )
672
673 case tui.EmailsAppendedMsg:
674 if m.emailsByAcct == nil {
675 m.emailsByAcct = make(map[string][]fetcher.Email)
676 }
677 unique := filterUnique(m.emailsByAcct[msg.AccountID], msg.Emails)
678 m.emailsByAcct[msg.AccountID] = append(m.emailsByAcct[msg.AccountID], unique...)
679 m.emails = append(m.emails, unique...)
680 return m, nil
681
682 case tui.GoToSendMsg:
683 hideTips := false
684 if m.config != nil {
685 hideTips = m.config.HideTips
686 }
687 if m.config != nil && len(m.config.Accounts) > 0 {
688 firstAccount := m.config.GetFirstAccount()
689 composer := tui.NewComposerWithAccounts(m.config.Accounts, firstAccount.ID, msg.To, msg.Subject, msg.Body, hideTips)
690 m.current = composer
691 } else {
692 m.current = tui.NewComposer("", msg.To, msg.Subject, msg.Body, hideTips)
693 }
694 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
695 return m, m.current.Init()
696
697 case tui.GoToDraftsMsg:
698 drafts := config.GetAllDrafts()
699 m.current = tui.NewDrafts(drafts)
700 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
701 return m, m.current.Init()
702
703 case tui.OpenDraftMsg:
704 var accounts []config.Account
705 hideTips := false
706 if m.config != nil {
707 accounts = m.config.Accounts
708 hideTips = m.config.HideTips
709 }
710 composer := tui.NewComposerFromDraft(msg.Draft, accounts, hideTips)
711 m.current = composer
712 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
713 return m, m.current.Init()
714
715 case tui.DeleteSavedDraftMsg:
716 go func() {
717 if err := config.DeleteDraft(msg.DraftID); err != nil {
718 log.Printf("Error deleting draft: %v", err)
719 }
720 }()
721 // Send message back to drafts view
722 m.current, cmd = m.current.Update(tui.DraftDeletedMsg{DraftID: msg.DraftID})
723 return m, cmd
724
725 case tui.GoToSettingsMsg:
726 m.current = tui.NewSettings(m.config)
727 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
728 return m, m.current.Init()
729
730 case tui.GoToAddAccountMsg:
731 hideTips := false
732 if m.config != nil {
733 hideTips = m.config.HideTips
734 }
735 m.current = tui.NewLogin(hideTips)
736 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
737 return m, m.current.Init()
738
739 case tui.GoToAddMailingListMsg:
740 m.current = tui.NewMailingListEditor()
741 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
742 return m, m.current.Init()
743
744 case tui.GoToEditAccountMsg:
745 hideTips := false
746 if m.config != nil {
747 hideTips = m.config.HideTips
748 }
749 login := tui.NewLogin(hideTips)
750 login.SetEditMode(msg.AccountID, msg.Protocol, msg.Provider, msg.Name, msg.Email, msg.FetchEmail, msg.IMAPServer, msg.IMAPPort, msg.SMTPServer, msg.SMTPPort, msg.JMAPEndpoint, msg.POP3Server, msg.POP3Port)
751 m.current = login
752 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
753 return m, m.current.Init()
754
755 case tui.GoToEditMailingListMsg:
756 editor := tui.NewMailingListEditor()
757 editor.SetEditMode(msg.Index, msg.Name, msg.Addresses)
758 m.current = editor
759 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
760 return m, m.current.Init()
761
762 case tui.SaveMailingListMsg:
763 if m.config != nil {
764 var addrs []string
765 for _, part := range strings.Split(msg.Addresses, ",") {
766 if trimmed := strings.TrimSpace(part); trimmed != "" {
767 addrs = append(addrs, trimmed)
768 }
769 }
770 if msg.EditIndex >= 0 && msg.EditIndex < len(m.config.MailingLists) {
771 m.config.MailingLists[msg.EditIndex] = config.MailingList{
772 Name: msg.Name,
773 Addresses: addrs,
774 }
775 } else {
776 m.config.MailingLists = append(m.config.MailingLists, config.MailingList{
777 Name: msg.Name,
778 Addresses: addrs,
779 })
780 }
781 if err := config.SaveConfig(m.config); err != nil {
782 log.Printf("could not save config: %v", err)
783 }
784 }
785 // Return to settings
786 m.current = tui.NewSettings(m.config)
787 // Try to navigate to the mailing list view internally if possible, but NewSettings will go to SettingsMain by default.
788 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
789 return m, m.current.Init()
790
791 case tui.GoToSignatureEditorMsg:
792 m.current = tui.NewSignatureEditor()
793 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
794 return m, m.current.Init()
795
796 case tui.GoToChoiceMenuMsg:
797 m.current = tui.NewChoice()
798 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
799 return m, m.current.Init()
800
801 case tui.DeleteAccountMsg:
802 if m.config != nil {
803 m.config.RemoveAccount(msg.AccountID)
804 if err := config.SaveConfig(m.config); err != nil {
805 log.Printf("could not save config: %v", err)
806 }
807 // Remove emails for this account
808 delete(m.emailsByAcct, msg.AccountID)
809
810 // Rebuild all emails
811 var allEmails []fetcher.Email
812 for _, emails := range m.emailsByAcct {
813 allEmails = append(allEmails, emails...)
814 }
815 m.emails = allEmails
816
817 // Go back to settings
818 m.current = tui.NewSettings(m.config)
819 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
820 }
821 return m, m.current.Init()
822
823 case tui.ViewEmailMsg:
824 email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox)
825 if email == nil {
826 return m, nil
827 }
828 folderName := "INBOX"
829 if m.folderInbox != nil {
830 folderName = m.folderInbox.GetCurrentFolder()
831 }
832 if m.plugins != nil {
833 t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, folderName)
834 m.plugins.CallHook(plugin.HookEmailViewed, t)
835 }
836 m.current = tui.NewStatus("Fetching email content...")
837 return m, tea.Batch(m.current.Init(), fetchFolderEmailBodyCmd(m.config, msg.UID, msg.AccountID, folderName, msg.Mailbox), m.pluginNotifyCmd())
838
839 case tui.EmailBodyFetchedMsg:
840 if msg.Err != nil {
841 log.Printf("could not fetch email body: %v", msg.Err)
842 if m.folderInbox != nil {
843 m.current = m.folderInbox
844 }
845 return m, nil
846 }
847
848 // Update the email in our stores
849 m.updateEmailBodyByUID(msg.UID, msg.AccountID, msg.Mailbox, msg.Body, msg.Attachments)
850
851 email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox)
852 if email == nil {
853 if m.folderInbox != nil {
854 m.current = m.folderInbox
855 }
856 return m, nil
857 }
858
859 // Mark as read in UI immediately and on the server
860 var markReadCmd tea.Cmd
861 if !email.IsRead {
862 m.markEmailAsReadInStores(msg.UID, msg.AccountID)
863
864 folderName := "INBOX"
865 if m.folderInbox != nil {
866 folderName = m.folderInbox.GetCurrentFolder()
867 }
868 account := m.config.GetAccountByID(msg.AccountID)
869 if account != nil {
870 markReadCmd = markEmailAsReadCmd(account, msg.UID, msg.AccountID, folderName)
871 }
872 }
873
874 // Find the index for the email view (used for display purposes)
875 emailIndex := m.getEmailIndex(msg.UID, msg.AccountID, msg.Mailbox)
876 emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height, msg.Mailbox, m.config.DisableImages)
877 m.current = emailView
878 m.syncPluginStatus()
879 cmds := []tea.Cmd{m.current.Init()}
880 if markReadCmd != nil {
881 cmds = append(cmds, markReadCmd)
882 }
883 return m, tea.Batch(cmds...)
884
885 case tui.ReplyToEmailMsg:
886 to := msg.Email.From
887 subject := msg.Email.Subject
888 normalizedSubject := strings.ToLower(strings.TrimSpace(subject))
889 if !strings.HasPrefix(normalizedSubject, "re:") {
890 subject = "Re: " + subject
891 }
892 quotedText := fmt.Sprintf("\n\nOn %s, %s wrote:\n> %s", msg.Email.Date.Format("Jan 2, 2006 at 3:04 PM"), msg.Email.From, strings.ReplaceAll(msg.Email.Body, "\n", "\n> "))
893
894 var composer *tui.Composer
895 hideTips := false
896 if m.config != nil {
897 hideTips = m.config.HideTips
898 }
899 if m.config != nil && len(m.config.Accounts) > 0 {
900 // Use the account that received the email
901 accountID := msg.Email.AccountID
902 if accountID == "" {
903 accountID = m.config.GetFirstAccount().ID
904 }
905 composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, to, subject, "", hideTips)
906 } else {
907 composer = tui.NewComposer("", to, subject, "", hideTips)
908 }
909 composer.SetQuotedText(quotedText)
910
911 // Set reply headers
912 inReplyTo := msg.Email.MessageID
913 references := append(msg.Email.References, msg.Email.MessageID)
914 composer.SetReplyContext(inReplyTo, references)
915
916 m.current = composer
917 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
918 return m, m.current.Init()
919
920 case tui.ForwardEmailMsg:
921 subject := msg.Email.Subject
922 if !strings.HasPrefix(strings.ToLower(subject), "fwd:") {
923 subject = "Fwd: " + subject
924 }
925
926 forwardHeader := fmt.Sprintf("\n\n---------- Forwarded message ----------\nFrom: %s\nDate: %s\nSubject: %s\nTo: %s\n\n",
927 msg.Email.From,
928 msg.Email.Date.Format("Mon, Jan 2, 2006 at 3:04 PM"),
929 msg.Email.Subject,
930 msg.Email.To,
931 )
932
933 body := forwardHeader + msg.Email.Body
934
935 var composer *tui.Composer
936 hideTips := false
937 if m.config != nil {
938 hideTips = m.config.HideTips
939 }
940 if m.config != nil && len(m.config.Accounts) > 0 {
941 // Use the account that received the email
942 accountID := msg.Email.AccountID
943 if accountID == "" {
944 accountID = m.config.GetFirstAccount().ID
945 }
946 composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, "", subject, body, hideTips)
947 } else {
948 composer = tui.NewComposer("", "", subject, body, hideTips)
949 }
950
951 m.current = composer
952 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
953 return m, m.current.Init()
954
955 case tui.OpenEditorMsg:
956 composer, ok := m.current.(*tui.Composer)
957 if !ok {
958 return m, nil
959 }
960 return m, openExternalEditor(composer.GetBody())
961
962 case tui.EditorFinishedMsg:
963 if msg.Err != nil {
964 log.Printf("Editor error: %v", msg.Err)
965 return m, nil
966 }
967 if composer, ok := m.current.(*tui.Composer); ok {
968 composer.SetBody(msg.Body)
969 }
970 return m, nil
971
972 case tui.GoToFilePickerMsg:
973 m.previousModel = m.current
974 wd, _ := os.Getwd()
975 m.current = tui.NewFilePicker(wd)
976 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
977 return m, m.current.Init()
978
979 case tui.FileSelectedMsg, tui.CancelFilePickerMsg:
980 if m.previousModel != nil {
981 m.current = m.previousModel
982 m.previousModel = nil
983 }
984 m.current, cmd = m.current.Update(msg)
985 cmds = append(cmds, cmd)
986
987 case tui.SendEmailMsg:
988 if m.plugins != nil {
989 m.plugins.CallSendHook(plugin.HookEmailSendBefore, msg.To, msg.Cc, msg.Subject, msg.AccountID)
990 }
991 // Get draft ID before clearing composer (if it's a composer)
992 var draftID string
993 if composer, ok := m.current.(*tui.Composer); ok {
994 draftID = composer.GetDraftID()
995 }
996 m.current = tui.NewStatus("Sending email...")
997
998 // Get the account to send from
999 var account *config.Account
1000 if msg.AccountID != "" && m.config != nil {
1001 account = m.config.GetAccountByID(msg.AccountID)
1002 }
1003 if account == nil && m.config != nil {
1004 account = m.config.GetFirstAccount()
1005 }
1006
1007 // Save contact and delete draft in background
1008 go func() {
1009 // Save the recipient as a contact
1010 if msg.To != "" {
1011 recipients := strings.Split(msg.To, ",")
1012 for _, r := range recipients {
1013 r = strings.TrimSpace(r)
1014 if r == "" {
1015 continue
1016 }
1017 name, email := parseEmailAddress(r)
1018 if err := config.AddContact(name, email); err != nil {
1019 log.Printf("Error saving contact: %v", err)
1020 }
1021 }
1022 }
1023 // Delete the draft since email is being sent
1024 if draftID != "" {
1025 if err := config.DeleteDraft(draftID); err != nil {
1026 log.Printf("Error deleting draft after send: %v", err)
1027 }
1028 }
1029 }()
1030
1031 return m, tea.Batch(m.current.Init(), sendEmail(account, msg))
1032
1033 case tui.EmailResultMsg:
1034 if msg.Err != nil {
1035 log.Printf("Failed to send email: %v", msg.Err)
1036 m.previousModel = tui.NewChoice()
1037 m.previousModel, _ = m.previousModel.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1038 m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
1039 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1040 return tui.RestoreViewMsg{}
1041 })
1042 }
1043 if m.plugins != nil {
1044 m.plugins.CallHook(plugin.HookEmailSendAfter)
1045 }
1046 m.current = tui.NewChoice()
1047 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1048 return m, m.current.Init()
1049
1050 case tui.DeleteEmailMsg:
1051 tui.ClearKittyGraphics()
1052 m.previousModel = m.current
1053 m.current = tui.NewStatus("Deleting email...")
1054
1055 account := m.config.GetAccountByID(msg.AccountID)
1056 if account == nil {
1057 if m.folderInbox != nil {
1058 m.current = m.folderInbox
1059 }
1060 return m, nil
1061 }
1062
1063 folderName := "INBOX"
1064 if m.folderInbox != nil {
1065 folderName = m.folderInbox.GetCurrentFolder()
1066 }
1067 return m, tea.Batch(m.current.Init(), deleteFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
1068
1069 case tui.ArchiveEmailMsg:
1070 tui.ClearKittyGraphics()
1071 m.previousModel = m.current
1072 m.current = tui.NewStatus("Archiving email...")
1073
1074 account := m.config.GetAccountByID(msg.AccountID)
1075 if account == nil {
1076 if m.folderInbox != nil {
1077 m.current = m.folderInbox
1078 }
1079 return m, nil
1080 }
1081
1082 folderName := "INBOX"
1083 if m.folderInbox != nil {
1084 folderName = m.folderInbox.GetCurrentFolder()
1085 }
1086 return m, tea.Batch(m.current.Init(), archiveFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
1087
1088 case tui.EmailMarkedReadMsg:
1089 if msg.Err != nil {
1090 log.Printf("Error marking email as read: %v", msg.Err)
1091 }
1092 return m, nil
1093
1094 case tui.EmailActionDoneMsg:
1095 if msg.Err != nil {
1096 log.Printf("Action failed: %v", msg.Err)
1097 if m.folderInbox != nil {
1098 m.previousModel = m.folderInbox
1099 }
1100 m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
1101 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1102 return tui.RestoreViewMsg{}
1103 })
1104 }
1105
1106 // Remove email from stores
1107 m.removeEmailFromStores(msg.UID, msg.AccountID)
1108
1109 if m.folderInbox != nil {
1110 m.folderInbox.RemoveEmail(msg.UID, msg.AccountID)
1111 m.current = m.folderInbox
1112 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1113 return m, m.current.Init()
1114 }
1115 m.current = tui.NewChoice()
1116 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1117 return m, m.current.Init()
1118
1119 case tui.DownloadAttachmentMsg:
1120 m.previousModel = m.current
1121 m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename))
1122
1123 account := m.config.GetAccountByID(msg.AccountID)
1124 if account == nil {
1125 m.current = m.previousModel
1126 return m, nil
1127 }
1128
1129 email := m.getEmailByIndex(msg.Index, msg.Mailbox)
1130 if email == nil {
1131 m.current = m.previousModel
1132 return m, nil
1133 }
1134
1135 // Find the correct attachment to get encoding
1136 var encoding string
1137 for _, att := range email.Attachments {
1138 if att.PartID == msg.PartID {
1139 encoding = att.Encoding
1140 break
1141 }
1142 }
1143 newMsg := tui.DownloadAttachmentMsg{
1144 Index: msg.Index,
1145 Filename: msg.Filename,
1146 PartID: msg.PartID,
1147 Data: msg.Data,
1148 AccountID: msg.AccountID,
1149 Encoding: encoding,
1150 Mailbox: msg.Mailbox,
1151 }
1152 return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(account, email.UID, newMsg))
1153
1154 case tui.AttachmentDownloadedMsg:
1155 var statusMsg string
1156 if msg.Err != nil {
1157 statusMsg = fmt.Sprintf("Error downloading: %v", msg.Err)
1158 } else {
1159 statusMsg = fmt.Sprintf("Saved to %s", msg.Path)
1160 }
1161 m.current = tui.NewStatus(statusMsg)
1162 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1163 return tui.RestoreViewMsg{}
1164 })
1165
1166 case tui.RestoreViewMsg:
1167 if m.previousModel != nil {
1168 m.current = m.previousModel
1169 m.previousModel = nil
1170 }
1171 return m, nil
1172 }
1173
1174 if cmd := m.pluginNotifyCmd(); cmd != nil {
1175 cmds = append(cmds, cmd)
1176 }
1177
1178 return m, tea.Batch(cmds...)
1179}
1180
1181func (m *mainModel) View() tea.View {
1182 v := m.current.View()
1183 v.AltScreen = true
1184 return v
1185}
1186
1187func (m *mainModel) getEmailByIndex(index int, mailbox tui.MailboxKind) *fetcher.Email {
1188 if index >= 0 && index < len(m.emails) {
1189 return &m.emails[index]
1190 }
1191 return nil
1192}
1193
1194func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string, mailbox tui.MailboxKind) *fetcher.Email {
1195 for i := range m.emails {
1196 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1197 return &m.emails[i]
1198 }
1199 }
1200 return nil
1201}
1202
1203func (m *mainModel) getEmailIndex(uid uint32, accountID string, mailbox tui.MailboxKind) int {
1204 for i := range m.emails {
1205 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1206 return i
1207 }
1208 }
1209 return -1
1210}
1211
1212func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, mailbox tui.MailboxKind, body string, attachments []fetcher.Attachment) {
1213 for i := range m.emails {
1214 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1215 m.emails[i].Body = body
1216 m.emails[i].Attachments = attachments
1217 break
1218 }
1219 }
1220 if emails, ok := m.emailsByAcct[accountID]; ok {
1221 for i := range emails {
1222 if emails[i].UID == uid {
1223 emails[i].Body = body
1224 emails[i].Attachments = attachments
1225 break
1226 }
1227 }
1228 }
1229}
1230
1231func (m *mainModel) markEmailAsReadInStores(uid uint32, accountID string) {
1232 for i := range m.emails {
1233 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1234 m.emails[i].IsRead = true
1235 break
1236 }
1237 }
1238 if emails, ok := m.emailsByAcct[accountID]; ok {
1239 for i := range emails {
1240 if emails[i].UID == uid {
1241 emails[i].IsRead = true
1242 break
1243 }
1244 }
1245 }
1246 // Update folder email cache
1247 for folderName, folderEmails := range m.folderEmails {
1248 for i := range folderEmails {
1249 if folderEmails[i].UID == uid && folderEmails[i].AccountID == accountID {
1250 folderEmails[i].IsRead = true
1251 m.folderEmails[folderName] = folderEmails
1252 go saveFolderEmailsToCache(folderName, folderEmails)
1253 break
1254 }
1255 }
1256 }
1257 // Update the inbox UI
1258 if m.folderInbox != nil {
1259 m.folderInbox.GetInbox().MarkEmailAsRead(uid, accountID)
1260 }
1261}
1262
1263func (m *mainModel) removeEmailFromStores(uid uint32, accountID string) {
1264 var filtered []fetcher.Email
1265 for _, e := range m.emails {
1266 if !(e.UID == uid && e.AccountID == accountID) {
1267 filtered = append(filtered, e)
1268 }
1269 }
1270 m.emails = filtered
1271 if emails, ok := m.emailsByAcct[accountID]; ok {
1272 var filteredAcct []fetcher.Email
1273 for _, e := range emails {
1274 if e.UID != uid {
1275 filteredAcct = append(filteredAcct, e)
1276 }
1277 }
1278 m.emailsByAcct[accountID] = filteredAcct
1279 }
1280}
1281
1282// pluginNotifyCmd checks for a pending plugin notification and returns a command if one exists.
1283func (m *mainModel) pluginNotifyCmd() tea.Cmd {
1284 if m.plugins == nil {
1285 return nil
1286 }
1287 if n, ok := m.plugins.TakePendingNotification(); ok {
1288 return func() tea.Msg {
1289 return tui.PluginNotifyMsg{Message: n.Message, Duration: n.Duration}
1290 }
1291 }
1292 return nil
1293}
1294
1295func (m *mainModel) syncPluginStatus() {
1296 if m.plugins == nil {
1297 return
1298 }
1299 if m.folderInbox != nil {
1300 m.folderInbox.GetInbox().SetPluginStatus(m.plugins.StatusText(plugin.StatusInbox))
1301 }
1302 switch v := m.current.(type) {
1303 case *tui.Composer:
1304 v.SetPluginStatus(m.plugins.StatusText(plugin.StatusComposer))
1305 case *tui.EmailView:
1306 v.SetPluginStatus(m.plugins.StatusText(plugin.StatusEmailView))
1307 }
1308}
1309
1310func flattenAndSort(emailsByAccount map[string][]fetcher.Email) []fetcher.Email {
1311 var allEmails []fetcher.Email
1312 for _, emails := range emailsByAccount {
1313 allEmails = append(allEmails, emails...)
1314 }
1315 for i := 0; i < len(allEmails); i++ {
1316 for j := i + 1; j < len(allEmails); j++ {
1317 if allEmails[j].Date.After(allEmails[i].Date) {
1318 allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
1319 }
1320 }
1321 }
1322 return allEmails
1323}
1324
1325func fetchAllAccountsEmails(cfg *config.Config, mailbox tui.MailboxKind) tea.Cmd {
1326 return func() tea.Msg {
1327 emailsByAccount := make(map[string][]fetcher.Email)
1328 var mu sync.Mutex
1329 var wg sync.WaitGroup
1330
1331 for _, account := range cfg.Accounts {
1332 wg.Add(1)
1333 go func(acc config.Account) {
1334 defer wg.Done()
1335 var emails []fetcher.Email
1336 var err error
1337 switch mailbox {
1338 case tui.MailboxSent:
1339 emails, err = fetcher.FetchSentEmails(&acc, initialEmailLimit, 0)
1340 case tui.MailboxTrash:
1341 emails, err = fetcher.FetchTrashEmails(&acc, initialEmailLimit, 0)
1342 case tui.MailboxArchive:
1343 emails, err = fetcher.FetchArchiveEmails(&acc, initialEmailLimit, 0)
1344 default:
1345 emails, err = fetcher.FetchEmails(&acc, initialEmailLimit, 0)
1346 }
1347 if err != nil {
1348 log.Printf("Error fetching from %s: %v", acc.Email, err)
1349 return
1350 }
1351 mu.Lock()
1352 emailsByAccount[acc.ID] = emails
1353 mu.Unlock()
1354 }(account)
1355 }
1356
1357 wg.Wait()
1358 return tui.AllEmailsFetchedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
1359 }
1360}
1361
1362func fetchEmails(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
1363 return func() tea.Msg {
1364 var emails []fetcher.Email
1365 var err error
1366 if mailbox == tui.MailboxSent {
1367 emails, err = fetcher.FetchSentEmails(account, limit, offset)
1368 } else {
1369 emails, err = fetcher.FetchEmails(account, limit, offset)
1370 }
1371 if err != nil {
1372 return tui.FetchErr(err)
1373 }
1374 if offset == 0 {
1375 return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1376 }
1377 return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1378 }
1379}
1380
1381func fetchEmailsForMailbox(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
1382 return func() tea.Msg {
1383 var emails []fetcher.Email
1384 var err error
1385 switch mailbox {
1386 case tui.MailboxSent:
1387 emails, err = fetcher.FetchSentEmails(account, limit, offset)
1388 case tui.MailboxTrash:
1389 emails, err = fetcher.FetchTrashEmails(account, limit, offset)
1390 case tui.MailboxArchive:
1391 emails, err = fetcher.FetchArchiveEmails(account, limit, offset)
1392 default:
1393 emails, err = fetcher.FetchEmails(account, limit, offset)
1394 }
1395 if err != nil {
1396 return tui.FetchErr(err)
1397 }
1398 if offset == 0 {
1399 return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1400 }
1401 return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1402 }
1403}
1404
1405func loadCachedEmails() tea.Cmd {
1406 return func() tea.Msg {
1407 cache, err := config.LoadEmailCache()
1408 if err != nil {
1409 return tui.CachedEmailsLoadedMsg{Cache: nil}
1410 }
1411 return tui.CachedEmailsLoadedMsg{Cache: cache}
1412 }
1413}
1414
1415func refreshEmails(cfg *config.Config, mailbox tui.MailboxKind, counts map[string]int) tea.Cmd {
1416 return func() tea.Msg {
1417 emailsByAccount := make(map[string][]fetcher.Email)
1418 var mu sync.Mutex
1419 var wg sync.WaitGroup
1420
1421 for _, account := range cfg.Accounts {
1422 wg.Add(1)
1423 go func(acc config.Account) {
1424 defer wg.Done()
1425 var emails []fetcher.Email
1426 var err error
1427
1428 limit := uint32(initialEmailLimit)
1429 if counts != nil {
1430 if c, ok := counts[acc.ID]; ok && c > 0 {
1431 limit = uint32(c)
1432 }
1433 }
1434
1435 if mailbox == tui.MailboxSent {
1436 emails, err = fetcher.FetchSentEmails(&acc, limit, 0)
1437 } else {
1438 emails, err = fetcher.FetchEmails(&acc, limit, 0)
1439 }
1440 if err != nil {
1441 log.Printf("Error fetching from %s: %v", acc.Email, err)
1442 return
1443 }
1444 mu.Lock()
1445 emailsByAccount[acc.ID] = emails
1446 mu.Unlock()
1447 }(account)
1448 }
1449
1450 wg.Wait()
1451 return tui.EmailsRefreshedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
1452 }
1453}
1454
1455func emailsToCache(emails []fetcher.Email) []config.CachedEmail {
1456 var cached []config.CachedEmail
1457 for _, email := range emails {
1458 cached = append(cached, config.CachedEmail{
1459 UID: email.UID,
1460 From: email.From,
1461 To: email.To,
1462 Subject: email.Subject,
1463 Date: email.Date,
1464 MessageID: email.MessageID,
1465 AccountID: email.AccountID,
1466 IsRead: email.IsRead,
1467 })
1468 }
1469 return cached
1470}
1471
1472func cacheToEmails(cached []config.CachedEmail) []fetcher.Email {
1473 var emails []fetcher.Email
1474 for _, c := range cached {
1475 emails = append(emails, fetcher.Email{
1476 UID: c.UID,
1477 From: c.From,
1478 To: c.To,
1479 Subject: c.Subject,
1480 Date: c.Date,
1481 MessageID: c.MessageID,
1482 AccountID: c.AccountID,
1483 IsRead: c.IsRead,
1484 })
1485 }
1486 return emails
1487}
1488
1489func saveFolderEmailsToCache(folderName string, emails []fetcher.Email) {
1490 cached := emailsToCache(emails)
1491 if err := config.SaveFolderEmailCache(folderName, cached); err != nil {
1492 log.Printf("Error saving folder email cache for %s: %v", folderName, err)
1493 }
1494}
1495
1496func loadFolderEmailsFromCache(folderName string) []fetcher.Email {
1497 cached, err := config.LoadFolderEmailCache(folderName)
1498 if err != nil {
1499 return nil
1500 }
1501 return cacheToEmails(cached)
1502}
1503
1504func saveEmailsToCache(emails []fetcher.Email) {
1505 if len(emails) > maxCacheEmails {
1506 emails = emails[:maxCacheEmails]
1507 }
1508 var cachedEmails []config.CachedEmail
1509 for _, email := range emails {
1510 cachedEmails = append(cachedEmails, config.CachedEmail{
1511 UID: email.UID,
1512 From: email.From,
1513 To: email.To,
1514 Subject: email.Subject,
1515 Date: email.Date,
1516 MessageID: email.MessageID,
1517 AccountID: email.AccountID,
1518 IsRead: email.IsRead,
1519 })
1520
1521 // Save sender as a contact
1522 if email.From != "" {
1523 name, emailAddr := parseEmailAddress(email.From)
1524 if err := config.AddContact(name, emailAddr); err != nil {
1525 log.Printf("Error saving contact from email: %v", err)
1526 }
1527 }
1528 }
1529 cache := &config.EmailCache{Emails: cachedEmails}
1530 if err := config.SaveEmailCache(cache); err != nil {
1531 log.Printf("Error saving email cache: %v", err)
1532 }
1533}
1534
1535// parseEmailAddress parses "Name <email>" or just "email" format
1536func parseEmailAddress(addr string) (name, email string) {
1537 addr = strings.TrimSpace(addr)
1538 if idx := strings.Index(addr, "<"); idx != -1 {
1539 name = strings.TrimSpace(addr[:idx])
1540 endIdx := strings.Index(addr, ">")
1541 if endIdx > idx {
1542 email = strings.TrimSpace(addr[idx+1 : endIdx])
1543 } else {
1544 email = strings.TrimSpace(addr[idx+1:])
1545 }
1546 } else {
1547 email = addr
1548 }
1549 return name, email
1550}
1551
1552func fetchEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1553 return func() tea.Msg {
1554 account := cfg.GetAccountByID(accountID)
1555 if account == nil {
1556 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
1557 }
1558
1559 var (
1560 body string
1561 attachments []fetcher.Attachment
1562 err error
1563 )
1564 switch mailbox {
1565 case tui.MailboxSent:
1566 body, attachments, err = fetcher.FetchSentEmailBody(account, uid)
1567 case tui.MailboxTrash:
1568 body, attachments, err = fetcher.FetchTrashEmailBody(account, uid)
1569 case tui.MailboxArchive:
1570 body, attachments, err = fetcher.FetchArchiveEmailBody(account, uid)
1571 default:
1572 body, attachments, err = fetcher.FetchEmailBody(account, uid)
1573 }
1574 if err != nil {
1575 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1576 }
1577
1578 return tui.EmailBodyFetchedMsg{
1579 UID: uid,
1580 Body: body,
1581 Attachments: attachments,
1582 AccountID: accountID,
1583 Mailbox: mailbox,
1584 }
1585 }
1586}
1587
1588func markdownToHTML(md []byte) []byte {
1589 return clib.MarkdownToHTML(md)
1590}
1591
1592func splitEmails(s string) []string {
1593 if s == "" {
1594 return nil
1595 }
1596 parts := strings.Split(s, ",")
1597 var res []string
1598 for _, p := range parts {
1599 if trimmed := strings.TrimSpace(p); trimmed != "" {
1600 res = append(res, trimmed)
1601 }
1602 }
1603 return res
1604}
1605
1606func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
1607 return func() tea.Msg {
1608 if account == nil {
1609 return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
1610 }
1611
1612 recipients := splitEmails(msg.To)
1613 cc := splitEmails(msg.Cc)
1614 bcc := splitEmails(msg.Bcc)
1615 body := msg.Body
1616 // Append signature if present
1617 if msg.Signature != "" {
1618 body = body + "\n\n" + msg.Signature
1619 }
1620 // Append quoted text if present (for replies)
1621 if msg.QuotedText != "" {
1622 body = body + msg.QuotedText
1623 }
1624 images := make(map[string][]byte)
1625 attachments := make(map[string][]byte)
1626
1627 re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
1628 matches := re.FindAllStringSubmatch(body, -1)
1629
1630 for _, match := range matches {
1631 imgPath := match[1]
1632 imgData, err := os.ReadFile(imgPath)
1633 if err != nil {
1634 log.Printf("Could not read image file %s: %v", imgPath, err)
1635 continue
1636 }
1637 cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
1638 images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
1639 body = strings.Replace(body, imgPath, "cid:"+cid, 1)
1640 }
1641
1642 htmlBody := markdownToHTML([]byte(body))
1643
1644 for _, attachPath := range msg.AttachmentPaths {
1645 fileData, err := os.ReadFile(attachPath)
1646 if err != nil {
1647 log.Printf("Could not read attachment file %s: %v", attachPath, err)
1648 continue
1649 }
1650 _, filename := filepath.Split(attachPath)
1651 attachments[filename] = fileData
1652 }
1653
1654 err := sender.SendEmail(account, recipients, cc, bcc, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References, msg.SignSMIME, msg.EncryptSMIME)
1655 if err != nil {
1656 log.Printf("Failed to send email: %v", err)
1657 return tui.EmailResultMsg{Err: err}
1658 }
1659 return tui.EmailResultMsg{}
1660 }
1661}
1662
1663func deleteEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1664 return func() tea.Msg {
1665 var err error
1666 switch mailbox {
1667 case tui.MailboxSent:
1668 err = fetcher.DeleteSentEmail(account, uid)
1669 case tui.MailboxTrash:
1670 err = fetcher.DeleteTrashEmail(account, uid)
1671 case tui.MailboxArchive:
1672 err = fetcher.DeleteArchiveEmail(account, uid)
1673 default:
1674 err = fetcher.DeleteEmail(account, uid)
1675 }
1676 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1677 }
1678}
1679
1680func archiveEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1681 return func() tea.Msg {
1682 var err error
1683 if mailbox == tui.MailboxSent {
1684 err = fetcher.ArchiveSentEmail(account, uid)
1685 } else {
1686 err = fetcher.ArchiveEmail(account, uid)
1687 }
1688 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1689 }
1690}
1691
1692// --- External editor command ---
1693
1694// openExternalEditor writes the body to a temp file, opens $EDITOR, and reads back the result.
1695func openExternalEditor(body string) tea.Cmd {
1696 editor := os.Getenv("EDITOR")
1697 if editor == "" {
1698 editor = os.Getenv("VISUAL")
1699 }
1700 if editor == "" {
1701 editor = "vi"
1702 }
1703
1704 tmpFile, err := os.CreateTemp("", "matcha-*.md")
1705 if err != nil {
1706 return func() tea.Msg {
1707 return tui.EditorFinishedMsg{Err: fmt.Errorf("creating temp file: %w", err)}
1708 }
1709 }
1710 tmpPath := tmpFile.Name()
1711
1712 if _, err := tmpFile.WriteString(body); err != nil {
1713 tmpFile.Close()
1714 os.Remove(tmpPath)
1715 return func() tea.Msg {
1716 return tui.EditorFinishedMsg{Err: fmt.Errorf("writing temp file: %w", err)}
1717 }
1718 }
1719 tmpFile.Close()
1720
1721 parts := strings.Fields(editor)
1722 args := append(parts[1:], tmpPath)
1723 c := exec.Command(parts[0], args...)
1724 return tea.ExecProcess(c, func(err error) tea.Msg {
1725 defer os.Remove(tmpPath)
1726 if err != nil {
1727 return tui.EditorFinishedMsg{Err: err}
1728 }
1729 content, readErr := os.ReadFile(tmpPath)
1730 if readErr != nil {
1731 return tui.EditorFinishedMsg{Err: readErr}
1732 }
1733 return tui.EditorFinishedMsg{Body: string(content)}
1734 })
1735}
1736
1737// --- IDLE command ---
1738
1739// listenForIdleUpdates blocks until an IDLE update arrives, then returns it as a tea.Msg.
1740func listenForIdleUpdates(ch <-chan fetcher.IdleUpdate) tea.Cmd {
1741 return func() tea.Msg {
1742 update, ok := <-ch
1743 if !ok {
1744 return nil
1745 }
1746 return tui.IdleNewMailMsg{
1747 AccountID: update.AccountID,
1748 FolderName: update.FolderName,
1749 }
1750 }
1751}
1752
1753// --- Folder-based command functions ---
1754
1755func fetchFoldersCmd(cfg *config.Config) tea.Cmd {
1756 return func() tea.Msg {
1757 if !cfg.HasAccounts() {
1758 return nil
1759 }
1760 foldersByAccount := make(map[string][]fetcher.Folder)
1761 seen := make(map[string]fetcher.Folder)
1762 var mu sync.Mutex
1763 var wg sync.WaitGroup
1764
1765 for _, account := range cfg.Accounts {
1766 wg.Add(1)
1767 go func(acc config.Account) {
1768 defer wg.Done()
1769 folders, err := fetcher.FetchFolders(&acc)
1770 if err != nil {
1771 return
1772 }
1773 mu.Lock()
1774 foldersByAccount[acc.ID] = folders
1775 for _, f := range folders {
1776 if _, ok := seen[f.Name]; !ok {
1777 seen[f.Name] = f
1778 }
1779 }
1780 mu.Unlock()
1781 }(account)
1782 }
1783 wg.Wait()
1784
1785 var merged []fetcher.Folder
1786 for _, f := range seen {
1787 merged = append(merged, f)
1788 }
1789
1790 return tui.FoldersFetchedMsg{
1791 FoldersByAccount: foldersByAccount,
1792 MergedFolders: merged,
1793 }
1794 }
1795}
1796
1797func fetchFolderEmailsCmd(cfg *config.Config, folderName string) tea.Cmd {
1798 return func() tea.Msg {
1799 emailsByAccount := make(map[string][]fetcher.Email)
1800 var mu sync.Mutex
1801 var wg sync.WaitGroup
1802
1803 for _, account := range cfg.Accounts {
1804 wg.Add(1)
1805 go func(acc config.Account) {
1806 defer wg.Done()
1807 emails, err := fetcher.FetchFolderEmails(&acc, folderName, initialEmailLimit, 0)
1808 if err != nil {
1809 // Folder may not exist for this account — silently skip
1810 return
1811 }
1812 mu.Lock()
1813 emailsByAccount[acc.ID] = emails
1814 mu.Unlock()
1815 }(account)
1816 }
1817
1818 wg.Wait()
1819
1820 // Flatten all account emails
1821 var allEmails []fetcher.Email
1822 for _, emails := range emailsByAccount {
1823 allEmails = append(allEmails, emails...)
1824 }
1825 // Sort newest first
1826 for i := 0; i < len(allEmails); i++ {
1827 for j := i + 1; j < len(allEmails); j++ {
1828 if allEmails[j].Date.After(allEmails[i].Date) {
1829 allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
1830 }
1831 }
1832 }
1833
1834 return tui.FolderEmailsFetchedMsg{
1835 Emails: allEmails,
1836 FolderName: folderName,
1837 }
1838 }
1839}
1840
1841func fetchFolderEmailsPaginatedCmd(account *config.Account, folderName string, limit, offset uint32) tea.Cmd {
1842 return func() tea.Msg {
1843 emails, err := fetcher.FetchFolderEmails(account, folderName, limit, offset)
1844 if err != nil {
1845 return tui.FetchErr(err)
1846 }
1847 return tui.FolderEmailsAppendedMsg{
1848 Emails: emails,
1849 AccountID: account.ID,
1850 FolderName: folderName,
1851 }
1852 }
1853}
1854
1855func fetchFolderEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
1856 return func() tea.Msg {
1857 account := cfg.GetAccountByID(accountID)
1858 if account == nil {
1859 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
1860 }
1861
1862 body, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid)
1863 if err != nil {
1864 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1865 }
1866
1867 return tui.EmailBodyFetchedMsg{
1868 UID: uid,
1869 Body: body,
1870 Attachments: attachments,
1871 AccountID: accountID,
1872 Mailbox: mailbox,
1873 }
1874 }
1875}
1876
1877func markEmailAsReadCmd(account *config.Account, uid uint32, accountID string, folderName string) tea.Cmd {
1878 return func() tea.Msg {
1879 err := fetcher.MarkEmailAsReadInMailbox(account, folderName, uid)
1880 return tui.EmailMarkedReadMsg{UID: uid, AccountID: accountID, Err: err}
1881 }
1882}
1883
1884func deleteFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
1885 return func() tea.Msg {
1886 err := fetcher.DeleteFolderEmail(account, folderName, uid)
1887 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1888 }
1889}
1890
1891func archiveFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
1892 return func() tea.Msg {
1893 err := fetcher.ArchiveFolderEmail(account, folderName, uid)
1894 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1895 }
1896}
1897
1898func moveEmailToFolderCmd(account *config.Account, uid uint32, accountID string, sourceFolder, destFolder string) tea.Cmd {
1899 return func() tea.Msg {
1900 err := fetcher.MoveEmailToFolder(account, uid, sourceFolder, destFolder)
1901 return tui.EmailMovedMsg{
1902 UID: uid,
1903 AccountID: accountID,
1904 SourceFolder: sourceFolder,
1905 DestFolder: destFolder,
1906 Err: err,
1907 }
1908 }
1909}
1910
1911func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
1912 return func() tea.Msg {
1913 // Download and decode the attachment using encoding provided in msg.Encoding.
1914 var data []byte
1915 var err error
1916 switch msg.Mailbox {
1917 case tui.MailboxSent:
1918 data, err = fetcher.FetchSentAttachment(account, uid, msg.PartID, msg.Encoding)
1919 case tui.MailboxTrash:
1920 data, err = fetcher.FetchTrashAttachment(account, uid, msg.PartID, msg.Encoding)
1921 case tui.MailboxArchive:
1922 data, err = fetcher.FetchArchiveAttachment(account, uid, msg.PartID, msg.Encoding)
1923 default:
1924 data, err = fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding)
1925 }
1926 if err != nil {
1927 return tui.AttachmentDownloadedMsg{Err: err}
1928 }
1929
1930 homeDir, err := os.UserHomeDir()
1931 if err != nil {
1932 return tui.AttachmentDownloadedMsg{Err: err}
1933 }
1934 downloadsPath := filepath.Join(homeDir, "Downloads")
1935 if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
1936 if mkErr := os.MkdirAll(downloadsPath, 0755); mkErr != nil {
1937 return tui.AttachmentDownloadedMsg{Err: mkErr}
1938 }
1939 }
1940
1941 // Save the attachment using an exclusive create so we never overwrite an existing file.
1942 // If the filename already exists, append \" (n)\" before the extension.
1943 origName := msg.Filename
1944 ext := filepath.Ext(origName)
1945 base := strings.TrimSuffix(origName, ext)
1946 candidate := origName
1947 i := 1
1948 var filePath string
1949
1950 for {
1951 filePath = filepath.Join(downloadsPath, candidate)
1952
1953 // Try to create file exclusively. If it already exists, os.OpenFile will return an error
1954 // that satisfies os.IsExist(err), so we can increment the candidate.
1955 f, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
1956 if err != nil {
1957 if os.IsExist(err) {
1958 // file exists, try next candidate
1959 candidate = fmt.Sprintf("%s (%d)%s", base, i, ext)
1960 i++
1961 continue
1962 }
1963 // Some other error while attempting to create file
1964 log.Printf("error creating file %s: %v", filePath, err)
1965 return tui.AttachmentDownloadedMsg{Err: err}
1966 }
1967
1968 // Successfully created the file descriptor; write and close.
1969 if _, writeErr := f.Write(data); writeErr != nil {
1970 _ = f.Close()
1971 log.Printf("error writing to file %s: %v", filePath, writeErr)
1972 return tui.AttachmentDownloadedMsg{Err: writeErr}
1973 }
1974 if closeErr := f.Close(); closeErr != nil {
1975 log.Printf("warning: error closing file %s: %v", filePath, closeErr)
1976 }
1977
1978 // file saved successfully
1979 break
1980 }
1981
1982 log.Printf("attachment saved to %s", filePath)
1983
1984 // Try to open the file using a platform-specific opener asynchronously and log the outcome.
1985 go func(p string) {
1986 var cmd *exec.Cmd
1987 switch runtime.GOOS {
1988 case "darwin":
1989 cmd = exec.Command("open", p)
1990 case "linux":
1991 cmd = exec.Command("xdg-open", p)
1992 case "windows":
1993 // 'start' is a cmd builtin; provide an empty title argument to avoid interpreting the path as the title.
1994 cmd = exec.Command("cmd", "/c", "start", "", p)
1995 default:
1996 // Unsupported OS: nothing to do.
1997 return
1998 }
1999 if err := cmd.Start(); err != nil {
2000 log.Printf("failed to open file %s: %v", p, err)
2001 }
2002 }(filePath)
2003
2004 return tui.AttachmentDownloadedMsg{Path: filePath, Err: nil}
2005 }
2006}
2007
2008/*
2009detectInstalledVersion returns a best-effort installed version string.
2010Priority:
2011 1. If the build-in `version` variable is set to something other than "dev", return it.
2012 2. If Homebrew is present and reports a version for `matcha`, return that.
2013 3. If snap is present and lists `matcha`, return that.
2014 4. Fallback to the build `version` (likely "dev").
2015*/
2016func detectInstalledVersion() string {
2017 v := strings.TrimSpace(version)
2018 if v != "dev" && v != "" {
2019 return v
2020 }
2021
2022 // Try Homebrew (macOS)
2023 if runtime.GOOS == "darwin" {
2024 if _, err := exec.LookPath("brew"); err == nil {
2025 // `brew list --versions matcha` prints: matcha 1.2.3
2026 if out, err := exec.Command("brew", "list", "--versions", "matcha").Output(); err == nil {
2027 parts := strings.Fields(string(out))
2028 if len(parts) >= 2 {
2029 return parts[1]
2030 }
2031 }
2032 }
2033 }
2034
2035 // Try WinGet (Windows)
2036 if runtime.GOOS == "windows" {
2037 if _, err := exec.LookPath("winget"); err == nil {
2038 if out, err := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity").Output(); err == nil {
2039 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2040 for _, line := range lines {
2041 if strings.Contains(strings.ToLower(line), "floatpane.matcha") {
2042 fields := strings.Fields(line)
2043 for _, f := range fields {
2044 if len(f) > 0 && f[0] >= '0' && f[0] <= '9' && strings.Contains(f, ".") {
2045 return f
2046 }
2047 }
2048 }
2049 }
2050 }
2051 }
2052 }
2053
2054 // Try snap (Linux)
2055 if runtime.GOOS == "linux" {
2056 if _, err := exec.LookPath("snap"); err == nil {
2057 if out, err := exec.Command("snap", "list", "matcha").Output(); err == nil {
2058 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2059 if len(lines) >= 2 {
2060 fields := strings.Fields(lines[1])
2061 if len(fields) >= 2 {
2062 return fields[1]
2063 }
2064 }
2065 }
2066 }
2067
2068 if _, err := exec.LookPath("flatpak"); err == nil {
2069 if out, err := exec.Command("flatpak", "info", "com.floatpane.matcha").Output(); err == nil {
2070 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2071 for _, line := range lines {
2072 line = strings.TrimSpace(line)
2073 if strings.HasPrefix(line, "Version:") {
2074 fields := strings.Fields(line)
2075 if len(fields) >= 2 {
2076 return fields[1]
2077 }
2078 }
2079 }
2080 }
2081 }
2082 }
2083
2084 return v
2085}
2086
2087/*
2088checkForUpdatesCmd queries GitHub for the latest release tag and returns a
2089tea.Msg (UpdateAvailableMsg) if the latest version differs from the current
2090installed version. This runs in the background when the TUI initializes.
2091*/
2092func checkForUpdatesCmd() tea.Cmd {
2093 return func() tea.Msg {
2094 // Non-fatal: if anything goes wrong we just don't show the update message.
2095 const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
2096 resp, err := http.Get(api)
2097 if err != nil {
2098 return nil
2099 }
2100 defer resp.Body.Close()
2101
2102 var rel githubRelease
2103 if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
2104 return nil
2105 }
2106
2107 latest := strings.TrimPrefix(rel.TagName, "v")
2108 installed := strings.TrimPrefix(detectInstalledVersion(), "v")
2109 if latest != "" && installed != "" && latest != installed {
2110 return UpdateAvailableMsg{Latest: latest, Current: installed}
2111 }
2112 return nil
2113 }
2114}
2115
2116// runUpdateCLI implements the CLI entrypoint for `matcha update`.
2117// It detects the likely installation method and attempts the appropriate
2118// update path (Homebrew, Snap, or GitHub release binary extract).
2119// runGmailOAuthCLI handles the "matcha gmail" subcommand for OAuth2 management.
2120// Usage:
2121//
2122// matcha gmail auth <email> [--client-id ID --client-secret SECRET]
2123// matcha gmail token <email>
2124// matcha gmail revoke <email>
2125func runGmailOAuthCLI(args []string) {
2126 if len(args) < 1 {
2127 fmt.Fprintln(os.Stderr, "Usage: matcha gmail <auth|token|revoke> <email> [flags]")
2128 fmt.Fprintln(os.Stderr, "")
2129 fmt.Fprintln(os.Stderr, "Commands:")
2130 fmt.Fprintln(os.Stderr, " auth <email> Authorize a Gmail account via OAuth2 (opens browser)")
2131 fmt.Fprintln(os.Stderr, " token <email> Print a fresh access token (refreshes automatically)")
2132 fmt.Fprintln(os.Stderr, " revoke <email> Revoke and delete stored OAuth2 tokens")
2133 fmt.Fprintln(os.Stderr, "")
2134 fmt.Fprintln(os.Stderr, "Before using OAuth2, create ~/.config/matcha/oauth_client.json with:")
2135 fmt.Fprintln(os.Stderr, ` {"client_id": "YOUR_ID", "client_secret": "YOUR_SECRET"}`)
2136 fmt.Fprintln(os.Stderr, "")
2137 fmt.Fprintln(os.Stderr, "Get credentials at: https://console.cloud.google.com/apis/credentials")
2138 os.Exit(1)
2139 }
2140
2141 // Find the Python script and pass through to it
2142 script, err := config.OAuthScriptPath()
2143 if err != nil {
2144 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
2145 os.Exit(1)
2146 }
2147
2148 cmdArgs := append([]string{script}, args...)
2149 cmd := exec.Command("python3", cmdArgs...)
2150 cmd.Stdin = os.Stdin
2151 cmd.Stdout = os.Stdout
2152 cmd.Stderr = os.Stderr
2153
2154 if err := cmd.Run(); err != nil {
2155 if exitErr, ok := err.(*exec.ExitError); ok {
2156 os.Exit(exitErr.ExitCode())
2157 }
2158 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
2159 os.Exit(1)
2160 }
2161}
2162
2163func runUpdateCLI() error {
2164 const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
2165 resp, err := http.Get(api)
2166 if err != nil {
2167 return fmt.Errorf("could not query releases: %w", err)
2168 }
2169 defer resp.Body.Close()
2170
2171 var rel githubRelease
2172 if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
2173 return fmt.Errorf("could not parse release info: %w", err)
2174 }
2175
2176 latestTag := rel.TagName
2177 if strings.HasPrefix(latestTag, "v") {
2178 latestTag = latestTag[1:]
2179 }
2180
2181 fmt.Printf("Current version: %s\n", version)
2182 fmt.Printf("Latest version: %s\n", latestTag)
2183
2184 // Quick check: if already up-to-date, exit
2185 cur := version
2186 if strings.HasPrefix(cur, "v") {
2187 cur = cur[1:]
2188 }
2189 if latestTag == "" || cur == latestTag {
2190 fmt.Println("Already up to date.")
2191 return nil
2192 }
2193
2194 // Detect Homebrew
2195 if _, err := exec.LookPath("brew"); err == nil {
2196 fmt.Println("Detected Homebrew — updating taps and attempting to upgrade via brew.")
2197
2198 updateCmd := exec.Command("brew", "update")
2199 updateCmd.Stdout = os.Stdout
2200 updateCmd.Stderr = os.Stderr
2201 if err := updateCmd.Run(); err != nil {
2202 fmt.Printf("Homebrew update failed: %v\n", err)
2203 // continue to attempt upgrade even if update failed
2204 }
2205
2206 upgradeCmd := exec.Command("brew", "upgrade", "floatpane/matcha/matcha")
2207 upgradeCmd.Stdout = os.Stdout
2208 upgradeCmd.Stderr = os.Stderr
2209 if err := upgradeCmd.Run(); err == nil {
2210 fmt.Println("Successfully upgraded via Homebrew.")
2211 return nil
2212 }
2213 fmt.Printf("Homebrew upgrade failed: %v\n", err)
2214 // fallthrough to other methods
2215 }
2216
2217 // Detect snap
2218 if _, err := exec.LookPath("snap"); err == nil {
2219 // Check if matcha is installed as a snap
2220 cmdCheck := exec.Command("snap", "list", "matcha")
2221 if err := cmdCheck.Run(); err == nil {
2222 fmt.Println("Detected Snap package — attempting to refresh.")
2223 cmd := exec.Command("snap", "refresh", "matcha")
2224 cmd.Stdout = os.Stdout
2225 cmd.Stderr = os.Stderr
2226 if err := cmd.Run(); err == nil {
2227 fmt.Println("Successfully refreshed snap.")
2228 return nil
2229 }
2230 fmt.Printf("Snap refresh failed: %v\n", err)
2231 // fallthrough
2232 }
2233 }
2234 // Detect flatpak
2235 if _, err := exec.LookPath("flatpak"); err == nil {
2236 // Check if matcha is installed as a flatpak
2237 cmdCheck := exec.Command("flatpak", "info", "com.floatpane.matcha")
2238 if err := cmdCheck.Run(); err == nil {
2239 fmt.Println("Detected Flatpak package — attempting to update.")
2240 cmd := exec.Command("flatpak", "update", "-y", "com.floatpane.matcha")
2241 cmd.Stdout = os.Stdout
2242 cmd.Stderr = os.Stderr
2243 if err := cmd.Run(); err == nil {
2244 fmt.Println("Successfully updated flatpak.")
2245 return nil
2246 }
2247 fmt.Printf("Flatpak update failed: %v\n", err)
2248 // fallthrough
2249 }
2250 }
2251
2252 // Detect WinGet
2253 if _, err := exec.LookPath("winget"); err == nil {
2254 cmdCheck := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity")
2255 if err := cmdCheck.Run(); err == nil {
2256 fmt.Println("Detected WinGet package — attempting to upgrade.")
2257 cmd := exec.Command("winget", "upgrade", "--id", "floatpane.matcha", "--disable-interactivity")
2258 cmd.Stdout = os.Stdout
2259 cmd.Stderr = os.Stderr
2260 if err := cmd.Run(); err == nil {
2261 fmt.Println("Successfully upgraded via WinGet.")
2262 return nil
2263 }
2264 fmt.Printf("WinGet upgrade failed: %v\n", err)
2265 // fallthrough
2266 }
2267 }
2268
2269 // Otherwise attempt to download the proper release asset and replace the binary.
2270 osName := runtime.GOOS
2271 arch := runtime.GOARCH
2272
2273 // Try to find a matching asset
2274 var assetURL, assetName string
2275 for _, a := range rel.Assets {
2276 n := strings.ToLower(a.Name)
2277 if strings.Contains(n, osName) && strings.Contains(n, arch) && (strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || strings.HasSuffix(n, ".zip")) {
2278 assetURL = a.BrowserDownloadURL
2279 assetName = a.Name
2280 break
2281 }
2282 }
2283 if assetURL == "" {
2284 // Try any asset that contains 'matcha' and os/arch as a fallback
2285 for _, a := range rel.Assets {
2286 n := strings.ToLower(a.Name)
2287 if strings.Contains(n, "matcha") && (strings.Contains(n, osName) || strings.Contains(n, arch)) {
2288 assetURL = a.BrowserDownloadURL
2289 assetName = a.Name
2290 break
2291 }
2292 }
2293 }
2294
2295 if assetURL == "" {
2296 return fmt.Errorf("no suitable release artifact found for %s/%s", osName, arch)
2297 }
2298
2299 fmt.Printf("Found release asset: %s\n", assetName)
2300 fmt.Println("Downloading...")
2301
2302 // Download asset
2303 respAsset, err := http.Get(assetURL)
2304 if err != nil {
2305 return fmt.Errorf("download failed: %w", err)
2306 }
2307 defer respAsset.Body.Close()
2308
2309 // Create a temp file for the download
2310 tmpDir, err := os.MkdirTemp("", "matcha-update-*")
2311 if err != nil {
2312 return fmt.Errorf("could not create temp dir: %w", err)
2313 }
2314 defer os.RemoveAll(tmpDir)
2315
2316 assetPath := filepath.Join(tmpDir, assetName)
2317 outFile, err := os.Create(assetPath)
2318 if err != nil {
2319 return fmt.Errorf("could not create temp file: %w", err)
2320 }
2321 _, err = io.Copy(outFile, respAsset.Body)
2322 outFile.Close()
2323 if err != nil {
2324 return fmt.Errorf("could not write asset to disk: %w", err)
2325 }
2326
2327 // Determine the expected binary name based on the OS.
2328 binaryName := "matcha"
2329 if runtime.GOOS == "windows" {
2330 binaryName = "matcha.exe"
2331 }
2332
2333 // Extract the binary from the archive.
2334 var binPath string
2335 if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") {
2336 f, err := os.Open(assetPath)
2337 if err != nil {
2338 return fmt.Errorf("could not open archive: %w", err)
2339 }
2340 defer f.Close()
2341 gzr, err := gzip.NewReader(f)
2342 if err != nil {
2343 return fmt.Errorf("could not create gzip reader: %w", err)
2344 }
2345 tr := tar.NewReader(gzr)
2346 for {
2347 hdr, err := tr.Next()
2348 if err == io.EOF {
2349 break
2350 }
2351 if err != nil {
2352 return fmt.Errorf("error reading tar: %w", err)
2353 }
2354 name := filepath.Base(hdr.Name)
2355 if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) {
2356 binPath = filepath.Join(tmpDir, binaryName)
2357 out, err := os.Create(binPath)
2358 if err != nil {
2359 return fmt.Errorf("could not create binary file: %w", err)
2360 }
2361 if _, err := io.Copy(out, tr); err != nil {
2362 out.Close()
2363 return fmt.Errorf("could not extract binary: %w", err)
2364 }
2365 out.Close()
2366 if err := os.Chmod(binPath, 0755); err != nil {
2367 return fmt.Errorf("could not make binary executable: %w", err)
2368 }
2369 break
2370 }
2371 }
2372 } else if strings.HasSuffix(assetName, ".zip") {
2373 zr, err := zip.OpenReader(assetPath)
2374 if err != nil {
2375 return fmt.Errorf("could not open zip archive: %w", err)
2376 }
2377 defer zr.Close()
2378 for _, zf := range zr.File {
2379 name := filepath.Base(zf.Name)
2380 if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && !zf.FileInfo().IsDir() {
2381 rc, err := zf.Open()
2382 if err != nil {
2383 return fmt.Errorf("could not open file in zip: %w", err)
2384 }
2385 binPath = filepath.Join(tmpDir, binaryName)
2386 out, err := os.Create(binPath)
2387 if err != nil {
2388 rc.Close()
2389 return fmt.Errorf("could not create binary file: %w", err)
2390 }
2391 if _, err := io.Copy(out, rc); err != nil {
2392 out.Close()
2393 rc.Close()
2394 return fmt.Errorf("could not extract binary: %w", err)
2395 }
2396 out.Close()
2397 rc.Close()
2398 if err := os.Chmod(binPath, 0755); err != nil {
2399 return fmt.Errorf("could not make binary executable: %w", err)
2400 }
2401 break
2402 }
2403 }
2404 } else {
2405 // For non-archive assets, assume the asset is the binary itself.
2406 binPath = assetPath
2407 if err := os.Chmod(binPath, 0755); err != nil {
2408 // ignore chmod errors but warn
2409 fmt.Printf("warning: could not chmod downloaded binary: %v\n", err)
2410 }
2411 }
2412
2413 if binPath == "" {
2414 return fmt.Errorf("could not locate matcha binary inside the release artifact")
2415 }
2416
2417 // Replace the running executable with the new binary
2418 execPath, err := os.Executable()
2419 if err != nil {
2420 return fmt.Errorf("could not determine executable path: %w", err)
2421 }
2422
2423 // Write the new binary to a temp file in same dir, then rename for atomic replacement.
2424 execDir := filepath.Dir(execPath)
2425 tmpNew := filepath.Join(execDir, fmt.Sprintf("matcha.new.%d", time.Now().Unix()))
2426 in, err := os.Open(binPath)
2427 if err != nil {
2428 return fmt.Errorf("could not open new binary: %w", err)
2429 }
2430 out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
2431 if err != nil {
2432 in.Close()
2433 return fmt.Errorf("could not create temp binary in target dir: %w", err)
2434 }
2435 if _, err := io.Copy(out, in); err != nil {
2436 in.Close()
2437 out.Close()
2438 return fmt.Errorf("could not write new binary to disk: %w", err)
2439 }
2440 in.Close()
2441 out.Close()
2442
2443 // On Windows, a running executable cannot be overwritten directly.
2444 // Move the old binary out of the way first, then rename the new one in.
2445 if runtime.GOOS == "windows" {
2446 oldPath := execPath + ".old"
2447 _ = os.Remove(oldPath) // clean up any previous leftover
2448 if err := os.Rename(execPath, oldPath); err != nil {
2449 return fmt.Errorf("could not move old executable out of the way: %w", err)
2450 }
2451 }
2452
2453 if err := os.Rename(tmpNew, execPath); err != nil {
2454 return fmt.Errorf("could not replace executable: %w", err)
2455 }
2456
2457 fmt.Println("Successfully updated matcha to", latestTag)
2458 return nil
2459}
2460
2461func filterUnique(existing, incoming []fetcher.Email) []fetcher.Email {
2462 seen := make(map[uint32]struct{})
2463 for _, e := range existing {
2464 seen[e.UID] = struct{}{}
2465 }
2466 var unique []fetcher.Email
2467 for _, e := range incoming {
2468 if _, ok := seen[e.UID]; !ok {
2469 unique = append(unique, e)
2470 }
2471 }
2472 return unique
2473}
2474
2475func main() {
2476 // If invoked with version flag, print version and exit
2477 if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") {
2478 fmt.Printf("matcha version %s", version)
2479 if commit != "" {
2480 fmt.Printf(" (%s)", commit)
2481 }
2482 if date != "" {
2483 fmt.Printf(" built on %s", date)
2484 }
2485 fmt.Println()
2486 os.Exit(0)
2487 }
2488
2489 // If invoked as CLI update command, run updater and exit.
2490 if len(os.Args) > 1 && os.Args[1] == "update" {
2491 if err := runUpdateCLI(); err != nil {
2492 fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
2493 os.Exit(1)
2494 }
2495 os.Exit(0)
2496 }
2497
2498 // Gmail OAuth2 CLI subcommand: matcha gmail <auth|token|revoke> <email> [flags]
2499 if len(os.Args) > 1 && os.Args[1] == "gmail" {
2500 runGmailOAuthCLI(os.Args[2:])
2501 os.Exit(0)
2502 }
2503
2504 cfg, err := config.LoadConfig()
2505 if err == nil && cfg.Theme != "" {
2506 theme.SetTheme(cfg.Theme)
2507 }
2508 tui.RebuildStyles()
2509
2510 var initialModel *mainModel
2511 if err != nil {
2512 initialModel = newInitialModel(nil)
2513 } else {
2514 initialModel = newInitialModel(cfg)
2515 }
2516
2517 // Initialize plugin system
2518 plugins := plugin.NewManager()
2519 plugins.LoadPlugins()
2520 initialModel.plugins = plugins
2521 plugins.CallHook(plugin.HookStartup)
2522
2523 p := tea.NewProgram(initialModel)
2524
2525 if _, err := p.Run(); err != nil {
2526 plugins.Close()
2527 fmt.Printf("Alas, there's been an error: %v", err)
2528 os.Exit(1)
2529 }
2530
2531 plugins.CallHook(plugin.HookShutdown)
2532 plugins.Close()
2533}