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