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