5476c04443
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
527 lines
15 KiB
Go
527 lines
15 KiB
Go
package cli
|
|
|
|
import (
|
|
"bufio"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/mattn/go-isatty"
|
|
|
|
"git.dcglab.co.uk/steve/emcli/internal/crypto"
|
|
"git.dcglab.co.uk/steve/emcli/internal/policy"
|
|
"git.dcglab.co.uk/steve/emcli/internal/store"
|
|
"git.dcglab.co.uk/steve/emcli/internal/tui"
|
|
)
|
|
|
|
// confirmRemoval prompts on a TTY for a y/N answer. Non-TTY callers never reach
|
|
// here (the caller requires --yes when stdin is not a terminal).
|
|
func confirmRemoval(name string, out io.Writer) bool {
|
|
fmt.Fprintf(out, "Remove account %q? [y/N]: ", name)
|
|
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
|
line = strings.ToLower(strings.TrimSpace(line))
|
|
return line == "y" || line == "yes"
|
|
}
|
|
|
|
// runAccount handles `account <add|edit|remove|show|list>`. Human-readable
|
|
// output (except the agent-only reduced-JSON branch of `list`).
|
|
func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
|
|
if len(args) == 0 || helpRequested(args[0]) {
|
|
printCmdUsage(out, "account")
|
|
fmt.Fprintln(out, "\nSubcommands: add, edit, remove, show, list")
|
|
if len(args) > 0 {
|
|
return 0
|
|
}
|
|
return 2
|
|
}
|
|
sub := normalizeVerb(args[0])
|
|
rest := args[1:]
|
|
st, err := openStore(role)
|
|
if err != nil {
|
|
if sub == "list" {
|
|
_ = Failure(CodeConfig, err.Error()).Write(out)
|
|
} else {
|
|
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
|
}
|
|
return 1
|
|
}
|
|
defer st.Close()
|
|
|
|
switch sub {
|
|
case "add":
|
|
if len(rest) == 0 { // no args → interactive TUI form
|
|
return addInteractive(st, tui.Fields{}, out, errOut)
|
|
}
|
|
// Peel a leading positional name (if present) before flag parsing.
|
|
var name string
|
|
if !strings.HasPrefix(rest[0], "-") {
|
|
name, rest = rest[0], rest[1:]
|
|
}
|
|
fs := flag.NewFlagSet("account add", flag.ContinueOnError)
|
|
fs.SetOutput(errOut)
|
|
mode := fs.String("mode", "RO", "RO|RW")
|
|
host := fs.String("imap-host", "", "IMAP host")
|
|
port := fs.Int("imap-port", 993, "IMAP port")
|
|
sec := fs.String("imap-security", "tls", "tls|starttls")
|
|
smtpHost := fs.String("smtp-host", "", "SMTP host (RW accounts)")
|
|
smtpPort := fs.Int("smtp-port", 465, "SMTP port")
|
|
smtpSec := fs.String("smtp-security", "tls", "tls|starttls")
|
|
user := fs.String("username", "", "login username")
|
|
pass := fs.String("password", "", "login password")
|
|
from := fs.String("from", "", "send-as address (blank = use username)")
|
|
subj := fs.String("subject-regex", "", "inbound subject filter")
|
|
backlog := fs.Bool("process-backlog", false, "treat existing mail as new")
|
|
if err := fs.Parse(rest); err != nil {
|
|
return 2
|
|
}
|
|
if fs.NArg() > 0 {
|
|
fmt.Fprintf(errOut, "unexpected argument %q\n", fs.Arg(0))
|
|
return 2
|
|
}
|
|
if name == "" || *host == "" || *user == "" {
|
|
fmt.Fprintln(errOut, "name, --imap-host, and --username are required")
|
|
return 2
|
|
}
|
|
if err := tui.ValidFromAddress(*from); err != nil {
|
|
fmt.Fprintln(errOut, err)
|
|
return 2
|
|
}
|
|
acc := store.Account{
|
|
Name: name, Mode: *mode, IMAPHost: *host, IMAPPort: *port, IMAPSecurity: *sec,
|
|
AuthType: "password", Username: *user, Password: *pass,
|
|
FromAddress: *from, SubjectRegex: *subj, ProcessBacklog: *backlog,
|
|
}
|
|
if *mode == "RW" {
|
|
acc.SMTPHost, acc.SMTPPort, acc.SMTPSecurity = *smtpHost, *smtpPort, *smtpSec
|
|
}
|
|
if _, err := st.AddAccount(acc); err != nil {
|
|
fmt.Fprintf(errOut, "add account: %v\n", err)
|
|
return 1
|
|
}
|
|
fmt.Fprintf(out, "account %q added (%s)\n", name, *mode)
|
|
return 0
|
|
case "edit":
|
|
if len(rest) == 0 || strings.HasPrefix(rest[0], "-") {
|
|
fmt.Fprintln(errOut, "usage: emcli account edit <name> [flags]")
|
|
return 2
|
|
}
|
|
name := rest[0]
|
|
flagArgs := rest[1:]
|
|
if len(flagArgs) == 0 { // only name → interactive prefilled form
|
|
return editInteractive(st, name, out, errOut)
|
|
}
|
|
fs := flag.NewFlagSet("account edit", flag.ContinueOnError)
|
|
fs.SetOutput(errOut)
|
|
mode := fs.String("mode", "", "RO|RW")
|
|
host := fs.String("imap-host", "", "IMAP host")
|
|
port := fs.Int("imap-port", 0, "IMAP port")
|
|
sec := fs.String("imap-security", "", "tls|starttls")
|
|
smtpHost := fs.String("smtp-host", "", "SMTP host")
|
|
smtpPort := fs.Int("smtp-port", 0, "SMTP port")
|
|
smtpSec := fs.String("smtp-security", "", "tls|starttls")
|
|
user := fs.String("username", "", "login username")
|
|
pass := fs.String("password", "", "login password (blank keeps existing)")
|
|
from := fs.String("from", "", "send-as address (empty reverts to username)")
|
|
subj := fs.String("subject-regex", "", "inbound subject filter")
|
|
if err := fs.Parse(flagArgs); err != nil {
|
|
return 2
|
|
}
|
|
if fs.NArg() > 0 {
|
|
fmt.Fprintf(errOut, "unexpected argument %q\n", fs.Arg(0))
|
|
return 2
|
|
}
|
|
if err := tui.ValidFromAddress(*from); err != nil {
|
|
fmt.Fprintln(errOut, err)
|
|
return 2
|
|
}
|
|
acc, err := st.GetAccount(name)
|
|
if err != nil {
|
|
fmt.Fprintf(errOut, "edit: %v\n", err)
|
|
return 1
|
|
}
|
|
fs.Visit(func(f *flag.Flag) {
|
|
switch f.Name {
|
|
case "mode":
|
|
acc.Mode = *mode
|
|
case "imap-host":
|
|
acc.IMAPHost = *host
|
|
case "imap-port":
|
|
acc.IMAPPort = *port
|
|
case "imap-security":
|
|
acc.IMAPSecurity = *sec
|
|
case "smtp-host":
|
|
acc.SMTPHost = *smtpHost
|
|
case "smtp-port":
|
|
acc.SMTPPort = *smtpPort
|
|
case "smtp-security":
|
|
acc.SMTPSecurity = *smtpSec
|
|
case "username":
|
|
acc.Username = *user
|
|
case "password":
|
|
acc.Password = *pass
|
|
case "from":
|
|
acc.FromAddress = *from
|
|
case "subject-regex":
|
|
acc.SubjectRegex = *subj
|
|
}
|
|
})
|
|
// GetAccount loaded the existing decrypted password into acc; fs.Visit
|
|
// overwrites acc.Password only when --password was passed; UpdateAccount
|
|
// re-seals whatever non-empty password is present, so omitting --password
|
|
// on edit preserves the existing password unchanged.
|
|
if err := st.UpdateAccount(acc); err != nil {
|
|
fmt.Fprintf(errOut, "edit: %v\n", err)
|
|
return 1
|
|
}
|
|
fmt.Fprintf(out, "account %q updated\n", name)
|
|
return 0
|
|
case "remove":
|
|
if len(rest) == 0 || strings.HasPrefix(rest[0], "-") {
|
|
fmt.Fprintln(errOut, "usage: emcli account remove <name> [--yes]")
|
|
return 2
|
|
}
|
|
name := rest[0]
|
|
fs := flag.NewFlagSet("account remove", flag.ContinueOnError)
|
|
fs.SetOutput(errOut)
|
|
yes := fs.Bool("yes", false, "skip confirmation")
|
|
if err := fs.Parse(rest[1:]); err != nil {
|
|
return 2
|
|
}
|
|
if fs.NArg() > 0 {
|
|
fmt.Fprintf(errOut, "unexpected argument %q\n", fs.Arg(0))
|
|
return 2
|
|
}
|
|
if !*yes {
|
|
if !isatty.IsTerminal(os.Stdin.Fd()) {
|
|
fmt.Fprintf(errOut, "refusing to remove %q without --yes (no terminal for confirmation)\n", name)
|
|
return 2
|
|
}
|
|
if !confirmRemoval(name, out) {
|
|
fmt.Fprintln(out, "aborted")
|
|
return 1
|
|
}
|
|
}
|
|
if err := st.DeleteAccount(name); err != nil {
|
|
fmt.Fprintf(errOut, "remove: %v\n", err)
|
|
return 1
|
|
}
|
|
fmt.Fprintf(out, "account %q removed\n", name)
|
|
return 0
|
|
case "show":
|
|
return accountShow(st, rest, out, errOut)
|
|
case "list":
|
|
if len(rest) > 0 {
|
|
fmt.Fprintf(errOut, "unexpected argument %q\n", rest[0])
|
|
return 2
|
|
}
|
|
_, adminErr := crypto.AdminKeyFromEnv()
|
|
isAdmin := adminErr == nil
|
|
accs, err := st.ListAccounts()
|
|
if err != nil {
|
|
if isAdmin {
|
|
fmt.Fprintf(errOut, "list: %v\n", err)
|
|
} else {
|
|
_ = Failure(CodeDB, err.Error()).Write(out)
|
|
}
|
|
return 1
|
|
}
|
|
if !isAdmin {
|
|
items := make([]map[string]any, 0, len(accs))
|
|
for _, a := range accs {
|
|
items = append(items, map[string]any{
|
|
"name": a.Name, "from": a.SendFrom(), "can_send": a.Mode == "RW",
|
|
})
|
|
}
|
|
_ = Success(map[string]any{"accounts": items}).Write(out)
|
|
return 0
|
|
}
|
|
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n", "NAME", "MODE", "IMAP", "USER")
|
|
for _, a := range accs {
|
|
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n",
|
|
a.Name, a.Mode, fmt.Sprintf("%s:%d", a.IMAPHost, a.IMAPPort), a.Username)
|
|
}
|
|
return 0
|
|
default:
|
|
fmt.Fprintf(errOut, "unknown account subcommand %q (want add|edit|remove|show|list)\n", sub)
|
|
return 2
|
|
}
|
|
}
|
|
|
|
// auditList renders recent audit entries (account "" = all) to out.
|
|
func auditList(st *store.Store, account string, limit int, out io.Writer) error {
|
|
entries, err := st.RecentAuditFor(account, limit)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(out, "%-20s %-12s %-8s %-8s %-20s %s\n",
|
|
"TS", "ACCOUNT", "ACTION", "RESULT", "TARGET", "REASON")
|
|
for _, e := range entries {
|
|
fmt.Fprintf(out, "%-20s %-12s %-8s %-8s %-20s %s\n",
|
|
e.TS, e.Account, e.Action, e.Result, e.Target, e.Reason)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// runConfig handles `config <list|get|set>` against the settings registry.
|
|
func runConfig(args []string, role store.Role, out, errOut io.Writer) int {
|
|
if len(args) == 0 || helpRequested(args[0]) {
|
|
printCmdUsage(out, "config")
|
|
if len(args) > 0 {
|
|
return 0
|
|
}
|
|
return 2
|
|
}
|
|
sub := normalizeVerb(args[0])
|
|
rest := args[1:]
|
|
st, err := openStore(role)
|
|
if err != nil {
|
|
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
|
return 1
|
|
}
|
|
defer st.Close()
|
|
|
|
switch sub {
|
|
case "list":
|
|
if len(rest) > 0 {
|
|
fmt.Fprintf(errOut, "unexpected argument %q\n", rest[0])
|
|
return 2
|
|
}
|
|
fmt.Fprintf(out, "%-22s %-8s %s\n", "KEY", "VALUE", "DESCRIPTION")
|
|
for _, k := range settingKeys() {
|
|
v, err := st.GetSetting(k)
|
|
if err != nil {
|
|
v = "(unset)"
|
|
}
|
|
fmt.Fprintf(out, "%-22s %-8s %s\n", k, v, settingsRegistry[k].desc)
|
|
}
|
|
return 0
|
|
case "get":
|
|
if len(rest) != 1 {
|
|
fmt.Fprintln(errOut, "usage: emcli config get <key>")
|
|
return 2
|
|
}
|
|
key := rest[0]
|
|
if _, ok := settingsRegistry[key]; !ok {
|
|
fmt.Fprintf(errOut, "unknown setting %q (see: emcli config list)\n", key)
|
|
return 2
|
|
}
|
|
v, err := st.GetSetting(key)
|
|
if err != nil {
|
|
fmt.Fprintf(errOut, "config get: %s not set\n", key)
|
|
return 1
|
|
}
|
|
fmt.Fprintf(out, "%s = %s\n", key, v)
|
|
return 0
|
|
case "set":
|
|
if len(rest) != 2 {
|
|
fmt.Fprintln(errOut, "usage: emcli config set <key> <value>")
|
|
return 2
|
|
}
|
|
key, value := rest[0], rest[1]
|
|
def, ok := settingsRegistry[key]
|
|
if !ok {
|
|
fmt.Fprintf(errOut, "unknown setting %q (see: emcli config list)\n", key)
|
|
return 2
|
|
}
|
|
if def.validate != nil {
|
|
if err := def.validate(value); err != nil {
|
|
fmt.Fprintf(errOut, "%s %v\n", key, err)
|
|
return 2
|
|
}
|
|
}
|
|
if err := st.SetSetting(key, value); err != nil {
|
|
fmt.Fprintf(errOut, "config set: %v\n", err)
|
|
return 1
|
|
}
|
|
fmt.Fprintf(out, "%s = %s\n", key, value)
|
|
return 0
|
|
default:
|
|
fmt.Fprintf(errOut, "unknown config subcommand %q (want list|get|set)\n", sub)
|
|
return 2
|
|
}
|
|
}
|
|
|
|
// runAudit handles `audit list [account] [--limit N]`.
|
|
func runAudit(args []string, role store.Role, out, errOut io.Writer) int {
|
|
if len(args) > 0 && helpRequested(args[0]) {
|
|
printCmdUsage(out, "audit")
|
|
return 0
|
|
}
|
|
if len(args) == 0 || normalizeVerb(args[0]) != "list" {
|
|
fmt.Fprintln(errOut, "usage: emcli audit list [account] [--limit N]")
|
|
return 2
|
|
}
|
|
// Peel an optional positional account before flag parsing.
|
|
rest := args[1:]
|
|
var account string
|
|
if len(rest) > 0 && !strings.HasPrefix(rest[0], "-") {
|
|
account, rest = rest[0], rest[1:]
|
|
}
|
|
fs := flag.NewFlagSet("audit list", flag.ContinueOnError)
|
|
fs.SetOutput(errOut)
|
|
limit := fs.Int("limit", 50, "max rows")
|
|
if err := fs.Parse(rest); err != nil {
|
|
return 2
|
|
}
|
|
if fs.NArg() > 0 {
|
|
fmt.Fprintf(errOut, "unexpected argument %q\n", fs.Arg(0))
|
|
return 2
|
|
}
|
|
st, err := openStore(role)
|
|
if err != nil {
|
|
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
|
return 1
|
|
}
|
|
defer st.Close()
|
|
if err := auditList(st, account, *limit, out); err != nil {
|
|
fmt.Fprintf(errOut, "audit list: %v\n", err)
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// flowName renders a direction for human-facing prose.
|
|
func flowName(dir store.Direction) string {
|
|
if dir == store.DirOut {
|
|
return "outbound"
|
|
}
|
|
return "inbound"
|
|
}
|
|
|
|
// runWhitelist handles `whitelist <add|remove|list|enable|disable> <account>
|
|
// [addr…] --in|--out`.
|
|
func runWhitelist(args []string, role store.Role, out, errOut io.Writer) int {
|
|
if len(args) == 0 || helpRequested(args[0]) {
|
|
printCmdUsage(out, "whitelist")
|
|
if len(args) > 0 {
|
|
return 0
|
|
}
|
|
return 2
|
|
}
|
|
sub := normalizeVerb(args[0])
|
|
switch sub {
|
|
case "add", "remove", "list", "enable", "disable": // valid
|
|
default:
|
|
fmt.Fprintf(errOut, "unknown whitelist subcommand %q (want add|remove|list|enable|disable)\n", sub)
|
|
return 2
|
|
}
|
|
|
|
// Split the remaining tokens into the direction flag and positionals.
|
|
var dir store.Direction
|
|
var dirSet bool
|
|
var pos []string
|
|
for _, a := range args[1:] {
|
|
switch a {
|
|
case "--in", "-in":
|
|
dir, dirSet = store.DirIn, true
|
|
case "--out", "-out":
|
|
dir, dirSet = store.DirOut, true
|
|
default:
|
|
if strings.HasPrefix(a, "-") {
|
|
fmt.Fprintf(errOut, "unknown flag %q (use --in or --out)\n", a)
|
|
return 2
|
|
}
|
|
pos = append(pos, a)
|
|
}
|
|
}
|
|
if !dirSet {
|
|
fmt.Fprintln(errOut, "direction is required: pass --in or --out")
|
|
return 2
|
|
}
|
|
if len(pos) == 0 {
|
|
fmt.Fprintln(errOut, "account is required")
|
|
return 2
|
|
}
|
|
account, addrs := pos[0], pos[1:]
|
|
|
|
st, err := openStore(role)
|
|
if err != nil {
|
|
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
|
return 1
|
|
}
|
|
defer st.Close()
|
|
|
|
switch sub {
|
|
case "add", "remove":
|
|
if len(addrs) == 0 {
|
|
fmt.Fprintln(errOut, "at least one address is required")
|
|
return 2
|
|
}
|
|
for _, addr := range addrs {
|
|
if err := policy.ValidWhitelistAddress(addr); err != nil {
|
|
fmt.Fprintln(errOut, err)
|
|
return 2
|
|
}
|
|
}
|
|
for _, addr := range addrs {
|
|
if sub == "add" {
|
|
err = st.AddWhitelist(account, dir, addr)
|
|
} else {
|
|
err = st.RemoveWhitelist(account, dir, addr)
|
|
}
|
|
if err != nil {
|
|
fmt.Fprintf(errOut, "%s: %v\n", sub, err)
|
|
return 1
|
|
}
|
|
}
|
|
verb := "added"
|
|
if sub == "remove" {
|
|
verb = "removed"
|
|
}
|
|
fmt.Fprintf(out, "%s %d address(es) in the %s whitelist of %q\n", verb, len(addrs), dir, account)
|
|
return 0
|
|
case "list":
|
|
if len(addrs) > 0 {
|
|
fmt.Fprintf(errOut, "unexpected argument %q\n", addrs[0])
|
|
return 2
|
|
}
|
|
acc, err := st.GetAccount(account)
|
|
if err != nil {
|
|
fmt.Fprintf(errOut, "list: %v\n", err)
|
|
return 1
|
|
}
|
|
enabled := acc.WhitelistInEnabled
|
|
if dir == store.DirOut {
|
|
enabled = acc.WhitelistOutEnabled
|
|
}
|
|
state := "DISABLED"
|
|
if enabled {
|
|
state = "ENABLED"
|
|
}
|
|
entries, err := st.ListWhitelist(account, dir)
|
|
if err != nil {
|
|
fmt.Fprintf(errOut, "list: %v\n", err)
|
|
return 1
|
|
}
|
|
fmt.Fprintf(out, "%s whitelist of %q: %s\n", dir, account, state)
|
|
for _, a := range entries {
|
|
fmt.Fprintln(out, a)
|
|
}
|
|
return 0
|
|
case "enable", "disable":
|
|
if len(addrs) > 0 {
|
|
fmt.Fprintf(errOut, "unexpected argument %q\n", addrs[0])
|
|
return 2
|
|
}
|
|
enable := sub == "enable"
|
|
if enable {
|
|
if entries, _ := st.ListWhitelist(account, dir); len(entries) == 0 {
|
|
fmt.Fprintf(errOut, "warning: %s whitelist for %q is empty — this blocks ALL %s mail until you add addresses\n", dir, account, flowName(dir))
|
|
}
|
|
}
|
|
if err := st.SetWhitelistEnabled(account, dir, enable); err != nil {
|
|
fmt.Fprintf(errOut, "%s: %v\n", sub, err)
|
|
return 1
|
|
}
|
|
state := "disabled"
|
|
if enable {
|
|
state = "enabled"
|
|
}
|
|
fmt.Fprintf(out, "%s whitelist of %q %s\n", dir, account, state)
|
|
return 0
|
|
}
|
|
return 0
|
|
}
|