outgoing-cred-cmd: delay execution until an email needs to be sent

This can be useful in cases when:

1. outgoing-cred-cmd requires a user action or confirmation (e.g. when
   using pass with a Yubikey or similar smart card that requires a user
   to enter a pin or touch the device when decrypting the password)

2. A user starts aerc frequently, but not all the sessions end up with
   sending emails

3. So the user only wants to execute outgoing-cred-cmd when the password
   is really used, so the user doesn't have to enter pin or touch their
   Yubikey each time aerc starts

Signed-off-by: Stas Rudakou <stas@garage22.net>
Acked-by: Robin Jarry <robin@jarry.cc>
This commit is contained in:
Stas Rudakou 2022-07-30 22:42:49 +02:00 committed by Robin Jarry
parent e73e5065b0
commit ca90343850
3 changed files with 60 additions and 45 deletions

View file

@ -51,7 +51,11 @@ func (Send) Execute(aerc *widgets.Aerc, args []string) error {
tabName := tab.Name tabName := tab.Name
config := composer.Config() config := composer.Config()
if config.Outgoing == "" { outgoing, err := config.Outgoing.ConnectionString()
if err != nil {
return errors.Wrap(err, "ReadCredentials(outgoing)")
}
if outgoing == "" {
return errors.New( return errors.New(
"No outgoing mail transport configured for this account") "No outgoing mail transport configured for this account")
} }
@ -74,7 +78,7 @@ func (Send) Execute(aerc *widgets.Aerc, args []string) error {
return errors.Wrap(err, "ParseAddress(config.From)") return errors.Wrap(err, "ParseAddress(config.From)")
} }
uri, err := url.Parse(config.Outgoing) uri, err := url.Parse(outgoing)
if err != nil { if err != nil {
return errors.Wrap(err, "url.Parse(outgoing)") return errors.Wrap(err, "url.Parse(outgoing)")
} }

View file

@ -95,6 +95,50 @@ const (
FILTER_HEADER FILTER_HEADER
) )
type RemoteConfig struct {
Value string
PasswordCmd string
}
func (c RemoteConfig) parseValue() (*url.URL, error) {
return url.Parse(c.Value)
}
func (c RemoteConfig) ConnectionString() (string, error) {
if c.Value == "" || c.PasswordCmd == "" {
return c.Value, nil
}
u, err := c.parseValue()
if err != nil {
return "", err
}
// ignore the command if a password is specified
if _, exists := u.User.Password(); exists {
return c.Value, nil
}
// don't attempt to parse the command if the url is a path (ie /usr/bin/sendmail)
if !u.IsAbs() {
return c.Value, nil
}
cmd := exec.Command("sh", "-c", c.PasswordCmd)
cmd.Stdin = os.Stdin
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to read password: %s", err)
}
pw := strings.TrimSpace(string(output))
u.User = url.UserPassword(u.User.Username(), pw)
c.Value = u.String()
c.PasswordCmd = ""
return c.Value, nil
}
type AccountConfig struct { type AccountConfig struct {
Archive string Archive string
CopyTo string CopyTo string
@ -104,12 +148,10 @@ type AccountConfig struct {
Aliases string Aliases string
Name string Name string
Source string Source string
SourceCredCmd string
Folders []string Folders []string
FoldersExclude []string FoldersExclude []string
Params map[string]string Params map[string]string
Outgoing string Outgoing RemoteConfig
OutgoingCredCmd string
SignatureFile string SignatureFile string
SignatureCmd string SignatureCmd string
EnableFoldersSort bool `ini:"enable-folders-sort"` EnableFoldersSort bool `ini:"enable-folders-sort"`
@ -239,6 +281,7 @@ func loadAccountConfig(path string) ([]AccountConfig, error) {
continue continue
} }
sec := file.Section(_sec) sec := file.Section(_sec)
sourceRemoteConfig := RemoteConfig{}
account := AccountConfig{ account := AccountConfig{
Archive: "Archive", Archive: "Archive",
Default: "INBOX", Default: "INBOX",
@ -260,12 +303,14 @@ func loadAccountConfig(path string) ([]AccountConfig, error) {
folders := strings.Split(val, ",") folders := strings.Split(val, ",")
sort.Strings(folders) sort.Strings(folders)
account.FoldersExclude = folders account.FoldersExclude = folders
} else if key == "source" {
sourceRemoteConfig.Value = val
} else if key == "source-cred-cmd" { } else if key == "source-cred-cmd" {
account.SourceCredCmd = val sourceRemoteConfig.PasswordCmd = val
} else if key == "outgoing" { } else if key == "outgoing" {
account.Outgoing = val account.Outgoing.Value = val
} else if key == "outgoing-cred-cmd" { } else if key == "outgoing-cred-cmd" {
account.OutgoingCredCmd = val account.Outgoing.PasswordCmd = val
} else if key == "from" { } else if key == "from" {
account.From = val account.From = val
} else if key == "aliases" { } else if key == "aliases" {
@ -295,56 +340,22 @@ func loadAccountConfig(path string) ([]AccountConfig, error) {
return nil, fmt.Errorf("Expected from for account %s", _sec) return nil, fmt.Errorf("Expected from for account %s", _sec)
} }
source, err := parseCredential(account.Source, account.SourceCredCmd) source, err := sourceRemoteConfig.ConnectionString()
if err != nil { if err != nil {
return nil, fmt.Errorf("Invalid source credentials for %s: %s", _sec, err) return nil, fmt.Errorf("Invalid source credentials for %s: %s", _sec, err)
} }
account.Source = source account.Source = source
outgoing, err := parseCredential(account.Outgoing, account.OutgoingCredCmd) _, err = account.Outgoing.parseValue()
if err != nil { if err != nil {
return nil, fmt.Errorf("Invalid outgoing credentials for %s: %s", _sec, err) return nil, fmt.Errorf("Invalid outgoing credentials for %s: %s", _sec, err)
} }
account.Outgoing = outgoing
accounts = append(accounts, account) accounts = append(accounts, account)
} }
return accounts, nil return accounts, nil
} }
func parseCredential(cred, command string) (string, error) {
if cred == "" || command == "" {
return cred, nil
}
u, err := url.Parse(cred)
if err != nil {
return "", err
}
// ignore the command if a password is specified
if _, exists := u.User.Password(); exists {
return cred, nil
}
// don't attempt to parse the command if the url is a path (ie /usr/bin/sendmail)
if !u.IsAbs() {
return cred, nil
}
cmd := exec.Command("sh", "-c", command)
cmd.Stdin = os.Stdin
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to read password: %s", err)
}
pw := strings.TrimSpace(string(output))
u.User = url.UserPassword(u.User.Username(), pw)
return u.String(), nil
}
// Set at build time // Set at build time
var shareDir string var shareDir string

View file

@ -522,7 +522,7 @@ func (wizard *AccountWizard) finish(tutorial bool) {
Default: "INBOX", Default: "INBOX",
From: sec.Key("from").String(), From: sec.Key("from").String(),
Source: sec.Key("source").String(), Source: sec.Key("source").String(),
Outgoing: sec.Key("outgoing").String(), Outgoing: config.RemoteConfig{Value: sec.Key("outgoing").String()},
} }
if wizard.smtpMode == SMTP_STARTTLS { if wizard.smtpMode == SMTP_STARTTLS {
account.Params = map[string]string{ account.Params = map[string]string{