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