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