@@ -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)
@@ -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...)
+ }
+}
@@ -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)
+ }
+}
@@ -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)
}