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.ForwardEmailMsg:
603 subject := msg.Email.Subject
604 if !strings.HasPrefix(strings.ToLower(subject), "fwd:") {
605 subject = "Fwd: " + subject
606 }
607
608 forwardHeader := fmt.Sprintf("\n\n---------- Forwarded message ----------\nFrom: %s\nDate: %s\nSubject: %s\nTo: %s\n\n",
609 msg.Email.From,
610 msg.Email.Date.Format("Mon, Jan 2, 2006 at 3:04 PM"),
611 msg.Email.Subject,
612 msg.Email.To,
613 )
614
615 body := forwardHeader + msg.Email.Body
616
617 var composer *tui.Composer
618 if m.config != nil && len(m.config.Accounts) > 0 {
619 // Use the account that received the email
620 accountID := msg.Email.AccountID
621 if accountID == "" {
622 accountID = m.config.GetFirstAccount().ID
623 }
624 composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, "", subject, body)
625 } else {
626 composer = tui.NewComposer("", "", subject, body)
627 }
628
629 m.current = composer
630 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
631 return m, m.current.Init()
632
633 case tui.GoToFilePickerMsg:
634 m.previousModel = m.current
635 wd, _ := os.Getwd()
636 m.current = tui.NewFilePicker(wd)
637 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
638 return m, m.current.Init()
639
640 case tui.FileSelectedMsg, tui.CancelFilePickerMsg:
641 if m.previousModel != nil {
642 m.current = m.previousModel
643 m.previousModel = nil
644 }
645 m.current, cmd = m.current.Update(msg)
646 cmds = append(cmds, cmd)
647
648 case tui.SendEmailMsg:
649 // Get draft ID before clearing composer (if it's a composer)
650 var draftID string
651 if composer, ok := m.current.(*tui.Composer); ok {
652 draftID = composer.GetDraftID()
653 }
654 m.current = tui.NewStatus("Sending email...")
655
656 // Get the account to send from
657 var account *config.Account
658 if msg.AccountID != "" && m.config != nil {
659 account = m.config.GetAccountByID(msg.AccountID)
660 }
661 if account == nil && m.config != nil {
662 account = m.config.GetFirstAccount()
663 }
664
665 // Save contact and delete draft in background
666 go func() {
667 // Save the recipient as a contact
668 if msg.To != "" {
669 // Parse "Name <email>" format
670 name, email := parseEmailAddress(msg.To)
671 if err := config.AddContact(name, email); err != nil {
672 log.Printf("Error saving contact: %v", err)
673 }
674 }
675 // Delete the draft since email is being sent
676 if draftID != "" {
677 if err := config.DeleteDraft(draftID); err != nil {
678 log.Printf("Error deleting draft after send: %v", err)
679 }
680 }
681 }()
682
683 return m, tea.Batch(m.current.Init(), sendEmail(account, msg))
684
685 case tui.EmailResultMsg:
686 if msg.Err != nil {
687 log.Printf("Failed to send email: %v", msg.Err)
688 m.previousModel = tui.NewChoice()
689 m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
690 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
691 return tui.RestoreViewMsg{}
692 })
693 }
694 m.current = tui.NewChoice()
695 return m, m.current.Init()
696
697 case tui.DeleteEmailMsg:
698 m.previousModel = m.current
699 m.current = tui.NewStatus("Deleting email...")
700
701 account := m.config.GetAccountByID(msg.AccountID)
702 if account == nil {
703 if msg.Mailbox == tui.MailboxSent && m.sentInbox != nil {
704 m.current = m.sentInbox
705 } else {
706 m.current = m.inbox
707 }
708 return m, nil
709 }
710
711 return m, tea.Batch(m.current.Init(), deleteEmailCmd(account, msg.UID, msg.AccountID, msg.Mailbox))
712
713 case tui.ArchiveEmailMsg:
714 m.previousModel = m.current
715 m.current = tui.NewStatus("Archiving email...")
716
717 account := m.config.GetAccountByID(msg.AccountID)
718 if account == nil {
719 if msg.Mailbox == tui.MailboxSent && m.sentInbox != nil {
720 m.current = m.sentInbox
721 } else {
722 m.current = m.inbox
723 }
724 return m, nil
725 }
726
727 return m, tea.Batch(m.current.Init(), archiveEmailCmd(account, msg.UID, msg.AccountID, msg.Mailbox))
728
729 case tui.EmailActionDoneMsg:
730 if msg.Err != nil {
731 log.Printf("Action failed: %v", msg.Err)
732 m.previousModel = m.current
733 switch msg.Mailbox {
734 case tui.MailboxSent:
735 if m.sentInbox != nil {
736 m.previousModel = m.sentInbox
737 }
738 case tui.MailboxTrash, tui.MailboxArchive:
739 if m.trashArchive != nil {
740 m.previousModel = m.trashArchive
741 }
742 default:
743 if m.inbox != nil {
744 m.previousModel = m.inbox
745 }
746 }
747 m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
748 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
749 return tui.RestoreViewMsg{}
750 })
751 }
752
753 // Remove email from stores
754 m.removeEmailByMailbox(msg.UID, msg.AccountID, msg.Mailbox)
755
756 if msg.Mailbox == tui.MailboxSent {
757 if m.sentInbox != nil {
758 m.sentInbox.RemoveEmail(msg.UID, msg.AccountID)
759 m.current = m.sentInbox
760 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
761 return m, m.current.Init()
762 }
763 m.current = tui.NewChoice()
764 return m, m.current.Init()
765 }
766
767 if msg.Mailbox == tui.MailboxTrash || msg.Mailbox == tui.MailboxArchive {
768 if m.trashArchive != nil {
769 m.trashArchive.RemoveEmail(msg.UID, msg.AccountID, msg.Mailbox)
770 m.current = m.trashArchive
771 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
772 return m, m.current.Init()
773 }
774 m.current = tui.NewChoice()
775 return m, m.current.Init()
776 }
777
778 if m.inbox != nil {
779 m.inbox.RemoveEmail(msg.UID, msg.AccountID)
780 m.current = m.inbox
781 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
782 return m, m.current.Init()
783 }
784 m.current = tui.NewChoice()
785 return m, m.current.Init()
786
787 case tui.DownloadAttachmentMsg:
788 m.previousModel = m.current
789 m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename))
790
791 account := m.config.GetAccountByID(msg.AccountID)
792 if account == nil {
793 m.current = m.previousModel
794 return m, nil
795 }
796
797 email := m.getEmailByIndex(msg.Index, msg.Mailbox)
798 if email == nil {
799 m.current = m.previousModel
800 return m, nil
801 }
802
803 // Find the correct attachment to get encoding
804 var encoding string
805 for _, att := range email.Attachments {
806 if att.PartID == msg.PartID {
807 encoding = att.Encoding
808 break
809 }
810 }
811 newMsg := tui.DownloadAttachmentMsg{
812 Index: msg.Index,
813 Filename: msg.Filename,
814 PartID: msg.PartID,
815 Data: msg.Data,
816 AccountID: msg.AccountID,
817 Encoding: encoding,
818 Mailbox: msg.Mailbox,
819 }
820 return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(account, email.UID, newMsg))
821
822 case tui.AttachmentDownloadedMsg:
823 var statusMsg string
824 if msg.Err != nil {
825 statusMsg = fmt.Sprintf("Error downloading: %v", msg.Err)
826 } else {
827 statusMsg = fmt.Sprintf("Saved to %s", msg.Path)
828 }
829 m.current = tui.NewStatus(statusMsg)
830 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
831 return tui.RestoreViewMsg{}
832 })
833
834 case tui.RestoreViewMsg:
835 if m.previousModel != nil {
836 m.current = m.previousModel
837 m.previousModel = nil
838 }
839 return m, nil
840 }
841
842 return m, tea.Batch(cmds...)
843}
844
845func (m *mainModel) getEmailByIndex(index int, mailbox tui.MailboxKind) *fetcher.Email {
846 switch mailbox {
847 case tui.MailboxSent:
848 if index >= 0 && index < len(m.sentEmails) {
849 return &m.sentEmails[index]
850 }
851 case tui.MailboxTrash:
852 if index >= 0 && index < len(m.trashEmails) {
853 return &m.trashEmails[index]
854 }
855 case tui.MailboxArchive:
856 if index >= 0 && index < len(m.archiveEmails) {
857 return &m.archiveEmails[index]
858 }
859 default:
860 if index >= 0 && index < len(m.emails) {
861 return &m.emails[index]
862 }
863 }
864 return nil
865}
866
867func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string, mailbox tui.MailboxKind) *fetcher.Email {
868 switch mailbox {
869 case tui.MailboxSent:
870 for i := range m.sentEmails {
871 if m.sentEmails[i].UID == uid && m.sentEmails[i].AccountID == accountID {
872 return &m.sentEmails[i]
873 }
874 }
875 case tui.MailboxTrash:
876 for i := range m.trashEmails {
877 if m.trashEmails[i].UID == uid && m.trashEmails[i].AccountID == accountID {
878 return &m.trashEmails[i]
879 }
880 }
881 case tui.MailboxArchive:
882 for i := range m.archiveEmails {
883 if m.archiveEmails[i].UID == uid && m.archiveEmails[i].AccountID == accountID {
884 return &m.archiveEmails[i]
885 }
886 }
887 default:
888 for i := range m.emails {
889 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
890 return &m.emails[i]
891 }
892 }
893 }
894 return nil
895}
896
897func (m *mainModel) getEmailIndex(uid uint32, accountID string, mailbox tui.MailboxKind) int {
898 switch mailbox {
899 case tui.MailboxSent:
900 for i := range m.sentEmails {
901 if m.sentEmails[i].UID == uid && m.sentEmails[i].AccountID == accountID {
902 return i
903 }
904 }
905 case tui.MailboxTrash:
906 for i := range m.trashEmails {
907 if m.trashEmails[i].UID == uid && m.trashEmails[i].AccountID == accountID {
908 return i
909 }
910 }
911 case tui.MailboxArchive:
912 for i := range m.archiveEmails {
913 if m.archiveEmails[i].UID == uid && m.archiveEmails[i].AccountID == accountID {
914 return i
915 }
916 }
917 default:
918 for i := range m.emails {
919 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
920 return i
921 }
922 }
923 }
924 return -1
925}
926
927func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, mailbox tui.MailboxKind, body string, attachments []fetcher.Attachment) {
928 switch mailbox {
929 case tui.MailboxSent:
930 for i := range m.sentEmails {
931 if m.sentEmails[i].UID == uid && m.sentEmails[i].AccountID == accountID {
932 m.sentEmails[i].Body = body
933 m.sentEmails[i].Attachments = attachments
934 break
935 }
936 }
937 if emails, ok := m.sentByAcct[accountID]; ok {
938 for i := range emails {
939 if emails[i].UID == uid {
940 emails[i].Body = body
941 emails[i].Attachments = attachments
942 break
943 }
944 }
945 }
946 case tui.MailboxTrash:
947 for i := range m.trashEmails {
948 if m.trashEmails[i].UID == uid && m.trashEmails[i].AccountID == accountID {
949 m.trashEmails[i].Body = body
950 m.trashEmails[i].Attachments = attachments
951 break
952 }
953 }
954 if emails, ok := m.trashByAcct[accountID]; ok {
955 for i := range emails {
956 if emails[i].UID == uid {
957 emails[i].Body = body
958 emails[i].Attachments = attachments
959 break
960 }
961 }
962 }
963 case tui.MailboxArchive:
964 for i := range m.archiveEmails {
965 if m.archiveEmails[i].UID == uid && m.archiveEmails[i].AccountID == accountID {
966 m.archiveEmails[i].Body = body
967 m.archiveEmails[i].Attachments = attachments
968 break
969 }
970 }
971 if emails, ok := m.archiveByAcct[accountID]; ok {
972 for i := range emails {
973 if emails[i].UID == uid {
974 emails[i].Body = body
975 emails[i].Attachments = attachments
976 break
977 }
978 }
979 }
980 default:
981 for i := range m.emails {
982 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
983 m.emails[i].Body = body
984 m.emails[i].Attachments = attachments
985 break
986 }
987 }
988 if emails, ok := m.emailsByAcct[accountID]; ok {
989 for i := range emails {
990 if emails[i].UID == uid {
991 emails[i].Body = body
992 emails[i].Attachments = attachments
993 break
994 }
995 }
996 }
997 }
998}
999
1000func (m *mainModel) removeEmailByMailbox(uid uint32, accountID string, mailbox tui.MailboxKind) {
1001 switch mailbox {
1002 case tui.MailboxSent:
1003 var filtered []fetcher.Email
1004 for _, e := range m.sentEmails {
1005 if !(e.UID == uid && e.AccountID == accountID) {
1006 filtered = append(filtered, e)
1007 }
1008 }
1009 m.sentEmails = filtered
1010 if emails, ok := m.sentByAcct[accountID]; ok {
1011 var filteredAcct []fetcher.Email
1012 for _, e := range emails {
1013 if e.UID != uid {
1014 filteredAcct = append(filteredAcct, e)
1015 }
1016 }
1017 m.sentByAcct[accountID] = filteredAcct
1018 }
1019 case tui.MailboxTrash:
1020 var filtered []fetcher.Email
1021 for _, e := range m.trashEmails {
1022 if !(e.UID == uid && e.AccountID == accountID) {
1023 filtered = append(filtered, e)
1024 }
1025 }
1026 m.trashEmails = filtered
1027 if emails, ok := m.trashByAcct[accountID]; ok {
1028 var filteredAcct []fetcher.Email
1029 for _, e := range emails {
1030 if e.UID != uid {
1031 filteredAcct = append(filteredAcct, e)
1032 }
1033 }
1034 m.trashByAcct[accountID] = filteredAcct
1035 }
1036 case tui.MailboxArchive:
1037 var filtered []fetcher.Email
1038 for _, e := range m.archiveEmails {
1039 if !(e.UID == uid && e.AccountID == accountID) {
1040 filtered = append(filtered, e)
1041 }
1042 }
1043 m.archiveEmails = filtered
1044 if emails, ok := m.archiveByAcct[accountID]; ok {
1045 var filteredAcct []fetcher.Email
1046 for _, e := range emails {
1047 if e.UID != uid {
1048 filteredAcct = append(filteredAcct, e)
1049 }
1050 }
1051 m.archiveByAcct[accountID] = filteredAcct
1052 }
1053 default:
1054 var filtered []fetcher.Email
1055 for _, e := range m.emails {
1056 if !(e.UID == uid && e.AccountID == accountID) {
1057 filtered = append(filtered, e)
1058 }
1059 }
1060 m.emails = filtered
1061 if emails, ok := m.emailsByAcct[accountID]; ok {
1062 var filteredAcct []fetcher.Email
1063 for _, e := range emails {
1064 if e.UID != uid {
1065 filteredAcct = append(filteredAcct, e)
1066 }
1067 }
1068 m.emailsByAcct[accountID] = filteredAcct
1069 }
1070 }
1071}
1072
1073func (m *mainModel) View() string {
1074 return m.current.View()
1075}
1076
1077func flattenAndSort(emailsByAccount map[string][]fetcher.Email) []fetcher.Email {
1078 var allEmails []fetcher.Email
1079 for _, emails := range emailsByAccount {
1080 allEmails = append(allEmails, emails...)
1081 }
1082 for i := 0; i < len(allEmails); i++ {
1083 for j := i + 1; j < len(allEmails); j++ {
1084 if allEmails[j].Date.After(allEmails[i].Date) {
1085 allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
1086 }
1087 }
1088 }
1089 return allEmails
1090}
1091
1092func fetchAllAccountsEmails(cfg *config.Config, mailbox tui.MailboxKind) tea.Cmd {
1093 return func() tea.Msg {
1094 emailsByAccount := make(map[string][]fetcher.Email)
1095 var mu sync.Mutex
1096 var wg sync.WaitGroup
1097
1098 for _, account := range cfg.Accounts {
1099 wg.Add(1)
1100 go func(acc config.Account) {
1101 defer wg.Done()
1102 var emails []fetcher.Email
1103 var err error
1104 switch mailbox {
1105 case tui.MailboxSent:
1106 emails, err = fetcher.FetchSentEmails(&acc, initialEmailLimit, 0)
1107 case tui.MailboxTrash:
1108 emails, err = fetcher.FetchTrashEmails(&acc, initialEmailLimit, 0)
1109 case tui.MailboxArchive:
1110 emails, err = fetcher.FetchArchiveEmails(&acc, initialEmailLimit, 0)
1111 default:
1112 emails, err = fetcher.FetchEmails(&acc, initialEmailLimit, 0)
1113 }
1114 if err != nil {
1115 log.Printf("Error fetching from %s: %v", acc.Email, err)
1116 return
1117 }
1118 mu.Lock()
1119 emailsByAccount[acc.ID] = emails
1120 mu.Unlock()
1121 }(account)
1122 }
1123
1124 wg.Wait()
1125 return tui.AllEmailsFetchedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
1126 }
1127}
1128
1129func fetchEmails(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
1130 return func() tea.Msg {
1131 var emails []fetcher.Email
1132 var err error
1133 if mailbox == tui.MailboxSent {
1134 emails, err = fetcher.FetchSentEmails(account, limit, offset)
1135 } else {
1136 emails, err = fetcher.FetchEmails(account, limit, offset)
1137 }
1138 if err != nil {
1139 return tui.FetchErr(err)
1140 }
1141 if offset == 0 {
1142 return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1143 }
1144 return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1145 }
1146}
1147
1148func fetchEmailsForMailbox(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
1149 return func() tea.Msg {
1150 var emails []fetcher.Email
1151 var err error
1152 switch mailbox {
1153 case tui.MailboxSent:
1154 emails, err = fetcher.FetchSentEmails(account, limit, offset)
1155 case tui.MailboxTrash:
1156 emails, err = fetcher.FetchTrashEmails(account, limit, offset)
1157 case tui.MailboxArchive:
1158 emails, err = fetcher.FetchArchiveEmails(account, limit, offset)
1159 default:
1160 emails, err = fetcher.FetchEmails(account, limit, offset)
1161 }
1162 if err != nil {
1163 return tui.FetchErr(err)
1164 }
1165 if offset == 0 {
1166 return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1167 }
1168 return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1169 }
1170}
1171
1172func loadCachedEmails() tea.Cmd {
1173 return func() tea.Msg {
1174 cache, err := config.LoadEmailCache()
1175 if err != nil {
1176 return tui.CachedEmailsLoadedMsg{Cache: nil}
1177 }
1178 return tui.CachedEmailsLoadedMsg{Cache: cache}
1179 }
1180}
1181
1182func refreshEmails(cfg *config.Config, mailbox tui.MailboxKind) tea.Cmd {
1183 return func() tea.Msg {
1184 emailsByAccount := make(map[string][]fetcher.Email)
1185 var mu sync.Mutex
1186 var wg sync.WaitGroup
1187
1188 for _, account := range cfg.Accounts {
1189 wg.Add(1)
1190 go func(acc config.Account) {
1191 defer wg.Done()
1192 var emails []fetcher.Email
1193 var err error
1194 if mailbox == tui.MailboxSent {
1195 emails, err = fetcher.FetchSentEmails(&acc, initialEmailLimit, 0)
1196 } else {
1197 emails, err = fetcher.FetchEmails(&acc, initialEmailLimit, 0)
1198 }
1199 if err != nil {
1200 log.Printf("Error fetching from %s: %v", acc.Email, err)
1201 return
1202 }
1203 mu.Lock()
1204 emailsByAccount[acc.ID] = emails
1205 mu.Unlock()
1206 }(account)
1207 }
1208
1209 wg.Wait()
1210 return tui.EmailsRefreshedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
1211 }
1212}
1213
1214func saveEmailsToCache(emails []fetcher.Email) {
1215 var cachedEmails []config.CachedEmail
1216 for _, email := range emails {
1217 cachedEmails = append(cachedEmails, config.CachedEmail{
1218 UID: email.UID,
1219 From: email.From,
1220 To: email.To,
1221 Subject: email.Subject,
1222 Date: email.Date,
1223 MessageID: email.MessageID,
1224 AccountID: email.AccountID,
1225 })
1226
1227 // Save sender as a contact
1228 if email.From != "" {
1229 name, emailAddr := parseEmailAddress(email.From)
1230 if err := config.AddContact(name, emailAddr); err != nil {
1231 log.Printf("Error saving contact from email: %v", err)
1232 }
1233 }
1234 }
1235 cache := &config.EmailCache{Emails: cachedEmails}
1236 if err := config.SaveEmailCache(cache); err != nil {
1237 log.Printf("Error saving email cache: %v", err)
1238 }
1239}
1240
1241// parseEmailAddress parses "Name <email>" or just "email" format
1242func parseEmailAddress(addr string) (name, email string) {
1243 addr = strings.TrimSpace(addr)
1244 if idx := strings.Index(addr, "<"); idx != -1 {
1245 name = strings.TrimSpace(addr[:idx])
1246 endIdx := strings.Index(addr, ">")
1247 if endIdx > idx {
1248 email = strings.TrimSpace(addr[idx+1 : endIdx])
1249 } else {
1250 email = strings.TrimSpace(addr[idx+1:])
1251 }
1252 } else {
1253 email = addr
1254 }
1255 return name, email
1256}
1257
1258func fetchEmailBodyCmd(cfg *config.Config, email fetcher.Email, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1259 return func() tea.Msg {
1260 account := cfg.GetAccountByID(accountID)
1261 if account == nil {
1262 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
1263 }
1264
1265 var (
1266 body string
1267 attachments []fetcher.Attachment
1268 err error
1269 )
1270 switch mailbox {
1271 case tui.MailboxSent:
1272 body, attachments, err = fetcher.FetchSentEmailBody(account, uid)
1273 case tui.MailboxTrash:
1274 body, attachments, err = fetcher.FetchTrashEmailBody(account, uid)
1275 case tui.MailboxArchive:
1276 body, attachments, err = fetcher.FetchArchiveEmailBody(account, uid)
1277 default:
1278 body, attachments, err = fetcher.FetchEmailBody(account, uid)
1279 }
1280 if err != nil {
1281 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1282 }
1283
1284 return tui.EmailBodyFetchedMsg{
1285 UID: uid,
1286 Body: body,
1287 Attachments: attachments,
1288 AccountID: accountID,
1289 Mailbox: mailbox,
1290 }
1291 }
1292}
1293
1294func markdownToHTML(md []byte) []byte {
1295 var buf bytes.Buffer
1296 p := goldmark.New(goldmark.WithRendererOptions(html.WithUnsafe()))
1297 if err := p.Convert(md, &buf); err != nil {
1298 return md
1299 }
1300 return buf.Bytes()
1301}
1302
1303func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
1304 return func() tea.Msg {
1305 if account == nil {
1306 return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
1307 }
1308
1309 recipients := []string{msg.To}
1310 body := msg.Body
1311 // Append signature if present
1312 if msg.Signature != "" {
1313 body = body + "\n\n" + msg.Signature
1314 }
1315 // Append quoted text if present (for replies)
1316 if msg.QuotedText != "" {
1317 body = body + msg.QuotedText
1318 }
1319 images := make(map[string][]byte)
1320 attachments := make(map[string][]byte)
1321
1322 re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
1323 matches := re.FindAllStringSubmatch(body, -1)
1324
1325 for _, match := range matches {
1326 imgPath := match[1]
1327 imgData, err := os.ReadFile(imgPath)
1328 if err != nil {
1329 log.Printf("Could not read image file %s: %v", imgPath, err)
1330 continue
1331 }
1332 cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
1333 images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
1334 body = strings.Replace(body, imgPath, "cid:"+cid, 1)
1335 }
1336
1337 htmlBody := markdownToHTML([]byte(body))
1338
1339 if msg.AttachmentPath != "" {
1340 fileData, err := os.ReadFile(msg.AttachmentPath)
1341 if err != nil {
1342 log.Printf("Could not read attachment file %s: %v", msg.AttachmentPath, err)
1343 } else {
1344 _, filename := filepath.Split(msg.AttachmentPath)
1345 attachments[filename] = fileData
1346 }
1347 }
1348
1349 err := sender.SendEmail(account, recipients, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References)
1350 if err != nil {
1351 log.Printf("Failed to send email: %v", err)
1352 return tui.EmailResultMsg{Err: err}
1353 }
1354 return tui.EmailResultMsg{}
1355 }
1356}
1357
1358func deleteEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1359 return func() tea.Msg {
1360 var err error
1361 switch mailbox {
1362 case tui.MailboxSent:
1363 err = fetcher.DeleteSentEmail(account, uid)
1364 case tui.MailboxTrash:
1365 err = fetcher.DeleteTrashEmail(account, uid)
1366 case tui.MailboxArchive:
1367 err = fetcher.DeleteArchiveEmail(account, uid)
1368 default:
1369 err = fetcher.DeleteEmail(account, uid)
1370 }
1371 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1372 }
1373}
1374
1375func archiveEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1376 return func() tea.Msg {
1377 var err error
1378 if mailbox == tui.MailboxSent {
1379 err = fetcher.ArchiveSentEmail(account, uid)
1380 } else {
1381 err = fetcher.ArchiveEmail(account, uid)
1382 }
1383 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1384 }
1385}
1386
1387func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
1388 return func() tea.Msg {
1389 // Download and decode the attachment using encoding provided in msg.Encoding.
1390 var data []byte
1391 var err error
1392 switch msg.Mailbox {
1393 case tui.MailboxSent:
1394 data, err = fetcher.FetchSentAttachment(account, uid, msg.PartID, msg.Encoding)
1395 case tui.MailboxTrash:
1396 data, err = fetcher.FetchTrashAttachment(account, uid, msg.PartID, msg.Encoding)
1397 case tui.MailboxArchive:
1398 data, err = fetcher.FetchArchiveAttachment(account, uid, msg.PartID, msg.Encoding)
1399 default:
1400 data, err = fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding)
1401 }
1402 if err != nil {
1403 return tui.AttachmentDownloadedMsg{Err: err}
1404 }
1405
1406 homeDir, err := os.UserHomeDir()
1407 if err != nil {
1408 return tui.AttachmentDownloadedMsg{Err: err}
1409 }
1410 downloadsPath := filepath.Join(homeDir, "Downloads")
1411 if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
1412 if mkErr := os.MkdirAll(downloadsPath, 0755); mkErr != nil {
1413 return tui.AttachmentDownloadedMsg{Err: mkErr}
1414 }
1415 }
1416
1417 // Save the attachment using an exclusive create so we never overwrite an existing file.
1418 // If the filename already exists, append \" (n)\" before the extension.
1419 origName := msg.Filename
1420 ext := filepath.Ext(origName)
1421 base := strings.TrimSuffix(origName, ext)
1422 candidate := origName
1423 i := 1
1424 var filePath string
1425
1426 for {
1427 filePath = filepath.Join(downloadsPath, candidate)
1428
1429 // Try to create file exclusively. If it already exists, os.OpenFile will return an error
1430 // that satisfies os.IsExist(err), so we can increment the candidate.
1431 f, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
1432 if err != nil {
1433 if os.IsExist(err) {
1434 // file exists, try next candidate
1435 candidate = fmt.Sprintf("%s (%d)%s", base, i, ext)
1436 i++
1437 continue
1438 }
1439 // Some other error while attempting to create file
1440 log.Printf("error creating file %s: %v", filePath, err)
1441 return tui.AttachmentDownloadedMsg{Err: err}
1442 }
1443
1444 // Successfully created the file descriptor; write and close.
1445 if _, writeErr := f.Write(data); writeErr != nil {
1446 _ = f.Close()
1447 log.Printf("error writing to file %s: %v", filePath, writeErr)
1448 return tui.AttachmentDownloadedMsg{Err: writeErr}
1449 }
1450 if closeErr := f.Close(); closeErr != nil {
1451 log.Printf("warning: error closing file %s: %v", filePath, closeErr)
1452 }
1453
1454 // file saved successfully
1455 break
1456 }
1457
1458 log.Printf("attachment saved to %s", filePath)
1459
1460 // Try to open the file using a platform-specific opener asynchronously and log the outcome.
1461 go func(p string) {
1462 var cmd *exec.Cmd
1463 switch runtime.GOOS {
1464 case "darwin":
1465 cmd = exec.Command("open", p)
1466 case "linux":
1467 cmd = exec.Command("xdg-open", p)
1468 case "windows":
1469 // 'start' is a cmd builtin; provide an empty title argument to avoid interpreting the path as the title.
1470 cmd = exec.Command("cmd", "/c", "start", "", p)
1471 default:
1472 // Unsupported OS: nothing to do.
1473 return
1474 }
1475 if err := cmd.Start(); err != nil {
1476 log.Printf("failed to open file %s: %v", p, err)
1477 }
1478 }(filePath)
1479
1480 return tui.AttachmentDownloadedMsg{Path: filePath, Err: nil}
1481 }
1482}
1483
1484/*
1485detectInstalledVersion returns a best-effort installed version string.
1486Priority:
1487 1. If the build-in `version` variable is set to something other than "dev", return it.
1488 2. If Homebrew is present and reports a version for `matcha`, return that.
1489 3. If snap is present and lists `matcha`, return that.
1490 4. Fallback to the build `version` (likely "dev").
1491*/
1492func detectInstalledVersion() string {
1493 v := strings.TrimSpace(version)
1494 if v != "dev" && v != "" {
1495 return v
1496 }
1497
1498 // Try Homebrew (macOS)
1499 if runtime.GOOS == "darwin" {
1500 if _, err := exec.LookPath("brew"); err == nil {
1501 // `brew list --versions matcha` prints: matcha 1.2.3
1502 if out, err := exec.Command("brew", "list", "--versions", "matcha").Output(); err == nil {
1503 parts := strings.Fields(string(out))
1504 if len(parts) >= 2 {
1505 return parts[1]
1506 }
1507 }
1508 }
1509 }
1510
1511 // Try snap (Linux)
1512 if runtime.GOOS == "linux" {
1513 if _, err := exec.LookPath("snap"); err == nil {
1514 if out, err := exec.Command("snap", "list", "matcha").Output(); err == nil {
1515 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
1516 if len(lines) >= 2 {
1517 fields := strings.Fields(lines[1])
1518 if len(fields) >= 2 {
1519 return fields[1]
1520 }
1521 }
1522 }
1523 }
1524 }
1525
1526 return v
1527}
1528
1529/*
1530checkForUpdatesCmd queries GitHub for the latest release tag and returns a
1531tea.Msg (UpdateAvailableMsg) if the latest version differs from the current
1532installed version. This runs in the background when the TUI initializes.
1533*/
1534func checkForUpdatesCmd() tea.Cmd {
1535 return func() tea.Msg {
1536 // Non-fatal: if anything goes wrong we just don't show the update message.
1537 const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
1538 resp, err := http.Get(api)
1539 if err != nil {
1540 return nil
1541 }
1542 defer resp.Body.Close()
1543
1544 var rel githubRelease
1545 if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
1546 return nil
1547 }
1548
1549 latest := strings.TrimPrefix(rel.TagName, "v")
1550 installed := strings.TrimPrefix(detectInstalledVersion(), "v")
1551 if latest != "" && installed != "" && latest != installed {
1552 return UpdateAvailableMsg{Latest: latest, Current: installed}
1553 }
1554 return nil
1555 }
1556}
1557
1558// runUpdateCLI implements the CLI entrypoint for `matcha update`.
1559// It detects the likely installation method and attempts the appropriate
1560// update path (Homebrew, Snap, or GitHub release binary extract).
1561func runUpdateCLI() error {
1562 const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
1563 resp, err := http.Get(api)
1564 if err != nil {
1565 return fmt.Errorf("could not query releases: %w", err)
1566 }
1567 defer resp.Body.Close()
1568
1569 var rel githubRelease
1570 if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
1571 return fmt.Errorf("could not parse release info: %w", err)
1572 }
1573
1574 latestTag := rel.TagName
1575 if strings.HasPrefix(latestTag, "v") {
1576 latestTag = latestTag[1:]
1577 }
1578
1579 fmt.Printf("Current version: %s\n", version)
1580 fmt.Printf("Latest version: %s\n", latestTag)
1581
1582 // Quick check: if already up-to-date, exit
1583 cur := version
1584 if strings.HasPrefix(cur, "v") {
1585 cur = cur[1:]
1586 }
1587 if latestTag == "" || cur == latestTag {
1588 fmt.Println("Already up to date.")
1589 return nil
1590 }
1591
1592 // Detect Homebrew
1593 if _, err := exec.LookPath("brew"); err == nil {
1594 fmt.Println("Detected Homebrew — updating taps and attempting to upgrade via brew.")
1595
1596 updateCmd := exec.Command("brew", "update")
1597 updateCmd.Stdout = os.Stdout
1598 updateCmd.Stderr = os.Stderr
1599 if err := updateCmd.Run(); err != nil {
1600 fmt.Printf("Homebrew update failed: %v\n", err)
1601 // continue to attempt upgrade even if update failed
1602 }
1603
1604 upgradeCmd := exec.Command("brew", "upgrade", "floatpane/matcha/matcha")
1605 upgradeCmd.Stdout = os.Stdout
1606 upgradeCmd.Stderr = os.Stderr
1607 if err := upgradeCmd.Run(); err == nil {
1608 fmt.Println("Successfully upgraded via Homebrew.")
1609 return nil
1610 }
1611 fmt.Printf("Homebrew upgrade failed: %v\n", err)
1612 // fallthrough to other methods
1613 }
1614
1615 // Detect snap
1616 if _, err := exec.LookPath("snap"); err == nil {
1617 // Check if matcha is installed as a snap
1618 cmdCheck := exec.Command("snap", "list", "matcha")
1619 if err := cmdCheck.Run(); err == nil {
1620 fmt.Println("Detected Snap package — attempting to refresh.")
1621 cmd := exec.Command("snap", "refresh", "matcha")
1622 cmd.Stdout = os.Stdout
1623 cmd.Stderr = os.Stderr
1624 if err := cmd.Run(); err == nil {
1625 fmt.Println("Successfully refreshed snap.")
1626 return nil
1627 }
1628 fmt.Printf("Snap refresh failed: %v\n", err)
1629 // fallthrough
1630 }
1631 }
1632
1633 // Otherwise attempt to download the proper release asset and replace the binary.
1634 osName := runtime.GOOS
1635 arch := runtime.GOARCH
1636
1637 // Try to find a matching asset
1638 var assetURL, assetName string
1639 for _, a := range rel.Assets {
1640 n := strings.ToLower(a.Name)
1641 if strings.Contains(n, osName) && strings.Contains(n, arch) && (strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || strings.HasSuffix(n, ".zip")) {
1642 assetURL = a.BrowserDownloadURL
1643 assetName = a.Name
1644 break
1645 }
1646 }
1647 if assetURL == "" {
1648 // Try any asset that contains 'matcha' and os/arch as a fallback
1649 for _, a := range rel.Assets {
1650 n := strings.ToLower(a.Name)
1651 if strings.Contains(n, "matcha") && (strings.Contains(n, osName) || strings.Contains(n, arch)) {
1652 assetURL = a.BrowserDownloadURL
1653 assetName = a.Name
1654 break
1655 }
1656 }
1657 }
1658
1659 if assetURL == "" {
1660 return fmt.Errorf("no suitable release artifact found for %s/%s", osName, arch)
1661 }
1662
1663 fmt.Printf("Found release asset: %s\n", assetName)
1664 fmt.Println("Downloading...")
1665
1666 // Download asset
1667 respAsset, err := http.Get(assetURL)
1668 if err != nil {
1669 return fmt.Errorf("download failed: %w", err)
1670 }
1671 defer respAsset.Body.Close()
1672
1673 // Create a temp file for the download
1674 tmpDir, err := os.MkdirTemp("", "matcha-update-*")
1675 if err != nil {
1676 return fmt.Errorf("could not create temp dir: %w", err)
1677 }
1678 defer os.RemoveAll(tmpDir)
1679
1680 assetPath := filepath.Join(tmpDir, assetName)
1681 outFile, err := os.Create(assetPath)
1682 if err != nil {
1683 return fmt.Errorf("could not create temp file: %w", err)
1684 }
1685 _, err = io.Copy(outFile, respAsset.Body)
1686 outFile.Close()
1687 if err != nil {
1688 return fmt.Errorf("could not write asset to disk: %w", err)
1689 }
1690
1691 // If it's a tar.gz, extract and find the `matcha` binary
1692 var binPath string
1693 if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") {
1694 f, err := os.Open(assetPath)
1695 if err != nil {
1696 return fmt.Errorf("could not open archive: %w", err)
1697 }
1698 defer f.Close()
1699 gzr, err := gzip.NewReader(f)
1700 if err != nil {
1701 return fmt.Errorf("could not create gzip reader: %w", err)
1702 }
1703 tr := tar.NewReader(gzr)
1704 for {
1705 hdr, err := tr.Next()
1706 if err == io.EOF {
1707 break
1708 }
1709 if err != nil {
1710 return fmt.Errorf("error reading tar: %w", err)
1711 }
1712 name := filepath.Base(hdr.Name)
1713 if name == "matcha" || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) {
1714 // write out the file
1715 binPath = filepath.Join(tmpDir, "matcha")
1716 out, err := os.Create(binPath)
1717 if err != nil {
1718 return fmt.Errorf("could not create binary file: %w", err)
1719 }
1720 if _, err := io.Copy(out, tr); err != nil {
1721 out.Close()
1722 return fmt.Errorf("could not extract binary: %w", err)
1723 }
1724 out.Close()
1725 if err := os.Chmod(binPath, 0755); err != nil {
1726 return fmt.Errorf("could not make binary executable: %w", err)
1727 }
1728 break
1729 }
1730 }
1731 } else {
1732 // For non-archive assets, assume the asset is the binary itself.
1733 binPath = assetPath
1734 if err := os.Chmod(binPath, 0755); err != nil {
1735 // ignore chmod errors but warn
1736 fmt.Printf("warning: could not chmod downloaded binary: %v\n", err)
1737 }
1738 }
1739
1740 if binPath == "" {
1741 return fmt.Errorf("could not locate matcha binary inside the release artifact")
1742 }
1743
1744 // Replace the running executable with the new binary
1745 execPath, err := os.Executable()
1746 if err != nil {
1747 return fmt.Errorf("could not determine executable path: %w", err)
1748 }
1749
1750 // Write the new binary to a temp file in same dir, then rename for atomic replacement.
1751 execDir := filepath.Dir(execPath)
1752 tmpNew := filepath.Join(execDir, fmt.Sprintf("matcha.new.%d", time.Now().Unix()))
1753 in, err := os.Open(binPath)
1754 if err != nil {
1755 return fmt.Errorf("could not open new binary: %w", err)
1756 }
1757 out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
1758 if err != nil {
1759 in.Close()
1760 return fmt.Errorf("could not create temp binary in target dir: %w", err)
1761 }
1762 if _, err := io.Copy(out, in); err != nil {
1763 in.Close()
1764 out.Close()
1765 return fmt.Errorf("could not write new binary to disk: %w", err)
1766 }
1767 in.Close()
1768 out.Close()
1769
1770 // Attempt to atomically replace
1771 if err := os.Rename(tmpNew, execPath); err != nil {
1772 return fmt.Errorf("could not replace executable: %w", err)
1773 }
1774
1775 fmt.Println("Successfully updated matcha to", latestTag)
1776 return nil
1777}
1778
1779func main() {
1780 // If invoked as CLI update command, run updater and exit.
1781 if len(os.Args) > 1 && os.Args[1] == "update" {
1782 if err := runUpdateCLI(); err != nil {
1783 fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
1784 os.Exit(1)
1785 }
1786 os.Exit(0)
1787 }
1788
1789 cfg, err := config.LoadConfig()
1790 var initialModel *mainModel
1791 if err != nil {
1792 initialModel = newInitialModel(nil)
1793 } else {
1794 initialModel = newInitialModel(cfg)
1795 }
1796
1797 p := tea.NewProgram(initialModel, tea.WithAltScreen())
1798
1799 if _, err := p.Run(); err != nil {
1800 fmt.Printf("Alas, there's been an error: %v", err)
1801 os.Exit(1)
1802 }
1803}