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