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