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