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