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