feat(cli): add log verbosity flags (#1311)

Matt Van Horn and Matt Van Horn created

## What?

Adds two global CLI flags for log verbosity:

- `--verbose` / `-V` raises the log level to verbose, which emits a
`matcha: loaded config with N account(s)` line once config loads.
Reserved for additional sites later.
- `--debug` raises the log level to debug, which enables the two
existing `debug:` log lines in the email-body cache path so they only
fire when explicitly requested.

A small `internal/loglevel` package holds the level state and the
`Debugf` / `Verbosef` / `Infof` helpers. Level state is
`sync/atomic`-backed so concurrent goroutines stay race-clean. The two
pre-existing `debug:` log sites in `main.go` are routed through
`loglevel.Debugf`.

The existing `-v` / `--version` short-circuit is preserved unchanged.
`--verbose` uses `-V` (capital) as its short form to avoid colliding
with `--version`.

Flags are parsed before subcommand dispatch by a small
`parseGlobalFlags` helper that strips them from `os.Args` so subcommand
parsers don't see them.

Documented in README under a new "Logging" section.

Unit tests cover the level-gating contract: Debugf/Verbosef/Infof each
fire only at their level or higher.

## Why?

Closes #576.

Today there is no way to control matcha's log verbosity from the CLI.
The repo has 106 `log.Printf` call sites, two of which already include a
`debug:` prefix that runs unconditionally. The issue asked for
`--verbose` / `--debug` flags to gate that output. This PR is the
smallest change that wires the flags through a level helper without
re-classifying every existing log line - that broader cleanup can happen
incrementally.

Default behavior is unchanged: 104 `log.Printf` calls still fire at the
default level. Only the two existing `debug:`-prefixed lines and the new
`Verbosef` config-load line are gated.

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>

Change summary

README.md                       | 12 ++++
internal/loglevel/level.go      | 47 ++++++++++++++++
internal/loglevel/level_test.go | 97 +++++++++++++++++++++++++++++++++++
main.go                         | 36 ++++++++++++
4 files changed, 190 insertions(+), 2 deletions(-)

Detailed changes

README.md 🔗

@@ -48,6 +48,18 @@ matcha send --to alice@example.com --subject "Hello" --body "Sent by my AI agent
 
 [Learn more](https://docs.matcha.email/Features/AI_AGENTS)
 
+### Logging
+
+Matcha supports global logging verbosity flags before the main command or subcommand:
+
+```bash
+matcha --verbose              # enable verbose logging
+matcha -V daemon status       # short form for verbose logging
+matcha --debug daemon status  # enable debug logging
+```
+
+The existing `-v` and `--version` flags continue to print the Matcha version.
+
 **AI Rewrite Plugin:** Matcha includes an AI rewrite plugin that allows you to rewrite your email drafts using OpenAI, Ollama, Gemini, or Claude.
 
 [Setup Guide](https://docs.matcha.email/setup-guides/ai-rewrite)

internal/loglevel/level.go 🔗

@@ -0,0 +1,47 @@
+package loglevel
+
+import (
+	"log"
+	"sync/atomic"
+)
+
+type Level int32
+
+const (
+	LevelSilent Level = iota
+	LevelInfo
+	LevelVerbose
+	LevelDebug
+)
+
+var current atomic.Int32
+
+func init() {
+	current.Store(int32(LevelInfo))
+}
+
+func Set(level Level) {
+	current.Store(int32(level))
+}
+
+func Get() Level {
+	return Level(current.Load())
+}
+
+func Debugf(format string, args ...any) {
+	if Get() >= LevelDebug {
+		log.Printf("debug: "+format, args...)
+	}
+}
+
+func Verbosef(format string, args ...any) {
+	if Get() >= LevelVerbose {
+		log.Printf(format, args...)
+	}
+}
+
+func Infof(format string, args ...any) {
+	if Get() >= LevelInfo {
+		log.Printf(format, args...)
+	}
+}

internal/loglevel/level_test.go 🔗

@@ -0,0 +1,97 @@
+package loglevel
+
+import (
+	"bytes"
+	"log"
+	"strings"
+	"testing"
+)
+
+func captureLog(t *testing.T, fn func()) string {
+	t.Helper()
+
+	var buf bytes.Buffer
+	originalOutput := log.Writer()
+	originalFlags := log.Flags()
+	log.SetOutput(&buf)
+	log.SetFlags(0)
+	defer func() {
+		log.SetOutput(originalOutput)
+		log.SetFlags(originalFlags)
+		Set(LevelInfo)
+	}()
+
+	fn()
+
+	return buf.String()
+}
+
+func TestDebugfRequiresDebugLevel(t *testing.T) {
+	for _, level := range []Level{LevelSilent, LevelInfo, LevelVerbose} {
+		Set(level)
+		output := captureLog(t, func() {
+			Debugf("details %d", 1)
+		})
+		if output != "" {
+			t.Fatalf("Debugf wrote at level %v: %q", level, output)
+		}
+	}
+
+	Set(LevelDebug)
+	output := captureLog(t, func() {
+		Debugf("details %d", 1)
+	})
+	if !strings.Contains(output, "debug: details 1") {
+		t.Fatalf("Debugf did not write expected message: %q", output)
+	}
+}
+
+func TestVerbosefRequiresVerboseLevel(t *testing.T) {
+	Set(LevelInfo)
+	output := captureLog(t, func() {
+		Verbosef("more details")
+	})
+	if output != "" {
+		t.Fatalf("Verbosef wrote at info level: %q", output)
+	}
+
+	for _, level := range []Level{LevelVerbose, LevelDebug} {
+		Set(level)
+		output := captureLog(t, func() {
+			Verbosef("more details")
+		})
+		if !strings.Contains(output, "more details") {
+			t.Fatalf("Verbosef did not write at level %v: %q", level, output)
+		}
+	}
+}
+
+func TestInfofRequiresInfoLevel(t *testing.T) {
+	Set(LevelSilent)
+	output := captureLog(t, func() {
+		Infof("hello")
+	})
+	if output != "" {
+		t.Fatalf("Infof wrote at silent level: %q", output)
+	}
+
+	Set(LevelInfo)
+	output = captureLog(t, func() {
+		Infof("hello")
+	})
+	if !strings.Contains(output, "hello") {
+		t.Fatalf("Infof did not write at info level: %q", output)
+	}
+}
+
+func TestSetAndGet(t *testing.T) {
+	Set(LevelDebug)
+	if Get() != LevelDebug {
+		t.Fatalf("Get() = %v, want %v", Get(), LevelDebug)
+	}
+
+	Set(LevelInfo)
+	if Get() != LevelInfo {
+		t.Fatalf("Get() = %v, want %v", Get(), LevelInfo)
+	}
+}

main.go 🔗

@@ -44,6 +44,7 @@ import (
 	"github.com/floatpane/matcha/i18n"
 	_ "github.com/floatpane/matcha/i18n/languages"
 	"github.com/floatpane/matcha/internal/httpclient"
+	"github.com/floatpane/matcha/internal/loglevel"
 	"github.com/floatpane/matcha/notify"
 	"github.com/floatpane/matcha/plugin"
 	"github.com/floatpane/matcha/sender"
@@ -837,7 +838,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 					Attachments:  cachedAttachments,
 				}, m.config.GetBodyCacheThreshold())
 				if err != nil {
-					log.Printf("debug: error caching email body fails (disk full, permission denied) for UID: %d: %v", msg.UID, err)
+					loglevel.Debugf("error caching email body fails (disk full, permission denied) for UID: %d: %v", msg.UID, err)
 				}
 			}()
 		}
@@ -1406,7 +1407,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}, m.config.GetBodyCacheThreshold())
 
 		if err != nil {
-			log.Printf("debug: error caching email body fails (disk full, permission denied) for UID: %d: %v", msg.UID, err)
+			loglevel.Debugf("error caching email body fails (disk full, permission denied) for UID: %d: %v", msg.UID, err)
 		}
 
 		email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox)
@@ -3912,7 +3913,37 @@ func filterUnique(existing, incoming []fetcher.Email) []fetcher.Email {
 	return unique
 }
 
+func parseGlobalFlags(args []string) ([]string, loglevel.Level) {
+	level := loglevel.LevelInfo
+	if len(args) <= 1 {
+		return args, level
+	}
+
+	filtered := make([]string, 0, len(args))
+	filtered = append(filtered, args[0])
+
+	for i := 1; i < len(args); i++ {
+		switch args[i] {
+		case "--debug":
+			level = loglevel.LevelDebug
+		case "--verbose", "-V":
+			if level < loglevel.LevelVerbose {
+				level = loglevel.LevelVerbose
+			}
+		default:
+			filtered = append(filtered, args[i:]...)
+			return filtered, level
+		}
+	}
+
+	return filtered, level
+}
+
 func main() {
+	args, level := parseGlobalFlags(os.Args)
+	os.Args = args
+	loglevel.Set(level)
+
 	// If invoked with version flag, print version and exit
 	if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") {
 		fmt.Printf("matcha version %s", version)
@@ -4037,6 +4068,7 @@ func main() {
 	} else {
 		cfg, err := config.LoadConfig()
 		if err == nil {
+			loglevel.Verbosef("matcha: loaded config with %d account(s)", len(cfg.GetAccountIDs()))
 			if migrateErr := config.MigrateContactsCacheUsage(cfg.GetAccountIDs()); migrateErr != nil {
 				log.Printf("warning: contacts migration failed: %v", migrateErr)
 			}