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