From 71533bbce1db7e5a2909720d66fbd4feea2b5c38 Mon Sep 17 00:00:00 2001 From: Robin Everaars Date: Fri, 10 Jul 2026 08:34:51 +0200 Subject: [PATCH 1/5] feat(backend): add JSON serialization tags to Attachment Attachment is about to be exposed through the CLI's --json output. Give it explicit snake_case field names and exclude the raw Data bytes so scripted consumers never receive megabytes of base64. --- backend/backend.go | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/backend/backend.go b/backend/backend.go index d6b266ee..da7b7fe6 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -91,19 +91,19 @@ type Email struct { // Attachment holds data for an email attachment. type Attachment struct { - Filename string - PartID string - Data []byte - Encoding string - MIMEType string - ContentID string - Inline bool - IsSMIMESignature bool - SMIMEVerified bool - IsSMIMEEncrypted bool - IsPGPSignature bool - PGPVerified bool - IsPGPEncrypted bool + Filename string `json:"filename"` + PartID string `json:"part_id"` + Data []byte `json:"-"` // Don't include raw data in JSON by default + Encoding string `json:"encoding"` + MIMEType string `json:"mime_type"` + ContentID string `json:"content_id"` + Inline bool `json:"inline"` + IsSMIMESignature bool `json:"is_smime_signature"` + SMIMEVerified bool `json:"smime_verified"` + IsSMIMEEncrypted bool `json:"is_smime_encrypted"` + IsPGPSignature bool `json:"is_pgp_signature"` + PGPVerified bool `json:"pgp_verified"` + IsPGPEncrypted bool `json:"is_pgp_encrypted"` } // SearchQuery is the parsed form of a user query string. From 7c1b859dd3f335b73614a1494196b323105617c5 Mon Sep 17 00:00:00 2001 From: Robin Everaars Date: Fri, 10 Jul 2026 08:34:52 +0200 Subject: [PATCH 2/5] feat(daemon): add Search RPC and optional-autostart client mode Expose provider search through the service layer: a Search method on daemonclient.Service (daemon and direct implementations), a Search RPC method with handler, and NewCLIClient(cfg, autoStart) so one-shot CLI invocations can fall back to direct mode instead of forking a daemon. The mark-read handler previously logged per-UID failures but reported success over RPC; it now joins and returns them, matching the other mutating handlers and direct-mode semantics. --- daemon/daemon.go | 1 + daemon/handler.go | 31 +++++++++++++++++++++++++++++++ daemonclient/service.go | 31 +++++++++++++++++++++++++++++++ daemonclient/service_test.go | 28 ++++++++++++++++++++++++++++ daemonrpc/protocol.go | 7 +++++++ 5 files changed, 98 insertions(+) create mode 100644 daemonclient/service_test.go diff --git a/daemon/daemon.go b/daemon/daemon.go index ea4e5f59..332a6e61 100644 --- a/daemon/daemon.go +++ b/daemon/daemon.go @@ -105,6 +105,7 @@ func (d *Daemon) registerHandlers() { d.server.Handle(daemonrpc.MethodUnsubscribe, d.handleUnsubscribe) d.server.Handle(daemonrpc.MethodQueueEmail, d.handleQueueEmail) d.server.Handle(daemonrpc.MethodCancelEmail, d.handleCancelEmail) + d.server.Handle(daemonrpc.MethodSearch, d.handleSearch) } // Run starts the daemon: creates providers, starts the socket listener, diff --git a/daemon/handler.go b/daemon/handler.go index bb1d64e5..f0d4366b 100644 --- a/daemon/handler.go +++ b/daemon/handler.go @@ -3,11 +3,13 @@ package daemon import ( "context" "encoding/json" + "errors" "fmt" "log" "os" "time" + "github.com/floatpane/matcha/backend" "github.com/floatpane/matcha/daemonrpc" "github.com/google/uuid" ) @@ -144,6 +146,30 @@ func (d *Daemon) handleFetchEmailBody(ctx context.Context, _ *daemonrpc.Conn, pa }, nil } +func (d *Daemon) handleSearch(ctx context.Context, _ *daemonrpc.Conn, params json.RawMessage) (any, error) { + args, err := decodeParams[daemonrpc.SearchParams](params) + if err != nil { + return nil, parseError(err) + } + + p, err := d.getProvider(args.AccountID) + if err != nil { + return nil, err + } + + ctx, cancel := context.WithTimeout(ctx, fetchTimeout) + defer cancel() + + // The daemon receives the raw query string and is responsible for parsing it. + query := backend.ParseSearchQuery(args.Query) + + emails, err := p.Search(ctx, args.Folder, query) + if err != nil { + return nil, err + } + return emails, nil +} + func (d *Daemon) handleDeleteEmails(ctx context.Context, _ *daemonrpc.Conn, params json.RawMessage) (any, error) { args, err := decodeParams[daemonrpc.DeleteEmailsParams](params) if err != nil { @@ -218,6 +244,7 @@ func (d *Daemon) handleMarkRead(ctx context.Context, _ *daemonrpc.Conn, params j ctx, cancel := context.WithTimeout(ctx, mutateTimeout) defer cancel() + var errs []error for _, uid := range args.UIDs { var err error if args.Read { @@ -227,8 +254,12 @@ func (d *Daemon) handleMarkRead(ctx context.Context, _ *daemonrpc.Conn, params j } if err != nil { log.Printf("daemon: mark read=%v %d failed: %v", args.Read, uid, err) + errs = append(errs, fmt.Errorf("uid %d: %w", uid, err)) } } + if len(errs) > 0 { + return nil, errors.Join(errs...) + } return true, nil } diff --git a/daemonclient/service.go b/daemonclient/service.go index a98a7d65..87da7dc7 100644 --- a/daemonclient/service.go +++ b/daemonclient/service.go @@ -36,6 +36,7 @@ type Service interface { RefreshFolder(accountID, folder string) error Subscribe(accountID, folder string) error Unsubscribe(accountID, folder string) error + Search(accountID, folder string, query backend.SearchQuery) ([]backend.Email, error) ReloadConfig() error Events() <-chan *daemonrpc.Event IsDaemon() bool @@ -45,6 +46,13 @@ type Service interface { // NewService connects to the daemon, auto-starting it if needed. // Falls back to direct mode only if daemon cannot be started, or if DisableDaemon is set. func NewService(cfg *config.Config) Service { + return NewCLIClient(cfg, true) +} + +// NewCLIClient connects to the daemon. If autoStart is true and daemon is not +// running, it auto-starts the daemon. If autoStart is false, it falls back to +// direct mode immediately if the daemon is not running. +func NewCLIClient(cfg *config.Config, autoStart bool) Service { if cfg.DisableDaemon { log.Println("service: daemon disabled by config, using direct mode") return newDirectService(cfg) @@ -55,6 +63,11 @@ func NewService(cfg *config.Config) Service { return svc } + if !autoStart { + loglevel.Debugf("service: daemon not running, auto-start disabled, using direct mode") + return newDirectService(cfg) + } + // Daemon not running — auto-start it. loglevel.Debugf("service: daemon not running, auto-starting") if err := autoStartDaemon(); err != nil { @@ -244,6 +257,16 @@ func (s *daemonService) Unsubscribe(accountID, folder string) error { }, nil) } +func (s *daemonService) Search(accountID, folder string, query backend.SearchQuery) ([]backend.Email, error) { + var emails []backend.Email + err := s.client.Call(daemonrpc.MethodSearch, daemonrpc.SearchParams{ + AccountID: accountID, + Folder: folder, + Query: query.Raw, // Send the raw query string over RPC + }, &emails) + return emails, err +} + func (s *daemonService) ReloadConfig() error { return s.client.Call(daemonrpc.MethodReloadConfig, nil, nil) } @@ -387,6 +410,14 @@ func (s *directService) Unsubscribe(_, _ string) error { return nil } +func (s *directService) Search(accountID, folder string, query backend.SearchQuery) ([]backend.Email, error) { + p, err := s.getProvider(accountID) + if err != nil { + return nil, err + } + return p.Search(context.Background(), folder, query) +} + func (s *directService) ReloadConfig() error { cfg, err := config.LoadConfig() if err != nil { diff --git a/daemonclient/service_test.go b/daemonclient/service_test.go new file mode 100644 index 00000000..f9b55d6c --- /dev/null +++ b/daemonclient/service_test.go @@ -0,0 +1,28 @@ +package daemonclient + +import ( + "testing" + + "github.com/floatpane/matcha/config" +) + +func TestNewCLIClient_NoAutoStart(t *testing.T) { + // Set XDG_RUNTIME_DIR to a non-existent path to ensure we cannot connect to a running daemon + t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + + cfg := &config.Config{ + DisableDaemon: false, + } + + // When autoStart is false and daemon is not running, we expect a directService fallback (IsDaemon() == false) + svc := NewCLIClient(cfg, false) + if svc == nil { + t.Fatal("expected non-nil service") + } + defer svc.Close() + + if svc.IsDaemon() { + t.Error("expected IsDaemon() to be false (direct service fallback) when autoStart is false and daemon is not running") + } +} diff --git a/daemonrpc/protocol.go b/daemonrpc/protocol.go index 2b07f27f..0d054c43 100644 --- a/daemonrpc/protocol.go +++ b/daemonrpc/protocol.go @@ -50,6 +50,7 @@ const ( MethodExportContacts = "ExportContacts" MethodQueueEmail = "QueueEmail" MethodCancelEmail = "CancelEmail" + MethodSearch = "Search" ) // Event type names. @@ -208,6 +209,12 @@ type ExportContactsParams struct { Format string `json:"format"` // "json" or "csv" } +type SearchParams struct { + AccountID string `json:"account_id"` + Folder string `json:"folder"` + Query string `json:"query"` +} + // Event data types. type NewMailEvent struct { From fecc1a911143090e0ec32290b0ce1a80e686b11d Mon Sep 17 00:00:00 2001 From: Robin Everaars Date: Fri, 10 Jul 2026 08:35:04 +0200 Subject: [PATCH 3/5] feat(cli): add list, read, search, folders and manage subcommands Round out the non-interactive CLI beyond send: list a folder, read a message body (with --json attachment metadata), search with the same query DSL the TUI uses, list folders, and manage mail (archive, delete, mark-read, mark-unread, move). Shared plumbing lives in cli/util.go: account resolution by --from (case-insensitive on Email with a FetchEmail fallback), tabular and JSON email rendering, folder-name normalization, and interspersed flag parsing so flags may follow positionals. Commands write through hookable Out/ErrOut writers, which the existing contacts-export, config, and upgrade commands now also use for package consistency. All commands connect with autoStart disabled: a one-shot invocation uses a running daemon when present and otherwise falls back to a direct connection rather than spawning a daemon. --- cli/config.go | 2 +- cli/contacts_export.go | 40 ++++----- cli/folders.go | 64 ++++++++++++++ cli/folders_test.go | 162 ++++++++++++++++++++++++++++++++++ cli/list.go | 55 ++++++++++++ cli/list_test.go | 168 +++++++++++++++++++++++++++++++++++ cli/main.go | 19 ++++ cli/manage.go | 128 +++++++++++++++++++++++++++ cli/manage_test.go | 157 +++++++++++++++++++++++++++++++++ cli/read.go | 87 ++++++++++++++++++ cli/read_test.go | 195 +++++++++++++++++++++++++++++++++++++++++ cli/search.go | 51 +++++++++++ cli/search_test.go | 155 ++++++++++++++++++++++++++++++++ cli/upgrade_v1.go | 6 +- cli/util.go | 140 +++++++++++++++++++++++++++++ cli/util_test.go | 107 ++++++++++++++++++++++ 16 files changed, 1512 insertions(+), 24 deletions(-) create mode 100644 cli/folders.go create mode 100644 cli/folders_test.go create mode 100644 cli/list.go create mode 100644 cli/list_test.go create mode 100644 cli/main.go create mode 100644 cli/manage.go create mode 100644 cli/manage_test.go create mode 100644 cli/read.go create mode 100644 cli/read_test.go create mode 100644 cli/search.go create mode 100644 cli/search_test.go create mode 100644 cli/util.go create mode 100644 cli/util_test.go diff --git a/cli/config.go b/cli/config.go index 73b602dd..ca9568c4 100644 --- a/cli/config.go +++ b/cli/config.go @@ -38,6 +38,6 @@ func RunConfig(args []string) error { cmd := exec.Command(editor, target) //nolint:gosec,noctx cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + cmd.Stderr = ErrOut return cmd.Run() } diff --git a/cli/contacts_export.go b/cli/contacts_export.go index 1fd4206d..f1866d25 100644 --- a/cli/contacts_export.go +++ b/cli/contacts_export.go @@ -15,29 +15,29 @@ import ( func RunContactsExport(args []string) error { fs := flag.NewFlagSet("contacts export", flag.ExitOnError) + fs.SetOutput(ErrOut) format := fs.String("f", "json", "output format: json or csv") output := fs.String("o", "", "output file path (default: stdout)") noHeader := fs.Bool("no-header", false, "omit CSV header row") - help := fs.Bool("h", false, "show help") - if err := fs.Parse(args); err != nil { - return err + fs.Usage = func() { + fmt.Fprintln(Out, "Usage: matcha contacts export [flags]") + fmt.Fprintln(Out, "") + fmt.Fprintln(Out, "Export contacts from cache to JSON or CSV format.") + fmt.Fprintln(Out, "") + fmt.Fprintln(Out, "Flags:") + fs.SetOutput(Out) + fs.PrintDefaults() + fmt.Fprintln(Out, "") + fmt.Fprintln(Out, "Examples:") + fmt.Fprintln(Out, " matcha contacts export # JSON to stdout") + fmt.Fprintln(Out, " matcha contacts export -f csv # CSV to stdout") + fmt.Fprintln(Out, " matcha contacts export -o out.json # JSON to file") + fmt.Fprintln(Out, " matcha contacts export -f csv --no-header # CSV without headers") } - if *help { - fmt.Println("Usage: matcha contacts export [flags]") - fmt.Println("") - fmt.Println("Export contacts from cache to JSON or CSV format.") - fmt.Println("") - fmt.Println("Flags:") - fs.PrintDefaults() - fmt.Println("") - fmt.Println("Examples:") - fmt.Println(" matcha contacts export # JSON to stdout") - fmt.Println(" matcha contacts export -f csv # CSV to stdout") - fmt.Println(" matcha contacts export -o out.json # JSON to file") - fmt.Println(" matcha contacts export -f csv --no-header # CSV without headers") - return nil + if err := fs.Parse(args); err != nil { + return err } formatStr := strings.ToLower(*format) @@ -78,7 +78,7 @@ func runExportContacts(format, outputPath string, noHeader bool) error { contacts = contactsCache.Contacts if len(contacts) == 0 { - fmt.Fprintln(os.Stderr, "No contacts found in cache") + fmt.Fprintln(ErrOut, "No contacts found in cache") return nil } @@ -108,9 +108,9 @@ func runExportContacts(format, outputPath string, noHeader bool) error { if err := os.WriteFile(outputPath, outputData, 0644); err != nil { return fmt.Errorf("failed to write output file: %w", err) } - fmt.Fprintf(os.Stderr, "Exported %d contacts to %s\n", len(contacts), outputPath) + fmt.Fprintf(ErrOut, "Exported %d contacts to %s\n", len(contacts), outputPath) } else { - fmt.Println(string(outputData)) + fmt.Fprintln(Out, string(outputData)) } return nil diff --git a/cli/folders.go b/cli/folders.go new file mode 100644 index 00000000..a02bbe83 --- /dev/null +++ b/cli/folders.go @@ -0,0 +1,64 @@ +package cli + +import ( + "encoding/json" + "flag" + "fmt" +) + +// FolderJSON represents the output schema for --json mode. +type FolderJSON struct { + Name string `json:"name"` +} + +// RunFolders implements the non-interactive CLI subcommand `matcha folders`. +func RunFolders(args []string) error { + fs := flag.NewFlagSet("folders", flag.ExitOnError) + fs.SetOutput(ErrOut) + + from := fs.String("from", "", "Sender account email (defaults to first configured account)") + jsonOut := fs.Bool("json", false, "Output in JSON format") + + fs.Usage = func() { + fmt.Fprintln(ErrOut, "Usage: matcha folders [flags]") + fmt.Fprintln(ErrOut, "") + fmt.Fprintln(ErrOut, "List all folders for a configured email account.") + fmt.Fprintln(ErrOut, "") + fmt.Fprintln(ErrOut, "Flags:") + fs.PrintDefaults() + } + + fs.Parse(args) + + cfg, account, err := resolveAccount(*from) + if err != nil { + return err + } + + // Instantiate the client with autoStart = false + svc := NewServiceFunc(cfg, false) + defer svc.Close() + + folders, err := svc.FetchFolders(account.ID) + if err != nil { + return fmt.Errorf("failed to fetch folders: %w", err) + } + + if *jsonOut { + output := []FolderJSON{} + for _, f := range folders { + output = append(output, FolderJSON{Name: f.Name}) + } + data, err := json.MarshalIndent(output, "", " ") + if err != nil { + return fmt.Errorf("json marshal: %w", err) + } + fmt.Fprintln(Out, string(data)) + } else { + for _, f := range folders { + fmt.Fprintln(Out, f.Name) + } + } + + return nil +} diff --git a/cli/folders_test.go b/cli/folders_test.go new file mode 100644 index 00000000..39c1ef63 --- /dev/null +++ b/cli/folders_test.go @@ -0,0 +1,162 @@ +package cli + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/floatpane/matcha/backend" + "github.com/floatpane/matcha/config" + "github.com/floatpane/matcha/daemonclient" +) + +type mockService struct { + daemonclient.Service // embed to satisfy interface without implementing all methods + folders []backend.Folder + err error +} + +func (m *mockService) FetchFolders(accountID string) ([]backend.Folder, error) { + return m.folders, m.err +} + +func (m *mockService) Close() error { + return nil +} + +func TestRunFolders_TextOutput(t *testing.T) { + // Setup mock config loading + oldLoadConfig := configLoadConfig + defer func() { configLoadConfig = oldLoadConfig }() + + configLoadConfig = func() (*config.Config, error) { + return &config.Config{ + Accounts: []config.Account{ + {ID: "acct-1", Email: "test@example.com"}, + }, + }, nil + } + + // Mock Service function + oldNewService := NewServiceFunc + defer func() { NewServiceFunc = oldNewService }() + + mockSvc := &mockService{ + folders: []backend.Folder{ + {Name: "INBOX"}, + {Name: "Sent"}, + }, + } + NewServiceFunc = func(cfg *config.Config, autoStart bool) daemonclient.Service { + return mockSvc + } + + // Mock Output + var buf bytes.Buffer + oldOut := Out + Out = &buf + defer func() { Out = oldOut }() + + err := RunFolders([]string{}) + if err != nil { + t.Fatalf("RunFolders failed: %v", err) + } + + output := buf.String() + if !containsSubstr(output, "INBOX") || !containsSubstr(output, "Sent") { + t.Errorf("expected INBOX and Sent in output, got: %q", output) + } +} + +func TestRunFolders_JSONOutput(t *testing.T) { + // Setup mock config loading + oldLoadConfig := configLoadConfig + defer func() { configLoadConfig = oldLoadConfig }() + + configLoadConfig = func() (*config.Config, error) { + return &config.Config{ + Accounts: []config.Account{ + {ID: "acct-1", Email: "test@example.com"}, + }, + }, nil + } + + // Mock Service function + oldNewService := NewServiceFunc + defer func() { NewServiceFunc = oldNewService }() + + mockSvc := &mockService{ + folders: []backend.Folder{ + {Name: "INBOX"}, + {Name: "Sent"}, + }, + } + NewServiceFunc = func(cfg *config.Config, autoStart bool) daemonclient.Service { + return mockSvc + } + + // Mock Output + var buf bytes.Buffer + oldOut := Out + Out = &buf + defer func() { Out = oldOut }() + + err := RunFolders([]string{"--json"}) + if err != nil { + t.Fatalf("RunFolders failed: %v", err) + } + + var folders []FolderJSON + if err := json.Unmarshal(buf.Bytes(), &folders); err != nil { + t.Fatalf("failed to unmarshal JSON output: %v", err) + } + + if len(folders) != 2 || folders[0].Name != "INBOX" || folders[1].Name != "Sent" { + t.Errorf("unexpected JSON folders output: %+v", folders) + } +} + +func containsSubstr(s, substr string) bool { + return bytes.Contains([]byte(s), []byte(substr)) +} + +func TestRunFolders_EmptyJSONOutput(t *testing.T) { + // Setup mock config loading + oldLoadConfig := configLoadConfig + defer func() { configLoadConfig = oldLoadConfig }() + + configLoadConfig = func() (*config.Config, error) { + return &config.Config{ + Accounts: []config.Account{ + {ID: "acct-1", Email: "test@example.com"}, + }, + }, nil + } + + // Mock Service function + oldNewService := NewServiceFunc + defer func() { NewServiceFunc = oldNewService }() + + mockSvc := &mockService{ + folders: []backend.Folder{}, + } + NewServiceFunc = func(cfg *config.Config, autoStart bool) daemonclient.Service { + return mockSvc + } + + // Mock Output + var buf bytes.Buffer + oldOut := Out + Out = &buf + defer func() { Out = oldOut }() + + err := RunFolders([]string{"--json"}) + if err != nil { + t.Fatalf("RunFolders failed: %v", err) + } + + trimmedOutput := bytes.TrimSpace(buf.Bytes()) + if string(trimmedOutput) != "[]" { + t.Errorf("expected empty JSON array [], got: %q", string(trimmedOutput)) + } +} diff --git a/cli/list.go b/cli/list.go new file mode 100644 index 00000000..2a3777f2 --- /dev/null +++ b/cli/list.go @@ -0,0 +1,55 @@ +package cli + +import ( + "flag" + "fmt" + "time" +) + +// ListJSON defines the structure for a single email in the JSON output. +type ListJSON struct { + UID uint32 `json:"uid"` + From string `json:"from"` + Subject string `json:"subject"` + Date time.Time `json:"date"` + IsRead bool `json:"is_read"` +} + +// RunList implements the "list" subcommand. +func RunList(args []string) error { + fs := flag.NewFlagSet("list", flag.ExitOnError) + fs.SetOutput(ErrOut) + from := fs.String("from", "", "Email address of the account to use") + jsonOutput := fs.Bool("json", false, "Output in JSON format") + fs.Usage = func() { + fmt.Fprintln(ErrOut, "Usage: matcha list [folder] [--from ] [--json]") + fs.PrintDefaults() + } + positionals, err := parseInterspersed(fs, args) + if err != nil { + return err + } + + cfg, account, err := resolveAccount(*from) + if err != nil { + return err + } + + folder := "INBOX" + if len(positionals) > 0 { + folder = positionals[0] + } + folder = NormalizeFolder(folder) + + // Per design constraints, autoStart is always false for CLI commands. + svc := NewServiceFunc(cfg, false) + defer svc.Close() + + // Using a hardcoded limit of 50 as per the spec. + emails, err := svc.FetchEmails(account.ID, folder, 50, 0) + if err != nil { + return fmt.Errorf("could not fetch emails: %w", err) + } + + return printEmails(emails, *jsonOutput) +} diff --git a/cli/list_test.go b/cli/list_test.go new file mode 100644 index 00000000..f71897e9 --- /dev/null +++ b/cli/list_test.go @@ -0,0 +1,168 @@ +package cli + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/floatpane/matcha/backend" + "github.com/floatpane/matcha/config" + "github.com/floatpane/matcha/daemonclient" +) + +// mockListService is a mock implementation of the daemonclient.Service for testing. +type mockListService struct { + daemonclient.Service // embed to satisfy interface + FetchEmailsCalled bool + LastAccountID string + LastFolder string + EmailsToReturn []backend.Email + ErrorToReturn error +} + +func (m *mockListService) FetchEmails(accountID, folderName string, limit, offset uint32) ([]backend.Email, error) { + m.FetchEmailsCalled = true + m.LastAccountID = accountID + m.LastFolder = folderName + return m.EmailsToReturn, m.ErrorToReturn +} + +func (m *mockListService) Close() error { return nil } +func (m *mockListService) Reset() { + m.FetchEmailsCalled = false + m.LastAccountID = "" + m.LastFolder = "" +} + +var mockEmails = []backend.Email{ + {UID: 101, From: "sender1@example.com", Subject: "Test Subject 1", Date: time.Date(2026, 7, 6, 10, 0, 0, 0, time.UTC), IsRead: false}, + {UID: 102, From: "sender2@example.com", Subject: "Test Subject 2", Date: time.Date(2026, 7, 6, 11, 0, 0, 0, time.UTC), IsRead: true}, +} + +func TestRunList(t *testing.T) { + cfg := &config.Config{ + Accounts: []config.Account{ + {Email: "test@example.com", FetchEmail: "test@example.com", ID: "acc1"}, + {Email: "another@example.com", FetchEmail: "another@example.com", ID: "acc2"}, + }, + } + + mockSvc := &mockListService{ + EmailsToReturn: mockEmails, + } + + originalLoadConfig := configLoadConfig + originalNewServiceFunc := NewServiceFunc + originalOut := Out + defer func() { + configLoadConfig = originalLoadConfig + NewServiceFunc = originalNewServiceFunc + Out = originalOut + }() + + configLoadConfig = func() (*config.Config, error) { + return cfg, nil + } + NewServiceFunc = func(cfg *config.Config, autoStart bool) daemonclient.Service { + if autoStart { + t.Error("autoStart should be false") + } + return mockSvc + } + + t.Run("list inbox default", func(t *testing.T) { + mockSvc.Reset() + var buf bytes.Buffer + Out = &buf + + if err := RunList([]string{}); err != nil { + t.Fatalf("RunList: %v", err) + } + + if !mockSvc.FetchEmailsCalled { + t.Error("FetchEmails should have been called") + } + if mockSvc.LastAccountID != "acc1" { + t.Errorf("account = %q, want acc1 (first account by default)", mockSvc.LastAccountID) + } + if mockSvc.LastFolder != "INBOX" { + t.Errorf("folder = %q, want INBOX (default)", mockSvc.LastFolder) + } + if out := buf.String(); !strings.Contains(out, "Test Subject 1") || !strings.Contains(out, "sender1@example.com") { + t.Errorf("output missing expected email fields: %q", out) + } + }) + + t.Run("list specific folder and account", func(t *testing.T) { + mockSvc.Reset() + var buf bytes.Buffer + Out = &buf + + if err := RunList([]string{"--from", "another@example.com", "Sent"}); err != nil { + t.Fatalf("RunList: %v", err) + } + + if !mockSvc.FetchEmailsCalled { + t.Error("FetchEmails should have been called") + } + if mockSvc.LastAccountID != "acc2" { + t.Errorf("account = %q, want acc2 (specified)", mockSvc.LastAccountID) + } + if mockSvc.LastFolder != "Sent" { + t.Errorf("folder = %q, want Sent (specified)", mockSvc.LastFolder) + } + }) + + t.Run("list with --json output", func(t *testing.T) { + mockSvc.Reset() + var buf bytes.Buffer + Out = &buf + + if err := RunList([]string{"--json"}); err != nil { + t.Fatalf("RunList: %v", err) + } + + if !mockSvc.FetchEmailsCalled { + t.Error("FetchEmails should have been called") + } + + var emails []map[string]interface{} + if err := json.Unmarshal(buf.Bytes(), &emails); err != nil { + t.Fatalf("output should be valid JSON: %v", err) + } + if len(emails) != 2 { + t.Fatalf("got %d emails, want 2", len(emails)) + } + if emails[0]["uid"] != float64(101) { + t.Errorf("uid = %v, want 101", emails[0]["uid"]) + } + if emails[0]["from"] != "sender1@example.com" { + t.Errorf("from = %v, want sender1@example.com", emails[0]["from"]) + } + if emails[0]["subject"] != "Test Subject 1" { + t.Errorf("subject = %v, want Test Subject 1", emails[0]["subject"]) + } + if emails[0]["date"] != "2026-07-06T10:00:00Z" { + t.Errorf("date = %v, want 2026-07-06T10:00:00Z", emails[0]["date"]) + } + if emails[0]["is_read"] != false { + t.Errorf("is_read = %v, want false", emails[0]["is_read"]) + } + }) + + t.Run("account not found", func(t *testing.T) { + mockSvc.Reset() + var buf bytes.Buffer + Out = &buf + + err := RunList([]string{"--from", "notfound@example.com"}) + if err == nil { + t.Fatal("expected an error for unknown account") + } + if !strings.Contains(err.Error(), `no account found matching "notfound@example.com"`) { + t.Errorf("unexpected error: %v", err) + } + }) +} diff --git a/cli/main.go b/cli/main.go new file mode 100644 index 00000000..f3f134ca --- /dev/null +++ b/cli/main.go @@ -0,0 +1,19 @@ +package cli + +import ( + "io" + "os" + + "github.com/floatpane/matcha/config" + "github.com/floatpane/matcha/daemonclient" +) + +// Package-level hookable variables for unit testing +var ( + configLoadConfig = config.LoadConfig + NewServiceFunc = func(cfg *config.Config, autoStart bool) daemonclient.Service { + return daemonclient.NewCLIClient(cfg, autoStart) + } + Out io.Writer = os.Stdout + ErrOut io.Writer = os.Stderr +) diff --git a/cli/manage.go b/cli/manage.go new file mode 100644 index 00000000..1cf4d736 --- /dev/null +++ b/cli/manage.go @@ -0,0 +1,128 @@ +package cli + +import ( + "flag" + "fmt" + "strconv" + "strings" +) + +func parseUIDs(s string) ([]uint32, error) { + s = strings.ReplaceAll(s, ",", " ") + parts := strings.Fields(s) + uids := make([]uint32, 0, len(parts)) + for _, part := range parts { + uid64, err := strconv.ParseUint(part, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid uid %q: %w", part, err) + } + uids = append(uids, uint32(uid64)) + } + return uids, nil +} + +// pluralizeEmails renders a count with the correctly pluralized noun, +// e.g. "1 email" or "3 emails". +func pluralizeEmails(n int) string { + if n == 1 { + return "1 email" + } + return fmt.Sprintf("%d emails", n) +} + +// RunManage implements all management subcommands. +func RunManage(args []string) error { + if len(args) < 1 { + return fmt.Errorf("subcommand is required: archive, delete, mark-read, mark-unread, move") + } + subcommand := args[0] + rest := args[1:] + + fs := flag.NewFlagSet(subcommand, flag.ExitOnError) + fs.SetOutput(ErrOut) + from := fs.String("from", "", "Email address of the account to use") + fs.Usage = func() { + fmt.Fprintln(ErrOut, "Usage:") + fmt.Fprintln(ErrOut, " matcha archive [--from ] ") + fmt.Fprintln(ErrOut, " matcha delete [--from ] ") + fmt.Fprintln(ErrOut, " matcha mark-read [--from ] ") + fmt.Fprintln(ErrOut, " matcha mark-unread [--from ] ") + fmt.Fprintln(ErrOut, " matcha move [--from ] ") + fmt.Fprintln(ErrOut, "") + fmt.Fprintln(ErrOut, " is a comma- or space-separated list of UIDs (e.g. 1,2,3).") + fs.PrintDefaults() + } + positionals, err := parseInterspersed(fs, rest) + if err != nil { + return err + } + + cfg, account, err := resolveAccount(*from) + if err != nil { + return err + } + + svc := NewServiceFunc(cfg, false) + defer svc.Close() + + switch subcommand { + case "archive", "delete", "mark-read", "mark-unread": + if len(positionals) < 2 { + return fmt.Errorf("folder and uids are required") + } + folder := NormalizeFolder(positionals[0]) + uids, err := parseUIDs(positionals[1]) + if err != nil { + return err + } + if len(uids) == 0 { + return fmt.Errorf("no valid UIDs provided") + } + + switch subcommand { + case "archive": + err = svc.ArchiveEmails(account.ID, folder, uids) + if err == nil { + fmt.Fprintf(Out, "Success: Archived %s.\n", pluralizeEmails(len(uids))) + } + case "delete": + err = svc.DeleteEmails(account.ID, folder, uids) + if err == nil { + fmt.Fprintf(Out, "Success: Deleted %s.\n", pluralizeEmails(len(uids))) + } + case "mark-read": + err = svc.MarkRead(account.ID, folder, uids) + if err == nil { + fmt.Fprintf(Out, "Success: Marked %s as read.\n", pluralizeEmails(len(uids))) + } + case "mark-unread": + err = svc.MarkUnread(account.ID, folder, uids) + if err == nil { + fmt.Fprintf(Out, "Success: Marked %s as unread.\n", pluralizeEmails(len(uids))) + } + } + return err + + case "move": + if len(positionals) < 3 { + return fmt.Errorf("source_folder, destination_folder, and uids are required") + } + srcFolder := NormalizeFolder(positionals[0]) + dstFolder := NormalizeFolder(positionals[1]) + uids, err := parseUIDs(positionals[2]) + if err != nil { + return err + } + if len(uids) == 0 { + return fmt.Errorf("no valid UIDs provided") + } + err = svc.MoveEmails(account.ID, uids, srcFolder, dstFolder) + if err == nil { + fmt.Fprintf(Out, "Success: Moved %s to %s.\n", pluralizeEmails(len(uids)), dstFolder) + } + return err + + default: + return fmt.Errorf("unknown subcommand %q", subcommand) + } +} diff --git a/cli/manage_test.go b/cli/manage_test.go new file mode 100644 index 00000000..dcbbe0d6 --- /dev/null +++ b/cli/manage_test.go @@ -0,0 +1,157 @@ +package cli + +import ( + "bytes" + "slices" + "strings" + "testing" + + "github.com/floatpane/matcha/config" + "github.com/floatpane/matcha/daemonclient" +) + +type mockManageService struct { + daemonclient.Service // embed + LastAccountID string + LastSrcFolder string + LastDstFolder string + LastUIDs []uint32 + Called map[string]bool +} + +func (m *mockManageService) Reset() { + m.Called = make(map[string]bool) + m.LastUIDs = nil + m.LastSrcFolder = "" + m.LastDstFolder = "" +} +func (m *mockManageService) ArchiveEmails(accountID, folder string, uids []uint32) error { + m.Called["ArchiveEmails"] = true + m.LastAccountID = accountID + m.LastSrcFolder = folder + m.LastUIDs = uids + return nil +} +func (m *mockManageService) DeleteEmails(accountID, folder string, uids []uint32) error { + m.Called["DeleteEmails"] = true + m.LastAccountID = accountID + m.LastSrcFolder = folder + m.LastUIDs = uids + return nil +} +func (m *mockManageService) MarkRead(accountID, folder string, uids []uint32) error { + m.Called["MarkRead"] = true + m.LastAccountID = accountID + m.LastSrcFolder = folder + m.LastUIDs = uids + return nil +} +func (m *mockManageService) MarkUnread(accountID, folder string, uids []uint32) error { + m.Called["MarkUnread"] = true + m.LastAccountID = accountID + m.LastSrcFolder = folder + m.LastUIDs = uids + return nil +} +func (m *mockManageService) MoveEmails(accountID string, uids []uint32, src, dst string) error { + m.Called["MoveEmails"] = true + m.LastAccountID = accountID + m.LastUIDs = uids + m.LastSrcFolder = src + m.LastDstFolder = dst + return nil +} +func (m *mockManageService) Close() error { return nil } + +func TestRunManage(t *testing.T) { + cfg := &config.Config{ + Accounts: []config.Account{ + {Email: "test@example.com", FetchEmail: "test@example.com", ID: "acc1"}, + }, + } + mockSvc := &mockManageService{} + + originalLoadConfig := configLoadConfig + originalNewServiceFunc := NewServiceFunc + originalOut := Out + defer func() { + configLoadConfig = originalLoadConfig + NewServiceFunc = originalNewServiceFunc + Out = originalOut + }() + configLoadConfig = func() (*config.Config, error) { return cfg, nil } + NewServiceFunc = func(cfg *config.Config, autoStart bool) daemonclient.Service { + return mockSvc + } + + testCases := []struct { + name string + args []string + expectedCall string + expectedUIDs []uint32 + expectedSrc string + expectedDst string + expectedMsg string + }{ + {"archive single", []string{"archive", "INBOX", "123"}, "ArchiveEmails", []uint32{123}, "INBOX", "", "Success: Archived 1 email."}, + {"archive multiple", []string{"archive", "INBOX", "1,2,3"}, "ArchiveEmails", []uint32{1, 2, 3}, "INBOX", "", "Success: Archived 3 emails."}, + {"delete", []string{"delete", "Trash", "4,5"}, "DeleteEmails", []uint32{4, 5}, "Trash", "", "Success: Deleted 2 emails."}, + {"mark-read", []string{"mark-read", "INBOX", "6"}, "MarkRead", []uint32{6}, "INBOX", "", "Success: Marked 1 email as read."}, + {"mark-unread", []string{"mark-unread", "INBOX", "7"}, "MarkUnread", []uint32{7}, "INBOX", "", "Success: Marked 1 email as unread."}, + {"move", []string{"move", "INBOX", "Archive", "8,9"}, "MoveEmails", []uint32{8, 9}, "INBOX", "Archive", "Success: Moved 2 emails to Archive."}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mockSvc.Reset() + var buf bytes.Buffer + Out = &buf + + if err := RunManage(tc.args); err != nil { + t.Fatalf("RunManage: %v", err) + } + + if !mockSvc.Called[tc.expectedCall] { + t.Errorf("%s should have been called", tc.expectedCall) + } + if mockSvc.LastAccountID != "acc1" { + t.Errorf("account = %q, want acc1", mockSvc.LastAccountID) + } + if !slices.Equal(mockSvc.LastUIDs, tc.expectedUIDs) { + t.Errorf("uids = %v, want %v", mockSvc.LastUIDs, tc.expectedUIDs) + } + if mockSvc.LastSrcFolder != tc.expectedSrc { + t.Errorf("src folder = %q, want %q", mockSvc.LastSrcFolder, tc.expectedSrc) + } + if tc.expectedDst != "" && mockSvc.LastDstFolder != tc.expectedDst { + t.Errorf("dst folder = %q, want %q", mockSvc.LastDstFolder, tc.expectedDst) + } + if got := strings.TrimSpace(buf.String()); got != tc.expectedMsg { + t.Errorf("message = %q, want %q", got, tc.expectedMsg) + } + }) + } + + errorCases := []struct { + name string + args []string + wantSub string + }{ + {"invalid subcommand", []string{"unknown", "INBOX", "123"}, "unknown subcommand"}, + {"invalid uids", []string{"archive", "INBOX", "abc"}, "invalid uid"}, + {"empty uids archive", []string{"archive", "INBOX", ""}, "no valid UIDs provided"}, + {"empty uids move", []string{"move", "INBOX", "Archive", ""}, "no valid UIDs provided"}, + } + + for _, tc := range errorCases { + t.Run(tc.name, func(t *testing.T) { + err := RunManage(tc.args) + if err == nil { + t.Fatalf("expected an error containing %q", tc.wantSub) + } + if !strings.Contains(err.Error(), tc.wantSub) { + t.Errorf("error = %v, want substring %q", err, tc.wantSub) + } + }) + } +} diff --git a/cli/read.go b/cli/read.go new file mode 100644 index 00000000..53f4074a --- /dev/null +++ b/cli/read.go @@ -0,0 +1,87 @@ +package cli + +import ( + "encoding/json" + "flag" + "fmt" + "strconv" + + "github.com/floatpane/matcha/backend" +) + +// ReadJSON defines the structure for the --json output of the read command. +type ReadJSON struct { + Body string `json:"body"` + MIMEType string `json:"mime_type"` + Attachments []backend.Attachment `json:"attachments,omitempty"` +} + +// RunRead implements the "read" subcommand. +func RunRead(args []string) error { + fs := flag.NewFlagSet("read", flag.ExitOnError) + fs.SetOutput(ErrOut) + from := fs.String("from", "", "Email address of the account to use") + jsonOutput := fs.Bool("json", false, "Output in JSON format") + fs.Usage = func() { + fmt.Fprintln(ErrOut, "Usage: matcha read [--from ] [--json] ") + fs.PrintDefaults() + } + positionals, err := parseInterspersed(fs, args) + if err != nil { + return err + } + + var folder string + var uidStr string + + switch len(positionals) { + case 1: + // A single positional is treated as a UID in INBOX, but only if it + // parses as a number; otherwise the folder was given without a UID. + if _, err := strconv.ParseUint(positionals[0], 10, 32); err == nil { + folder = "INBOX" + uidStr = positionals[0] + } else { + return fmt.Errorf("folder and uid are required") + } + case 0: + return fmt.Errorf("folder and uid are required") + default: + folder = positionals[0] + uidStr = positionals[1] + } + + uid64, err := strconv.ParseUint(uidStr, 10, 32) + if err != nil { + return fmt.Errorf("invalid uid %q: %w", uidStr, err) + } + uid := uint32(uid64) + + cfg, account, err := resolveAccount(*from) + if err != nil { + return err + } + folder = NormalizeFolder(folder) + + svc := NewServiceFunc(cfg, false) + defer svc.Close() + + body, mime, attachments, err := svc.FetchEmailBody(account.ID, folder, uid) + if err != nil { + return fmt.Errorf("could not fetch email body: %w", err) + } + + if *jsonOutput { + output := ReadJSON{ + Body: body, + MIMEType: mime, + Attachments: attachments, + } + encoder := json.NewEncoder(Out) + encoder.SetIndent("", " ") + return encoder.Encode(output) + } + + fmt.Fprint(Out, body) + return nil +} diff --git a/cli/read_test.go b/cli/read_test.go new file mode 100644 index 00000000..4b5ac2bd --- /dev/null +++ b/cli/read_test.go @@ -0,0 +1,195 @@ +package cli + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/floatpane/matcha/backend" + "github.com/floatpane/matcha/config" + "github.com/floatpane/matcha/daemonclient" +) + +type mockReadService struct { + daemonclient.Service // embed + FetchEmailBodyCalled bool + LastAccountID string + LastFolder string + LastUID uint32 + BodyToReturn string + MIMEToReturn string + AttachmentsToReturn []backend.Attachment + ErrorToReturn error +} + +func (m *mockReadService) FetchEmailBody(accountID, folderName string, uid uint32) (string, string, []backend.Attachment, error) { + m.FetchEmailBodyCalled = true + m.LastAccountID = accountID + m.LastFolder = folderName + m.LastUID = uid + return m.BodyToReturn, m.MIMEToReturn, m.AttachmentsToReturn, m.ErrorToReturn +} + +func (m *mockReadService) Close() error { return nil } +func (m *mockReadService) Reset() { + m.FetchEmailBodyCalled = false + m.LastFolder = "" + m.LastUID = 0 +} + +func TestRunRead(t *testing.T) { + cfg := &config.Config{ + Accounts: []config.Account{ + {Email: "test@example.com", FetchEmail: "test@example.com", ID: "acc1"}, + }, + } + mockSvc := &mockReadService{ + BodyToReturn: "This is the plain text body.", + MIMEToReturn: "text/plain", + AttachmentsToReturn: []backend.Attachment{ + {Filename: "att1.txt"}, + }, + } + + originalLoadConfig := configLoadConfig + originalNewServiceFunc := NewServiceFunc + originalOut := Out + defer func() { + configLoadConfig = originalLoadConfig + NewServiceFunc = originalNewServiceFunc + Out = originalOut + }() + configLoadConfig = func() (*config.Config, error) { return cfg, nil } + NewServiceFunc = func(cfg *config.Config, autoStart bool) daemonclient.Service { + return mockSvc + } + + t.Run("read email plain text", func(t *testing.T) { + mockSvc.Reset() + var buf bytes.Buffer + Out = &buf + if err := RunRead([]string{"INBOX", "12345"}); err != nil { + t.Fatalf("RunRead: %v", err) + } + + if !mockSvc.FetchEmailBodyCalled { + t.Error("FetchEmailBody should have been called") + } + if mockSvc.LastAccountID != "acc1" { + t.Errorf("account = %q, want acc1", mockSvc.LastAccountID) + } + if mockSvc.LastFolder != "INBOX" { + t.Errorf("folder = %q, want INBOX", mockSvc.LastFolder) + } + if mockSvc.LastUID != 12345 { + t.Errorf("uid = %d, want 12345", mockSvc.LastUID) + } + if buf.String() != "This is the plain text body." { + t.Errorf("body = %q", buf.String()) + } + }) + + t.Run("read email with json output", func(t *testing.T) { + mockSvc.Reset() + var buf bytes.Buffer + Out = &buf + if err := RunRead([]string{"--json", "INBOX", "12345"}); err != nil { + t.Fatalf("RunRead: %v", err) + } + + if !mockSvc.FetchEmailBodyCalled { + t.Error("FetchEmailBody should have been called") + } + + var bodyData map[string]interface{} + if err := json.Unmarshal(buf.Bytes(), &bodyData); err != nil { + t.Fatalf("output should be valid JSON: %v", err) + } + if bodyData["body"] != "This is the plain text body." { + t.Errorf("body = %v", bodyData["body"]) + } + if bodyData["mime_type"] != "text/plain" { + t.Errorf("mime_type = %v", bodyData["mime_type"]) + } + attachments, ok := bodyData["attachments"].([]interface{}) + if !ok { + t.Fatalf("attachments not an array: %v", bodyData["attachments"]) + } + if len(attachments) != 1 { + t.Fatalf("got %d attachments, want 1", len(attachments)) + } + att, ok := attachments[0].(map[string]interface{}) + if !ok { + t.Fatalf("attachment not an object: %v", attachments[0]) + } + if att["filename"] != "att1.txt" { + t.Errorf("filename = %v, want att1.txt", att["filename"]) + } + }) + + t.Run("read email missing uid", func(t *testing.T) { + mockSvc.Reset() + err := RunRead([]string{"INBOX"}) + if err == nil { + t.Fatal("expected an error when uid is missing") + } + if !strings.Contains(err.Error(), "folder and uid are required") { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("read email with default folder INBOX", func(t *testing.T) { + mockSvc.Reset() + var buf bytes.Buffer + Out = &buf + if err := RunRead([]string{"12345"}); err != nil { + t.Fatalf("RunRead: %v", err) + } + + if !mockSvc.FetchEmailBodyCalled { + t.Error("FetchEmailBody should have been called") + } + if mockSvc.LastAccountID != "acc1" { + t.Errorf("account = %q, want acc1", mockSvc.LastAccountID) + } + if mockSvc.LastFolder != "INBOX" { + t.Errorf("folder = %q, want INBOX (default)", mockSvc.LastFolder) + } + if mockSvc.LastUID != 12345 { + t.Errorf("uid = %d, want 12345", mockSvc.LastUID) + } + if buf.String() != "This is the plain text body." { + t.Errorf("body = %q", buf.String()) + } + }) + + t.Run("read email with no attachments json output omitempty", func(t *testing.T) { + mockSvc.Reset() + mockSvc.AttachmentsToReturn = nil + defer func() { mockSvc.AttachmentsToReturn = []backend.Attachment{{Filename: "att1.txt"}} }() + var buf bytes.Buffer + Out = &buf + if err := RunRead([]string{"--json", "INBOX", "12345"}); err != nil { + t.Fatalf("RunRead: %v", err) + } + + if !mockSvc.FetchEmailBodyCalled { + t.Error("FetchEmailBody should have been called") + } + + var bodyData map[string]interface{} + if err := json.Unmarshal(buf.Bytes(), &bodyData); err != nil { + t.Fatalf("output should be valid JSON: %v", err) + } + if bodyData["body"] != "This is the plain text body." { + t.Errorf("body = %v", bodyData["body"]) + } + if bodyData["mime_type"] != "text/plain" { + t.Errorf("mime_type = %v", bodyData["mime_type"]) + } + if _, ok := bodyData["attachments"]; ok { + t.Error("attachments key should be omitted when empty") + } + }) +} diff --git a/cli/search.go b/cli/search.go new file mode 100644 index 00000000..dafda956 --- /dev/null +++ b/cli/search.go @@ -0,0 +1,51 @@ +package cli + +import ( + "flag" + "fmt" + "strings" + + "github.com/floatpane/matcha/backend" +) + +// RunSearch implements the "search" subcommand. +func RunSearch(args []string) error { + fs := flag.NewFlagSet("search", flag.ExitOnError) + fs.SetOutput(ErrOut) + from := fs.String("from", "", "Email address of the account to use") + jsonOutput := fs.Bool("json", false, "Output in JSON format") + fs.Usage = func() { + fmt.Fprintln(ErrOut, "Usage: matcha search [--from ] [--json] ") + fs.PrintDefaults() + } + positionals, err := parseInterspersed(fs, args) + if err != nil { + return err + } + + if len(positionals) < 2 { + return fmt.Errorf("folder and query_string are required") + } + + folder := positionals[0] + // Join the remaining positionals so unquoted multi-term queries are not + // silently truncated (e.g. `matcha search INBOX from:a subject:b`). + queryString := strings.Join(positionals[1:], " ") + + cfg, account, err := resolveAccount(*from) + if err != nil { + return err + } + folder = NormalizeFolder(folder) + + svc := NewServiceFunc(cfg, false) + defer svc.Close() + + query := backend.ParseSearchQuery(queryString) + emails, err := svc.Search(account.ID, folder, query) + if err != nil { + return fmt.Errorf("could not search emails: %w", err) + } + + return printEmails(emails, *jsonOutput) +} diff --git a/cli/search_test.go b/cli/search_test.go new file mode 100644 index 00000000..e3e68562 --- /dev/null +++ b/cli/search_test.go @@ -0,0 +1,155 @@ +package cli + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/floatpane/matcha/backend" + "github.com/floatpane/matcha/config" + "github.com/floatpane/matcha/daemonclient" +) + +type mockSearchService struct { + daemonclient.Service // embed + SearchCalled bool + LastAccountID string + LastFolder string + LastQuery backend.SearchQuery + ResultsToReturn []backend.Email + ErrorToReturn error +} + +func (m *mockSearchService) Search(accountID, folder string, query backend.SearchQuery) ([]backend.Email, error) { + m.SearchCalled = true + m.LastAccountID = accountID + m.LastFolder = folder + m.LastQuery = query + return m.ResultsToReturn, m.ErrorToReturn +} +func (m *mockSearchService) Close() error { return nil } +func (m *mockSearchService) Reset() { + m.SearchCalled = false + m.LastFolder = "" + m.LastQuery = backend.SearchQuery{} +} + +func TestRunSearch(t *testing.T) { + cfg := &config.Config{ + Accounts: []config.Account{ + {Email: "test@example.com", FetchEmail: "test@example.com", ID: "acc1"}, + }, + } + mockSvc := &mockSearchService{ResultsToReturn: mockEmails} // using mockEmails from list_test + + originalLoadConfig := configLoadConfig + originalNewServiceFunc := NewServiceFunc + originalOut := Out + defer func() { + configLoadConfig = originalLoadConfig + NewServiceFunc = originalNewServiceFunc + Out = originalOut + }() + configLoadConfig = func() (*config.Config, error) { return cfg, nil } + NewServiceFunc = func(cfg *config.Config, autoStart bool) daemonclient.Service { + return mockSvc + } + + t.Run("search with text output", func(t *testing.T) { + mockSvc.Reset() + var buf bytes.Buffer + Out = &buf + if err := RunSearch([]string{"INBOX", "important"}); err != nil { + t.Fatalf("RunSearch: %v", err) + } + + if !mockSvc.SearchCalled { + t.Error("Search should have been called") + } + if mockSvc.LastAccountID != "acc1" { + t.Errorf("account = %q, want acc1", mockSvc.LastAccountID) + } + if mockSvc.LastFolder != "INBOX" { + t.Errorf("folder = %q, want INBOX", mockSvc.LastFolder) + } + // A bare term with no operators is parsed into the Body field. + if mockSvc.LastQuery.Body != "important" { + t.Errorf("query body = %q, want important", mockSvc.LastQuery.Body) + } + if !strings.Contains(buf.String(), "Test Subject 1") { + t.Errorf("output missing expected result: %q", buf.String()) + } + }) + + t.Run("search with json output", func(t *testing.T) { + mockSvc.Reset() + var buf bytes.Buffer + Out = &buf + if err := RunSearch([]string{"--json", "INBOX", "important"}); err != nil { + t.Fatalf("RunSearch: %v", err) + } + + if !mockSvc.SearchCalled { + t.Error("Search should have been called") + } + + var emails []ListJSON + if err := json.Unmarshal(buf.Bytes(), &emails); err != nil { + t.Fatalf("output should be valid JSON: %v", err) + } + if len(emails) != 2 { + t.Fatalf("got %d emails, want 2", len(emails)) + } + if emails[0].Subject != "Test Subject 1" { + t.Errorf("subject = %q, want Test Subject 1", emails[0].Subject) + } + }) + + t.Run("search missing query", func(t *testing.T) { + mockSvc.Reset() + err := RunSearch([]string{"INBOX"}) + if err == nil { + t.Fatal("expected an error when query is missing") + } + if !strings.Contains(err.Error(), "folder and query_string are required") { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("search with structured query", func(t *testing.T) { + mockSvc.Reset() + var buf bytes.Buffer + Out = &buf + if err := RunSearch([]string{"INBOX", "from:alice@example.com subject:report"}); err != nil { + t.Fatalf("RunSearch: %v", err) + } + + if !mockSvc.SearchCalled { + t.Error("Search should have been called") + } + if mockSvc.LastQuery.From != "alice@example.com" { + t.Errorf("query from = %q, want alice@example.com", mockSvc.LastQuery.From) + } + if mockSvc.LastQuery.Subject != "report" { + t.Errorf("query subject = %q, want report", mockSvc.LastQuery.Subject) + } + }) + + t.Run("search joins unquoted multi-term query", func(t *testing.T) { + mockSvc.Reset() + var buf bytes.Buffer + Out = &buf + // Unquoted operators arrive as separate positionals; they must be + // joined rather than silently truncated to the first token. + if err := RunSearch([]string{"INBOX", "from:alice@example.com", "subject:report"}); err != nil { + t.Fatalf("RunSearch: %v", err) + } + if mockSvc.LastQuery.From != "alice@example.com" { + t.Errorf("query from = %q, want alice@example.com", mockSvc.LastQuery.From) + } + if mockSvc.LastQuery.Subject != "report" { + t.Errorf("query subject = %q, want report", mockSvc.LastQuery.Subject) + } + }) +} diff --git a/cli/upgrade_v1.go b/cli/upgrade_v1.go index 75ce07a2..d5154709 100644 --- a/cli/upgrade_v1.go +++ b/cli/upgrade_v1.go @@ -119,7 +119,7 @@ func tryHomebrewV1Upgrade(cask bool) bool { cmd := exec.Command("brew", installArgs...) //nolint:noctx cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + cmd.Stderr = ErrOut if err := cmd.Run(); err == nil { fmt.Println("Successfully upgraded via Homebrew.") return true @@ -127,7 +127,7 @@ func tryHomebrewV1Upgrade(cask bool) bool { cmd = exec.Command("brew", upgradeArgs...) //nolint:noctx cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + cmd.Stderr = ErrOut if err := cmd.Run(); err == nil { fmt.Println("Successfully upgraded via Homebrew.") return true @@ -150,7 +150,7 @@ func trySnapV1Refresh() bool { fmt.Println("Detected Snap package — attempting to refresh to candidate v1.") cmd := exec.Command("snap", "refresh", "matcha", "--candidate") //nolint:noctx cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + cmd.Stderr = ErrOut if err := cmd.Run(); err == nil { fmt.Println("Successfully refreshed snap to candidate v1.") return true diff --git a/cli/util.go b/cli/util.go new file mode 100644 index 00000000..4d924ab9 --- /dev/null +++ b/cli/util.go @@ -0,0 +1,140 @@ +package cli + +import ( + "encoding/json" + "flag" + "fmt" + "strings" + "text/tabwriter" + + "github.com/floatpane/matcha/backend" + "github.com/floatpane/matcha/config" + "github.com/floatpane/matcha/i18n" + _ "github.com/floatpane/matcha/i18n/languages" +) + +// resolveAccount centralizes the logic for loading the config and selecting an account. +// It returns the loaded config and the selected account, or an error. +func resolveAccount(fromEmail string) (*config.Config, *config.Account, error) { + cfg, err := configLoadConfig() + if err != nil { + return nil, nil, fmt.Errorf("could not load config: %w", err) + } + + // Initialize i18n if not already initialized, set language based on configuration/env + // GetManager() auto-initializes with English if not already initialized + if i18n.GetManager() == nil { + if err := i18n.Init("en"); err != nil { + fmt.Fprintf(ErrOut, "warning: failed to initialize i18n: %v\n", err) + } + } + + if manager := i18n.GetManager(); manager != nil { + lang := i18n.DetectLanguage(cfg) + if err := manager.SetLanguage(lang); err != nil { + fmt.Fprintf(ErrOut, "warning: failed to set i18n language: %v\n", err) + } + } + + if !cfg.HasAccounts() { + return nil, nil, fmt.Errorf("no accounts configured") + } + + var account *config.Account + if fromEmail != "" { + // Try matching against login Email case-insensitively first + for i := range cfg.Accounts { + if strings.EqualFold(cfg.Accounts[i].Email, fromEmail) { + account = &cfg.Accounts[i] + break + } + } + if account == nil { + // Try matching against FetchEmail as a fallback case-insensitively + for i := range cfg.Accounts { + if strings.EqualFold(cfg.Accounts[i].FetchEmail, fromEmail) { + account = &cfg.Accounts[i] + break + } + } + } + if account == nil { + return nil, nil, fmt.Errorf("no account found matching %q", fromEmail) + } + } else { + account = cfg.GetFirstAccount() + } + + return cfg, account, nil +} + +// printEmails centralizes the logic for rendering a list of emails. +func printEmails(emails []backend.Email, jsonOutput bool) error { + if jsonOutput { + jsonEmails := make([]ListJSON, len(emails)) + for i, e := range emails { + jsonEmails[i] = ListJSON{ + UID: e.UID, + From: e.From, + Subject: e.Subject, + Date: e.Date, + IsRead: e.IsRead, + } + } + encoder := json.NewEncoder(Out) + encoder.SetIndent("", " ") + return encoder.Encode(jsonEmails) + } + + // Text output + w := tabwriter.NewWriter(Out, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "UID\tFROM\tSUBJECT\tDATE\tREAD") + for _, email := range emails { + readStatus := " " + if email.IsRead { + readStatus = "✔" + } + fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", + email.UID, + sanitizeTextForTable(email.From), + sanitizeTextForTable(email.Subject), + email.Date.Format("2006-01-02 15:04"), + readStatus, + ) + } + return w.Flush() +} + +// NormalizeFolder normalizes a folder name, mapping empty or case-insensitive "inbox" to "INBOX". +func NormalizeFolder(folder string) string { + if folder == "" || strings.EqualFold(folder, "inbox") { + return "INBOX" + } + return folder +} + +// parseInterspersed parses a flag set with interspersed positional arguments. +// It returns the collected positional arguments in the order they were encountered. +func parseInterspersed(fs *flag.FlagSet, args []string) ([]string, error) { + var positionals []string + argsToParse := args + for { + if err := fs.Parse(argsToParse); err != nil { + return nil, err + } + if fs.NArg() == 0 { + break + } + positionals = append(positionals, fs.Arg(0)) + argsToParse = fs.Args()[1:] + } + return positionals, nil +} + +// sanitizeTextForTable replaces tabs and newlines with spaces to avoid breaking tabwriter formatting. +func sanitizeTextForTable(s string) string { + s = strings.ReplaceAll(s, "\t", " ") + s = strings.ReplaceAll(s, "\n", " ") + s = strings.ReplaceAll(s, "\r", " ") + return s +} diff --git a/cli/util_test.go b/cli/util_test.go new file mode 100644 index 00000000..5fa486b9 --- /dev/null +++ b/cli/util_test.go @@ -0,0 +1,107 @@ +package cli + +import ( + "flag" + "testing" +) + +func TestNormalizeFolder(t *testing.T) { + // Test standard values + tests := []struct { + input string + expected string + }{ + {"", "INBOX"}, + {"inbox", "INBOX"}, + {"INBOX", "INBOX"}, + {"Sent", "Sent"}, + {"sent", "sent"}, + {"Trash", "Trash"}, + {"Archive", "Archive"}, + {"custom-folder", "custom-folder"}, + {"Posteingang", "Posteingang"}, + {"Gesendet", "Gesendet"}, + } + + for _, tt := range tests { + if got := NormalizeFolder(tt.input); got != tt.expected { + t.Errorf("NormalizeFolder(%q) = %q; want %q", tt.input, got, tt.expected) + } + } +} + +func TestSanitizeTextForTable(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"hello\tworld", "hello world"}, + {"line1\nline2", "line1 line2"}, + {"line1\r\nline2", "line1 line2"}, + {"normal text", "normal text"}, + } + + for _, tt := range tests { + if got := sanitizeTextForTable(tt.input); got != tt.expected { + t.Errorf("sanitizeTextForTable(%q) = %q; want %q", tt.input, got, tt.expected) + } + } +} + +func TestParseInterspersed(t *testing.T) { + importFlag := func(args []string) ([]string, string, bool, error) { + fs := flag.NewFlagSet("test-interspersed", flag.ContinueOnError) + from := fs.String("from", "", "") + jsonOut := fs.Bool("json", false, "") + pos, err := parseInterspersed(fs, args) + return pos, *from, *jsonOut, err + } + + tests := []struct { + args []string + expectedPos []string + expectedFrom string + expectedJSON bool + }{ + { + args: []string{"INBOX", "--json"}, + expectedPos: []string{"INBOX"}, + expectedFrom: "", + expectedJSON: true, + }, + { + args: []string{"INBOX", "1,2,3", "--from", "work@example.com"}, + expectedPos: []string{"INBOX", "1,2,3"}, + expectedFrom: "work@example.com", + expectedJSON: false, + }, + { + args: []string{"--from", "work@example.com", "--json", "INBOX", "123"}, + expectedPos: []string{"INBOX", "123"}, + expectedFrom: "work@example.com", + expectedJSON: true, + }, + } + + for _, tt := range tests { + pos, from, jsonOut, err := importFlag(tt.args) + if err != nil { + t.Fatalf("unexpected error parsing interspersed: %v", err) + } + if len(pos) != len(tt.expectedPos) { + t.Errorf("for args %v: expected positionals %v, got %v", tt.args, tt.expectedPos, pos) + continue + } + for i := range pos { + if pos[i] != tt.expectedPos[i] { + t.Errorf("for args %v: expected positional %d to be %q, got %q", tt.args, i, tt.expectedPos[i], pos[i]) + } + } + if from != tt.expectedFrom { + t.Errorf("for args %v: expected from %q, got %q", tt.args, tt.expectedFrom, from) + } + if jsonOut != tt.expectedJSON { + t.Errorf("for args %v: expected json %v, got %v", tt.args, tt.expectedJSON, jsonOut) + } + } +} From 053602697f224e1280af5899d8c22e4e38297b1a Mon Sep 17 00:00:00 2001 From: Robin Everaars Date: Fri, 10 Jul 2026 08:36:14 +0200 Subject: [PATCH 4/5] feat(main): dispatch the new CLI subcommands Route folders, list, read, search, archive, delete, mark-read, mark-unread and move through the cli package; errors print to stderr and exit 1. --- main.go | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/main.go b/main.go index 18a191b3..5de3b126 100644 --- a/main.go +++ b/main.go @@ -4480,6 +4480,44 @@ func main() { //nolint:gocyclo exit(0) } + // --- New CLI Subcommands --- + if len(os.Args) > 1 { + switch os.Args[1] { + case "folders": + if err := matchaCli.RunFolders(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + exit(1) + } + exit(0) + case "list": + if err := matchaCli.RunList(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + exit(1) + } + exit(0) + case "read": + if err := matchaCli.RunRead(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + exit(1) + } + exit(0) + case "search": + if err := matchaCli.RunSearch(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + exit(1) + } + exit(0) + case "archive", "delete", "mark-read", "mark-unread", "move": + // RunManage expects the subcommand as the first argument + if err := matchaCli.RunManage(os.Args[1:]); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + exit(1) + } + exit(0) + } + } + // --- End New CLI Subcommands --- + // Marketplace TUI subcommand: matcha marketplace if len(os.Args) > 1 && os.Args[1] == "marketplace" { mp := tui.NewMarketplace(true) From efd0eb87d5e9d8d9e459463f27c955f530def921 Mon Sep 17 00:00:00 2001 From: Robin Everaars Date: Mon, 13 Jul 2026 11:21:13 +0200 Subject: [PATCH 5/5] chore(cli): satisfy golangci-lint Address the golangci-lint findings on the new CLI files: - errcheck: route CLI stdout/stderr writes through fprintln/fprintf/ fprint helpers that discard the unactionable write error, and check the previously-ignored fs.Parse and svc.Close returns. - goconst: hoist the repeated "INBOX" and management subcommand names into constants. - gocritic (unlambda): assign daemonclient.NewCLIClient directly instead of wrapping it in a pass-through closure. - revive (blank-imports): justify the i18n/languages blank import. No behaviour change; build and tests remain green. --- cli/contacts_export.go | 28 ++++++++++++------------ cli/folders.go | 20 +++++++++-------- cli/list.go | 6 +++--- cli/main.go | 10 ++++----- cli/manage.go | 49 +++++++++++++++++++++++++----------------- cli/read.go | 8 +++---- cli/search.go | 4 ++-- cli/util.go | 32 ++++++++++++++++++++++----- 8 files changed, 94 insertions(+), 63 deletions(-) diff --git a/cli/contacts_export.go b/cli/contacts_export.go index f1866d25..8cd0ddeb 100644 --- a/cli/contacts_export.go +++ b/cli/contacts_export.go @@ -21,19 +21,19 @@ func RunContactsExport(args []string) error { noHeader := fs.Bool("no-header", false, "omit CSV header row") fs.Usage = func() { - fmt.Fprintln(Out, "Usage: matcha contacts export [flags]") - fmt.Fprintln(Out, "") - fmt.Fprintln(Out, "Export contacts from cache to JSON or CSV format.") - fmt.Fprintln(Out, "") - fmt.Fprintln(Out, "Flags:") + fprintln(Out, "Usage: matcha contacts export [flags]") + fprintln(Out, "") + fprintln(Out, "Export contacts from cache to JSON or CSV format.") + fprintln(Out, "") + fprintln(Out, "Flags:") fs.SetOutput(Out) fs.PrintDefaults() - fmt.Fprintln(Out, "") - fmt.Fprintln(Out, "Examples:") - fmt.Fprintln(Out, " matcha contacts export # JSON to stdout") - fmt.Fprintln(Out, " matcha contacts export -f csv # CSV to stdout") - fmt.Fprintln(Out, " matcha contacts export -o out.json # JSON to file") - fmt.Fprintln(Out, " matcha contacts export -f csv --no-header # CSV without headers") + fprintln(Out, "") + fprintln(Out, "Examples:") + fprintln(Out, " matcha contacts export # JSON to stdout") + fprintln(Out, " matcha contacts export -f csv # CSV to stdout") + fprintln(Out, " matcha contacts export -o out.json # JSON to file") + fprintln(Out, " matcha contacts export -f csv --no-header # CSV without headers") } if err := fs.Parse(args); err != nil { @@ -78,7 +78,7 @@ func runExportContacts(format, outputPath string, noHeader bool) error { contacts = contactsCache.Contacts if len(contacts) == 0 { - fmt.Fprintln(ErrOut, "No contacts found in cache") + fprintln(ErrOut, "No contacts found in cache") return nil } @@ -108,9 +108,9 @@ func runExportContacts(format, outputPath string, noHeader bool) error { if err := os.WriteFile(outputPath, outputData, 0644); err != nil { return fmt.Errorf("failed to write output file: %w", err) } - fmt.Fprintf(ErrOut, "Exported %d contacts to %s\n", len(contacts), outputPath) + fprintf(ErrOut, "Exported %d contacts to %s\n", len(contacts), outputPath) } else { - fmt.Fprintln(Out, string(outputData)) + fprintln(Out, string(outputData)) } return nil diff --git a/cli/folders.go b/cli/folders.go index a02bbe83..566ca571 100644 --- a/cli/folders.go +++ b/cli/folders.go @@ -20,15 +20,17 @@ func RunFolders(args []string) error { jsonOut := fs.Bool("json", false, "Output in JSON format") fs.Usage = func() { - fmt.Fprintln(ErrOut, "Usage: matcha folders [flags]") - fmt.Fprintln(ErrOut, "") - fmt.Fprintln(ErrOut, "List all folders for a configured email account.") - fmt.Fprintln(ErrOut, "") - fmt.Fprintln(ErrOut, "Flags:") + fprintln(ErrOut, "Usage: matcha folders [flags]") + fprintln(ErrOut, "") + fprintln(ErrOut, "List all folders for a configured email account.") + fprintln(ErrOut, "") + fprintln(ErrOut, "Flags:") fs.PrintDefaults() } - fs.Parse(args) + if err := fs.Parse(args); err != nil { + return err + } cfg, account, err := resolveAccount(*from) if err != nil { @@ -37,7 +39,7 @@ func RunFolders(args []string) error { // Instantiate the client with autoStart = false svc := NewServiceFunc(cfg, false) - defer svc.Close() + defer func() { _ = svc.Close() }() folders, err := svc.FetchFolders(account.ID) if err != nil { @@ -53,10 +55,10 @@ func RunFolders(args []string) error { if err != nil { return fmt.Errorf("json marshal: %w", err) } - fmt.Fprintln(Out, string(data)) + fprintln(Out, string(data)) } else { for _, f := range folders { - fmt.Fprintln(Out, f.Name) + fprintln(Out, f.Name) } } diff --git a/cli/list.go b/cli/list.go index 2a3777f2..3e044afa 100644 --- a/cli/list.go +++ b/cli/list.go @@ -22,7 +22,7 @@ func RunList(args []string) error { from := fs.String("from", "", "Email address of the account to use") jsonOutput := fs.Bool("json", false, "Output in JSON format") fs.Usage = func() { - fmt.Fprintln(ErrOut, "Usage: matcha list [folder] [--from ] [--json]") + fprintln(ErrOut, "Usage: matcha list [folder] [--from ] [--json]") fs.PrintDefaults() } positionals, err := parseInterspersed(fs, args) @@ -35,7 +35,7 @@ func RunList(args []string) error { return err } - folder := "INBOX" + folder := inboxFolder if len(positionals) > 0 { folder = positionals[0] } @@ -43,7 +43,7 @@ func RunList(args []string) error { // Per design constraints, autoStart is always false for CLI commands. svc := NewServiceFunc(cfg, false) - defer svc.Close() + defer func() { _ = svc.Close() }() // Using a hardcoded limit of 50 as per the spec. emails, err := svc.FetchEmails(account.ID, folder, 50, 0) diff --git a/cli/main.go b/cli/main.go index f3f134ca..f4bb8a6b 100644 --- a/cli/main.go +++ b/cli/main.go @@ -10,10 +10,8 @@ import ( // Package-level hookable variables for unit testing var ( - configLoadConfig = config.LoadConfig - NewServiceFunc = func(cfg *config.Config, autoStart bool) daemonclient.Service { - return daemonclient.NewCLIClient(cfg, autoStart) - } - Out io.Writer = os.Stdout - ErrOut io.Writer = os.Stderr + configLoadConfig = config.LoadConfig + NewServiceFunc = daemonclient.NewCLIClient + Out io.Writer = os.Stdout + ErrOut io.Writer = os.Stderr ) diff --git a/cli/manage.go b/cli/manage.go index 1cf4d736..81a6ffe4 100644 --- a/cli/manage.go +++ b/cli/manage.go @@ -7,6 +7,15 @@ import ( "strings" ) +// Management subcommand names. +const ( + cmdArchive = "archive" + cmdDelete = "delete" + cmdMarkRead = "mark-read" + cmdMarkUnread = "mark-unread" + cmdMove = "move" +) + func parseUIDs(s string) ([]uint32, error) { s = strings.ReplaceAll(s, ",", " ") parts := strings.Fields(s) @@ -42,14 +51,14 @@ func RunManage(args []string) error { fs.SetOutput(ErrOut) from := fs.String("from", "", "Email address of the account to use") fs.Usage = func() { - fmt.Fprintln(ErrOut, "Usage:") - fmt.Fprintln(ErrOut, " matcha archive [--from ] ") - fmt.Fprintln(ErrOut, " matcha delete [--from ] ") - fmt.Fprintln(ErrOut, " matcha mark-read [--from ] ") - fmt.Fprintln(ErrOut, " matcha mark-unread [--from ] ") - fmt.Fprintln(ErrOut, " matcha move [--from ] ") - fmt.Fprintln(ErrOut, "") - fmt.Fprintln(ErrOut, " is a comma- or space-separated list of UIDs (e.g. 1,2,3).") + fprintln(ErrOut, "Usage:") + fprintln(ErrOut, " matcha archive [--from ] ") + fprintln(ErrOut, " matcha delete [--from ] ") + fprintln(ErrOut, " matcha mark-read [--from ] ") + fprintln(ErrOut, " matcha mark-unread [--from ] ") + fprintln(ErrOut, " matcha move [--from ] ") + fprintln(ErrOut, "") + fprintln(ErrOut, " is a comma- or space-separated list of UIDs (e.g. 1,2,3).") fs.PrintDefaults() } positionals, err := parseInterspersed(fs, rest) @@ -63,10 +72,10 @@ func RunManage(args []string) error { } svc := NewServiceFunc(cfg, false) - defer svc.Close() + defer func() { _ = svc.Close() }() switch subcommand { - case "archive", "delete", "mark-read", "mark-unread": + case cmdArchive, cmdDelete, cmdMarkRead, cmdMarkUnread: if len(positionals) < 2 { return fmt.Errorf("folder and uids are required") } @@ -80,30 +89,30 @@ func RunManage(args []string) error { } switch subcommand { - case "archive": + case cmdArchive: err = svc.ArchiveEmails(account.ID, folder, uids) if err == nil { - fmt.Fprintf(Out, "Success: Archived %s.\n", pluralizeEmails(len(uids))) + fprintf(Out, "Success: Archived %s.\n", pluralizeEmails(len(uids))) } - case "delete": + case cmdDelete: err = svc.DeleteEmails(account.ID, folder, uids) if err == nil { - fmt.Fprintf(Out, "Success: Deleted %s.\n", pluralizeEmails(len(uids))) + fprintf(Out, "Success: Deleted %s.\n", pluralizeEmails(len(uids))) } - case "mark-read": + case cmdMarkRead: err = svc.MarkRead(account.ID, folder, uids) if err == nil { - fmt.Fprintf(Out, "Success: Marked %s as read.\n", pluralizeEmails(len(uids))) + fprintf(Out, "Success: Marked %s as read.\n", pluralizeEmails(len(uids))) } - case "mark-unread": + case cmdMarkUnread: err = svc.MarkUnread(account.ID, folder, uids) if err == nil { - fmt.Fprintf(Out, "Success: Marked %s as unread.\n", pluralizeEmails(len(uids))) + fprintf(Out, "Success: Marked %s as unread.\n", pluralizeEmails(len(uids))) } } return err - case "move": + case cmdMove: if len(positionals) < 3 { return fmt.Errorf("source_folder, destination_folder, and uids are required") } @@ -118,7 +127,7 @@ func RunManage(args []string) error { } err = svc.MoveEmails(account.ID, uids, srcFolder, dstFolder) if err == nil { - fmt.Fprintf(Out, "Success: Moved %s to %s.\n", pluralizeEmails(len(uids)), dstFolder) + fprintf(Out, "Success: Moved %s to %s.\n", pluralizeEmails(len(uids)), dstFolder) } return err diff --git a/cli/read.go b/cli/read.go index 53f4074a..90065482 100644 --- a/cli/read.go +++ b/cli/read.go @@ -23,7 +23,7 @@ func RunRead(args []string) error { from := fs.String("from", "", "Email address of the account to use") jsonOutput := fs.Bool("json", false, "Output in JSON format") fs.Usage = func() { - fmt.Fprintln(ErrOut, "Usage: matcha read [--from ] [--json] ") + fprintln(ErrOut, "Usage: matcha read [--from ] [--json] ") fs.PrintDefaults() } positionals, err := parseInterspersed(fs, args) @@ -39,7 +39,7 @@ func RunRead(args []string) error { // A single positional is treated as a UID in INBOX, but only if it // parses as a number; otherwise the folder was given without a UID. if _, err := strconv.ParseUint(positionals[0], 10, 32); err == nil { - folder = "INBOX" + folder = inboxFolder uidStr = positionals[0] } else { return fmt.Errorf("folder and uid are required") @@ -64,7 +64,7 @@ func RunRead(args []string) error { folder = NormalizeFolder(folder) svc := NewServiceFunc(cfg, false) - defer svc.Close() + defer func() { _ = svc.Close() }() body, mime, attachments, err := svc.FetchEmailBody(account.ID, folder, uid) if err != nil { @@ -82,6 +82,6 @@ func RunRead(args []string) error { return encoder.Encode(output) } - fmt.Fprint(Out, body) + fprint(Out, body) return nil } diff --git a/cli/search.go b/cli/search.go index dafda956..d2948083 100644 --- a/cli/search.go +++ b/cli/search.go @@ -15,7 +15,7 @@ func RunSearch(args []string) error { from := fs.String("from", "", "Email address of the account to use") jsonOutput := fs.Bool("json", false, "Output in JSON format") fs.Usage = func() { - fmt.Fprintln(ErrOut, "Usage: matcha search [--from ] [--json] ") + fprintln(ErrOut, "Usage: matcha search [--from ] [--json] ") fs.PrintDefaults() } positionals, err := parseInterspersed(fs, args) @@ -39,7 +39,7 @@ func RunSearch(args []string) error { folder = NormalizeFolder(folder) svc := NewServiceFunc(cfg, false) - defer svc.Close() + defer func() { _ = svc.Close() }() query := backend.ParseSearchQuery(queryString) emails, err := svc.Search(account.ID, folder, query) diff --git a/cli/util.go b/cli/util.go index 4d924ab9..290a54a7 100644 --- a/cli/util.go +++ b/cli/util.go @@ -4,15 +4,37 @@ import ( "encoding/json" "flag" "fmt" + "io" "strings" "text/tabwriter" "github.com/floatpane/matcha/backend" "github.com/floatpane/matcha/config" "github.com/floatpane/matcha/i18n" + + // Register the bundled i18n translation catalogs via their init functions. _ "github.com/floatpane/matcha/i18n/languages" ) +// inboxFolder is the canonical name of the default mailbox. +const inboxFolder = "INBOX" + +// fprintln writes a line to w. Failed writes to the CLI's stdout/stderr are +// not actionable, so the error is intentionally discarded. +func fprintln(w io.Writer, a ...any) { + _, _ = fmt.Fprintln(w, a...) +} + +// fprintf is the fmt.Fprintf counterpart of fprintln; see its note on errors. +func fprintf(w io.Writer, format string, a ...any) { + _, _ = fmt.Fprintf(w, format, a...) +} + +// fprint is the fmt.Fprint counterpart of fprintln; see its note on errors. +func fprint(w io.Writer, a ...any) { + _, _ = fmt.Fprint(w, a...) +} + // resolveAccount centralizes the logic for loading the config and selecting an account. // It returns the loaded config and the selected account, or an error. func resolveAccount(fromEmail string) (*config.Config, *config.Account, error) { @@ -25,14 +47,14 @@ func resolveAccount(fromEmail string) (*config.Config, *config.Account, error) { // GetManager() auto-initializes with English if not already initialized if i18n.GetManager() == nil { if err := i18n.Init("en"); err != nil { - fmt.Fprintf(ErrOut, "warning: failed to initialize i18n: %v\n", err) + fprintf(ErrOut, "warning: failed to initialize i18n: %v\n", err) } } if manager := i18n.GetManager(); manager != nil { lang := i18n.DetectLanguage(cfg) if err := manager.SetLanguage(lang); err != nil { - fmt.Fprintf(ErrOut, "warning: failed to set i18n language: %v\n", err) + fprintf(ErrOut, "warning: failed to set i18n language: %v\n", err) } } @@ -88,13 +110,13 @@ func printEmails(emails []backend.Email, jsonOutput bool) error { // Text output w := tabwriter.NewWriter(Out, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "UID\tFROM\tSUBJECT\tDATE\tREAD") + fprintln(w, "UID\tFROM\tSUBJECT\tDATE\tREAD") for _, email := range emails { readStatus := " " if email.IsRead { readStatus = "✔" } - fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", + fprintf(w, "%d\t%s\t%s\t%s\t%s\n", email.UID, sanitizeTextForTable(email.From), sanitizeTextForTable(email.Subject), @@ -108,7 +130,7 @@ func printEmails(emails []backend.Email, jsonOutput bool) error { // NormalizeFolder normalizes a folder name, mapping empty or case-insensitive "inbox" to "INBOX". func NormalizeFolder(folder string) string { if folder == "" || strings.EqualFold(folder, "inbox") { - return "INBOX" + return inboxFolder } return folder }