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