Files
emcli/internal/cli/admin.go
T
2026-06-23 20:29:37 +01:00

352 lines
10 KiB
Go

package cli
import (
"flag"
"fmt"
"io"
"strconv"
"git.dcglab.co.uk/steve/emcli/internal/store"
"git.dcglab.co.uk/steve/emcli/internal/tui"
)
// runAccount handles `account add|list`. Human-readable output (never JSON).
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, list")
if len(args) > 0 {
return 0 // explicit --help
}
return 2
}
sub, rest := args[0], args[1:]
st, err := openStore(role)
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
defer st.Close()
switch sub {
case "add":
if len(rest) == 0 { // no flags → interactive TUI form
return addInteractive(st, tui.Fields{}, out, errOut)
}
fs := flag.NewFlagSet("account add", flag.ContinueOnError)
fs.SetOutput(errOut)
name := fs.String("name", "", "account name")
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")
wlIn := fs.Bool("whitelist-in", false, "enable inbound whitelist")
wlOut := fs.Bool("whitelist-out", false, "enable outbound whitelist")
backlog := fs.Bool("process-backlog", false, "treat existing mail as new")
if err := fs.Parse(rest); err != nil {
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, WhitelistInEnabled: *wlIn, WhitelistOutEnabled: *wlOut,
ProcessBacklog: *backlog,
}
if *mode == "RW" {
acc.SMTPHost, acc.SMTPPort, acc.SMTPSecurity = *smtpHost, *smtpPort, *smtpSec
}
_, err := st.AddAccount(acc)
if 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":
fs := flag.NewFlagSet("account edit", flag.ContinueOnError)
fs.SetOutput(errOut)
name := fs.String("name", "", "account name (required)")
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(rest); err != nil {
return 2
}
if *name == "" {
fmt.Fprintln(errOut, "--name is required")
return 2
}
if fs.NFlag() == 1 { // only --name → interactive TUI form, prefilled
return editInteractive(st, *name, out, errOut)
}
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
}
// Overlay only the flags the user actually set.
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
}
})
// acc.Password holds the existing (decrypted) password from GetAccount; the
// Visit above overwrites it only when --password was passed. UpdateAccount
// re-seals whatever non-empty value is present, so the password is preserved.
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":
fs := flag.NewFlagSet("account remove", flag.ContinueOnError)
fs.SetOutput(errOut)
name := fs.String("name", "", "account name (required)")
yes := fs.Bool("yes", false, "skip confirmation")
if err := fs.Parse(rest); err != nil {
return 2
}
if *name == "" {
fmt.Fprintln(errOut, "--name is required")
return 2
}
if !*yes {
fmt.Fprintf(errOut, "refusing to remove %q without --yes\n", *name)
return 2
}
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 "list":
accs, err := st.ListAccounts()
if err != nil {
fmt.Fprintf(errOut, "list: %v\n", err)
return 1
}
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\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 set <key> <value>` and `config get <key>`.
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
}
if len(args) < 2 {
fmt.Fprintln(errOut, "usage: emcli config <set|get> <key> [value]")
return 2
}
sub, key := args[0], args[1]
st, err := openStore(role)
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
defer st.Close()
switch sub {
case "set":
if len(args) < 3 {
fmt.Fprintln(errOut, "usage: emcli config set <key> <value>")
return 2
}
value := args[2]
if key == "audit_retention_days" {
n, err := strconv.Atoi(value)
if err != nil || n < 0 {
fmt.Fprintf(errOut, "audit_retention_days must be an integer >= 0, got %q\n", value)
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
case "get":
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
default:
fmt.Fprintf(errOut, "unknown config subcommand %q\n", sub)
return 2
}
}
// runAudit handles `audit list [--account <name>] [--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 || args[0] != "list" {
fmt.Fprintln(errOut, "usage: emcli audit list [--account <name>] [--limit N]")
return 2
}
fs := flag.NewFlagSet("audit list", flag.ContinueOnError)
fs.SetOutput(errOut)
account := fs.String("account", "", "filter by account")
limit := fs.Int("limit", 50, "max rows")
if err := fs.Parse(args[1:]); err != nil {
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
}
// runWhitelist handles `whitelist <in|out> add --account NAME --address A`.
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
}
if len(args) < 2 {
fmt.Fprintln(errOut, "usage: emcli whitelist <in|out> <add|remove|list> [flags]")
return 2
}
dir := store.Direction(args[0])
sub, rest := args[1], args[2:]
fs := flag.NewFlagSet("whitelist", flag.ContinueOnError)
fs.SetOutput(errOut)
account := fs.String("account", "", "account name")
address := fs.String("address", "", "email or @domain")
if err := fs.Parse(rest); err != nil {
return 2
}
if *account == "" {
fmt.Fprintln(errOut, "--account is required")
return 2
}
st, err := openStore(role)
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
defer st.Close()
switch sub {
case "add":
if err := st.AddWhitelist(*account, dir, *address); err != nil {
fmt.Fprintf(errOut, "add: %v\n", err)
return 1
}
fmt.Fprintf(out, "added %s to %s whitelist of %q\n", *address, dir, *account)
case "remove":
if err := st.RemoveWhitelist(*account, dir, *address); err != nil {
fmt.Fprintf(errOut, "remove: %v\n", err)
return 1
}
fmt.Fprintf(out, "removed %s\n", *address)
case "list":
addrs, err := st.ListWhitelist(*account, dir)
if err != nil {
fmt.Fprintf(errOut, "list: %v\n", err)
return 1
}
for _, a := range addrs {
fmt.Fprintln(out, a)
}
default:
fmt.Fprintf(errOut, "unknown whitelist subcommand %q\n", sub)
return 2
}
return 0
}