feat: support pass commands (#1473)

Drew Smirnoff created

## What?

Adds support for the `pass` commands

## Why?

Closes #684

---------

Signed-off-by: drew <me@andrinoff.com>

Change summary

config/config.go              | 33 +++++++++++++++++-
config/config_test.go         | 65 +++++++++++++++++++++++++++++++++++++
docs/docs/Configuration.md    |  4 ++
docs/docs/Features/PassCmd.md | 59 +++++++++++++++++++++++++++++++++
4 files changed, 159 insertions(+), 2 deletions(-)

Detailed changes

config/config.go 🔗

@@ -1,12 +1,14 @@
 package config
 
 import (
+	"context"
 	"crypto/tls"
 	"encoding/json"
 	"errors"
 	"fmt"
 	"log"
 	"os"
+	"os/exec"
 	"path/filepath"
 	"strings"
 	"sync"
@@ -95,6 +97,9 @@ type Account struct {
 
 	// OAuth2 settings
 	AuthMethod string `json:"auth_method,omitempty"` // "password" (default) or "oauth2"
+	// PassCmd is a shell command whose stdout is used as the password (e.g. "pass show email/user").
+	// When set, the keyring is bypassed and the command is evaluated at startup.
+	PassCmd string `json:"pass_cmd,omitempty"`
 
 	// Multi-protocol settings
 	Protocol     string `json:"protocol,omitempty"`      // "imap" (default), "jmap", or "pop3"
@@ -440,6 +445,7 @@ type secureDiskAccount struct {
 	PGPPIN             string `json:"pgp_pin,omitempty"`
 	PGPSignByDefault   bool   `json:"pgp_sign_by_default,omitempty"`
 	AuthMethod         string `json:"auth_method,omitempty"`
+	PassCmd            string `json:"pass_cmd,omitempty"`
 	Protocol           string `json:"protocol,omitempty"`
 	JMAPEndpoint       string `json:"jmap_endpoint,omitempty"`
 	POP3Server         string `json:"pop3_server,omitempty"`
@@ -476,7 +482,7 @@ func SaveConfig(config *Config) error {
 		// any hint to the user. Log the error as a warning so the misconfiguration
 		// (no keyring backend, locked keyring, etc.) is at least visible. See #616.
 		for _, acc := range config.Accounts {
-			if acc.Password != "" {
+			if acc.Password != "" && acc.PassCmd == "" {
 				if err := keyring.Set(keyringServiceName, acc.Email, acc.Password); err != nil {
 					log.Printf("matcha: failed to store password for %s in keyring: %v", acc.Email, err)
 				}
@@ -516,11 +522,15 @@ func SaveConfig(config *Config) error {
 			PluginSettings:          config.PluginSettings,
 		}
 		for _, acc := range config.Accounts {
+			var securePassword string
+			if acc.PassCmd == "" {
+				securePassword = acc.Password
+			}
 			sdc.Accounts = append(sdc.Accounts, secureDiskAccount{
 				ID:                 acc.ID,
 				Name:               acc.Name,
 				Email:              acc.Email,
-				Password:           acc.Password,
+				Password:           securePassword,
 				ServiceProvider:    acc.ServiceProvider,
 				FetchEmail:         acc.FetchEmail,
 				SendAsEmail:        acc.SendAsEmail,
@@ -538,6 +548,7 @@ func SaveConfig(config *Config) error {
 				PGPPIN:             acc.PGPPIN,
 				PGPSignByDefault:   acc.PGPSignByDefault,
 				AuthMethod:         acc.AuthMethod,
+				PassCmd:            acc.PassCmd,
 				Protocol:           acc.Protocol,
 				JMAPEndpoint:       acc.JMAPEndpoint,
 				POP3Server:         acc.POP3Server,
@@ -601,6 +612,7 @@ func LoadConfig() (*Config, error) {
 		PGPPIN             string `json:"pgp_pin,omitempty"`
 		PGPSignByDefault   bool   `json:"pgp_sign_by_default,omitempty"`
 		AuthMethod         string `json:"auth_method,omitempty"`
+		PassCmd            string `json:"pass_cmd,omitempty"`
 		Protocol           string `json:"protocol,omitempty"`
 		JMAPEndpoint       string `json:"jmap_endpoint,omitempty"`
 		POP3Server         string `json:"pop3_server,omitempty"`
@@ -692,6 +704,7 @@ func LoadConfig() (*Config, error) {
 			PGPKeySource:       rawAcc.PGPKeySource,
 			PGPSignByDefault:   rawAcc.PGPSignByDefault,
 			AuthMethod:         rawAcc.AuthMethod,
+			PassCmd:            rawAcc.PassCmd,
 			Protocol:           rawAcc.Protocol,
 			JMAPEndpoint:       rawAcc.JMAPEndpoint,
 			POP3Server:         rawAcc.POP3Server,
@@ -707,6 +720,13 @@ func LoadConfig() (*Config, error) {
 		}
 
 		switch {
+		case rawAcc.PassCmd != "":
+			// Evaluate the external command and use its stdout as the password.
+			if pwd, err := resolvePassCmd(rawAcc.PassCmd); err != nil {
+				log.Printf("matcha: pass_cmd for %s failed: %v", acc.Email, err)
+			} else {
+				acc.Password = pwd
+			}
 		case secureMode:
 			// In secure mode, passwords and PINs are stored in the encrypted config JSON
 			acc.Password = rawAcc.Password
@@ -746,6 +766,15 @@ func LoadConfig() (*Config, error) {
 	return &config, nil
 }
 
+// resolvePassCmd runs cmd via the shell and returns its trimmed stdout as the password.
+func resolvePassCmd(cmd string) (string, error) {
+	out, err := exec.CommandContext(context.Background(), "sh", "-c", cmd).Output()
+	if err != nil {
+		return "", err
+	}
+	return strings.TrimRight(string(out), "\r\n"), nil
+}
+
 // legacyConfigFormat represents the old single-account configuration format.
 type legacyConfigFormat struct {
 	ServiceProvider string `json:"service_provider"`

config/config_test.go 🔗

@@ -1,6 +1,7 @@
 package config
 
 import (
+	"encoding/json"
 	"os"
 	"path/filepath"
 	"reflect"
@@ -613,3 +614,67 @@ func TestConfigGetDateFormatCustom(t *testing.T) {
 		t.Fatalf("GetDateFormat() = %q, want %q", got, want)
 	}
 }
+
+// TestPassCmd verifies that pass_cmd is persisted to JSON, that the password is resolved
+// from the command at load time, and that no password is written to the keyring.
+func TestPassCmd(t *testing.T) {
+	keyring.MockInit()
+	t.Setenv("HOME", t.TempDir())
+
+	cfg := &Config{
+		Accounts: []Account{
+			{
+				ID:              "pass-id-1",
+				Name:            "PassCmd User",
+				Email:           "pass@example.com",
+				PassCmd:         "echo supersecret",
+				ServiceProvider: "custom",
+				SC:              &SessionCache{},
+			},
+		},
+	}
+
+	if err := SaveConfig(cfg); err != nil {
+		t.Fatalf("SaveConfig() failed: %v", err)
+	}
+
+	// The JSON on disk must contain pass_cmd and must NOT contain a password field.
+	cfgPath, err := configFile()
+	if err != nil {
+		t.Fatalf("configFile() failed: %v", err)
+	}
+	raw, err := os.ReadFile(cfgPath)
+	if err != nil {
+		t.Fatalf("ReadFile() failed: %v", err)
+	}
+	var disk map[string]interface{}
+	if err := json.Unmarshal(raw, &disk); err != nil {
+		t.Fatalf("Unmarshal() failed: %v", err)
+	}
+	accounts := disk["accounts"].([]interface{})
+	diskAcc := accounts[0].(map[string]interface{})
+	if diskAcc["pass_cmd"] != "echo supersecret" {
+		t.Errorf("expected pass_cmd in JSON, got %v", diskAcc["pass_cmd"])
+	}
+	if _, ok := diskAcc["password"]; ok {
+		t.Error("password must not appear in JSON when pass_cmd is set")
+	}
+
+	// Keyring must not have been written for this account.
+	if _, err := keyring.Get(keyringServiceName, "pass@example.com"); err == nil {
+		t.Error("keyring entry must not be created when pass_cmd is set")
+	}
+
+	// On reload, Password must be populated by running the command.
+	loaded, err := LoadConfig()
+	if err != nil {
+		t.Fatalf("LoadConfig() failed: %v", err)
+	}
+	acc := loaded.Accounts[0]
+	if acc.PassCmd != "echo supersecret" {
+		t.Errorf("PassCmd not preserved: got %q", acc.PassCmd)
+	}
+	if acc.Password != "supersecret" {
+		t.Errorf("Password not resolved from pass_cmd: got %q", acc.Password)
+	}
+}

docs/docs/Configuration.md 🔗

@@ -102,3 +102,7 @@ Cache files are automatically refreshed from the server on each app launch and m
 All data files can optionally be encrypted with a password. See [Encryption](/docs/Features/Encryption) for details.
 
 When encryption is enabled, account passwords are stored inside the encrypted `config.json` instead of the OS keyring.
+
+## Password Command
+
+Instead of storing a password in the OS keyring, you can set `pass_cmd` on an account to have matcha fetch the password from an external command (e.g. `pass`, `gopass`, or an age script). See [Password Command](/docs/Features/PassCmd) for details.

docs/docs/Features/PassCmd.md 🔗

@@ -0,0 +1,59 @@
+# Password Command (`pass_cmd`)
+
+Matcha can fetch your account password from an external command rather than the OS keyring. This lets you integrate any CLI-based password manager — [pass](https://www.passwordstore.org/), [gopass](https://github.com/gopasspw/gopass), [age](https://github.com/FiloSottile/age) scripts, or any tool that prints a password to stdout.
+
+This is the same pattern used by [isync (`PassCmd`)](https://isync.sourceforge.io/mbsync.html) and [msmtp (`passwordeval`)](https://marlam.de/msmtp/msmtp.html).
+
+## Configuration
+
+Add `pass_cmd` to an account in `~/.config/matcha/config.json`:
+
+```json
+{
+  "accounts": [
+    {
+      "id": "unique-id-1",
+      "name": "John Doe",
+      "email": "john@example.com",
+      "service_provider": "custom",
+      "imap_server": "imap.example.com",
+      "smtp_server": "smtp.example.com",
+      "pass_cmd": "pass show email/john@example.com"
+    }
+  ]
+}
+```
+
+Matcha runs the command via `sh -c` at startup and uses its stdout (trailing newlines stripped) as the password. The password is never written to `config.json` or the OS keyring.
+
+## Examples
+
+### pass / gopass
+
+```json
+"pass_cmd": "pass show email/john@example.com"
+```
+
+```json
+"pass_cmd": "gopass show -o email/john@example.com"
+```
+
+### age-encrypted file
+
+```json
+"pass_cmd": "age --decrypt -i ~/.age/key.txt ~/.secrets/mail.age"
+```
+
+### Custom script
+
+```json
+"pass_cmd": "/home/john/.local/bin/get-mail-password.sh"
+```
+
+The command can be anything that exits `0` and prints the password to stdout.
+
+## Notes
+
+- **Priority**: `pass_cmd` takes precedence over both the OS keyring and any password stored in a secure (encrypted) config. If `pass_cmd` is set, no other source is consulted.
+- **Errors**: If the command exits non-zero or cannot be found, matcha logs the error and continues with an empty password, which will cause authentication to fail. Check the command works in a shell before adding it to your config.
+- **Encryption compatibility**: `pass_cmd` works alongside [Encryption](/docs/Features/Encryption). The command is stored in the encrypted config, and the resolved password is never written to disk.