c99eaedafd
Adds the `send` agent command and everything behind it: - store: Account carries SMTP host/port/security (NULL-safe scan/insert/select); admin `account add` gains --smtp-* flags (applied for RW accounts). - policy: OutboundRule.Check(recipients) → (ok, reason); RO ⇒ ro_mode, whitelist-out blocks the whole send if any recipient fails (no partial send). - mail: Header.References; OutgoingMessage + BuildMIME (plain text + attachments, In-Reply-To/References threading, Bcc envelope-only); SendSMTP (tls/starttls, SASL PLAIN, envelope send) via emersion/go-smtp. - cli: SendCmd gates outbound, resolves --reply-to under the inbound filter (filtered/absent source ⇒ not_found), reads attachments, audits, emits the JSON envelope; repeatable --to/--cc/--bcc/--attach flags wired into the router. Implemented test-first; full suite passes incl -race. Validated live against friday.mxlogin.com: real send to me@stevecliff.com, RO + whitelist-out blocks, and --reply-to threading off a live INBOX message. test-creds.md gitignored. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
138 lines
4.0 KiB
Go
138 lines
4.0 KiB
Go
package cli
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
|
|
"git.dcglab.co.uk/steve/emcli/internal/store"
|
|
)
|
|
|
|
// runAccount handles `account add|list`. Human-readable output (never JSON).
|
|
func runAccount(args []string, out, errOut io.Writer) int {
|
|
if len(args) == 0 {
|
|
fmt.Fprintln(errOut, "usage: emcli account <add|list>")
|
|
return 2
|
|
}
|
|
sub, rest := args[0], args[1:]
|
|
st, err := openStore()
|
|
if err != nil {
|
|
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
|
return 1
|
|
}
|
|
defer st.Close()
|
|
|
|
switch sub {
|
|
case "add":
|
|
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")
|
|
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
|
|
}
|
|
acc := store.Account{
|
|
Name: *name, Mode: *mode, IMAPHost: *host, IMAPPort: *port, IMAPSecurity: *sec,
|
|
AuthType: "password", Username: *user, Password: *pass,
|
|
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 "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
|
|
}
|
|
}
|
|
|
|
// runWhitelist handles `whitelist <in|out> add --account NAME --address A`.
|
|
func runWhitelist(args []string, out, errOut io.Writer) int {
|
|
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()
|
|
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
|
|
}
|