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