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