Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 13 additions & 13 deletions backend/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion cli/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
40 changes: 20 additions & 20 deletions cli/contacts_export.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
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()
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 *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)
Expand Down Expand Up @@ -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")
fprintln(ErrOut, "No contacts found in cache")
return nil
}

Expand Down Expand Up @@ -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)
fprintf(ErrOut, "Exported %d contacts to %s\n", len(contacts), outputPath)
} else {
fmt.Println(string(outputData))
fprintln(Out, string(outputData))
}

return nil
Expand Down
66 changes: 66 additions & 0 deletions cli/folders.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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() {
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()
}

if err := fs.Parse(args); err != nil {
return err
}

cfg, account, err := resolveAccount(*from)
if err != nil {
return err
}

// Instantiate the client with autoStart = false
svc := NewServiceFunc(cfg, false)
defer func() { _ = 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)
}
fprintln(Out, string(data))
} else {
for _, f := range folders {
fprintln(Out, f.Name)
}
}

return nil
}
162 changes: 162 additions & 0 deletions cli/folders_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
55 changes: 55 additions & 0 deletions cli/list.go
Original file line number Diff line number Diff line change
@@ -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() {
fprintln(ErrOut, "Usage: matcha list [folder] [--from <email>] [--json]")
fs.PrintDefaults()
}
positionals, err := parseInterspersed(fs, args)
if err != nil {
return err
}

cfg, account, err := resolveAccount(*from)
if err != nil {
return err
}

folder := inboxFolder
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 func() { _ = 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)
}
Loading
Loading