1package main
2
3import (
4 "archive/tar"
5 "archive/zip"
6 "compress/gzip"
7 "context"
8 "encoding/base64"
9 "encoding/json"
10 "flag"
11 "fmt"
12 "io"
13 "log"
14 "net/http"
15 "os"
16 "os/exec"
17 "path/filepath"
18 "regexp"
19 "runtime"
20 "slices"
21 "strings"
22 "sync"
23 "time"
24
25 tea "charm.land/bubbletea/v2"
26 "github.com/floatpane/matcha/backend"
27 _ "github.com/floatpane/matcha/backend/imap"
28 _ "github.com/floatpane/matcha/backend/jmap"
29 _ "github.com/floatpane/matcha/backend/pop3"
30 "github.com/floatpane/matcha/calendar"
31 matchaCli "github.com/floatpane/matcha/cli"
32 "github.com/floatpane/matcha/clib"
33 "github.com/floatpane/matcha/config"
34 matchaDaemon "github.com/floatpane/matcha/daemon"
35 "github.com/floatpane/matcha/daemonclient"
36 "github.com/floatpane/matcha/daemonrpc"
37 "github.com/floatpane/matcha/fetcher"
38 "github.com/floatpane/matcha/notify"
39 "github.com/floatpane/matcha/plugin"
40 "github.com/floatpane/matcha/sender"
41 "github.com/floatpane/matcha/theme"
42 "github.com/floatpane/matcha/tui"
43 "github.com/google/uuid"
44 lua "github.com/yuin/gopher-lua"
45)
46
47const (
48 initialEmailLimit = 50
49 paginationLimit = 50
50 maxCacheEmails = 100
51)
52
53// Version variables are injected by the build (GoReleaser ldflags).
54// They default to "dev" when not set by the build system.
55var (
56 version = "dev"
57 commit = ""
58 date = ""
59)
60
61// UpdateAvailableMsg is sent into the TUI when a newer release is detected.
62type UpdateAvailableMsg struct {
63 Latest string
64 Current string
65}
66
67// internal struct for parsing GitHub release JSON.
68type githubRelease struct {
69 TagName string `json:"tag_name"`
70 Assets []struct {
71 Name string `json:"name"`
72 BrowserDownloadURL string `json:"browser_download_url"`
73 } `json:"assets"`
74}
75
76type mainModel struct {
77 current tea.Model
78 previousModel tea.Model
79 config *config.Config
80 plugins *plugin.Manager
81 // Folder-based email storage
82 folderEmails map[string][]fetcher.Email // key: folderName
83 folderInbox *tui.FolderInbox
84 // Legacy fields kept for email actions
85 emails []fetcher.Email
86 emailsByAcct map[string][]fetcher.Email
87 width int
88 height int
89 err error
90 // IMAP IDLE
91 idleWatcher *fetcher.IdleWatcher
92 idleUpdates chan fetcher.IdleUpdate
93 // Multi-protocol backend providers (keyed by account ID)
94 providers map[string]backend.Provider
95 // Daemon client service (daemon or direct fallback)
96 service daemonclient.Service
97 // Plugin prompt waiting for user input
98 pendingPrompt *plugin.PendingPrompt
99}
100
101func newInitialModel(cfg *config.Config) *mainModel {
102 idleUpdates := make(chan fetcher.IdleUpdate, 16)
103 initialModel := &mainModel{
104 emailsByAcct: make(map[string][]fetcher.Email),
105 folderEmails: make(map[string][]fetcher.Email),
106 idleUpdates: idleUpdates,
107 idleWatcher: fetcher.NewIdleWatcher(idleUpdates),
108 providers: make(map[string]backend.Provider),
109 }
110
111 if cfg == nil || !cfg.HasAccounts() {
112 hideTips := false
113 if cfg != nil {
114 hideTips = cfg.HideTips
115 }
116 initialModel.current = tui.NewLogin(hideTips)
117 } else {
118 initialModel.current = tui.NewChoice()
119 initialModel.config = cfg
120 }
121 return initialModel
122}
123
124// ensureProviders creates backend providers for all configured accounts.
125func (m *mainModel) ensureProviders() {
126 if m.config == nil {
127 return
128 }
129 for _, acct := range m.config.Accounts {
130 if _, ok := m.providers[acct.ID]; ok {
131 continue
132 }
133 p, err := backend.New(&acct)
134 if err != nil {
135 log.Printf("backend: failed to create provider for %s: %v", acct.Email, err)
136 continue
137 }
138 m.providers[acct.ID] = p
139 }
140}
141
142// getProvider returns the backend provider for the given account.
143func (m *mainModel) getProvider(acct *config.Account) backend.Provider {
144 if acct == nil {
145 return nil
146 }
147 return m.providers[acct.ID]
148}
149
150func (m *mainModel) Init() tea.Cmd {
151 return tea.Batch(m.current.Init(), checkForUpdatesCmd())
152}
153
154func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
155 var cmd tea.Cmd
156 var cmds []tea.Cmd
157
158 m.current, cmd = m.current.Update(msg)
159 cmds = append(cmds, cmd)
160
161 // Fire composer_updated hook on key presses when the composer is active
162 if keyMsg, isKey := msg.(tea.KeyPressMsg); isKey {
163 if composer, ok := m.current.(*tui.Composer); ok && m.plugins != nil {
164 m.plugins.CallComposerHook(plugin.HookComposerUpdated, composer.GetBody(), composer.GetSubject(), composer.GetTo(), composer.GetCc(), composer.GetBcc())
165 m.syncPluginStatus()
166 m.applyPluginFields(composer)
167 }
168
169 // Check plugin key bindings for the current view
170 if m.plugins != nil {
171 m.handlePluginKeyBinding(keyMsg)
172 }
173 }
174
175 switch msg := msg.(type) {
176 case tea.WindowSizeMsg:
177 m.width = msg.Width
178 m.height = msg.Height
179 return m, nil
180
181 case tea.KeyPressMsg:
182 if msg.String() == "ctrl+c" {
183 m.idleWatcher.StopAll()
184 if m.service != nil {
185 m.service.Close()
186 }
187 return m, tea.Quit
188 }
189 if msg.String() == "esc" {
190 switch m.current.(type) {
191 case *tui.FilePicker:
192 return m, func() tea.Msg { return tui.CancelFilePickerMsg{} }
193 case *tui.FolderInbox, *tui.Inbox, *tui.Login:
194 m.idleWatcher.StopAll()
195 m.current = tui.NewChoice()
196 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
197 return m, m.current.Init()
198 }
199 }
200
201 case tui.BackToInboxMsg:
202 if m.folderInbox != nil {
203 m.current = m.folderInbox
204 } else {
205 m.current = tui.NewChoice()
206 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
207 }
208 return m, nil
209
210 case tui.BackToMailboxMsg:
211 // Ensure kitty graphics are cleared when leaving email view
212 tui.ClearKittyGraphics()
213 if m.folderInbox != nil {
214 m.current = m.folderInbox
215 return m, nil
216 }
217 m.current = tui.NewChoice()
218 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
219 return m, nil
220
221 case tui.DiscardDraftMsg:
222 // Save draft to disk
223 if msg.ComposerState != nil {
224 draft := msg.ComposerState.ToDraft()
225
226 if err := config.SaveDraft(draft); err != nil {
227 log.Printf("Error saving draft: %v", err)
228 }
229
230 }
231 m.current = tui.NewChoice()
232 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
233 return m, m.current.Init()
234
235 case tui.OAuth2CompleteMsg:
236 if msg.Err != nil {
237 log.Printf("OAuth2 authorization failed: %v", msg.Err)
238 }
239 // After OAuth2 flow, go to the choice menu so user can proceed
240 m.current = tui.NewChoice()
241 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
242 return m, m.current.Init()
243
244 case tui.Credentials:
245 // Split FetchEmail by commas to support multiple fetch addresses.
246 // Each address creates a separate account sharing the same login credentials.
247 fetchEmails := []string{""}
248 if msg.FetchEmail != "" {
249 fetchEmails = fetchEmails[:0]
250 for _, fe := range strings.Split(msg.FetchEmail, ",") {
251 if trimmed := strings.TrimSpace(fe); trimmed != "" {
252 fetchEmails = append(fetchEmails, trimmed)
253 }
254 }
255 if len(fetchEmails) == 0 {
256 fetchEmails = []string{""}
257 }
258 }
259
260 if m.config == nil {
261 m.config = &config.Config{}
262 }
263
264 // Check if we're editing an existing account
265 isEdit := false
266 var lastAccount config.Account
267 if login, ok := m.current.(*tui.Login); ok && login.IsEditMode() {
268 isEdit = true
269 existingID := login.GetAccountID()
270
271 account := config.Account{
272 ID: existingID,
273 Name: msg.Name,
274 Email: msg.Host,
275 Password: msg.Password,
276 ServiceProvider: msg.Provider,
277 FetchEmail: fetchEmails[0],
278 SendAsEmail: msg.SendAsEmail,
279 AuthMethod: msg.AuthMethod,
280 Protocol: msg.Protocol,
281 Insecure: msg.Insecure,
282 JMAPEndpoint: msg.JMAPEndpoint,
283 POP3Server: msg.POP3Server,
284 POP3Port: msg.POP3Port,
285 }
286
287 if msg.Provider == "custom" || msg.Protocol == "pop3" {
288 account.IMAPServer = msg.IMAPServer
289 account.IMAPPort = msg.IMAPPort
290 account.SMTPServer = msg.SMTPServer
291 account.SMTPPort = msg.SMTPPort
292 }
293
294 if account.FetchEmail == "" && account.Email != "" {
295 account.FetchEmail = account.Email
296 }
297
298 // Find and update the existing account, preserving S/MIME settings
299 for i, acc := range m.config.Accounts {
300 if acc.ID == existingID {
301 account.SMIMECert = acc.SMIMECert
302 account.SMIMEKey = acc.SMIMEKey
303 account.SMIMESignByDefault = acc.SMIMESignByDefault
304 if account.Password == "" {
305 account.Password = acc.Password
306 }
307 m.config.Accounts[i] = account
308 break
309 }
310 }
311 lastAccount = account
312 } else {
313 // New account: create one account per fetch email address
314 for _, fe := range fetchEmails {
315 account := config.Account{
316 ID: uuid.New().String(),
317 Name: msg.Name,
318 Email: msg.Host,
319 Password: msg.Password,
320 ServiceProvider: msg.Provider,
321 FetchEmail: fe,
322 SendAsEmail: msg.SendAsEmail,
323 AuthMethod: msg.AuthMethod,
324 Protocol: msg.Protocol,
325 JMAPEndpoint: msg.JMAPEndpoint,
326 POP3Server: msg.POP3Server,
327 POP3Port: msg.POP3Port,
328 }
329
330 if msg.Provider == "custom" || msg.Protocol == "pop3" {
331 account.IMAPServer = msg.IMAPServer
332 account.IMAPPort = msg.IMAPPort
333 account.SMTPServer = msg.SMTPServer
334 account.SMTPPort = msg.SMTPPort
335 }
336
337 if account.FetchEmail == "" && account.Email != "" {
338 account.FetchEmail = account.Email
339 }
340
341 m.config.AddAccount(account)
342 lastAccount = account
343 }
344 }
345
346 if err := config.SaveConfig(m.config); err != nil {
347 log.Printf("could not save config: %v", err)
348 return m, tea.Quit
349 }
350
351 // If OAuth2, launch the authorization flow after saving the account
352 if lastAccount.IsOAuth2() {
353 email := lastAccount.Email
354 provider := lastAccount.ServiceProvider
355 return m, func() tea.Msg {
356 err := config.RunOAuth2Flow(email, provider, "", "")
357 return tui.OAuth2CompleteMsg{Email: email, Err: err}
358 }
359 }
360
361 if isEdit {
362 m.current = tui.NewSettings(m.config)
363 } else {
364 m.current = tui.NewChoice()
365 }
366 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
367 return m, m.current.Init()
368
369 case tui.GoToInboxMsg:
370 if m.config == nil || !m.config.HasAccounts() {
371 hideTips := false
372 if m.config != nil {
373 hideTips = m.config.HideTips
374 }
375 m.current = tui.NewLogin(hideTips)
376 return m, m.current.Init()
377 }
378 m.ensureProviders()
379 // Load cached folders from all accounts, merge unique names
380 seen := make(map[string]bool)
381 var cachedFolders []string
382 for _, acc := range m.config.Accounts {
383 for _, f := range config.GetCachedFolders(acc.ID) {
384 if !seen[f] {
385 seen[f] = true
386 cachedFolders = append(cachedFolders, f)
387 }
388 }
389 }
390 if len(cachedFolders) == 0 {
391 cachedFolders = []string{"INBOX"}
392 }
393 m.folderInbox = tui.NewFolderInbox(cachedFolders, m.config.Accounts)
394 m.folderInbox.SetDateFormat(m.config.GetDateFormat())
395 // Use cached INBOX emails for instant display (memory first, then disk)
396 if cached, ok := m.folderEmails["INBOX"]; ok && len(cached) > 0 {
397 m.folderInbox.SetEmails(cached, m.config.Accounts)
398 } else if diskCached := loadFolderEmailsFromCache("INBOX"); len(diskCached) > 0 {
399 m.folderEmails["INBOX"] = diskCached
400 m.emails = diskCached
401 m.emailsByAcct = make(map[string][]fetcher.Email)
402 for _, email := range diskCached {
403 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
404 }
405 m.folderInbox.SetEmails(diskCached, m.config.Accounts)
406 }
407 m.current = m.folderInbox
408 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
409 // Initialize daemon service if not already set.
410 if m.service == nil {
411 m.service = daemonclient.NewService(m.config)
412 }
413 if m.service.IsDaemon() {
414 // Subscribe to INBOX updates if using daemon.
415 for _, acct := range m.config.Accounts {
416 m.service.Subscribe(acct.ID, "INBOX")
417 }
418 } else {
419 // Start IDLE watchers for all accounts on INBOX
420 for i := range m.config.Accounts {
421 m.idleWatcher.Watch(&m.config.Accounts[i], "INBOX")
422 }
423 }
424 // Fetch folders and INBOX emails in parallel (background refresh)
425 batchCmds := []tea.Cmd{
426 m.current.Init(),
427 fetchFoldersCmd(m.config),
428 fetchFolderEmailsCmd(m.config, "INBOX"),
429 listenForIdleUpdates(m.idleUpdates),
430 }
431 if m.service.IsDaemon() {
432 batchCmds = append(batchCmds, listenForDaemonEvents(m.service.Events()))
433 }
434 return m, tea.Batch(batchCmds...)
435
436 case tui.FoldersFetchedMsg:
437 if m.folderInbox == nil {
438 return m, nil
439 }
440 var folderNames []string
441 for _, f := range msg.MergedFolders {
442 folderNames = append(folderNames, f.Name)
443 }
444 m.folderInbox.SetFolders(folderNames)
445 // Cache folder lists per account
446 for accID, folders := range msg.FoldersByAccount {
447 var names []string
448 for _, f := range folders {
449 names = append(names, f.Name)
450 }
451 go config.SaveAccountFolders(accID, names)
452 }
453 return m, nil
454
455 case tui.SwitchFolderMsg:
456 if m.config == nil {
457 return m, nil
458 }
459 // Update IDLE watchers to monitor the new folder
460 for i := range m.config.Accounts {
461 // Only start IDLE for accounts that actually have this folder
462 folders := config.GetCachedFolders(m.config.Accounts[i].ID)
463 if !slices.Contains(folders, msg.FolderName) {
464 if m.service != nil && m.service.IsDaemon() {
465 m.service.Unsubscribe(m.config.Accounts[i].ID, msg.PreviousFolder)
466 } else {
467 m.idleWatcher.Stop(m.config.Accounts[i].ID)
468 }
469 continue
470 }
471 if m.service != nil && m.service.IsDaemon() {
472 // Unsubscribe from old, subscribe to new.
473 if msg.PreviousFolder != "" {
474 m.service.Unsubscribe(m.config.Accounts[i].ID, msg.PreviousFolder)
475 }
476 m.service.Subscribe(m.config.Accounts[i].ID, msg.FolderName)
477 } else {
478 m.idleWatcher.Watch(&m.config.Accounts[i], msg.FolderName)
479 }
480 }
481 if m.plugins != nil {
482 m.plugins.CallFolderHook(plugin.HookFolderChanged, msg.FolderName)
483 m.syncPluginStatus()
484 m.syncPluginKeyBindings()
485 }
486 // Use in-memory cache if available
487 if cached, ok := m.folderEmails[msg.FolderName]; ok {
488 m.emails = cached
489 m.emailsByAcct = make(map[string][]fetcher.Email)
490 for _, email := range cached {
491 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
492 }
493 if m.folderInbox != nil {
494 m.folderInbox.SetEmails(cached, m.config.Accounts)
495 m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
496 m.folderInbox.SetLoadingEmails(false)
497 }
498 return m, m.pluginNotifyCmd()
499 }
500 // Fall back to disk cache for instant display, then fetch fresh in background
501 if diskCached := loadFolderEmailsFromCache(msg.FolderName); len(diskCached) > 0 {
502 m.folderEmails[msg.FolderName] = diskCached
503 m.emails = diskCached
504 m.emailsByAcct = make(map[string][]fetcher.Email)
505 for _, email := range diskCached {
506 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
507 }
508 if m.folderInbox != nil {
509 m.folderInbox.SetEmails(diskCached, m.config.Accounts)
510 m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
511 m.folderInbox.SetLoadingEmails(false)
512 }
513 // Still fetch fresh emails in background
514 return m, tea.Batch(fetchFolderEmailsCmd(m.config, msg.FolderName), m.pluginNotifyCmd())
515 }
516 if m.folderInbox != nil {
517 m.folderInbox.SetLoadingEmails(true)
518 }
519 return m, tea.Batch(fetchFolderEmailsCmd(m.config, msg.FolderName), m.pluginNotifyCmd())
520
521 case tui.PluginNotifyMsg:
522 m.previousModel = m.current
523 m.current = tui.NewStatus(msg.Message)
524 dur := time.Duration(msg.Duration * float64(time.Second))
525 if dur <= 0 {
526 dur = 2 * time.Second
527 }
528 return m, tea.Tick(dur, func(t time.Time) tea.Msg {
529 return tui.RestoreViewMsg{}
530 })
531
532 case tui.PluginPromptSubmitMsg:
533 if m.pendingPrompt != nil {
534 if composer, ok := m.current.(*tui.Composer); ok {
535 composer.HidePluginPrompt()
536 m.plugins.ResolvePrompt(m.pendingPrompt, msg.Value)
537 m.applyPluginFields(composer)
538 m.syncPluginStatus()
539 }
540 m.pendingPrompt = nil
541 }
542 return m, nil
543
544 case tui.PluginPromptCancelMsg:
545 if composer, ok := m.current.(*tui.Composer); ok {
546 composer.HidePluginPrompt()
547 }
548 m.pendingPrompt = nil
549 return m, nil
550
551 case tui.FolderEmailsFetchedMsg:
552 if m.folderInbox == nil {
553 return m, nil
554 }
555 // Call plugin hooks for received emails
556 if m.plugins != nil {
557 for _, email := range msg.Emails {
558 t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, msg.FolderName)
559 m.plugins.CallHook(plugin.HookEmailReceived, t)
560 }
561 }
562 // Always cache in memory and to disk
563 m.folderEmails[msg.FolderName] = msg.Emails
564 go saveFolderEmailsToCache(msg.FolderName, msg.Emails)
565 // Prune stale body cache entries
566 go func() {
567 validUIDs := make(map[uint32]string, len(msg.Emails))
568 for _, e := range msg.Emails {
569 validUIDs[e.UID] = e.AccountID
570 }
571 _ = config.PruneEmailBodyCache(msg.FolderName, validUIDs)
572 }()
573 // Only update the view if the user is still on this folder
574 if m.folderInbox.GetCurrentFolder() != msg.FolderName {
575 return m, nil
576 }
577 m.emails = msg.Emails
578 m.emailsByAcct = make(map[string][]fetcher.Email)
579 for _, email := range msg.Emails {
580 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
581 }
582 m.folderInbox.SetEmails(msg.Emails, m.config.Accounts)
583 m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
584 m.folderInbox.SetLoadingEmails(false)
585 m.syncPluginStatus()
586 m.syncPluginKeyBindings()
587 return m, m.pluginNotifyCmd()
588
589 case tui.FetchFolderMoreEmailsMsg:
590 if msg.AccountID == "" || m.config == nil {
591 return m, nil
592 }
593 account := m.config.GetAccountByID(msg.AccountID)
594 if account == nil {
595 return m, nil
596 }
597 limit := uint32(paginationLimit)
598 if msg.Limit > 0 {
599 limit = msg.Limit
600 }
601 return m, tea.Batch(
602 func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
603 fetchFolderEmailsPaginatedCmd(account, msg.FolderName, limit, msg.Offset),
604 )
605
606 case tui.FolderEmailsAppendedMsg:
607 // Ignore stale appends for a folder the user has moved away from
608 if m.folderInbox == nil || m.folderInbox.GetCurrentFolder() != msg.FolderName {
609 return m, nil
610 }
611 m.folderInbox.Update(msg)
612 // Update local stores and per-folder cache
613 for _, email := range msg.Emails {
614 m.emails = append(m.emails, email)
615 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
616 }
617 m.folderEmails[msg.FolderName] = append(m.folderEmails[msg.FolderName], msg.Emails...)
618 go saveFolderEmailsToCache(msg.FolderName, m.folderEmails[msg.FolderName])
619 return m, nil
620
621 case tui.MoveEmailToFolderMsg:
622 if m.config == nil {
623 return m, nil
624 }
625 account := m.config.GetAccountByID(msg.AccountID)
626 if account == nil {
627 return m, nil
628 }
629 m.previousModel = m.current
630 m.current = tui.NewStatus("Moving email...")
631 return m, tea.Batch(m.current.Init(), moveEmailToFolderCmd(account, msg.UID, msg.AccountID, msg.SourceFolder, msg.DestFolder))
632
633 case tui.EmailMovedMsg:
634 if msg.Err != nil {
635 log.Printf("Move failed: %v", msg.Err)
636 if m.folderInbox != nil {
637 m.previousModel = m.folderInbox
638 }
639 m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
640 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
641 return tui.RestoreViewMsg{}
642 })
643 }
644 // Remove email from current view
645 if m.folderInbox != nil {
646 m.folderInbox.RemoveEmail(msg.UID, msg.AccountID)
647 m.current = m.folderInbox
648 }
649 return m, nil
650
651 case tui.CachedEmailsLoadedMsg:
652 // Cache is no longer used for the folder-based inbox flow
653 // This handler is kept for backwards compatibility but simply fetches normally
654 if m.folderInbox == nil {
655 return m, nil
656 }
657 return m, fetchFolderEmailsCmd(m.config, m.folderInbox.GetCurrentFolder())
658
659 case tui.IdleNewMailMsg:
660 // Send desktop notification for new mail (if enabled)
661 if m.config == nil || !m.config.DisableNotifications {
662 accountName := msg.AccountID
663 if m.config != nil {
664 if acc := m.config.GetAccountByID(msg.AccountID); acc != nil {
665 accountName = acc.Email
666 }
667 }
668 go notify.Send("Matcha", fmt.Sprintf("New mail in %s (%s)", msg.FolderName, accountName))
669 }
670
671 // IDLE detected new mail — refetch the folder if we're viewing it
672 if m.folderInbox != nil && m.folderInbox.GetCurrentFolder() == msg.FolderName {
673 return m, tea.Batch(
674 fetchFolderEmailsCmd(m.config, msg.FolderName),
675 listenForIdleUpdates(m.idleUpdates),
676 )
677 }
678 // Re-subscribe even if not viewing the affected folder
679 return m, listenForIdleUpdates(m.idleUpdates)
680
681 case tui.DaemonEventMsg:
682 if msg.Event == nil {
683 return m, nil
684 }
685 var cmds []tea.Cmd
686 // Re-subscribe for next event.
687 if m.service != nil && m.service.IsDaemon() {
688 cmds = append(cmds, listenForDaemonEvents(m.service.Events()))
689 }
690 switch msg.Event.Type {
691 case daemonrpc.EventNewMail:
692 var ev daemonrpc.NewMailEvent
693 if err := json.Unmarshal(msg.Event.Data, &ev); err == nil {
694 if m.config == nil || !m.config.DisableNotifications {
695 accountName := ev.AccountID
696 if m.config != nil {
697 if acc := m.config.GetAccountByID(ev.AccountID); acc != nil {
698 accountName = acc.Email
699 }
700 }
701 go notify.Send("Matcha", fmt.Sprintf("New mail in %s (%s)", ev.Folder, accountName))
702 }
703
704 if m.folderInbox != nil && m.folderInbox.GetCurrentFolder() == ev.Folder {
705 cmds = append(cmds, fetchFolderEmailsCmd(m.config, ev.Folder))
706 }
707 }
708 case daemonrpc.EventSyncComplete:
709 var ev daemonrpc.SyncCompleteEvent
710 if err := json.Unmarshal(msg.Event.Data, &ev); err == nil {
711 if m.folderInbox != nil && m.folderInbox.GetCurrentFolder() == ev.Folder {
712 cmds = append(cmds, fetchFolderEmailsCmd(m.config, ev.Folder))
713 }
714 }
715 }
716 return m, tea.Batch(cmds...)
717
718 case tui.RequestRefreshMsg:
719 // Folder-based refresh: clear folder cache and refetch
720 if msg.FolderName != "" && m.config != nil {
721 delete(m.folderEmails, msg.FolderName)
722 if m.folderInbox != nil {
723 m.folderInbox.SetRefreshing(true)
724 }
725 return m, fetchFolderEmailsCmd(m.config, msg.FolderName)
726 }
727 return m, tea.Batch(
728 func() tea.Msg { return tui.RefreshingEmailsMsg{Mailbox: msg.Mailbox} },
729 refreshEmails(m.config, msg.Mailbox, msg.Counts),
730 )
731
732 case tui.EmailsRefreshedMsg:
733 // Merge refreshed emails with any paginated emails already loaded.
734 for accID, refreshed := range msg.EmailsByAccount {
735 refreshedUIDs := make(map[uint32]struct{}, len(refreshed))
736 for _, e := range refreshed {
737 refreshedUIDs[e.UID] = struct{}{}
738 }
739 if existing, ok := m.emailsByAcct[accID]; ok {
740 for _, e := range existing {
741 if _, found := refreshedUIDs[e.UID]; !found {
742 refreshed = append(refreshed, e)
743 }
744 }
745 }
746 m.emailsByAcct[accID] = refreshed
747 }
748 m.emails = flattenAndSort(m.emailsByAcct)
749
750 // Update folder inbox if it exists
751 if m.folderInbox != nil {
752 m.folderInbox.SetEmails(m.emails, m.config.Accounts)
753 m.folderInbox.GetInbox().Update(msg)
754 }
755 return m, nil
756
757 case tui.AllEmailsFetchedMsg:
758 m.emailsByAcct = msg.EmailsByAccount
759 m.emails = flattenAndSort(msg.EmailsByAccount)
760
761 if m.folderInbox != nil {
762 m.folderInbox.SetEmails(m.emails, m.config.Accounts)
763 m.folderInbox.SetLoadingEmails(false)
764 }
765 return m, nil
766
767 case tui.EmailsFetchedMsg:
768 if m.emailsByAcct == nil {
769 m.emailsByAcct = make(map[string][]fetcher.Email)
770 }
771 m.emailsByAcct[msg.AccountID] = msg.Emails
772 m.emails = flattenAndSort(m.emailsByAcct)
773 if m.folderInbox != nil {
774 m.folderInbox.SetEmails(m.emails, m.config.Accounts)
775 }
776 return m, nil
777
778 case tui.FetchMoreEmailsMsg:
779 if msg.AccountID == "" {
780 return m, nil
781 }
782 account := m.config.GetAccountByID(msg.AccountID)
783 if account == nil {
784 return m, nil
785 }
786 limit := uint32(paginationLimit)
787 if msg.Limit > 0 {
788 limit = msg.Limit
789 }
790 folderName := "INBOX"
791 if m.folderInbox != nil {
792 folderName = m.folderInbox.GetCurrentFolder()
793 }
794 return m, tea.Batch(
795 func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
796 fetchFolderEmailsPaginatedCmd(account, folderName, limit, msg.Offset),
797 )
798
799 case tui.EmailsAppendedMsg:
800 if m.emailsByAcct == nil {
801 m.emailsByAcct = make(map[string][]fetcher.Email)
802 }
803 unique := filterUnique(m.emailsByAcct[msg.AccountID], msg.Emails)
804 m.emailsByAcct[msg.AccountID] = append(m.emailsByAcct[msg.AccountID], unique...)
805 m.emails = append(m.emails, unique...)
806 return m, nil
807
808 case tui.GoToSendMsg:
809 hideTips := false
810 if m.config != nil {
811 hideTips = m.config.HideTips
812 }
813 if m.config != nil && len(m.config.Accounts) > 0 {
814 firstAccount := m.config.GetFirstAccount()
815 composer := tui.NewComposerWithAccounts(m.config.Accounts, firstAccount.ID, msg.To, msg.Subject, msg.Body, hideTips)
816 m.current = composer
817 } else {
818 m.current = tui.NewComposer("", msg.To, msg.Subject, msg.Body, hideTips)
819 }
820 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
821 m.syncPluginKeyBindings()
822 return m, m.current.Init()
823
824 case tui.GoToDraftsMsg:
825 drafts := config.GetAllDrafts()
826 m.current = tui.NewDrafts(drafts)
827 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
828 return m, m.current.Init()
829
830 case tui.OpenDraftMsg:
831 var accounts []config.Account
832 hideTips := false
833 if m.config != nil {
834 accounts = m.config.Accounts
835 hideTips = m.config.HideTips
836 }
837 composer := tui.NewComposerFromDraft(msg.Draft, accounts, hideTips)
838 m.current = composer
839 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
840 m.syncPluginKeyBindings()
841 return m, m.current.Init()
842
843 case tui.DeleteSavedDraftMsg:
844 go func() {
845 if err := config.DeleteDraft(msg.DraftID); err != nil {
846 log.Printf("Error deleting draft: %v", err)
847 }
848 }()
849 // Send message back to drafts view
850 m.current, cmd = m.current.Update(tui.DraftDeletedMsg{DraftID: msg.DraftID})
851 return m, cmd
852
853 case tui.GoToMarketplaceMsg:
854 m.current = tui.NewMarketplace(false)
855 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
856 return m, m.current.Init()
857
858 case tui.GoToSettingsMsg:
859 m.current = tui.NewSettings(m.config)
860 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
861 return m, m.current.Init()
862
863 case tui.GoToAddAccountMsg:
864 hideTips := false
865 if m.config != nil {
866 hideTips = m.config.HideTips
867 }
868 m.current = tui.NewLogin(hideTips)
869 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
870 return m, m.current.Init()
871
872 case tui.GoToAddMailingListMsg:
873 m.current = tui.NewMailingListEditor()
874 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
875 return m, m.current.Init()
876
877 case tui.GoToEditAccountMsg:
878 hideTips := false
879 if m.config != nil {
880 hideTips = m.config.HideTips
881 }
882 login := tui.NewLogin(hideTips)
883 login.SetEditMode(msg.AccountID, msg.Protocol, msg.Provider, msg.Name, msg.Email, msg.FetchEmail, msg.SendAsEmail, msg.IMAPServer, msg.IMAPPort, msg.SMTPServer, msg.SMTPPort, msg.Insecure, msg.JMAPEndpoint, msg.POP3Server, msg.POP3Port)
884 m.current = login
885 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
886 return m, m.current.Init()
887
888 case tui.GoToEditMailingListMsg:
889 editor := tui.NewMailingListEditor()
890 editor.SetEditMode(msg.Index, msg.Name, msg.Addresses)
891 m.current = editor
892 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
893 return m, m.current.Init()
894
895 case tui.SaveMailingListMsg:
896 if m.config != nil {
897 var addrs []string
898 for _, part := range strings.Split(msg.Addresses, ",") {
899 if trimmed := strings.TrimSpace(part); trimmed != "" {
900 addrs = append(addrs, trimmed)
901 }
902 }
903 if msg.EditIndex >= 0 && msg.EditIndex < len(m.config.MailingLists) {
904 m.config.MailingLists[msg.EditIndex] = config.MailingList{
905 Name: msg.Name,
906 Addresses: addrs,
907 }
908 } else {
909 m.config.MailingLists = append(m.config.MailingLists, config.MailingList{
910 Name: msg.Name,
911 Addresses: addrs,
912 })
913 }
914 if err := config.SaveConfig(m.config); err != nil {
915 log.Printf("could not save config: %v", err)
916 }
917 }
918 // Return to settings
919 m.current = tui.NewSettings(m.config)
920 // Try to navigate to the mailing list view internally if possible, but NewSettings will go to SettingsMain by default.
921 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
922 return m, m.current.Init()
923
924 case tui.GoToSignatureEditorMsg:
925 m.current = tui.NewSignatureEditor()
926 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
927 return m, m.current.Init()
928
929 case tui.PasswordVerifiedMsg:
930 if msg.Err != nil {
931 // Error is handled inside PasswordPrompt itself
932 return m, nil
933 }
934 // Password verified — set session key and load config
935 config.SetSessionKey(msg.Key)
936 cfg, err := config.LoadConfig()
937 if err == nil && cfg.Theme != "" {
938 theme.SetTheme(cfg.Theme)
939 tui.RebuildStyles()
940 }
941 _ = config.EnsurePGPDir()
942 if err != nil {
943 m.config = nil
944 hideTips := false
945 m.current = tui.NewLogin(hideTips)
946 } else {
947 m.config = cfg
948 m.current = tui.NewChoice()
949 }
950 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
951 return m, m.current.Init()
952
953 case tui.SecureModeEnabledMsg:
954 if msg.Err != nil {
955 log.Printf("Failed to enable encryption: %v", msg.Err)
956 }
957 return m, nil
958
959 case tui.SecureModeDisabledMsg:
960 if msg.Err != nil {
961 log.Printf("Failed to disable encryption: %v", msg.Err)
962 }
963 return m, nil
964
965 case tui.GoToChoiceMenuMsg:
966 m.current = tui.NewChoice()
967 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
968 return m, m.current.Init()
969
970 case tui.DeleteAccountMsg:
971 if m.config != nil {
972 m.config.RemoveAccount(msg.AccountID)
973 if err := config.SaveConfig(m.config); err != nil {
974 log.Printf("could not save config: %v", err)
975 }
976 // Remove emails for this account
977 delete(m.emailsByAcct, msg.AccountID)
978
979 // Rebuild all emails
980 var allEmails []fetcher.Email
981 for _, emails := range m.emailsByAcct {
982 allEmails = append(allEmails, emails...)
983 }
984 m.emails = allEmails
985
986 // Go back to settings
987 m.current = tui.NewSettings(m.config)
988 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
989 }
990 return m, m.current.Init()
991
992 case tui.ViewEmailMsg:
993 email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox)
994 if email == nil {
995 return m, nil
996 }
997 folderName := "INBOX"
998 if m.folderInbox != nil {
999 folderName = m.folderInbox.GetCurrentFolder()
1000 }
1001 if m.plugins != nil {
1002 t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, folderName)
1003 m.plugins.CallHook(plugin.HookEmailViewed, t)
1004 }
1005 // Check body cache first
1006 if cached := config.GetCachedEmailBody(folderName, msg.UID, msg.AccountID); cached != nil {
1007 // Convert cached attachments back to fetcher.Attachment
1008 var attachments []fetcher.Attachment
1009 for _, ca := range cached.Attachments {
1010 att := fetcher.Attachment{
1011 Filename: ca.Filename,
1012 PartID: ca.PartID,
1013 Encoding: ca.Encoding,
1014 MIMEType: ca.MIMEType,
1015 ContentID: ca.ContentID,
1016 Inline: ca.Inline,
1017 IsSMIMESignature: ca.IsSMIMESignature,
1018 SMIMEVerified: ca.SMIMEVerified,
1019 IsSMIMEEncrypted: ca.IsSMIMEEncrypted,
1020 IsCalendarInvite: ca.IsCalendarInvite,
1021 }
1022 if ca.IsCalendarInvite && len(ca.CalendarData) > 0 {
1023 att.Data = ca.CalendarData
1024 }
1025 attachments = append(attachments, att)
1026 }
1027 return m, func() tea.Msg {
1028 return tui.EmailBodyFetchedMsg{
1029 UID: msg.UID,
1030 Body: cached.Body,
1031 Attachments: attachments,
1032 AccountID: msg.AccountID,
1033 Mailbox: msg.Mailbox,
1034 }
1035 }
1036 }
1037 m.current = tui.NewStatus("Fetching email content...")
1038 return m, tea.Batch(m.current.Init(), fetchFolderEmailBodyCmd(m.config, msg.UID, msg.AccountID, folderName, msg.Mailbox), m.pluginNotifyCmd())
1039
1040 case tui.EmailBodyFetchedMsg:
1041 if msg.Err != nil {
1042 log.Printf("could not fetch email body: %v", msg.Err)
1043 if m.folderInbox != nil {
1044 m.current = m.folderInbox
1045 }
1046 return m, nil
1047 }
1048
1049 // Update the email in our stores
1050 m.updateEmailBodyByUID(msg.UID, msg.AccountID, msg.Mailbox, msg.Body, msg.Attachments)
1051
1052 // Cache the body to disk
1053 folderForCache := "INBOX"
1054 if m.folderInbox != nil {
1055 folderForCache = m.folderInbox.GetCurrentFolder()
1056 }
1057 var cachedAttachments []config.CachedAttachment
1058 for _, a := range msg.Attachments {
1059 ca := config.CachedAttachment{
1060 Filename: a.Filename,
1061 PartID: a.PartID,
1062 Encoding: a.Encoding,
1063 MIMEType: a.MIMEType,
1064 ContentID: a.ContentID,
1065 Inline: a.Inline,
1066 IsSMIMESignature: a.IsSMIMESignature,
1067 SMIMEVerified: a.SMIMEVerified,
1068 IsSMIMEEncrypted: a.IsSMIMEEncrypted,
1069 IsCalendarInvite: a.IsCalendarInvite,
1070 }
1071 if a.IsCalendarInvite && len(a.Data) > 0 {
1072 ca.CalendarData = a.Data
1073 }
1074 cachedAttachments = append(cachedAttachments, ca)
1075 }
1076 _ = config.SaveEmailBody(folderForCache, config.CachedEmailBody{
1077 UID: msg.UID,
1078 AccountID: msg.AccountID,
1079 Body: msg.Body,
1080 Attachments: cachedAttachments,
1081 })
1082
1083 email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox)
1084 if email == nil {
1085 if m.folderInbox != nil {
1086 m.current = m.folderInbox
1087 }
1088 return m, nil
1089 }
1090
1091 // Mark as read in UI immediately and on the server
1092 var markReadCmd tea.Cmd
1093 if !email.IsRead {
1094 m.markEmailAsReadInStores(msg.UID, msg.AccountID)
1095
1096 folderName := "INBOX"
1097 if m.folderInbox != nil {
1098 folderName = m.folderInbox.GetCurrentFolder()
1099 }
1100 account := m.config.GetAccountByID(msg.AccountID)
1101 if account != nil {
1102 markReadCmd = markEmailAsReadCmd(account, msg.UID, msg.AccountID, folderName)
1103 }
1104 }
1105
1106 // Find the index for the email view (used for display purposes)
1107 emailIndex := m.getEmailIndex(msg.UID, msg.AccountID, msg.Mailbox)
1108 emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height, msg.Mailbox, m.config.DisableImages)
1109 m.current = emailView
1110 m.syncPluginStatus()
1111 m.syncPluginKeyBindings()
1112 cmds := []tea.Cmd{m.current.Init()}
1113 if markReadCmd != nil {
1114 cmds = append(cmds, markReadCmd)
1115 }
1116 return m, tea.Batch(cmds...)
1117
1118 case tui.ReplyToEmailMsg:
1119 var to string
1120 if len(msg.Email.ReplyTo) > 0 {
1121 to = strings.Join(msg.Email.ReplyTo, ", ")
1122 } else {
1123 to = msg.Email.From
1124 }
1125 subject := msg.Email.Subject
1126 normalizedSubject := strings.ToLower(strings.TrimSpace(subject))
1127 if !strings.HasPrefix(normalizedSubject, "re:") {
1128 subject = "Re: " + subject
1129 }
1130 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> "))
1131
1132 var composer *tui.Composer
1133 hideTips := false
1134 if m.config != nil {
1135 hideTips = m.config.HideTips
1136 }
1137 if m.config != nil && len(m.config.Accounts) > 0 {
1138 // Use the account that received the email
1139 accountID := msg.Email.AccountID
1140 if accountID == "" {
1141 accountID = m.config.GetFirstAccount().ID
1142 }
1143 composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, to, subject, "", hideTips)
1144 } else {
1145 composer = tui.NewComposer("", to, subject, "", hideTips)
1146 }
1147 composer.SetQuotedText(quotedText)
1148
1149 // Set reply headers
1150 inReplyTo := msg.Email.MessageID
1151 references := append(msg.Email.References, msg.Email.MessageID)
1152 composer.SetReplyContext(inReplyTo, references)
1153
1154 m.current = composer
1155 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1156 m.syncPluginKeyBindings()
1157 return m, m.current.Init()
1158
1159 case tui.ForwardEmailMsg:
1160 subject := msg.Email.Subject
1161 if !strings.HasPrefix(strings.ToLower(subject), "fwd:") {
1162 subject = "Fwd: " + subject
1163 }
1164
1165 forwardHeader := fmt.Sprintf("\n\n---------- Forwarded message ----------\nFrom: %s\nDate: %s\nSubject: %s\nTo: %s\n\n",
1166 msg.Email.From,
1167 msg.Email.Date.Format("Mon, Jan 2, 2006 at 3:04 PM"),
1168 msg.Email.Subject,
1169 msg.Email.To,
1170 )
1171
1172 body := forwardHeader + msg.Email.Body
1173
1174 var composer *tui.Composer
1175 hideTips := false
1176 if m.config != nil {
1177 hideTips = m.config.HideTips
1178 }
1179 if m.config != nil && len(m.config.Accounts) > 0 {
1180 // Use the account that received the email
1181 accountID := msg.Email.AccountID
1182 if accountID == "" {
1183 accountID = m.config.GetFirstAccount().ID
1184 }
1185 composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, "", subject, body, hideTips)
1186 } else {
1187 composer = tui.NewComposer("", "", subject, body, hideTips)
1188 }
1189
1190 m.current = composer
1191 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1192 m.syncPluginKeyBindings()
1193 return m, m.current.Init()
1194
1195 case tui.OpenEditorMsg:
1196 composer, ok := m.current.(*tui.Composer)
1197 if !ok {
1198 return m, nil
1199 }
1200 return m, openExternalEditor(composer.GetBody())
1201
1202 case tui.EditorFinishedMsg:
1203 if msg.Err != nil {
1204 log.Printf("Editor error: %v", msg.Err)
1205 return m, nil
1206 }
1207 if composer, ok := m.current.(*tui.Composer); ok {
1208 composer.SetBody(msg.Body)
1209 }
1210 return m, nil
1211
1212 case tui.GoToFilePickerMsg:
1213 m.previousModel = m.current
1214 wd, _ := os.Getwd()
1215 m.current = tui.NewFilePicker(wd)
1216 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1217 return m, m.current.Init()
1218
1219 case tui.FileSelectedMsg, tui.CancelFilePickerMsg:
1220 if m.previousModel != nil {
1221 m.current = m.previousModel
1222 m.previousModel = nil
1223 }
1224 m.current, cmd = m.current.Update(msg)
1225 cmds = append(cmds, cmd)
1226
1227 case tui.SendEmailMsg:
1228 if m.plugins != nil {
1229 m.plugins.CallSendHook(plugin.HookEmailSendBefore, msg.To, msg.Cc, msg.Subject, msg.AccountID)
1230 }
1231 // Get draft ID before clearing composer (if it's a composer)
1232 var draftID string
1233 if composer, ok := m.current.(*tui.Composer); ok {
1234 draftID = composer.GetDraftID()
1235 }
1236 // Get the account to send from
1237 var account *config.Account
1238 if msg.AccountID != "" && m.config != nil {
1239 account = m.config.GetAccountByID(msg.AccountID)
1240 }
1241 if account == nil && m.config != nil {
1242 account = m.config.GetFirstAccount()
1243 }
1244
1245 statusText := "Sending email..."
1246 if msg.SignPGP && account != nil && account.PGPKeySource == "yubikey" {
1247 statusText = "Touch your YubiKey to sign..."
1248 }
1249 m.current = tui.NewStatus(statusText)
1250
1251 // Save contact and delete draft in background
1252 go func() {
1253 // Save the recipient as a contact
1254 if msg.To != "" {
1255 recipients := strings.Split(msg.To, ",")
1256 for _, r := range recipients {
1257 r = strings.TrimSpace(r)
1258 if r == "" {
1259 continue
1260 }
1261 name, email := parseEmailAddress(r)
1262 if err := config.AddContact(name, email); err != nil {
1263 log.Printf("Error saving contact: %v", err)
1264 }
1265 }
1266 }
1267 // Delete the draft since email is being sent
1268 if draftID != "" {
1269 if err := config.DeleteDraft(draftID); err != nil {
1270 log.Printf("Error deleting draft after send: %v", err)
1271 }
1272 }
1273 }()
1274
1275 return m, tea.Batch(m.current.Init(), sendEmail(account, msg))
1276
1277 case tui.SendRSVPMsg:
1278 account := m.config.GetAccountByID(msg.AccountID)
1279 if account == nil {
1280 m.current = tui.NewStatus("Error: account not found")
1281 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1282 return tui.RestoreViewMsg{}
1283 })
1284 }
1285
1286 m.current = tui.NewStatus("Sending RSVP...")
1287 return m, tea.Batch(m.current.Init(), sendRSVP(account, msg))
1288
1289 case tui.RSVPResultMsg:
1290 if msg.Err != nil {
1291 log.Printf("Failed to send RSVP: %v", msg.Err)
1292 m.previousModel = tui.NewChoice()
1293 m.previousModel, _ = m.previousModel.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1294 m.current = tui.NewStatus(fmt.Sprintf("RSVP error: %v", msg.Err))
1295 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1296 return tui.RestoreViewMsg{}
1297 })
1298 }
1299 status := fmt.Sprintf("RSVP sent: %s", msg.Response)
1300 if strings.HasSuffix(strings.ToLower(msg.Organizer), "@gmail.com") || strings.HasSuffix(strings.ToLower(msg.Organizer), "@googlemail.com") {
1301 status += " (Google Calendar may not auto-update — use Gmail buttons for Google events)"
1302 }
1303 m.current = tui.NewStatus(status)
1304 return m, tea.Tick(3*time.Second, func(t time.Time) tea.Msg {
1305 return tui.RestoreViewMsg{}
1306 })
1307
1308 case tui.EmailResultMsg:
1309 if msg.Err != nil {
1310 log.Printf("Failed to send email: %v", msg.Err)
1311 m.previousModel = tui.NewChoice()
1312 m.previousModel, _ = m.previousModel.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1313 m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
1314 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1315 return tui.RestoreViewMsg{}
1316 })
1317 }
1318 if m.plugins != nil {
1319 m.plugins.CallHook(plugin.HookEmailSendAfter)
1320 }
1321 m.current = tui.NewChoice()
1322 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1323 return m, m.current.Init()
1324
1325 case tui.DeleteEmailMsg:
1326 tui.ClearKittyGraphics()
1327 m.previousModel = m.current
1328 m.current = tui.NewStatus("Deleting email...")
1329
1330 account := m.config.GetAccountByID(msg.AccountID)
1331 if account == nil {
1332 if m.folderInbox != nil {
1333 m.current = m.folderInbox
1334 }
1335 return m, nil
1336 }
1337
1338 folderName := "INBOX"
1339 if m.folderInbox != nil {
1340 folderName = m.folderInbox.GetCurrentFolder()
1341 }
1342 return m, tea.Batch(m.current.Init(), deleteFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
1343
1344 case tui.ArchiveEmailMsg:
1345 tui.ClearKittyGraphics()
1346 m.previousModel = m.current
1347 m.current = tui.NewStatus("Archiving email...")
1348
1349 account := m.config.GetAccountByID(msg.AccountID)
1350 if account == nil {
1351 if m.folderInbox != nil {
1352 m.current = m.folderInbox
1353 }
1354 return m, nil
1355 }
1356
1357 folderName := "INBOX"
1358 if m.folderInbox != nil {
1359 folderName = m.folderInbox.GetCurrentFolder()
1360 }
1361 return m, tea.Batch(m.current.Init(), archiveFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
1362
1363 case tui.EmailMarkedReadMsg:
1364 if msg.Err != nil {
1365 log.Printf("Error marking email as read: %v", msg.Err)
1366 }
1367 return m, nil
1368
1369 case tui.EmailActionDoneMsg:
1370 if msg.Err != nil {
1371 log.Printf("Action failed: %v", msg.Err)
1372 if m.folderInbox != nil {
1373 m.previousModel = m.folderInbox
1374 }
1375 m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
1376 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1377 return tui.RestoreViewMsg{}
1378 })
1379 }
1380
1381 // Remove email from stores
1382 m.removeEmailFromStores(msg.UID, msg.AccountID)
1383
1384 if m.folderInbox != nil {
1385 m.folderInbox.RemoveEmail(msg.UID, msg.AccountID)
1386 m.current = m.folderInbox
1387 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1388 return m, m.current.Init()
1389 }
1390 m.current = tui.NewChoice()
1391 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1392 return m, m.current.Init()
1393
1394 case tui.BatchDeleteEmailsMsg:
1395 tui.ClearKittyGraphics()
1396 m.previousModel = m.current
1397 count := len(msg.UIDs)
1398 m.current = tui.NewStatus(fmt.Sprintf("Deleting %d emails...", count))
1399
1400 account := m.config.GetAccountByID(msg.AccountID)
1401 if account == nil {
1402 if m.folderInbox != nil {
1403 m.current = m.folderInbox
1404 }
1405 return m, nil
1406 }
1407
1408 folderName := "INBOX"
1409 if m.folderInbox != nil {
1410 folderName = m.folderInbox.GetCurrentFolder()
1411 }
1412
1413 return m, tea.Batch(
1414 m.current.Init(),
1415 m.batchDeleteEmailsCmd(account, msg.UIDs, msg.AccountID, folderName, msg.Mailbox, count),
1416 )
1417
1418 case tui.BatchArchiveEmailsMsg:
1419 tui.ClearKittyGraphics()
1420 m.previousModel = m.current
1421 count := len(msg.UIDs)
1422 m.current = tui.NewStatus(fmt.Sprintf("Archiving %d emails...", count))
1423
1424 account := m.config.GetAccountByID(msg.AccountID)
1425 if account == nil {
1426 if m.folderInbox != nil {
1427 m.current = m.folderInbox
1428 }
1429 return m, nil
1430 }
1431
1432 folderName := "INBOX"
1433 if m.folderInbox != nil {
1434 folderName = m.folderInbox.GetCurrentFolder()
1435 }
1436
1437 return m, tea.Batch(
1438 m.current.Init(),
1439 m.batchArchiveEmailsCmd(account, msg.UIDs, msg.AccountID, folderName, msg.Mailbox, count),
1440 )
1441
1442 case tui.BatchMoveEmailsMsg:
1443 if m.config == nil {
1444 return m, nil
1445 }
1446 account := m.config.GetAccountByID(msg.AccountID)
1447 if account == nil {
1448 return m, nil
1449 }
1450
1451 count := len(msg.UIDs)
1452 m.previousModel = m.current
1453 m.current = tui.NewStatus(fmt.Sprintf("Moving %d emails...", count))
1454
1455 return m, tea.Batch(
1456 m.current.Init(),
1457 m.batchMoveEmailsCmd(account, msg.UIDs, msg.AccountID, msg.SourceFolder, msg.DestFolder, count),
1458 )
1459
1460 case tui.BatchEmailActionDoneMsg:
1461 if msg.Err != nil {
1462 log.Printf("Batch %s failed: %v", msg.Action, msg.Err)
1463 m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
1464 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1465 return tui.RestoreViewMsg{}
1466 })
1467 }
1468
1469 // Success - show brief confirmation
1470 successMsg := fmt.Sprintf("%d emails %sd successfully", msg.SuccessCount, msg.Action)
1471 if msg.FailureCount > 0 {
1472 successMsg = fmt.Sprintf("%d of %d emails %sd (%d failed)",
1473 msg.SuccessCount, msg.Count, msg.Action, msg.FailureCount)
1474 }
1475
1476 m.current = tui.NewStatus(successMsg)
1477
1478 return m, tea.Tick(1500*time.Millisecond, func(t time.Time) tea.Msg {
1479 return tui.RestoreViewMsg{}
1480 })
1481
1482 case tui.DownloadAttachmentMsg:
1483 m.previousModel = m.current
1484 m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename))
1485
1486 account := m.config.GetAccountByID(msg.AccountID)
1487 if account == nil {
1488 m.current = m.previousModel
1489 return m, nil
1490 }
1491
1492 email := m.getEmailByIndex(msg.Index, msg.Mailbox)
1493 if email == nil {
1494 m.current = m.previousModel
1495 return m, nil
1496 }
1497
1498 // Find the correct attachment to get encoding
1499 var encoding string
1500 for _, att := range email.Attachments {
1501 if att.PartID == msg.PartID {
1502 encoding = att.Encoding
1503 break
1504 }
1505 }
1506 newMsg := tui.DownloadAttachmentMsg{
1507 Index: msg.Index,
1508 Filename: msg.Filename,
1509 PartID: msg.PartID,
1510 Data: msg.Data,
1511 AccountID: msg.AccountID,
1512 Encoding: encoding,
1513 Mailbox: msg.Mailbox,
1514 }
1515 return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(account, email.UID, newMsg))
1516
1517 case tui.AttachmentDownloadedMsg:
1518 var statusMsg string
1519 if msg.Err != nil {
1520 statusMsg = fmt.Sprintf("Error downloading: %v", msg.Err)
1521 } else {
1522 statusMsg = fmt.Sprintf("Saved to %s", msg.Path)
1523 }
1524 m.current = tui.NewStatus(statusMsg)
1525 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1526 return tui.RestoreViewMsg{}
1527 })
1528
1529 case tui.RestoreViewMsg:
1530 if m.previousModel != nil {
1531 m.current = m.previousModel
1532 m.previousModel = nil
1533 }
1534 return m, nil
1535 }
1536
1537 if cmd := m.pluginNotifyCmd(); cmd != nil {
1538 cmds = append(cmds, cmd)
1539 }
1540
1541 return m, tea.Batch(cmds...)
1542}
1543
1544func (m *mainModel) View() tea.View {
1545 v := m.current.View()
1546 v.AltScreen = true
1547 return v
1548}
1549
1550func (m *mainModel) getEmailByIndex(index int, mailbox tui.MailboxKind) *fetcher.Email {
1551 if index >= 0 && index < len(m.emails) {
1552 return &m.emails[index]
1553 }
1554 return nil
1555}
1556
1557func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string, mailbox tui.MailboxKind) *fetcher.Email {
1558 for i := range m.emails {
1559 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1560 return &m.emails[i]
1561 }
1562 }
1563 return nil
1564}
1565
1566func (m *mainModel) getEmailIndex(uid uint32, accountID string, mailbox tui.MailboxKind) int {
1567 for i := range m.emails {
1568 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1569 return i
1570 }
1571 }
1572 return -1
1573}
1574
1575func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, mailbox tui.MailboxKind, body string, attachments []fetcher.Attachment) {
1576 for i := range m.emails {
1577 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1578 m.emails[i].Body = body
1579 m.emails[i].Attachments = attachments
1580 break
1581 }
1582 }
1583 if emails, ok := m.emailsByAcct[accountID]; ok {
1584 for i := range emails {
1585 if emails[i].UID == uid {
1586 emails[i].Body = body
1587 emails[i].Attachments = attachments
1588 break
1589 }
1590 }
1591 }
1592}
1593
1594func (m *mainModel) markEmailAsReadInStores(uid uint32, accountID string) {
1595 for i := range m.emails {
1596 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1597 m.emails[i].IsRead = true
1598 break
1599 }
1600 }
1601 if emails, ok := m.emailsByAcct[accountID]; ok {
1602 for i := range emails {
1603 if emails[i].UID == uid {
1604 emails[i].IsRead = true
1605 break
1606 }
1607 }
1608 }
1609 // Update folder email cache
1610 for folderName, folderEmails := range m.folderEmails {
1611 for i := range folderEmails {
1612 if folderEmails[i].UID == uid && folderEmails[i].AccountID == accountID {
1613 folderEmails[i].IsRead = true
1614 m.folderEmails[folderName] = folderEmails
1615 go saveFolderEmailsToCache(folderName, folderEmails)
1616 break
1617 }
1618 }
1619 }
1620 // Update the inbox UI
1621 if m.folderInbox != nil {
1622 m.folderInbox.GetInbox().MarkEmailAsRead(uid, accountID)
1623 }
1624}
1625
1626func (m *mainModel) removeEmailFromStores(uid uint32, accountID string) {
1627 var filtered []fetcher.Email
1628 for _, e := range m.emails {
1629 if !(e.UID == uid && e.AccountID == accountID) {
1630 filtered = append(filtered, e)
1631 }
1632 }
1633 m.emails = filtered
1634 if emails, ok := m.emailsByAcct[accountID]; ok {
1635 var filteredAcct []fetcher.Email
1636 for _, e := range emails {
1637 if e.UID != uid {
1638 filteredAcct = append(filteredAcct, e)
1639 }
1640 }
1641 m.emailsByAcct[accountID] = filteredAcct
1642 }
1643}
1644
1645// pluginNotifyCmd checks for a pending plugin notification and returns a command if one exists.
1646func (m *mainModel) pluginNotifyCmd() tea.Cmd {
1647 if m.plugins == nil {
1648 return nil
1649 }
1650 if n, ok := m.plugins.TakePendingNotification(); ok {
1651 return func() tea.Msg {
1652 return tui.PluginNotifyMsg{Message: n.Message, Duration: n.Duration}
1653 }
1654 }
1655 return nil
1656}
1657
1658func (m *mainModel) syncPluginStatus() {
1659 if m.plugins == nil {
1660 return
1661 }
1662 if m.folderInbox != nil {
1663 m.folderInbox.GetInbox().SetPluginStatus(m.plugins.StatusText(plugin.StatusInbox))
1664 }
1665 switch v := m.current.(type) {
1666 case *tui.Composer:
1667 v.SetPluginStatus(m.plugins.StatusText(plugin.StatusComposer))
1668 case *tui.EmailView:
1669 v.SetPluginStatus(m.plugins.StatusText(plugin.StatusEmailView))
1670 }
1671}
1672
1673func (m *mainModel) handlePluginKeyBinding(msg tea.KeyPressMsg) {
1674 keyStr := msg.String()
1675
1676 var area string
1677 switch m.current.(type) {
1678 case *tui.Inbox:
1679 area = plugin.StatusInbox
1680 case *tui.FolderInbox:
1681 area = plugin.StatusInbox
1682 case *tui.EmailView:
1683 area = plugin.StatusEmailView
1684 case *tui.Composer:
1685 area = plugin.StatusComposer
1686 default:
1687 return
1688 }
1689
1690 bindings := m.plugins.Bindings(area)
1691 for _, binding := range bindings {
1692 if binding.Key != keyStr {
1693 continue
1694 }
1695
1696 // Build context table based on the current view
1697 switch v := m.current.(type) {
1698 case *tui.Inbox:
1699 if email := v.GetSelectedEmail(); email != nil {
1700 t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, "")
1701 m.plugins.CallKeyBinding(binding, t)
1702 } else {
1703 m.plugins.CallKeyBinding(binding)
1704 }
1705 case *tui.FolderInbox:
1706 if email := v.GetInbox().GetSelectedEmail(); email != nil {
1707 t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, v.GetCurrentFolder())
1708 m.plugins.CallKeyBinding(binding, t)
1709 } else {
1710 m.plugins.CallKeyBinding(binding)
1711 }
1712 case *tui.EmailView:
1713 email := v.GetEmail()
1714 t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, "")
1715 m.plugins.CallKeyBinding(binding, t)
1716 case *tui.Composer:
1717 L := m.plugins.LuaState()
1718 t := L.NewTable()
1719 t.RawSetString("body", lua.LString(v.GetBody()))
1720 t.RawSetString("body_len", lua.LNumber(len(v.GetBody())))
1721 t.RawSetString("subject", lua.LString(v.GetSubject()))
1722 t.RawSetString("to", lua.LString(v.GetTo()))
1723 t.RawSetString("cc", lua.LString(v.GetCc()))
1724 t.RawSetString("bcc", lua.LString(v.GetBcc()))
1725 m.plugins.CallKeyBinding(binding, t)
1726 m.applyPluginFields(v)
1727
1728 // Check if the plugin requested a prompt overlay
1729 if p, ok := m.plugins.TakePendingPrompt(); ok {
1730 m.pendingPrompt = p
1731 v.ShowPluginPrompt(p.Placeholder)
1732 }
1733 }
1734
1735 m.syncPluginStatus()
1736 return
1737 }
1738}
1739
1740func (m *mainModel) syncPluginKeyBindings() {
1741 if m.plugins == nil {
1742 return
1743 }
1744
1745 toPluginKeyBindings := func(bindings []plugin.KeyBinding) []tui.PluginKeyBinding {
1746 result := make([]tui.PluginKeyBinding, len(bindings))
1747 for i, b := range bindings {
1748 result[i] = tui.PluginKeyBinding{Key: b.Key, Description: b.Description}
1749 }
1750 return result
1751 }
1752
1753 if m.folderInbox != nil {
1754 m.folderInbox.GetInbox().SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusInbox)))
1755 }
1756 switch v := m.current.(type) {
1757 case *tui.Composer:
1758 v.SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusComposer)))
1759 case *tui.EmailView:
1760 v.SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusEmailView)))
1761 }
1762}
1763
1764func (m *mainModel) applyPluginFields(composer *tui.Composer) {
1765 fields := m.plugins.TakePendingFields()
1766 if fields == nil {
1767 return
1768 }
1769 for field, value := range fields {
1770 switch field {
1771 case "to":
1772 composer.SetTo(value)
1773 case "cc":
1774 composer.SetCc(value)
1775 case "bcc":
1776 composer.SetBcc(value)
1777 case "subject":
1778 composer.SetSubject(value)
1779 case "body":
1780 composer.SetBody(value)
1781 }
1782 }
1783}
1784
1785func flattenAndSort(emailsByAccount map[string][]fetcher.Email) []fetcher.Email {
1786 var allEmails []fetcher.Email
1787 for _, emails := range emailsByAccount {
1788 allEmails = append(allEmails, emails...)
1789 }
1790 for i := 0; i < len(allEmails); i++ {
1791 for j := i + 1; j < len(allEmails); j++ {
1792 if allEmails[j].Date.After(allEmails[i].Date) {
1793 allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
1794 }
1795 }
1796 }
1797 return allEmails
1798}
1799
1800func fetchAllAccountsEmails(cfg *config.Config, mailbox tui.MailboxKind) tea.Cmd {
1801 return func() tea.Msg {
1802 emailsByAccount := make(map[string][]fetcher.Email)
1803 var mu sync.Mutex
1804 var wg sync.WaitGroup
1805
1806 for _, account := range cfg.Accounts {
1807 wg.Add(1)
1808 go func(acc config.Account) {
1809 defer wg.Done()
1810 var emails []fetcher.Email
1811 var err error
1812 switch mailbox {
1813 case tui.MailboxSent:
1814 emails, err = fetcher.FetchSentEmails(&acc, initialEmailLimit, 0)
1815 case tui.MailboxTrash:
1816 emails, err = fetcher.FetchTrashEmails(&acc, initialEmailLimit, 0)
1817 case tui.MailboxArchive:
1818 emails, err = fetcher.FetchArchiveEmails(&acc, initialEmailLimit, 0)
1819 default:
1820 emails, err = fetcher.FetchEmails(&acc, initialEmailLimit, 0)
1821 }
1822 if err != nil {
1823 log.Printf("Error fetching from %s: %v", acc.Email, err)
1824 return
1825 }
1826 mu.Lock()
1827 emailsByAccount[acc.ID] = emails
1828 mu.Unlock()
1829 }(account)
1830 }
1831
1832 wg.Wait()
1833 return tui.AllEmailsFetchedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
1834 }
1835}
1836
1837func fetchEmails(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
1838 return func() tea.Msg {
1839 var emails []fetcher.Email
1840 var err error
1841 if mailbox == tui.MailboxSent {
1842 emails, err = fetcher.FetchSentEmails(account, limit, offset)
1843 } else {
1844 emails, err = fetcher.FetchEmails(account, limit, offset)
1845 }
1846 if err != nil {
1847 return tui.FetchErr(err)
1848 }
1849 if offset == 0 {
1850 return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1851 }
1852 return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1853 }
1854}
1855
1856func fetchEmailsForMailbox(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
1857 return func() tea.Msg {
1858 var emails []fetcher.Email
1859 var err error
1860 switch mailbox {
1861 case tui.MailboxSent:
1862 emails, err = fetcher.FetchSentEmails(account, limit, offset)
1863 case tui.MailboxTrash:
1864 emails, err = fetcher.FetchTrashEmails(account, limit, offset)
1865 case tui.MailboxArchive:
1866 emails, err = fetcher.FetchArchiveEmails(account, limit, offset)
1867 default:
1868 emails, err = fetcher.FetchEmails(account, limit, offset)
1869 }
1870 if err != nil {
1871 return tui.FetchErr(err)
1872 }
1873 if offset == 0 {
1874 return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1875 }
1876 return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1877 }
1878}
1879
1880func loadCachedEmails() tea.Cmd {
1881 return func() tea.Msg {
1882 cache, err := config.LoadEmailCache()
1883 if err != nil {
1884 return tui.CachedEmailsLoadedMsg{Cache: nil}
1885 }
1886 return tui.CachedEmailsLoadedMsg{Cache: cache}
1887 }
1888}
1889
1890func refreshEmails(cfg *config.Config, mailbox tui.MailboxKind, counts map[string]int) tea.Cmd {
1891 return func() tea.Msg {
1892 emailsByAccount := make(map[string][]fetcher.Email)
1893 var mu sync.Mutex
1894 var wg sync.WaitGroup
1895
1896 for _, account := range cfg.Accounts {
1897 wg.Add(1)
1898 go func(acc config.Account) {
1899 defer wg.Done()
1900 var emails []fetcher.Email
1901 var err error
1902
1903 limit := uint32(initialEmailLimit)
1904 if counts != nil {
1905 if c, ok := counts[acc.ID]; ok && c > 0 {
1906 limit = uint32(c)
1907 }
1908 }
1909
1910 if mailbox == tui.MailboxSent {
1911 emails, err = fetcher.FetchSentEmails(&acc, limit, 0)
1912 } else {
1913 emails, err = fetcher.FetchEmails(&acc, limit, 0)
1914 }
1915 if err != nil {
1916 log.Printf("Error fetching from %s: %v", acc.Email, err)
1917 return
1918 }
1919 mu.Lock()
1920 emailsByAccount[acc.ID] = emails
1921 mu.Unlock()
1922 }(account)
1923 }
1924
1925 wg.Wait()
1926 return tui.EmailsRefreshedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
1927 }
1928}
1929
1930func emailsToCache(emails []fetcher.Email) []config.CachedEmail {
1931 var cached []config.CachedEmail
1932 for _, email := range emails {
1933 cached = append(cached, config.CachedEmail{
1934 UID: email.UID,
1935 From: email.From,
1936 To: email.To,
1937 Subject: email.Subject,
1938 Date: email.Date,
1939 MessageID: email.MessageID,
1940 AccountID: email.AccountID,
1941 IsRead: email.IsRead,
1942 })
1943 }
1944 return cached
1945}
1946
1947func cacheToEmails(cached []config.CachedEmail) []fetcher.Email {
1948 var emails []fetcher.Email
1949 for _, c := range cached {
1950 emails = append(emails, fetcher.Email{
1951 UID: c.UID,
1952 From: c.From,
1953 To: c.To,
1954 Subject: c.Subject,
1955 Date: c.Date,
1956 MessageID: c.MessageID,
1957 AccountID: c.AccountID,
1958 IsRead: c.IsRead,
1959 })
1960 }
1961 return emails
1962}
1963
1964func saveFolderEmailsToCache(folderName string, emails []fetcher.Email) {
1965 cached := emailsToCache(emails)
1966 if err := config.SaveFolderEmailCache(folderName, cached); err != nil {
1967 log.Printf("Error saving folder email cache for %s: %v", folderName, err)
1968 }
1969}
1970
1971func loadFolderEmailsFromCache(folderName string) []fetcher.Email {
1972 cached, err := config.LoadFolderEmailCache(folderName)
1973 if err != nil {
1974 return nil
1975 }
1976 return cacheToEmails(cached)
1977}
1978
1979func saveEmailsToCache(emails []fetcher.Email) {
1980 if len(emails) > maxCacheEmails {
1981 emails = emails[:maxCacheEmails]
1982 }
1983 var cachedEmails []config.CachedEmail
1984 for _, email := range emails {
1985 cachedEmails = append(cachedEmails, config.CachedEmail{
1986 UID: email.UID,
1987 From: email.From,
1988 To: email.To,
1989 Subject: email.Subject,
1990 Date: email.Date,
1991 MessageID: email.MessageID,
1992 AccountID: email.AccountID,
1993 IsRead: email.IsRead,
1994 })
1995
1996 // Save sender as a contact
1997 if email.From != "" {
1998 name, emailAddr := parseEmailAddress(email.From)
1999 if err := config.AddContact(name, emailAddr); err != nil {
2000 log.Printf("Error saving contact from email: %v", err)
2001 }
2002 }
2003 }
2004 cache := &config.EmailCache{Emails: cachedEmails}
2005 if err := config.SaveEmailCache(cache); err != nil {
2006 log.Printf("Error saving email cache: %v", err)
2007 }
2008}
2009
2010// parseEmailAddress parses "Name <email>" or just "email" format
2011func parseEmailAddress(addr string) (name, email string) {
2012 addr = strings.TrimSpace(addr)
2013 if idx := strings.Index(addr, "<"); idx != -1 {
2014 name = strings.TrimSpace(addr[:idx])
2015 endIdx := strings.Index(addr, ">")
2016 if endIdx > idx {
2017 email = strings.TrimSpace(addr[idx+1 : endIdx])
2018 } else {
2019 email = strings.TrimSpace(addr[idx+1:])
2020 }
2021 } else {
2022 email = addr
2023 }
2024 return name, email
2025}
2026
2027func fetchEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
2028 return func() tea.Msg {
2029 account := cfg.GetAccountByID(accountID)
2030 if account == nil {
2031 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
2032 }
2033
2034 var (
2035 body string
2036 attachments []fetcher.Attachment
2037 err error
2038 )
2039 switch mailbox {
2040 case tui.MailboxSent:
2041 body, attachments, err = fetcher.FetchSentEmailBody(account, uid)
2042 case tui.MailboxTrash:
2043 body, attachments, err = fetcher.FetchTrashEmailBody(account, uid)
2044 case tui.MailboxArchive:
2045 body, attachments, err = fetcher.FetchArchiveEmailBody(account, uid)
2046 default:
2047 body, attachments, err = fetcher.FetchEmailBody(account, uid)
2048 }
2049 if err != nil {
2050 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2051 }
2052
2053 return tui.EmailBodyFetchedMsg{
2054 UID: uid,
2055 Body: body,
2056 Attachments: attachments,
2057 AccountID: accountID,
2058 Mailbox: mailbox,
2059 }
2060 }
2061}
2062
2063func markdownToHTML(md []byte) []byte {
2064 return clib.MarkdownToHTML(md)
2065}
2066
2067func splitEmails(s string) []string {
2068 if s == "" {
2069 return nil
2070 }
2071 parts := strings.Split(s, ",")
2072 var res []string
2073 for _, p := range parts {
2074 if trimmed := strings.TrimSpace(p); trimmed != "" {
2075 res = append(res, trimmed)
2076 }
2077 }
2078 return res
2079}
2080
2081func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
2082 return func() tea.Msg {
2083 if account == nil {
2084 return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
2085 }
2086
2087 recipients := splitEmails(msg.To)
2088 cc := splitEmails(msg.Cc)
2089 bcc := splitEmails(msg.Bcc)
2090 body := msg.Body
2091 // Append signature if present
2092 if msg.Signature != "" {
2093 body = body + "\n\n" + msg.Signature
2094 }
2095 // Append quoted text if present (for replies)
2096 if msg.QuotedText != "" {
2097 body = body + msg.QuotedText
2098 }
2099 images := make(map[string][]byte)
2100 attachments := make(map[string][]byte)
2101
2102 re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
2103 matches := re.FindAllStringSubmatch(body, -1)
2104
2105 for _, match := range matches {
2106 imgPath := match[1]
2107 imgData, err := os.ReadFile(imgPath)
2108 if err != nil {
2109 log.Printf("Could not read image file %s: %v", imgPath, err)
2110 continue
2111 }
2112 cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
2113 images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
2114 body = strings.Replace(body, imgPath, "cid:"+cid, 1)
2115 }
2116
2117 htmlBody := markdownToHTML([]byte(body))
2118
2119 for _, attachPath := range msg.AttachmentPaths {
2120 fileData, err := os.ReadFile(attachPath)
2121 if err != nil {
2122 log.Printf("Could not read attachment file %s: %v", attachPath, err)
2123 continue
2124 }
2125 _, filename := filepath.Split(attachPath)
2126 attachments[filename] = fileData
2127 }
2128
2129 rawMsg, err := sender.SendEmail(account, recipients, cc, bcc, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References, msg.SignSMIME, msg.EncryptSMIME, msg.SignPGP, false)
2130 if err != nil {
2131 log.Printf("Failed to send email: %v", err)
2132 return tui.EmailResultMsg{Err: err}
2133 }
2134
2135 // Append to Sent folder via IMAP (Gmail auto-saves, so skip it)
2136 if account.ServiceProvider != "gmail" {
2137 if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
2138 log.Printf("Failed to append sent message to Sent folder: %v", err)
2139 }
2140 }
2141
2142 return tui.EmailResultMsg{}
2143 }
2144}
2145
2146func sendRSVP(account *config.Account, msg tui.SendRSVPMsg) tea.Cmd {
2147 return func() tea.Msg {
2148 if account == nil {
2149 return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
2150 }
2151
2152 // Generate RSVP .ics
2153 rsvpICS, err := calendar.GenerateRSVP(msg.OriginalICS, account.Email, msg.Response)
2154 if err != nil {
2155 return tui.EmailResultMsg{Err: fmt.Errorf("generate RSVP: %w", err)}
2156 }
2157
2158 // Compose reply email
2159 subject := fmt.Sprintf("Re: %s", msg.Event.Summary)
2160 bodyText := fmt.Sprintf("%s: %s\n\n%s",
2161 msg.Response,
2162 msg.Event.Summary,
2163 msg.Event.Start.Format("Mon Jan 2, 2006 3:04 PM"))
2164 if msg.Event.Location != "" {
2165 bodyText += " at " + msg.Event.Location
2166 }
2167
2168 // Send as multipart/alternative with text/calendar; method=REPLY
2169 // This iMIP format is required for Google Calendar to recognize the RSVP
2170 references := append(msg.References, msg.InReplyTo)
2171 rawMsg, err := sender.SendCalendarReply(
2172 account,
2173 []string{msg.Event.Organizer},
2174 subject,
2175 bodyText,
2176 rsvpICS,
2177 msg.InReplyTo,
2178 references,
2179 )
2180
2181 if err != nil {
2182 return tui.RSVPResultMsg{Err: fmt.Errorf("send RSVP: %w", err), Response: msg.Response, Organizer: msg.Event.Organizer}
2183 }
2184
2185 // Append to Sent folder
2186 if account.ServiceProvider != "gmail" {
2187 if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
2188 log.Printf("Failed to append RSVP to Sent folder: %v", err)
2189 }
2190 }
2191
2192 return tui.RSVPResultMsg{Response: msg.Response, Organizer: msg.Event.Organizer}
2193 }
2194}
2195
2196func deleteEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
2197 return func() tea.Msg {
2198 var err error
2199 switch mailbox {
2200 case tui.MailboxSent:
2201 err = fetcher.DeleteSentEmail(account, uid)
2202 case tui.MailboxTrash:
2203 err = fetcher.DeleteTrashEmail(account, uid)
2204 case tui.MailboxArchive:
2205 err = fetcher.DeleteArchiveEmail(account, uid)
2206 default:
2207 err = fetcher.DeleteEmail(account, uid)
2208 }
2209 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2210 }
2211}
2212
2213func archiveEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
2214 return func() tea.Msg {
2215 var err error
2216 if mailbox == tui.MailboxSent {
2217 err = fetcher.ArchiveSentEmail(account, uid)
2218 } else {
2219 err = fetcher.ArchiveEmail(account, uid)
2220 }
2221 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2222 }
2223}
2224
2225// --- External editor command ---
2226
2227// openExternalEditor writes the body to a temp file, opens $EDITOR, and reads back the result.
2228func openExternalEditor(body string) tea.Cmd {
2229 editor := os.Getenv("EDITOR")
2230 if editor == "" {
2231 editor = os.Getenv("VISUAL")
2232 }
2233 if editor == "" {
2234 editor = "vi"
2235 }
2236
2237 tmpFile, err := os.CreateTemp("", "matcha-*.md")
2238 if err != nil {
2239 return func() tea.Msg {
2240 return tui.EditorFinishedMsg{Err: fmt.Errorf("creating temp file: %w", err)}
2241 }
2242 }
2243 tmpPath := tmpFile.Name()
2244
2245 if _, err := tmpFile.WriteString(body); err != nil {
2246 tmpFile.Close()
2247 os.Remove(tmpPath)
2248 return func() tea.Msg {
2249 return tui.EditorFinishedMsg{Err: fmt.Errorf("writing temp file: %w", err)}
2250 }
2251 }
2252 tmpFile.Close()
2253
2254 parts := strings.Fields(editor)
2255 args := append(parts[1:], tmpPath)
2256 c := exec.Command(parts[0], args...)
2257 return tea.ExecProcess(c, func(err error) tea.Msg {
2258 defer os.Remove(tmpPath)
2259 if err != nil {
2260 return tui.EditorFinishedMsg{Err: err}
2261 }
2262 content, readErr := os.ReadFile(tmpPath)
2263 if readErr != nil {
2264 return tui.EditorFinishedMsg{Err: readErr}
2265 }
2266 return tui.EditorFinishedMsg{Body: string(content)}
2267 })
2268}
2269
2270// --- IDLE command ---
2271
2272// listenForIdleUpdates blocks until an IDLE update arrives, then returns it as a tea.Msg.
2273func listenForIdleUpdates(ch <-chan fetcher.IdleUpdate) tea.Cmd {
2274 return func() tea.Msg {
2275 update, ok := <-ch
2276 if !ok {
2277 return nil
2278 }
2279 return tui.IdleNewMailMsg{
2280 AccountID: update.AccountID,
2281 FolderName: update.FolderName,
2282 }
2283 }
2284}
2285
2286// --- Daemon event listener ---
2287
2288// listenForDaemonEvents blocks until a daemon event arrives, then returns it as a tea.Msg.
2289func listenForDaemonEvents(ch <-chan *daemonrpc.Event) tea.Cmd {
2290 return func() tea.Msg {
2291 ev, ok := <-ch
2292 if !ok {
2293 return nil
2294 }
2295 return tui.DaemonEventMsg{Event: ev}
2296 }
2297}
2298
2299// --- Folder-based command functions ---
2300
2301func fetchFoldersCmd(cfg *config.Config) tea.Cmd {
2302 return func() tea.Msg {
2303 if !cfg.HasAccounts() {
2304 return nil
2305 }
2306 foldersByAccount := make(map[string][]fetcher.Folder)
2307 seen := make(map[string]fetcher.Folder)
2308 var mu sync.Mutex
2309 var wg sync.WaitGroup
2310
2311 for _, account := range cfg.Accounts {
2312 wg.Add(1)
2313 go func(acc config.Account) {
2314 defer wg.Done()
2315 folders, err := fetcher.FetchFolders(&acc)
2316 if err != nil {
2317 return
2318 }
2319 mu.Lock()
2320 foldersByAccount[acc.ID] = folders
2321 for _, f := range folders {
2322 if _, ok := seen[f.Name]; !ok {
2323 seen[f.Name] = f
2324 }
2325 }
2326 mu.Unlock()
2327 }(account)
2328 }
2329 wg.Wait()
2330
2331 var merged []fetcher.Folder
2332 for _, f := range seen {
2333 merged = append(merged, f)
2334 }
2335
2336 return tui.FoldersFetchedMsg{
2337 FoldersByAccount: foldersByAccount,
2338 MergedFolders: merged,
2339 }
2340 }
2341}
2342
2343func fetchFolderEmailsCmd(cfg *config.Config, folderName string) tea.Cmd {
2344 return func() tea.Msg {
2345 emailsByAccount := make(map[string][]fetcher.Email)
2346 var mu sync.Mutex
2347 var wg sync.WaitGroup
2348
2349 for _, account := range cfg.Accounts {
2350 wg.Add(1)
2351 go func(acc config.Account) {
2352 defer wg.Done()
2353 emails, err := fetcher.FetchFolderEmails(&acc, folderName, initialEmailLimit, 0)
2354 if err != nil {
2355 // Folder may not exist for this account — silently skip
2356 return
2357 }
2358 mu.Lock()
2359 emailsByAccount[acc.ID] = emails
2360 mu.Unlock()
2361 }(account)
2362 }
2363
2364 wg.Wait()
2365
2366 // Flatten all account emails
2367 var allEmails []fetcher.Email
2368 for _, emails := range emailsByAccount {
2369 allEmails = append(allEmails, emails...)
2370 }
2371 // Sort newest first
2372 for i := 0; i < len(allEmails); i++ {
2373 for j := i + 1; j < len(allEmails); j++ {
2374 if allEmails[j].Date.After(allEmails[i].Date) {
2375 allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
2376 }
2377 }
2378 }
2379
2380 return tui.FolderEmailsFetchedMsg{
2381 Emails: allEmails,
2382 FolderName: folderName,
2383 }
2384 }
2385}
2386
2387func fetchFolderEmailsPaginatedCmd(account *config.Account, folderName string, limit, offset uint32) tea.Cmd {
2388 return func() tea.Msg {
2389 emails, err := fetcher.FetchFolderEmails(account, folderName, limit, offset)
2390 if err != nil {
2391 return tui.FetchErr(err)
2392 }
2393 return tui.FolderEmailsAppendedMsg{
2394 Emails: emails,
2395 AccountID: account.ID,
2396 FolderName: folderName,
2397 }
2398 }
2399}
2400
2401func fetchFolderEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
2402 return func() tea.Msg {
2403 account := cfg.GetAccountByID(accountID)
2404 if account == nil {
2405 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
2406 }
2407
2408 body, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid)
2409 if err != nil {
2410 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2411 }
2412
2413 return tui.EmailBodyFetchedMsg{
2414 UID: uid,
2415 Body: body,
2416 Attachments: attachments,
2417 AccountID: accountID,
2418 Mailbox: mailbox,
2419 }
2420 }
2421}
2422
2423func markEmailAsReadCmd(account *config.Account, uid uint32, accountID string, folderName string) tea.Cmd {
2424 return func() tea.Msg {
2425 err := fetcher.MarkEmailAsReadInMailbox(account, folderName, uid)
2426 return tui.EmailMarkedReadMsg{UID: uid, AccountID: accountID, Err: err}
2427 }
2428}
2429
2430func deleteFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
2431 return func() tea.Msg {
2432 err := fetcher.DeleteFolderEmail(account, folderName, uid)
2433 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2434 }
2435}
2436
2437func archiveFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
2438 return func() tea.Msg {
2439 err := fetcher.ArchiveFolderEmail(account, folderName, uid)
2440 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2441 }
2442}
2443
2444func (m *mainModel) batchDeleteEmailsCmd(account *config.Account, uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd {
2445 return func() tea.Msg {
2446 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
2447 defer cancel()
2448
2449 p := m.getProvider(account)
2450 if p == nil {
2451 return tui.BatchEmailActionDoneMsg{
2452 Count: count,
2453 Action: "delete",
2454 Err: fmt.Errorf("provider not found"),
2455 }
2456 }
2457
2458 err := p.DeleteEmails(ctx, folderName, uids)
2459
2460 // Remove emails from local state on success
2461 if err == nil && m.folderInbox != nil {
2462 m.folderInbox.GetInbox().RemoveEmails(uids, accountID)
2463 }
2464
2465 successCount := count
2466 failureCount := 0
2467 if err != nil {
2468 failureCount = count
2469 successCount = 0
2470 }
2471
2472 return tui.BatchEmailActionDoneMsg{
2473 Count: count,
2474 SuccessCount: successCount,
2475 FailureCount: failureCount,
2476 Action: "delete",
2477 Mailbox: mailbox,
2478 Err: err,
2479 }
2480 }
2481}
2482
2483func (m *mainModel) batchArchiveEmailsCmd(account *config.Account, uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd {
2484 return func() tea.Msg {
2485 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
2486 defer cancel()
2487
2488 p := m.getProvider(account)
2489 if p == nil {
2490 return tui.BatchEmailActionDoneMsg{
2491 Count: count,
2492 Action: "archive",
2493 Err: fmt.Errorf("provider not found"),
2494 }
2495 }
2496
2497 err := p.ArchiveEmails(ctx, folderName, uids)
2498
2499 if err == nil && m.folderInbox != nil {
2500 m.folderInbox.GetInbox().RemoveEmails(uids, accountID)
2501 }
2502
2503 successCount := count
2504 failureCount := 0
2505 if err != nil {
2506 failureCount = count
2507 successCount = 0
2508 }
2509
2510 return tui.BatchEmailActionDoneMsg{
2511 Count: count,
2512 SuccessCount: successCount,
2513 FailureCount: failureCount,
2514 Action: "archive",
2515 Mailbox: mailbox,
2516 Err: err,
2517 }
2518 }
2519}
2520
2521func (m *mainModel) batchMoveEmailsCmd(account *config.Account, uids []uint32, accountID, sourceFolder, destFolder string, count int) tea.Cmd {
2522 return func() tea.Msg {
2523 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
2524 defer cancel()
2525
2526 p := m.getProvider(account)
2527 if p == nil {
2528 return tui.BatchEmailActionDoneMsg{
2529 Count: count,
2530 Action: "move",
2531 Err: fmt.Errorf("provider not found"),
2532 }
2533 }
2534
2535 err := p.MoveEmails(ctx, uids, sourceFolder, destFolder)
2536
2537 if err == nil && m.folderInbox != nil {
2538 m.folderInbox.GetInbox().RemoveEmails(uids, accountID)
2539 }
2540
2541 successCount := count
2542 failureCount := 0
2543 if err != nil {
2544 failureCount = count
2545 successCount = 0
2546 }
2547
2548 return tui.BatchEmailActionDoneMsg{
2549 Count: count,
2550 SuccessCount: successCount,
2551 FailureCount: failureCount,
2552 Action: "move",
2553 Err: err,
2554 }
2555 }
2556}
2557
2558func moveEmailToFolderCmd(account *config.Account, uid uint32, accountID string, sourceFolder, destFolder string) tea.Cmd {
2559 return func() tea.Msg {
2560 err := fetcher.MoveEmailToFolder(account, uid, sourceFolder, destFolder)
2561 return tui.EmailMovedMsg{
2562 UID: uid,
2563 AccountID: accountID,
2564 SourceFolder: sourceFolder,
2565 DestFolder: destFolder,
2566 Err: err,
2567 }
2568 }
2569}
2570
2571func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
2572 return func() tea.Msg {
2573 // Download and decode the attachment using encoding provided in msg.Encoding.
2574 var data []byte
2575 var err error
2576 switch msg.Mailbox {
2577 case tui.MailboxSent:
2578 data, err = fetcher.FetchSentAttachment(account, uid, msg.PartID, msg.Encoding)
2579 case tui.MailboxTrash:
2580 data, err = fetcher.FetchTrashAttachment(account, uid, msg.PartID, msg.Encoding)
2581 case tui.MailboxArchive:
2582 data, err = fetcher.FetchArchiveAttachment(account, uid, msg.PartID, msg.Encoding)
2583 default:
2584 data, err = fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding)
2585 }
2586 if err != nil {
2587 return tui.AttachmentDownloadedMsg{Err: err}
2588 }
2589
2590 homeDir, err := os.UserHomeDir()
2591 if err != nil {
2592 return tui.AttachmentDownloadedMsg{Err: err}
2593 }
2594 downloadsPath := filepath.Join(homeDir, "Downloads")
2595 if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
2596 if mkErr := os.MkdirAll(downloadsPath, 0755); mkErr != nil {
2597 return tui.AttachmentDownloadedMsg{Err: mkErr}
2598 }
2599 }
2600
2601 // Save the attachment using an exclusive create so we never overwrite an existing file.
2602 // If the filename already exists, append \" (n)\" before the extension.
2603 origName := msg.Filename
2604 ext := filepath.Ext(origName)
2605 base := strings.TrimSuffix(origName, ext)
2606 candidate := origName
2607 i := 1
2608 var filePath string
2609
2610 for {
2611 filePath = filepath.Join(downloadsPath, candidate)
2612
2613 // Try to create file exclusively. If it already exists, os.OpenFile will return an error
2614 // that satisfies os.IsExist(err), so we can increment the candidate.
2615 f, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
2616 if err != nil {
2617 if os.IsExist(err) {
2618 // file exists, try next candidate
2619 candidate = fmt.Sprintf("%s (%d)%s", base, i, ext)
2620 i++
2621 continue
2622 }
2623 // Some other error while attempting to create file
2624 log.Printf("error creating file %s: %v", filePath, err)
2625 return tui.AttachmentDownloadedMsg{Err: err}
2626 }
2627
2628 // Successfully created the file descriptor; write and close.
2629 if _, writeErr := f.Write(data); writeErr != nil {
2630 _ = f.Close()
2631 log.Printf("error writing to file %s: %v", filePath, writeErr)
2632 return tui.AttachmentDownloadedMsg{Err: writeErr}
2633 }
2634 if closeErr := f.Close(); closeErr != nil {
2635 log.Printf("warning: error closing file %s: %v", filePath, closeErr)
2636 }
2637
2638 // file saved successfully
2639 break
2640 }
2641
2642 log.Printf("attachment saved to %s", filePath)
2643
2644 // Try to open the file using a platform-specific opener asynchronously and log the outcome.
2645 go func(p string) {
2646 var cmd *exec.Cmd
2647 switch runtime.GOOS {
2648 case "darwin":
2649 cmd = exec.Command("open", p)
2650 case "linux":
2651 cmd = exec.Command("xdg-open", p)
2652 case "windows":
2653 // 'start' is a cmd builtin; provide an empty title argument to avoid interpreting the path as the title.
2654 cmd = exec.Command("cmd", "/c", "start", "", p)
2655 default:
2656 // Unsupported OS: nothing to do.
2657 return
2658 }
2659 if err := cmd.Start(); err != nil {
2660 log.Printf("failed to open file %s: %v", p, err)
2661 }
2662 }(filePath)
2663
2664 return tui.AttachmentDownloadedMsg{Path: filePath, Err: nil}
2665 }
2666}
2667
2668/*
2669detectInstalledVersion returns a best-effort installed version string.
2670Priority:
2671 1. If the build-in `version` variable is set to something other than "dev", return it.
2672 2. If Homebrew is present and reports a version for `matcha`, return that.
2673 3. If snap is present and lists `matcha`, return that.
2674 4. Fallback to the build `version` (likely "dev").
2675*/
2676func detectInstalledVersion() string {
2677 v := strings.TrimSpace(version)
2678 if v != "dev" && v != "" {
2679 return v
2680 }
2681
2682 // Try Homebrew (macOS)
2683 if runtime.GOOS == "darwin" {
2684 if _, err := exec.LookPath("brew"); err == nil {
2685 // `brew list --versions matcha` prints: matcha 1.2.3
2686 if out, err := exec.Command("brew", "list", "--versions", "matcha").Output(); err == nil {
2687 parts := strings.Fields(string(out))
2688 if len(parts) >= 2 {
2689 return parts[1]
2690 }
2691 }
2692 }
2693 }
2694
2695 // Try WinGet (Windows)
2696 if runtime.GOOS == "windows" {
2697 if _, err := exec.LookPath("winget"); err == nil {
2698 if out, err := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity").Output(); err == nil {
2699 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2700 for _, line := range lines {
2701 if strings.Contains(strings.ToLower(line), "floatpane.matcha") {
2702 fields := strings.Fields(line)
2703 for _, f := range fields {
2704 if len(f) > 0 && f[0] >= '0' && f[0] <= '9' && strings.Contains(f, ".") {
2705 return f
2706 }
2707 }
2708 }
2709 }
2710 }
2711 }
2712 }
2713
2714 // Try snap (Linux)
2715 if runtime.GOOS == "linux" {
2716 if _, err := exec.LookPath("snap"); err == nil {
2717 if out, err := exec.Command("snap", "list", "matcha").Output(); err == nil {
2718 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2719 if len(lines) >= 2 {
2720 fields := strings.Fields(lines[1])
2721 if len(fields) >= 2 {
2722 return fields[1]
2723 }
2724 }
2725 }
2726 }
2727
2728 if _, err := exec.LookPath("flatpak"); err == nil {
2729 if out, err := exec.Command("flatpak", "info", "com.floatpane.matcha").Output(); err == nil {
2730 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2731 for _, line := range lines {
2732 line = strings.TrimSpace(line)
2733 if strings.HasPrefix(line, "Version:") {
2734 fields := strings.Fields(line)
2735 if len(fields) >= 2 {
2736 return fields[1]
2737 }
2738 }
2739 }
2740 }
2741 }
2742 }
2743
2744 return v
2745}
2746
2747/*
2748checkForUpdatesCmd queries GitHub for the latest release tag and returns a
2749tea.Msg (UpdateAvailableMsg) if the latest version differs from the current
2750installed version. This runs in the background when the TUI initializes.
2751*/
2752func checkForUpdatesCmd() tea.Cmd {
2753 return func() tea.Msg {
2754 // Non-fatal: if anything goes wrong we just don't show the update message.
2755 const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
2756 resp, err := http.Get(api)
2757 if err != nil {
2758 return nil
2759 }
2760 defer resp.Body.Close()
2761
2762 var rel githubRelease
2763 if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
2764 return nil
2765 }
2766
2767 latest := strings.TrimPrefix(rel.TagName, "v")
2768 installed := strings.TrimPrefix(detectInstalledVersion(), "v")
2769 if latest != "" && installed != "" && latest != installed {
2770 return UpdateAvailableMsg{Latest: latest, Current: installed}
2771 }
2772 return nil
2773 }
2774}
2775
2776// runUpdateCLI implements the CLI entrypoint for `matcha update`.
2777// It detects the likely installation method and attempts the appropriate
2778// update path (Homebrew, Snap, or GitHub release binary extract).
2779// runOAuthCLI handles the "matcha oauth" subcommand for OAuth2 management.
2780// Usage:
2781//
2782// matcha oauth auth <email> [--provider gmail|outlook] [--client-id ID --client-secret SECRET]
2783// matcha oauth token <email>
2784// matcha oauth revoke <email>
2785func runOAuthCLI(args []string) {
2786 if len(args) < 1 {
2787 fmt.Fprintln(os.Stderr, "Usage: matcha oauth <auth|token|revoke> <email> [flags]")
2788 fmt.Fprintln(os.Stderr, "")
2789 fmt.Fprintln(os.Stderr, "Commands:")
2790 fmt.Fprintln(os.Stderr, " auth <email> Authorize an email account via OAuth2 (opens browser)")
2791 fmt.Fprintln(os.Stderr, " token <email> Print a fresh access token (refreshes automatically)")
2792 fmt.Fprintln(os.Stderr, " revoke <email> Revoke and delete stored OAuth2 tokens")
2793 fmt.Fprintln(os.Stderr, "")
2794 fmt.Fprintln(os.Stderr, "Flags for auth:")
2795 fmt.Fprintln(os.Stderr, " --provider gmail|outlook OAuth2 provider (auto-detected from email)")
2796 fmt.Fprintln(os.Stderr, " --client-id ID OAuth2 client ID")
2797 fmt.Fprintln(os.Stderr, " --client-secret SECRET OAuth2 client secret")
2798 fmt.Fprintln(os.Stderr, "")
2799 fmt.Fprintln(os.Stderr, "Credentials are stored per provider in:")
2800 fmt.Fprintln(os.Stderr, " Gmail: ~/.config/matcha/oauth_client.json")
2801 fmt.Fprintln(os.Stderr, " Outlook: ~/.config/matcha/oauth_client_outlook.json")
2802 os.Exit(1)
2803 }
2804
2805 // Find the Python script and pass through to it
2806 script, err := config.OAuthScriptPath()
2807 if err != nil {
2808 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
2809 os.Exit(1)
2810 }
2811
2812 cmdArgs := append([]string{script}, args...)
2813 cmd := exec.Command("python3", cmdArgs...)
2814 cmd.Stdin = os.Stdin
2815 cmd.Stdout = os.Stdout
2816 cmd.Stderr = os.Stderr
2817
2818 if err := cmd.Run(); err != nil {
2819 if exitErr, ok := err.(*exec.ExitError); ok {
2820 os.Exit(exitErr.ExitCode())
2821 }
2822 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
2823 os.Exit(1)
2824 }
2825}
2826
2827// stringSliceFlag implements flag.Value to allow repeated --attach flags.
2828type stringSliceFlag []string
2829
2830func (s *stringSliceFlag) String() string { return strings.Join(*s, ", ") }
2831func (s *stringSliceFlag) Set(val string) error {
2832 *s = append(*s, val)
2833 return nil
2834}
2835
2836// runSendCLI implements the CLI entrypoint for `matcha send`.
2837// It sends an email non-interactively using configured accounts.
2838func runSendCLI(args []string) {
2839 fs := flag.NewFlagSet("send", flag.ExitOnError)
2840
2841 to := fs.String("to", "", "Recipient(s), comma-separated (required)")
2842 cc := fs.String("cc", "", "CC recipient(s), comma-separated")
2843 bcc := fs.String("bcc", "", "BCC recipient(s), comma-separated")
2844 subject := fs.String("subject", "", "Email subject (required)")
2845 body := fs.String("body", "", `Email body (Markdown supported). Use "-" to read from stdin`)
2846 from := fs.String("from", "", "Sender account email (defaults to first configured account)")
2847 withSignature := fs.Bool("signature", true, "Append default signature")
2848 signSMIME := fs.Bool("sign-smime", false, "Sign with S/MIME")
2849 encryptSMIME := fs.Bool("encrypt-smime", false, "Encrypt with S/MIME")
2850 signPGP := fs.Bool("sign-pgp", false, "Sign with PGP")
2851
2852 var attachments stringSliceFlag
2853 fs.Var(&attachments, "attach", "Attachment file path (can be repeated)")
2854
2855 fs.Usage = func() {
2856 fmt.Fprintln(os.Stderr, "Usage: matcha send [flags]")
2857 fmt.Fprintln(os.Stderr, "")
2858 fmt.Fprintln(os.Stderr, "Send an email non-interactively using a configured account.")
2859 fmt.Fprintln(os.Stderr, "")
2860 fmt.Fprintln(os.Stderr, "Flags:")
2861 fs.PrintDefaults()
2862 fmt.Fprintln(os.Stderr, "")
2863 fmt.Fprintln(os.Stderr, "Examples:")
2864 fmt.Fprintln(os.Stderr, ` matcha send --to user@example.com --subject "Hello" --body "Hi there"`)
2865 fmt.Fprintln(os.Stderr, ` echo "Body text" | matcha send --to user@example.com --subject "Hello" --body -`)
2866 fmt.Fprintln(os.Stderr, ` matcha send --to user@example.com --subject "Report" --body "See attached" --attach report.pdf`)
2867 }
2868
2869 if err := fs.Parse(args); err != nil {
2870 os.Exit(1)
2871 }
2872
2873 if *to == "" || *subject == "" {
2874 fmt.Fprintln(os.Stderr, "Error: --to and --subject are required")
2875 fs.Usage()
2876 os.Exit(1)
2877 }
2878
2879 // Read body from stdin if "-"
2880 emailBody := *body
2881 if emailBody == "-" {
2882 data, err := io.ReadAll(os.Stdin)
2883 if err != nil {
2884 fmt.Fprintf(os.Stderr, "Error reading stdin: %v\n", err)
2885 os.Exit(1)
2886 }
2887 emailBody = string(data)
2888 }
2889
2890 // Load config
2891 cfg, err := config.LoadConfig()
2892 if err != nil {
2893 fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
2894 os.Exit(1)
2895 }
2896 if !cfg.HasAccounts() {
2897 fmt.Fprintln(os.Stderr, "Error: no accounts configured. Run matcha to set up an account first.")
2898 os.Exit(1)
2899 }
2900
2901 // Resolve account
2902 var account *config.Account
2903 if *from != "" {
2904 account = cfg.GetAccountByEmail(*from)
2905 if account == nil {
2906 // Also try matching against FetchEmail
2907 for i := range cfg.Accounts {
2908 if strings.EqualFold(cfg.Accounts[i].FetchEmail, *from) {
2909 account = &cfg.Accounts[i]
2910 break
2911 }
2912 }
2913 }
2914 if account == nil {
2915 fmt.Fprintf(os.Stderr, "Error: no account found matching %q\n", *from)
2916 os.Exit(1)
2917 }
2918 } else {
2919 account = cfg.GetFirstAccount()
2920 }
2921
2922 // Use account S/MIME/PGP defaults unless explicitly set
2923 if !isFlagSet(fs, "sign-smime") {
2924 *signSMIME = account.SMIMESignByDefault
2925 }
2926 if !isFlagSet(fs, "sign-pgp") {
2927 *signPGP = account.PGPSignByDefault
2928 }
2929
2930 // Append signature
2931 if *withSignature {
2932 if sig, err := config.LoadSignature(); err == nil && sig != "" {
2933 emailBody = emailBody + "\n\n" + sig
2934 }
2935 }
2936
2937 // Process inline images (same logic as TUI sendEmail)
2938 images := make(map[string][]byte)
2939 re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
2940 matches := re.FindAllStringSubmatch(emailBody, -1)
2941 for _, match := range matches {
2942 imgPath := match[1]
2943 imgData, err := os.ReadFile(imgPath)
2944 if err != nil {
2945 log.Printf("Could not read image file %s: %v", imgPath, err)
2946 continue
2947 }
2948 cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
2949 images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
2950 emailBody = strings.Replace(emailBody, imgPath, "cid:"+cid, 1)
2951 }
2952
2953 htmlBody := markdownToHTML([]byte(emailBody))
2954
2955 // Process attachments
2956 attachMap := make(map[string][]byte)
2957 for _, attachPath := range attachments {
2958 fileData, err := os.ReadFile(attachPath)
2959 if err != nil {
2960 fmt.Fprintf(os.Stderr, "Error reading attachment %s: %v\n", attachPath, err)
2961 os.Exit(1)
2962 }
2963 attachMap[filepath.Base(attachPath)] = fileData
2964 }
2965
2966 // Send
2967 recipients := splitEmails(*to)
2968 ccList := splitEmails(*cc)
2969 bccList := splitEmails(*bcc)
2970
2971 rawMsg, sendErr := sender.SendEmail(account, recipients, ccList, bccList, *subject, emailBody, string(htmlBody), images, attachMap, "", nil, *signSMIME, *encryptSMIME, *signPGP, false)
2972 if sendErr != nil {
2973 fmt.Fprintf(os.Stderr, "Error: %v\n", sendErr)
2974 os.Exit(1)
2975 }
2976
2977 // Append to Sent folder via IMAP (Gmail auto-saves, so skip it)
2978 if account.ServiceProvider != "gmail" {
2979 if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
2980 log.Printf("Failed to append sent message to Sent folder: %v", err)
2981 }
2982 }
2983
2984 fmt.Println("Email sent successfully.")
2985}
2986
2987// isFlagSet returns true if the named flag was explicitly provided on the command line.
2988func isFlagSet(fs *flag.FlagSet, name string) bool {
2989 found := false
2990 fs.Visit(func(f *flag.Flag) {
2991 if f.Name == name {
2992 found = true
2993 }
2994 })
2995 return found
2996}
2997
2998func runUpdateCLI() error {
2999 const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
3000 resp, err := http.Get(api)
3001 if err != nil {
3002 return fmt.Errorf("could not query releases: %w", err)
3003 }
3004 defer resp.Body.Close()
3005
3006 var rel githubRelease
3007 if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
3008 return fmt.Errorf("could not parse release info: %w", err)
3009 }
3010
3011 latestTag := rel.TagName
3012 if strings.HasPrefix(latestTag, "v") {
3013 latestTag = latestTag[1:]
3014 }
3015
3016 fmt.Printf("Current version: %s\n", version)
3017 fmt.Printf("Latest version: %s\n", latestTag)
3018
3019 // Quick check: if already up-to-date, exit
3020 cur := version
3021 if strings.HasPrefix(cur, "v") {
3022 cur = cur[1:]
3023 }
3024 if latestTag == "" || cur == latestTag {
3025 fmt.Println("Already up to date.")
3026 return nil
3027 }
3028
3029 // Detect Homebrew
3030 if _, err := exec.LookPath("brew"); err == nil {
3031 fmt.Println("Detected Homebrew — updating taps and attempting to upgrade via brew.")
3032
3033 updateCmd := exec.Command("brew", "update")
3034 updateCmd.Stdout = os.Stdout
3035 updateCmd.Stderr = os.Stderr
3036 if err := updateCmd.Run(); err != nil {
3037 fmt.Printf("Homebrew update failed: %v\n", err)
3038 // continue to attempt upgrade even if update failed
3039 }
3040
3041 upgradeCmd := exec.Command("brew", "upgrade", "floatpane/matcha/matcha")
3042 upgradeCmd.Stdout = os.Stdout
3043 upgradeCmd.Stderr = os.Stderr
3044 if err := upgradeCmd.Run(); err == nil {
3045 fmt.Println("Successfully upgraded via Homebrew.")
3046 return nil
3047 }
3048 fmt.Printf("Homebrew upgrade failed: %v\n", err)
3049 // fallthrough to other methods
3050 }
3051
3052 // Detect snap
3053 if _, err := exec.LookPath("snap"); err == nil {
3054 // Check if matcha is installed as a snap
3055 cmdCheck := exec.Command("snap", "list", "matcha")
3056 if err := cmdCheck.Run(); err == nil {
3057 fmt.Println("Detected Snap package — attempting to refresh.")
3058 cmd := exec.Command("snap", "refresh", "matcha")
3059 cmd.Stdout = os.Stdout
3060 cmd.Stderr = os.Stderr
3061 if err := cmd.Run(); err == nil {
3062 fmt.Println("Successfully refreshed snap.")
3063 return nil
3064 }
3065 fmt.Printf("Snap refresh failed: %v\n", err)
3066 // fallthrough
3067 }
3068 }
3069 // Detect flatpak
3070 if _, err := exec.LookPath("flatpak"); err == nil {
3071 // Check if matcha is installed as a flatpak
3072 cmdCheck := exec.Command("flatpak", "info", "com.floatpane.matcha")
3073 if err := cmdCheck.Run(); err == nil {
3074 fmt.Println("Detected Flatpak package — attempting to update.")
3075 cmd := exec.Command("flatpak", "update", "-y", "com.floatpane.matcha")
3076 cmd.Stdout = os.Stdout
3077 cmd.Stderr = os.Stderr
3078 if err := cmd.Run(); err == nil {
3079 fmt.Println("Successfully updated flatpak.")
3080 return nil
3081 }
3082 fmt.Printf("Flatpak update failed: %v\n", err)
3083 // fallthrough
3084 }
3085 }
3086
3087 // Detect WinGet
3088 if _, err := exec.LookPath("winget"); err == nil {
3089 cmdCheck := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity")
3090 if err := cmdCheck.Run(); err == nil {
3091 fmt.Println("Detected WinGet package — attempting to upgrade.")
3092 cmd := exec.Command("winget", "upgrade", "--id", "floatpane.matcha", "--disable-interactivity")
3093 cmd.Stdout = os.Stdout
3094 cmd.Stderr = os.Stderr
3095 if err := cmd.Run(); err == nil {
3096 fmt.Println("Successfully upgraded via WinGet.")
3097 return nil
3098 }
3099 fmt.Printf("WinGet upgrade failed: %v\n", err)
3100 // fallthrough
3101 }
3102 }
3103
3104 // Otherwise attempt to download the proper release asset and replace the binary.
3105 osName := runtime.GOOS
3106 arch := runtime.GOARCH
3107
3108 // Try to find a matching asset
3109 var assetURL, assetName string
3110 for _, a := range rel.Assets {
3111 n := strings.ToLower(a.Name)
3112 if strings.Contains(n, osName) && strings.Contains(n, arch) && (strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || strings.HasSuffix(n, ".zip")) {
3113 assetURL = a.BrowserDownloadURL
3114 assetName = a.Name
3115 break
3116 }
3117 }
3118 if assetURL == "" {
3119 // Try any asset that contains 'matcha' and os/arch as a fallback
3120 for _, a := range rel.Assets {
3121 n := strings.ToLower(a.Name)
3122 if strings.Contains(n, "matcha") && (strings.Contains(n, osName) || strings.Contains(n, arch)) {
3123 assetURL = a.BrowserDownloadURL
3124 assetName = a.Name
3125 break
3126 }
3127 }
3128 }
3129
3130 if assetURL == "" {
3131 return fmt.Errorf("no suitable release artifact found for %s/%s", osName, arch)
3132 }
3133
3134 fmt.Printf("Found release asset: %s\n", assetName)
3135 fmt.Println("Downloading...")
3136
3137 // Download asset
3138 respAsset, err := http.Get(assetURL)
3139 if err != nil {
3140 return fmt.Errorf("download failed: %w", err)
3141 }
3142 defer respAsset.Body.Close()
3143
3144 // Create a temp file for the download
3145 tmpDir, err := os.MkdirTemp("", "matcha-update-*")
3146 if err != nil {
3147 return fmt.Errorf("could not create temp dir: %w", err)
3148 }
3149 defer os.RemoveAll(tmpDir)
3150
3151 assetPath := filepath.Join(tmpDir, assetName)
3152 outFile, err := os.Create(assetPath)
3153 if err != nil {
3154 return fmt.Errorf("could not create temp file: %w", err)
3155 }
3156 _, err = io.Copy(outFile, respAsset.Body)
3157 outFile.Close()
3158 if err != nil {
3159 return fmt.Errorf("could not write asset to disk: %w", err)
3160 }
3161
3162 // Determine the expected binary name based on the OS.
3163 binaryName := "matcha"
3164 if runtime.GOOS == "windows" {
3165 binaryName = "matcha.exe"
3166 }
3167
3168 // Extract the binary from the archive.
3169 var binPath string
3170 if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") {
3171 f, err := os.Open(assetPath)
3172 if err != nil {
3173 return fmt.Errorf("could not open archive: %w", err)
3174 }
3175 defer f.Close()
3176 gzr, err := gzip.NewReader(f)
3177 if err != nil {
3178 return fmt.Errorf("could not create gzip reader: %w", err)
3179 }
3180 tr := tar.NewReader(gzr)
3181 for {
3182 hdr, err := tr.Next()
3183 if err == io.EOF {
3184 break
3185 }
3186 if err != nil {
3187 return fmt.Errorf("error reading tar: %w", err)
3188 }
3189 name := filepath.Base(hdr.Name)
3190 if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) {
3191 binPath = filepath.Join(tmpDir, binaryName)
3192 out, err := os.Create(binPath)
3193 if err != nil {
3194 return fmt.Errorf("could not create binary file: %w", err)
3195 }
3196 if _, err := io.Copy(out, tr); err != nil {
3197 out.Close()
3198 return fmt.Errorf("could not extract binary: %w", err)
3199 }
3200 out.Close()
3201 if err := os.Chmod(binPath, 0755); err != nil {
3202 return fmt.Errorf("could not make binary executable: %w", err)
3203 }
3204 break
3205 }
3206 }
3207 } else if strings.HasSuffix(assetName, ".zip") {
3208 zr, err := zip.OpenReader(assetPath)
3209 if err != nil {
3210 return fmt.Errorf("could not open zip archive: %w", err)
3211 }
3212 defer zr.Close()
3213 for _, zf := range zr.File {
3214 name := filepath.Base(zf.Name)
3215 if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && !zf.FileInfo().IsDir() {
3216 rc, err := zf.Open()
3217 if err != nil {
3218 return fmt.Errorf("could not open file in zip: %w", err)
3219 }
3220 binPath = filepath.Join(tmpDir, binaryName)
3221 out, err := os.Create(binPath)
3222 if err != nil {
3223 rc.Close()
3224 return fmt.Errorf("could not create binary file: %w", err)
3225 }
3226 if _, err := io.Copy(out, rc); err != nil {
3227 out.Close()
3228 rc.Close()
3229 return fmt.Errorf("could not extract binary: %w", err)
3230 }
3231 out.Close()
3232 rc.Close()
3233 if err := os.Chmod(binPath, 0755); err != nil {
3234 return fmt.Errorf("could not make binary executable: %w", err)
3235 }
3236 break
3237 }
3238 }
3239 } else {
3240 // For non-archive assets, assume the asset is the binary itself.
3241 binPath = assetPath
3242 if err := os.Chmod(binPath, 0755); err != nil {
3243 // ignore chmod errors but warn
3244 fmt.Printf("warning: could not chmod downloaded binary: %v\n", err)
3245 }
3246 }
3247
3248 if binPath == "" {
3249 return fmt.Errorf("could not locate matcha binary inside the release artifact")
3250 }
3251
3252 // Replace the running executable with the new binary
3253 execPath, err := os.Executable()
3254 if err != nil {
3255 return fmt.Errorf("could not determine executable path: %w", err)
3256 }
3257
3258 // Write the new binary to a temp file in same dir, then rename for atomic replacement.
3259 execDir := filepath.Dir(execPath)
3260 tmpNew := filepath.Join(execDir, fmt.Sprintf("matcha.new.%d", time.Now().Unix()))
3261 in, err := os.Open(binPath)
3262 if err != nil {
3263 return fmt.Errorf("could not open new binary: %w", err)
3264 }
3265 out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
3266 if err != nil {
3267 in.Close()
3268 return fmt.Errorf("could not create temp binary in target dir: %w", err)
3269 }
3270 if _, err := io.Copy(out, in); err != nil {
3271 in.Close()
3272 out.Close()
3273 return fmt.Errorf("could not write new binary to disk: %w", err)
3274 }
3275 in.Close()
3276 out.Close()
3277
3278 // On Windows, a running executable cannot be overwritten directly.
3279 // Move the old binary out of the way first, then rename the new one in.
3280 if runtime.GOOS == "windows" {
3281 oldPath := execPath + ".old"
3282 _ = os.Remove(oldPath) // clean up any previous leftover
3283 if err := os.Rename(execPath, oldPath); err != nil {
3284 return fmt.Errorf("could not move old executable out of the way: %w", err)
3285 }
3286 }
3287
3288 if err := os.Rename(tmpNew, execPath); err != nil {
3289 return fmt.Errorf("could not replace executable: %w", err)
3290 }
3291
3292 fmt.Println("Successfully updated matcha to", latestTag)
3293 return nil
3294}
3295
3296func filterUnique(existing, incoming []fetcher.Email) []fetcher.Email {
3297 seen := make(map[uint32]struct{})
3298 for _, e := range existing {
3299 seen[e.UID] = struct{}{}
3300 }
3301 var unique []fetcher.Email
3302 for _, e := range incoming {
3303 if _, ok := seen[e.UID]; !ok {
3304 unique = append(unique, e)
3305 }
3306 }
3307 return unique
3308}
3309
3310func main() {
3311 // If invoked with version flag, print version and exit
3312 if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") {
3313 fmt.Printf("matcha version %s", version)
3314 if commit != "" {
3315 fmt.Printf(" (%s)", commit)
3316 }
3317 if date != "" {
3318 fmt.Printf(" built on %s", date)
3319 }
3320 fmt.Println()
3321 os.Exit(0)
3322 }
3323
3324 // If invoked as CLI update command, run updater and exit.
3325 if len(os.Args) > 1 && os.Args[1] == "update" {
3326 if err := runUpdateCLI(); err != nil {
3327 fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
3328 os.Exit(1)
3329 }
3330 os.Exit(0)
3331 }
3332
3333 // Daemon CLI subcommand: matcha daemon <start|stop|status|run>
3334 if len(os.Args) > 1 && os.Args[1] == "daemon" {
3335 runDaemonCLI(os.Args[2:])
3336 os.Exit(0)
3337 }
3338
3339 // OAuth2 CLI subcommand: matcha oauth <auth|token|revoke> <email> [flags]
3340 // "gmail" is kept as an alias for backwards compatibility.
3341 if len(os.Args) > 1 && (os.Args[1] == "oauth" || os.Args[1] == "gmail") {
3342 runOAuthCLI(os.Args[2:])
3343 os.Exit(0)
3344 }
3345
3346 // Send email CLI subcommand: matcha send --to <email> --subject <subject> [flags]
3347 if len(os.Args) > 1 && os.Args[1] == "send" {
3348 runSendCLI(os.Args[2:])
3349 os.Exit(0)
3350 }
3351
3352 // Install plugin CLI subcommand: matcha install <url_or_file>
3353 if len(os.Args) > 1 && os.Args[1] == "install" {
3354 if err := matchaCli.RunInstall(os.Args[2:]); err != nil {
3355 fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
3356 os.Exit(1)
3357 }
3358 os.Exit(0)
3359 }
3360
3361 // Config CLI subcommand: matcha config [plugin_name]
3362 if len(os.Args) > 1 && os.Args[1] == "config" {
3363 if err := matchaCli.RunConfig(os.Args[2:]); err != nil {
3364 fmt.Fprintf(os.Stderr, "config failed: %v\n", err)
3365 os.Exit(1)
3366 }
3367 os.Exit(0)
3368 }
3369
3370 // Contacts export CLI subcommand: matcha contacts export [flags]
3371 if len(os.Args) > 1 && os.Args[1] == "contacts" && len(os.Args) > 2 && os.Args[2] == "export" {
3372 if err := matchaCli.RunContactsExport(os.Args[3:]); err != nil {
3373 fmt.Fprintf(os.Stderr, "contacts export failed: %v\n", err)
3374 os.Exit(1)
3375 }
3376 os.Exit(0)
3377 }
3378
3379 // Marketplace TUI subcommand: matcha marketplace
3380 if len(os.Args) > 1 && os.Args[1] == "marketplace" {
3381 mp := tui.NewMarketplace(true)
3382 p := tea.NewProgram(mp)
3383 if _, err := p.Run(); err != nil {
3384 fmt.Fprintf(os.Stderr, "marketplace failed: %v\n", err)
3385 os.Exit(1)
3386 }
3387 os.Exit(0)
3388 }
3389
3390 // Migrate cache files from ~/.config/matcha/ to ~/.cache/matcha/ if needed
3391 _ = config.MigrateCacheFiles()
3392
3393 var initialModel *mainModel
3394
3395 if config.IsSecureModeEnabled() {
3396 // Secure mode: show password prompt before loading config
3397 tui.RebuildStyles()
3398 initialModel = newInitialModel(nil)
3399 initialModel.current = tui.NewPasswordPrompt()
3400 } else {
3401 cfg, err := config.LoadConfig()
3402 if err == nil && cfg.Theme != "" {
3403 theme.SetTheme(cfg.Theme)
3404 }
3405 tui.RebuildStyles()
3406
3407 // Ensure PGP keys directory exists
3408 _ = config.EnsurePGPDir()
3409
3410 if err != nil {
3411 initialModel = newInitialModel(nil)
3412 } else {
3413 initialModel = newInitialModel(cfg)
3414 }
3415 }
3416
3417 // Initialize plugin system
3418 plugins := plugin.NewManager()
3419 plugins.LoadPlugins()
3420 initialModel.plugins = plugins
3421 plugins.CallHook(plugin.HookStartup)
3422
3423 p := tea.NewProgram(initialModel)
3424
3425 if _, err := p.Run(); err != nil {
3426 plugins.Close()
3427 fmt.Printf("Alas, there's been an error: %v", err)
3428 os.Exit(1)
3429 }
3430
3431 plugins.CallHook(plugin.HookShutdown)
3432 plugins.Close()
3433}
3434
3435func runDaemonCLI(args []string) {
3436 if len(args) == 0 {
3437 fmt.Println("Usage: matcha daemon <start|stop|status|run>")
3438 fmt.Println()
3439 fmt.Println("Commands:")
3440 fmt.Println(" start Start the daemon in the background")
3441 fmt.Println(" stop Stop the running daemon")
3442 fmt.Println(" status Show daemon status")
3443 fmt.Println(" run Run the daemon in the foreground")
3444 os.Exit(1)
3445 }
3446
3447 switch args[0] {
3448 case "start":
3449 runDaemonStart()
3450 case "stop":
3451 runDaemonStop()
3452 case "status":
3453 runDaemonStatus()
3454 case "run":
3455 runDaemonRun()
3456 default:
3457 fmt.Fprintf(os.Stderr, "unknown daemon command: %s\n", args[0])
3458 os.Exit(1)
3459 }
3460}
3461
3462func runDaemonStart() {
3463 pidPath := daemonrpc.PIDPath()
3464 if pid, running := matchaDaemon.IsRunning(pidPath); running {
3465 fmt.Printf("Daemon already running (PID %d)\n", pid)
3466 return
3467 }
3468
3469 // Fork ourselves with "daemon run".
3470 exe, err := os.Executable()
3471 if err != nil {
3472 fmt.Fprintf(os.Stderr, "cannot find executable: %v\n", err)
3473 os.Exit(1)
3474 }
3475
3476 cmd := exec.Command(exe, "daemon", "run")
3477 cmd.Stdout = nil
3478 cmd.Stderr = nil
3479 cmd.Stdin = nil
3480
3481 // Detach from parent process.
3482 cmd.SysProcAttr = daemonclient.DaemonProcAttr()
3483
3484 if err := cmd.Start(); err != nil {
3485 fmt.Fprintf(os.Stderr, "failed to start daemon: %v\n", err)
3486 os.Exit(1)
3487 }
3488
3489 fmt.Printf("Daemon started (PID %d)\n", cmd.Process.Pid)
3490}
3491
3492func runDaemonStop() {
3493 pidPath := daemonrpc.PIDPath()
3494 pid, running := matchaDaemon.IsRunning(pidPath)
3495 if !running {
3496 fmt.Println("Daemon is not running")
3497 return
3498 }
3499
3500 process, err := os.FindProcess(pid)
3501 if err != nil {
3502 fmt.Fprintf(os.Stderr, "cannot find process %d: %v\n", pid, err)
3503 os.Exit(1)
3504 }
3505
3506 if err := process.Signal(os.Interrupt); err != nil {
3507 fmt.Fprintf(os.Stderr, "failed to stop daemon: %v\n", err)
3508 os.Exit(1)
3509 }
3510
3511 fmt.Printf("Daemon stopped (PID %d)\n", pid)
3512}
3513
3514func runDaemonStatus() {
3515 // Try connecting to daemon for live status.
3516 client, err := daemonclient.Dial()
3517 if err != nil {
3518 pidPath := daemonrpc.PIDPath()
3519 if pid, running := matchaDaemon.IsRunning(pidPath); running {
3520 fmt.Printf("Daemon running (PID %d) but not responding\n", pid)
3521 } else {
3522 fmt.Println("Daemon is not running")
3523 }
3524 return
3525 }
3526 defer client.Close()
3527
3528 status, err := client.Status()
3529 if err != nil {
3530 fmt.Fprintf(os.Stderr, "failed to get status: %v\n", err)
3531 os.Exit(1)
3532 }
3533
3534 fmt.Printf("Daemon running (PID %d)\n", status.PID)
3535 fmt.Printf("Uptime: %s\n", formatUptime(status.Uptime))
3536 fmt.Printf("Accounts: %d\n", len(status.Accounts))
3537 for _, acct := range status.Accounts {
3538 fmt.Printf(" - %s\n", acct)
3539 }
3540}
3541
3542func runDaemonRun() {
3543 cfg, err := config.LoadConfig()
3544 if err != nil {
3545 fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
3546 os.Exit(1)
3547 }
3548
3549 d := matchaDaemon.New(cfg)
3550 if err := d.Run(); err != nil {
3551 fmt.Fprintf(os.Stderr, "daemon error: %v\n", err)
3552 os.Exit(1)
3553 }
3554}
3555
3556func formatUptime(seconds int64) string {
3557 d := time.Duration(seconds) * time.Second
3558 if d < time.Minute {
3559 return fmt.Sprintf("%ds", int(d.Seconds()))
3560 }
3561 if d < time.Hour {
3562 return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60)
3563 }
3564 return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
3565}