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