1package main
2
3import (
4 "bufio"
5 "bytes"
6 "context"
7 "crypto"
8 "crypto/ecdsa"
9 "crypto/ed25519"
10 "crypto/elliptic"
11 cryptorand "crypto/rand"
12 "crypto/rsa"
13 "crypto/sha256"
14 "crypto/sha512"
15 "crypto/tls"
16 "crypto/x509"
17 "encoding/base64"
18 "encoding/json"
19 "encoding/pem"
20 "errors"
21 "flag"
22 "fmt"
23 "io"
24 "io/fs"
25 "log"
26 "log/slog"
27 "maps"
28 "net"
29 "net/http"
30 "net/url"
31 "os"
32 "path/filepath"
33 "reflect"
34 "runtime"
35 "slices"
36 "strconv"
37 "strings"
38 "time"
39
40 "golang.org/x/crypto/bcrypt"
41 "golang.org/x/text/secure/precis"
42
43 "github.com/mjl-/adns"
44
45 "github.com/mjl-/autocert"
46 "github.com/mjl-/bstore"
47 "github.com/mjl-/sconf"
48 "github.com/mjl-/sherpa"
49
50 "github.com/mjl-/mox/admin"
51 "github.com/mjl-/mox/config"
52 "github.com/mjl-/mox/dane"
53 "github.com/mjl-/mox/dkim"
54 "github.com/mjl-/mox/dmarc"
55 "github.com/mjl-/mox/dmarcdb"
56 "github.com/mjl-/mox/dmarcrpt"
57 "github.com/mjl-/mox/dns"
58 "github.com/mjl-/mox/dnsbl"
59 "github.com/mjl-/mox/message"
60 "github.com/mjl-/mox/mlog"
61 "github.com/mjl-/mox/mox-"
62 "github.com/mjl-/mox/moxio"
63 "github.com/mjl-/mox/moxvar"
64 "github.com/mjl-/mox/mtasts"
65 "github.com/mjl-/mox/publicsuffix"
66 "github.com/mjl-/mox/queue"
67 "github.com/mjl-/mox/rdap"
68 "github.com/mjl-/mox/smtp"
69 "github.com/mjl-/mox/smtpclient"
70 "github.com/mjl-/mox/spf"
71 "github.com/mjl-/mox/store"
72 "github.com/mjl-/mox/tlsrpt"
73 "github.com/mjl-/mox/tlsrptdb"
74 "github.com/mjl-/mox/updates"
75 "github.com/mjl-/mox/webadmin"
76 "github.com/mjl-/mox/webapi"
77)
78
79var (
80 changelogDomain = "xmox.nl"
81 changelogURL = "https://updates.xmox.nl/changelog"
82 changelogPubKey = base64Decode("sPNiTDQzvb4FrytNEiebJhgyQzn57RwEjNbGWMM/bDY=")
83)
84
85func base64Decode(s string) []byte {
86 buf, err := base64.StdEncoding.DecodeString(s)
87 if err != nil {
88 panic(err)
89 }
90 return buf
91}
92
93func envString(k, def string) string {
94 s := os.Getenv(k)
95 if s == "" {
96 return def
97 }
98 return s
99}
100
101var commands = []struct {
102 cmd string
103 fn func(c *cmd)
104}{
105 {"serve", cmdServe},
106 {"quickstart", cmdQuickstart},
107 {"stop", cmdStop},
108 {"setaccountpassword", cmdSetaccountpassword},
109 {"setadminpassword", cmdSetadminpassword},
110 {"loglevels", cmdLoglevels},
111 {"queue holdrules list", cmdQueueHoldrulesList},
112 {"queue holdrules add", cmdQueueHoldrulesAdd},
113 {"queue holdrules remove", cmdQueueHoldrulesRemove},
114 {"queue list", cmdQueueList},
115 {"queue hold", cmdQueueHold},
116 {"queue unhold", cmdQueueUnhold},
117 {"queue schedule", cmdQueueSchedule},
118 {"queue transport", cmdQueueTransport},
119 {"queue requiretls", cmdQueueRequireTLS},
120 {"queue fail", cmdQueueFail},
121 {"queue drop", cmdQueueDrop},
122 {"queue dump", cmdQueueDump},
123 {"queue retired list", cmdQueueRetiredList},
124 {"queue retired print", cmdQueueRetiredPrint},
125 {"queue suppress list", cmdQueueSuppressList},
126 {"queue suppress add", cmdQueueSuppressAdd},
127 {"queue suppress remove", cmdQueueSuppressRemove},
128 {"queue suppress lookup", cmdQueueSuppressLookup},
129 {"queue webhook list", cmdQueueHookList},
130 {"queue webhook schedule", cmdQueueHookSchedule},
131 {"queue webhook cancel", cmdQueueHookCancel},
132 {"queue webhook print", cmdQueueHookPrint},
133 {"queue webhook retired list", cmdQueueHookRetiredList},
134 {"queue webhook retired print", cmdQueueHookRetiredPrint},
135 {"import maildir", cmdImportMaildir},
136 {"import mbox", cmdImportMbox},
137 {"export maildir", cmdExportMaildir},
138 {"export mbox", cmdExportMbox},
139 {"localserve", cmdLocalserve},
140 {"help", cmdHelp},
141 {"backup", cmdBackup},
142 {"verifydata", cmdVerifydata},
143 {"licenses", cmdLicenses},
144
145 {"config test", cmdConfigTest},
146 {"config dnscheck", cmdConfigDNSCheck},
147 {"config dnsrecords", cmdConfigDNSRecords},
148 {"config describe-domains", cmdConfigDescribeDomains},
149 {"config describe-static", cmdConfigDescribeStatic},
150 {"config account list", cmdConfigAccountList},
151 {"config account addresses", cmdConfigAccountAddresses},
152 {"config account add", cmdConfigAccountAdd},
153 {"config account rm", cmdConfigAccountRemove},
154 {"config account disable", cmdConfigAccountDisable},
155 {"config account enable", cmdConfigAccountEnable},
156 {"config address add", cmdConfigAddressAdd},
157 {"config address rm", cmdConfigAddressRemove},
158 {"config address account", cmdConfigAddressAccount},
159 {"config domain add", cmdConfigDomainAdd},
160 {"config domain rm", cmdConfigDomainRemove},
161 {"config domain disable", cmdConfigDomainDisable},
162 {"config domain enable", cmdConfigDomainEnable},
163 {"config tlspubkey list", cmdConfigTlspubkeyList},
164 {"config tlspubkey get", cmdConfigTlspubkeyGet},
165 {"config tlspubkey add", cmdConfigTlspubkeyAdd},
166 {"config tlspubkey rm", cmdConfigTlspubkeyRemove},
167 {"config tlspubkey gen", cmdConfigTlspubkeyGen},
168 {"config alias list", cmdConfigAliasList},
169 {"config alias print", cmdConfigAliasPrint},
170 {"config alias add", cmdConfigAliasAdd},
171 {"config alias update", cmdConfigAliasUpdate},
172 {"config alias rm", cmdConfigAliasRemove},
173 {"config alias addaddr", cmdConfigAliasAddaddr},
174 {"config alias rmaddr", cmdConfigAliasRemoveaddr},
175
176 {"config describe-sendmail", cmdConfigDescribeSendmail},
177 {"config printservice", cmdConfigPrintservice},
178 {"config ensureacmehostprivatekeys", cmdConfigEnsureACMEHostprivatekeys},
179 {"config example", cmdConfigExample},
180
181 {"admin imapserve", cmdIMAPServe},
182
183 {"checkupdate", cmdCheckupdate},
184 {"cid", cmdCid},
185 {"clientconfig", cmdClientConfig},
186 {"deliver", cmdDeliver},
187 // todo: turn cmdDANEDialmx into a regular "dialmx" command that follows mta-sts policy, with options to require dane, mta-sts or requiretls. the code will be similar to queue/direct.go
188 {"dane dial", cmdDANEDial},
189 {"dane dialmx", cmdDANEDialmx},
190 {"dane makerecord", cmdDANEMakeRecord},
191 {"dns lookup", cmdDNSLookup},
192 {"dkim gened25519", cmdDKIMGened25519},
193 {"dkim genrsa", cmdDKIMGenrsa},
194 {"dkim lookup", cmdDKIMLookup},
195 {"dkim txt", cmdDKIMTXT},
196 {"dkim verify", cmdDKIMVerify},
197 {"dkim sign", cmdDKIMSign},
198 {"dmarc lookup", cmdDMARCLookup},
199 {"dmarc parsereportmsg", cmdDMARCParsereportmsg},
200 {"dmarc verify", cmdDMARCVerify},
201 {"dmarc checkreportaddrs", cmdDMARCCheckreportaddrs},
202 {"dnsbl check", cmdDNSBLCheck},
203 {"dnsbl checkhealth", cmdDNSBLCheckhealth},
204 {"mtasts lookup", cmdMTASTSLookup},
205 {"rdap domainage", cmdRDAPDomainage},
206 {"retrain", cmdRetrain},
207 {"sendmail", cmdSendmail},
208 {"smtp dial", cmdSMTPDial},
209 {"spf check", cmdSPFCheck},
210 {"spf lookup", cmdSPFLookup},
211 {"spf parse", cmdSPFParse},
212 {"tlsrpt lookup", cmdTLSRPTLookup},
213 {"tlsrpt parsereportmsg", cmdTLSRPTParsereportmsg},
214 {"version", cmdVersion},
215 {"webapi", cmdWebapi},
216
217 {"example", cmdExample},
218 {"bumpuidvalidity", cmdBumpUIDValidity},
219 {"reassignuids", cmdReassignUIDs},
220 {"fixuidmeta", cmdFixUIDMeta},
221 {"fixmsgsize", cmdFixmsgsize},
222 {"reparse", cmdReparse},
223 {"ensureparsed", cmdEnsureParsed},
224 {"recalculatemailboxcounts", cmdRecalculateMailboxCounts},
225 {"message parse", cmdMessageParse},
226 {"reassignthreads", cmdReassignthreads},
227
228 // Not listed.
229 {"helpall", cmdHelpall},
230 {"junk analyze", cmdJunkAnalyze},
231 {"junk check", cmdJunkCheck},
232 {"junk play", cmdJunkPlay},
233 {"junk test", cmdJunkTest},
234 {"junk train", cmdJunkTrain},
235 {"dmarcdb addreport", cmdDMARCDBAddReport},
236 {"tlsrptdb addreport", cmdTLSRPTDBAddReport},
237 {"updates addsigned", cmdUpdatesAddSigned},
238 {"updates genkey", cmdUpdatesGenkey},
239 {"updates pubkey", cmdUpdatesPubkey},
240 {"updates serve", cmdUpdatesServe},
241 {"updates verify", cmdUpdatesVerify},
242 {"gentestdata", cmdGentestdata},
243 {"ximport maildir", cmdXImportMaildir},
244 {"ximport mbox", cmdXImportMbox},
245 {"openaccounts", cmdOpenaccounts},
246 {"readmessages", cmdReadmessages},
247 {"queuefillretired", cmdQueueFillRetired},
248}
249
250var cmds []cmd
251
252func init() {
253 for _, xc := range commands {
254 c := cmd{words: strings.Split(xc.cmd, " "), fn: xc.fn}
255 cmds = append(cmds, c)
256 }
257}
258
259type cmd struct {
260 words []string
261 fn func(c *cmd)
262
263 // Set before calling command.
264 flag *flag.FlagSet
265 flagArgs []string
266 _gather bool // Set when using Parse to gather usage for a command.
267
268 // Set by invoked command or Parse.
269 unlisted bool // If set, command is not listed until at least some words are matched from command.
270 params string // Arguments to command. Multiple lines possible.
271 help string // Additional explanation. First line is synopsis, the rest is only printed for an explicit help/usage for that command.
272 args []string
273
274 log mlog.Log
275}
276
277func (c *cmd) Parse() []string {
278 // To gather params and usage information, we just run the command but cause this
279 // panic after the command has registered its flags and set its params and help
280 // information. This is then caught and that info printed.
281 if c._gather {
282 panic("gather")
283 }
284
285 c.flag.Usage = c.Usage
286 c.flag.Parse(c.flagArgs)
287 c.args = c.flag.Args()
288 return c.args
289}
290
291func (c *cmd) gather() {
292 c.flag = flag.NewFlagSet("mox "+strings.Join(c.words, " "), flag.ExitOnError)
293 c._gather = true
294 defer func() {
295 x := recover()
296 // panic generated by Parse.
297 if x != "gather" {
298 panic(x)
299 }
300 }()
301 c.fn(c)
302}
303
304func (c *cmd) makeUsage() string {
305 var r strings.Builder
306 cs := "mox " + strings.Join(c.words, " ")
307 for i, line := range strings.Split(strings.TrimSpace(c.params), "\n") {
308 s := ""
309 if i == 0 {
310 s = "usage:"
311 }
312 if line != "" {
313 line = " " + line
314 }
315 fmt.Fprintf(&r, "%6s %s%s\n", s, cs, line)
316 }
317 c.flag.SetOutput(&r)
318 c.flag.PrintDefaults()
319 return r.String()
320}
321
322func (c *cmd) printUsage() {
323 fmt.Fprint(os.Stderr, c.makeUsage())
324 if c.help != "" {
325 fmt.Fprint(os.Stderr, "\n"+c.help+"\n")
326 }
327}
328
329func (c *cmd) Usage() {
330 c.printUsage()
331 os.Exit(2)
332}
333
334func cmdHelp(c *cmd) {
335 c.params = "[$command ...]"
336 c.help = `Prints help about matching commands.
337
338If multiple commands match, they are listed along with the first line of their help text.
339If a single command matches, its usage and full help text is printed.
340`
341 args := c.Parse()
342 if len(args) == 0 {
343 c.Usage()
344 }
345
346 prefix := func(l, pre []string) bool {
347 if len(pre) > len(l) {
348 return false
349 }
350 return slices.Equal(pre, l[:len(pre)])
351 }
352
353 var partial []cmd
354 for _, c := range cmds {
355 if slices.Equal(c.words, args) {
356 c.gather()
357 fmt.Print(c.makeUsage())
358 if c.help != "" {
359 fmt.Print("\n" + c.help + "\n")
360 }
361 return
362 } else if prefix(c.words, args) {
363 partial = append(partial, c)
364 }
365 }
366 if len(partial) == 0 {
367 fmt.Fprintf(os.Stderr, "%s: unknown command\n", strings.Join(args, " "))
368 os.Exit(2)
369 }
370 for _, c := range partial {
371 c.gather()
372 line := "mox " + strings.Join(c.words, " ")
373 fmt.Printf("%s\n", line)
374 if c.help != "" {
375 fmt.Printf("\t%s\n", strings.Split(c.help, "\n")[0])
376 }
377 }
378}
379
380func cmdHelpall(c *cmd) {
381 c.unlisted = true
382 c.help = `Print all detailed usage and help information for all listed commands.
383
384Used to generate documentation.
385`
386 args := c.Parse()
387 if len(args) != 0 {
388 c.Usage()
389 }
390
391 n := 0
392 for _, c := range cmds {
393 c.gather()
394 if c.unlisted {
395 continue
396 }
397 if n > 0 {
398 fmt.Fprintf(os.Stderr, "\n")
399 }
400 n++
401
402 fmt.Fprintf(os.Stderr, "# mox %s\n\n", strings.Join(c.words, " "))
403 if c.help != "" {
404 fmt.Fprintln(os.Stderr, c.help+"\n")
405 }
406 s := c.makeUsage()
407 s = "\t" + strings.ReplaceAll(s, "\n", "\n\t")
408 fmt.Fprintln(os.Stderr, s)
409 }
410}
411
412func usage(l []cmd, unlisted bool) {
413 var lines []string
414 if !unlisted {
415 lines = append(lines, "mox [-config config/mox.conf] [-pedantic] ...")
416 }
417 for _, c := range l {
418 c.gather()
419 if c.unlisted && !unlisted {
420 continue
421 }
422 for line := range strings.SplitSeq(c.params, "\n") {
423 x := append([]string{"mox"}, c.words...)
424 if line != "" {
425 x = append(x, line)
426 }
427 lines = append(lines, strings.Join(x, " "))
428 }
429 }
430 for i, line := range lines {
431 pre := " "
432 if i == 0 {
433 pre = "usage: "
434 }
435 fmt.Fprintln(os.Stderr, pre+line)
436 }
437 os.Exit(2)
438}
439
440var loglevel string // Empty will be interpreted as info, except by localserve.
441var pedantic bool
442
443// subcommands that are not "serve" should use this function to load the config, it
444// restores any loglevel specified on the command-line, instead of using the
445// loglevels from the config file and it does not load files like TLS keys/certs.
446func mustLoadConfig() {
447 mox.MustLoadConfig(false, false)
448 ll := loglevel
449 if ll == "" {
450 ll = "info"
451 }
452 if level, ok := mlog.Levels[ll]; ok {
453 mox.Conf.Log[""] = level
454 mlog.SetConfig(mox.Conf.Log)
455 } else {
456 log.Fatal("unknown loglevel", slog.String("loglevel", loglevel))
457 }
458 if pedantic {
459 mox.SetPedantic(true)
460 }
461}
462
463func main() {
464 // CheckConsistencyOnClose is true by default, for all the test packages. A regular
465 // mox server should never use it. But integration tests enable it again with a
466 // flag.
467 store.CheckConsistencyOnClose = false
468 store.MsgFilesPerDirShiftSet(13) // For 1<<13 = 8k message files per directory.
469
470 ctxbg := context.Background()
471 mox.Shutdown = ctxbg
472 mox.Context = ctxbg
473
474 log.SetFlags(0)
475
476 // If invoked as sendmail, e.g. /usr/sbin/sendmail, we do enough so cron can get a
477 // message sent using smtp submission to a configured server.
478 if len(os.Args) > 0 && filepath.Base(os.Args[0]) == "sendmail" {
479 c := &cmd{
480 flag: flag.NewFlagSet("sendmail", flag.ExitOnError),
481 flagArgs: os.Args[1:],
482 log: mlog.New("sendmail", nil),
483 }
484 cmdSendmail(c)
485 return
486 }
487
488 flag.StringVar(&mox.ConfigStaticPath, "config", envString("MOXCONF", filepath.FromSlash("config/mox.conf")), "configuration file, other config files are looked up in the same directory, defaults to $MOXCONF with a fallback to mox.conf")
489 flag.StringVar(&loglevel, "loglevel", "", "if non-empty, this log level is set early in startup")
490 flag.BoolVar(&pedantic, "pedantic", false, "protocol violations result in errors instead of accepting/working around them")
491 flag.BoolVar(&store.CheckConsistencyOnClose, "checkconsistency", false, "dangerous option for testing only, enables data checks that abort/panic when inconsistencies are found")
492
493 var cpuprofile, memprofile, tracefile string
494 flag.StringVar(&cpuprofile, "cpuprof", "", "store cpu profile to file")
495 flag.StringVar(&memprofile, "memprof", "", "store mem profile to file")
496 flag.StringVar(&tracefile, "trace", "", "store execution trace to file")
497
498 flag.Usage = func() { usage(cmds, false) }
499 flag.Parse()
500 args := flag.Args()
501 if len(args) == 0 {
502 usage(cmds, false)
503 }
504
505 if tracefile != "" {
506 defer traceExecution(tracefile)()
507 }
508 defer profile(cpuprofile, memprofile)()
509
510 if pedantic {
511 mox.SetPedantic(true)
512 }
513
514 mox.ConfigDynamicPath = filepath.Join(filepath.Dir(mox.ConfigStaticPath), "domains.conf")
515 ll := loglevel
516 if ll == "" {
517 ll = "info"
518 }
519 if level, ok := mlog.Levels[ll]; ok {
520 mox.Conf.Log[""] = level
521 mlog.SetConfig(mox.Conf.Log)
522 // note: SetConfig may be called again when subcommands loads config.
523 } else {
524 log.Fatalf("unknown loglevel %q", loglevel)
525 }
526
527 var partial []cmd
528next:
529 for _, c := range cmds {
530 for i, w := range c.words {
531 if i >= len(args) || w != args[i] {
532 if i > 0 {
533 partial = append(partial, c)
534 }
535 continue next
536 }
537 }
538 c.flag = flag.NewFlagSet("mox "+strings.Join(c.words, " "), flag.ExitOnError)
539 c.flagArgs = args[len(c.words):]
540 c.log = mlog.New(strings.Join(c.words, ""), nil)
541 c.fn(&c)
542 return
543 }
544 if len(partial) > 0 {
545 usage(partial, true)
546 }
547 usage(cmds, false)
548}
549
550func xcheckf(err error, format string, args ...any) {
551 if err == nil {
552 return
553 }
554 msg := fmt.Sprintf(format, args...)
555 log.Fatalf("%s: %s", msg, err)
556}
557
558func xparseIP(s, what string) net.IP {
559 ip := net.ParseIP(s)
560 if ip == nil {
561 log.Fatalf("invalid %s: %q", what, s)
562 }
563 return ip
564}
565
566func xparseDomain(s, what string) dns.Domain {
567 d, err := dns.ParseDomain(s)
568 xcheckf(err, "parsing %s %q", what, s)
569 return d
570}
571
572func cmdClientConfig(c *cmd) {
573 c.params = "$domain"
574 c.help = `Print the configuration for email clients for a domain.
575
576Sending email is typically not done on the SMTP port 25, but on submission
577ports 465 (with TLS) and 587 (without initial TLS, but usually added to the
578connection with STARTTLS). For IMAP, the port with TLS is 993 and without is
579143.
580
581Without TLS/STARTTLS, passwords are sent in clear text, which should only be
582configured over otherwise secured connections, like a VPN.
583`
584 args := c.Parse()
585 if len(args) != 1 {
586 c.Usage()
587 }
588 d := xparseDomain(args[0], "domain")
589 mustLoadConfig()
590 printClientConfig(d)
591}
592
593func printClientConfig(d dns.Domain) {
594 cc, err := admin.ClientConfigsDomain(d)
595 xcheckf(err, "getting client config")
596 fmt.Printf("%-20s %-30s %5s %-15s %s\n", "Protocol", "Host", "Port", "Listener", "Note")
597 for _, e := range cc.Entries {
598 fmt.Printf("%-20s %-30s %5d %-15s %s\n", e.Protocol, e.Host, e.Port, e.Listener, e.Note)
599 }
600 fmt.Printf(`
601To prevent authentication mechanism downgrade attempts that may result in
602clients sending plain text passwords to a MitM, clients should always be
603explicitly configured with the most secure authentication mechanism supported,
604the first of: SCRAM-SHA-256-PLUS, SCRAM-SHA-1-PLUS, SCRAM-SHA-256, SCRAM-SHA-1,
605CRAM-MD5.
606`)
607}
608
609func cmdConfigTest(c *cmd) {
610 c.help = `Parses and validates the configuration files.
611
612If valid, the command exits with status 0. If not valid, all errors encountered
613are printed.
614`
615 args := c.Parse()
616 if len(args) != 0 {
617 c.Usage()
618 }
619
620 mox.FilesImmediate = true
621
622 _, errs := mox.ParseConfig(context.Background(), c.log, mox.ConfigStaticPath, true, true, false)
623 if len(errs) > 1 {
624 log.Printf("multiple errors:")
625 for _, err := range errs {
626 log.Printf("%s", err)
627 }
628 os.Exit(1)
629 } else if len(errs) == 1 {
630 log.Fatalf("%s", errs[0])
631 os.Exit(1)
632 }
633 fmt.Println("config OK")
634}
635
636func cmdConfigDescribeStatic(c *cmd) {
637 c.params = ">mox.conf"
638 c.help = `Prints an annotated empty configuration for use as mox.conf.
639
640The static configuration file cannot be reloaded while mox is running. Mox has
641to be restarted for changes to the static configuration file to take effect.
642
643This configuration file needs modifications to make it valid. For example, it
644may contain unfinished list items.
645`
646 if len(c.Parse()) != 0 {
647 c.Usage()
648 }
649
650 var sc config.Static
651 err := sconf.Describe(os.Stdout, &sc)
652 xcheckf(err, "describing config")
653}
654
655func cmdConfigDescribeDomains(c *cmd) {
656 c.params = ">domains.conf"
657 c.help = `Prints an annotated empty configuration for use as domains.conf.
658
659The domains configuration file contains the domains and their configuration,
660and accounts and their configuration. This includes the configured email
661addresses. The mox admin web interface, and the mox command line interface, can
662make changes to this file. Mox automatically reloads this file when it changes.
663
664Like the static configuration, the example domains.conf printed by this command
665needs modifications to make it valid.
666`
667 if len(c.Parse()) != 0 {
668 c.Usage()
669 }
670
671 var dc config.Dynamic
672 err := sconf.Describe(os.Stdout, &dc)
673 xcheckf(err, "describing config")
674}
675
676func cmdConfigPrintservice(c *cmd) {
677 c.params = ">mox.service"
678 c.help = `Prints a systemd unit service file for mox.
679
680This is the same file as generated using quickstart. If the systemd service file
681has changed with a newer version of mox, use this command to generate an up to
682date version.
683`
684 if len(c.Parse()) != 0 {
685 c.Usage()
686 }
687
688 pwd, err := os.Getwd()
689 if err != nil {
690 log.Printf("current working directory: %v", err)
691 pwd = "/home/mox"
692 }
693 service := strings.ReplaceAll(moxService, "/home/mox", pwd)
694 fmt.Print(service)
695}
696
697func cmdConfigDomainAdd(c *cmd) {
698 c.params = "[-disabled] $domain $account [$localpart]"
699 c.help = `Adds a new domain to the configuration and reloads the configuration.
700
701The account is used for the postmaster mailboxes the domain, including as DMARC and
702TLS reporting. Localpart is the "username" at the domain for this account. If
703must be set if and only if account does not yet exist.
704
705The domain can be created in disabled mode, preventing automatically requesting
706TLS certificates with ACME, and rejecting incoming/outgoing messages involving
707the domain, but allowing further configuration of the domain.
708`
709 var disabled bool
710 c.flag.BoolVar(&disabled, "disabled", false, "disable the new domain")
711 args := c.Parse()
712 if len(args) != 2 && len(args) != 3 {
713 c.Usage()
714 }
715
716 d := xparseDomain(args[0], "domain")
717 mustLoadConfig()
718 var localpart smtp.Localpart
719 if len(args) == 3 {
720 var err error
721 localpart, err = smtp.ParseLocalpart(args[2])
722 xcheckf(err, "parsing localpart")
723 }
724 ctlcmdConfigDomainAdd(xctl(), disabled, d, args[1], localpart)
725}
726
727func ctlcmdConfigDomainAdd(ctl *ctl, disabled bool, domain dns.Domain, account string, localpart smtp.Localpart) {
728 ctl.xwrite("domainadd")
729 if disabled {
730 ctl.xwrite("true")
731 } else {
732 ctl.xwrite("false")
733 }
734 ctl.xwrite(domain.Name())
735 ctl.xwrite(account)
736 ctl.xwrite(string(localpart))
737 ctl.xreadok()
738 fmt.Printf("domain added, remember to add dns records, see:\n\nmox config dnsrecords %s\nmox config dnscheck %s\n", domain.Name(), domain.Name())
739}
740
741func cmdConfigDomainRemove(c *cmd) {
742 c.params = "$domain"
743 c.help = `Remove a domain from the configuration and reload the configuration.
744
745This is a dangerous operation. Incoming email delivery for this domain will be
746rejected.
747`
748 args := c.Parse()
749 if len(args) != 1 {
750 c.Usage()
751 }
752
753 d := xparseDomain(args[0], "domain")
754 mustLoadConfig()
755 ctlcmdConfigDomainRemove(xctl(), d)
756}
757
758func ctlcmdConfigDomainRemove(ctl *ctl, d dns.Domain) {
759 ctl.xwrite("domainrm")
760 ctl.xwrite(d.Name())
761 ctl.xreadok()
762 fmt.Printf("domain removed, remember to remove dns records for %s\n", d)
763}
764
765func cmdConfigDomainDisable(c *cmd) {
766 c.params = "$domain"
767 c.help = `Disable a domain and reload the configuration.
768
769This is a dangerous operation. Incoming/outgoing messages involving this domain
770will be rejected.
771`
772 args := c.Parse()
773 if len(args) != 1 {
774 c.Usage()
775 }
776
777 d := xparseDomain(args[0], "domain")
778 mustLoadConfig()
779 ctlcmdConfigDomainDisabled(xctl(), d, true)
780 fmt.Printf("domain disabled")
781}
782
783func cmdConfigDomainEnable(c *cmd) {
784 c.params = "$domain"
785 c.help = `Enable a domain and reload the configuration.
786
787Incoming/outgoing messages involving this domain will be accepted again.
788`
789 args := c.Parse()
790 if len(args) != 1 {
791 c.Usage()
792 }
793
794 d := xparseDomain(args[0], "domain")
795 mustLoadConfig()
796 ctlcmdConfigDomainDisabled(xctl(), d, false)
797}
798
799func ctlcmdConfigDomainDisabled(ctl *ctl, d dns.Domain, disabled bool) {
800 ctl.xwrite("domaindisabled")
801 ctl.xwrite(d.Name())
802 if disabled {
803 ctl.xwrite("true")
804 } else {
805 ctl.xwrite("false")
806 }
807 ctl.xreadok()
808}
809
810func cmdConfigAliasList(c *cmd) {
811 c.params = "$domain"
812 c.help = `Show aliases (lists) for domain.`
813 args := c.Parse()
814 if len(args) != 1 {
815 c.Usage()
816 }
817
818 mustLoadConfig()
819 ctlcmdConfigAliasList(xctl(), args[0])
820}
821
822func ctlcmdConfigAliasList(ctl *ctl, address string) {
823 ctl.xwrite("aliaslist")
824 ctl.xwrite(address)
825 ctl.xreadok()
826 ctl.xstreamto(os.Stdout)
827}
828
829func cmdConfigAliasPrint(c *cmd) {
830 c.params = "$alias"
831 c.help = `Print settings and members of alias (list).`
832 args := c.Parse()
833 if len(args) != 1 {
834 c.Usage()
835 }
836
837 mustLoadConfig()
838 ctlcmdConfigAliasPrint(xctl(), args[0])
839}
840
841func ctlcmdConfigAliasPrint(ctl *ctl, address string) {
842 ctl.xwrite("aliasprint")
843 ctl.xwrite(address)
844 ctl.xreadok()
845 ctl.xstreamto(os.Stdout)
846}
847
848func cmdConfigAliasAdd(c *cmd) {
849 c.params = "$alias@domain $rcpt1@domain ..."
850 c.help = `Add new alias (list) with one or more addresses and public posting enabled.
851
852An alias is used for delivering incoming email to multiple recipients. If you
853want to add an address to an account, don't use an alias, just add the address
854to the account.
855`
856 args := c.Parse()
857 if len(args) < 2 {
858 c.Usage()
859 }
860
861 alias := config.Alias{PostPublic: true, Addresses: args[1:]}
862
863 mustLoadConfig()
864 ctlcmdConfigAliasAdd(xctl(), args[0], alias)
865}
866
867func ctlcmdConfigAliasAdd(ctl *ctl, address string, alias config.Alias) {
868 ctl.xwrite("aliasadd")
869 ctl.xwrite(address)
870 xctlwriteJSON(ctl, alias)
871 ctl.xreadok()
872}
873
874func cmdConfigAliasUpdate(c *cmd) {
875 c.params = "$alias@domain [-postpublic false|true -listmembers false|true -allowmsgfrom false|true]"
876 c.help = `Update alias (list) configuration.`
877 var postpublic, listmembers, allowmsgfrom string
878 c.flag.StringVar(&postpublic, "postpublic", "", "whether anyone or only list members can post")
879 c.flag.StringVar(&listmembers, "listmembers", "", "whether list members can list members")
880 c.flag.StringVar(&allowmsgfrom, "allowmsgfrom", "", "whether alias address can be used in message from header")
881 args := c.Parse()
882 if len(args) != 1 {
883 c.Usage()
884 }
885
886 alias := args[0]
887 mustLoadConfig()
888 ctlcmdConfigAliasUpdate(xctl(), alias, postpublic, listmembers, allowmsgfrom)
889}
890
891func ctlcmdConfigAliasUpdate(ctl *ctl, alias, postpublic, listmembers, allowmsgfrom string) {
892 ctl.xwrite("aliasupdate")
893 ctl.xwrite(alias)
894 ctl.xwrite(postpublic)
895 ctl.xwrite(listmembers)
896 ctl.xwrite(allowmsgfrom)
897 ctl.xreadok()
898}
899
900func cmdConfigAliasRemove(c *cmd) {
901 c.params = "$alias@domain"
902 c.help = "Remove alias (list)."
903 args := c.Parse()
904 if len(args) != 1 {
905 c.Usage()
906 }
907
908 mustLoadConfig()
909 ctlcmdConfigAliasRemove(xctl(), args[0])
910}
911
912func ctlcmdConfigAliasRemove(ctl *ctl, alias string) {
913 ctl.xwrite("aliasrm")
914 ctl.xwrite(alias)
915 ctl.xreadok()
916}
917
918func cmdConfigAliasAddaddr(c *cmd) {
919 c.params = "$alias@domain $rcpt1@domain ..."
920 c.help = `Add addresses to alias (list).`
921 args := c.Parse()
922 if len(args) < 2 {
923 c.Usage()
924 }
925
926 mustLoadConfig()
927 ctlcmdConfigAliasAddaddr(xctl(), args[0], args[1:])
928}
929
930func ctlcmdConfigAliasAddaddr(ctl *ctl, alias string, addresses []string) {
931 ctl.xwrite("aliasaddaddr")
932 ctl.xwrite(alias)
933 xctlwriteJSON(ctl, addresses)
934 ctl.xreadok()
935}
936
937func cmdConfigAliasRemoveaddr(c *cmd) {
938 c.params = "$alias@domain $rcpt1@domain ..."
939 c.help = `Remove addresses from alias (list).`
940 args := c.Parse()
941 if len(args) < 2 {
942 c.Usage()
943 }
944
945 mustLoadConfig()
946 ctlcmdConfigAliasRmaddr(xctl(), args[0], args[1:])
947}
948
949func ctlcmdConfigAliasRmaddr(ctl *ctl, alias string, addresses []string) {
950 ctl.xwrite("aliasrmaddr")
951 ctl.xwrite(alias)
952 xctlwriteJSON(ctl, addresses)
953 ctl.xreadok()
954}
955
956func cmdConfigAccountAdd(c *cmd) {
957 c.params = "$account $address"
958 c.help = `Add an account with an email address and reload the configuration.
959
960Email can be delivered to this address/account. A password has to be configured
961explicitly, see the setaccountpassword command.
962`
963 args := c.Parse()
964 if len(args) != 2 {
965 c.Usage()
966 }
967
968 mustLoadConfig()
969 ctlcmdConfigAccountAdd(xctl(), args[0], args[1])
970}
971
972func ctlcmdConfigAccountAdd(ctl *ctl, account, address string) {
973 ctl.xwrite("accountadd")
974 ctl.xwrite(account)
975 ctl.xwrite(address)
976 ctl.xreadok()
977 fmt.Printf("account added, set a password with \"mox setaccountpassword %s\"\n", account)
978}
979
980func cmdConfigAccountRemove(c *cmd) {
981 c.params = "$account"
982 c.help = `Remove an account and reload the configuration.
983
984Email addresses for this account will also be removed, and incoming email for
985these addresses will be rejected.
986
987All data for the account will be removed.
988`
989 args := c.Parse()
990 if len(args) != 1 {
991 c.Usage()
992 }
993
994 mustLoadConfig()
995 ctlcmdConfigAccountRemove(xctl(), args[0])
996}
997
998func ctlcmdConfigAccountRemove(ctl *ctl, account string) {
999 ctl.xwrite("accountrm")
1000 ctl.xwrite(account)
1001 ctl.xreadok()
1002 fmt.Println("account removed")
1003}
1004
1005func cmdConfigAccountList(c *cmd) {
1006 c.help = `List all accounts.
1007
1008Each account is printed on a line, with optional additional tab-separated
1009information, such as "(disabled)".
1010`
1011 args := c.Parse()
1012 if len(args) != 0 {
1013 c.Usage()
1014 }
1015
1016 mustLoadConfig()
1017 ctlcmdConfigAccountList(xctl())
1018}
1019
1020func ctlcmdConfigAccountList(ctl *ctl) {
1021 ctl.xwrite("accountlist")
1022 ctl.xreadok()
1023 ctl.xstreamto(os.Stdout)
1024}
1025
1026func cmdConfigAccountAddresses(c *cmd) {
1027 c.help = `List all addresses for an account.
1028
1029Each address is printed on a line.
1030An address starting with an "@" indicate it is a catchall address for the domain.
1031
1032Does not check whether account is disabled.
1033`
1034 args := c.Parse()
1035 if len(args) != 1 {
1036 c.Usage()
1037 }
1038
1039 mustLoadConfig()
1040 ctlcmdConfigAccountAddresses(xctl(), args[0])
1041}
1042
1043func ctlcmdConfigAccountAddresses(ctl *ctl, account string) {
1044 ctl.xwrite("accountaddresses")
1045 ctl.xwrite(account)
1046 ctl.xreadok()
1047 ctl.xstreamto(os.Stdout)
1048}
1049
1050func cmdConfigAccountDisable(c *cmd) {
1051 c.params = "$account $message"
1052 c.help = `Disable login for an account, showing message to users when they try to login.
1053
1054Incoming email will still be accepted for the account, and queued email from the
1055account will still be delivered. No new login sessions are possible.
1056
1057Message must be non-empty, ascii-only without control characters including
1058newline, and maximum 256 characters because it is used in SMTP/IMAP.
1059`
1060 args := c.Parse()
1061 if len(args) != 2 {
1062 c.Usage()
1063 }
1064 if args[1] == "" {
1065 log.Fatalf("message must be non-empty")
1066 }
1067
1068 mustLoadConfig()
1069 ctlcmdConfigAccountDisabled(xctl(), args[0], args[1])
1070 fmt.Println("account disabled")
1071}
1072
1073func cmdConfigAccountEnable(c *cmd) {
1074 c.params = "$account"
1075 c.help = `Enable login again for an account.
1076
1077Login attempts by the user no long result in an error message.
1078`
1079 args := c.Parse()
1080 if len(args) != 1 {
1081 c.Usage()
1082 }
1083
1084 mustLoadConfig()
1085 ctlcmdConfigAccountDisabled(xctl(), args[0], "")
1086 fmt.Println("account enabled")
1087}
1088
1089func ctlcmdConfigAccountDisabled(ctl *ctl, account, loginDisabled string) {
1090 ctl.xwrite("accountdisabled")
1091 ctl.xwrite(account)
1092 ctl.xwrite(loginDisabled)
1093 ctl.xreadok()
1094}
1095
1096func cmdConfigTlspubkeyList(c *cmd) {
1097 c.params = "[$account]"
1098 c.help = `List TLS public keys for TLS client certificate authentication.
1099
1100If account is absent, the TLS public keys for all accounts are listed.
1101`
1102 args := c.Parse()
1103 var accountOpt string
1104 if len(args) == 1 {
1105 accountOpt = args[0]
1106 } else if len(args) > 1 {
1107 c.Usage()
1108 }
1109
1110 mustLoadConfig()
1111 ctlcmdConfigTlspubkeyList(xctl(), accountOpt)
1112}
1113
1114func ctlcmdConfigTlspubkeyList(ctl *ctl, accountOpt string) {
1115 ctl.xwrite("tlspubkeylist")
1116 ctl.xwrite(accountOpt)
1117 ctl.xreadok()
1118 ctl.xstreamto(os.Stdout)
1119}
1120
1121func cmdConfigTlspubkeyGet(c *cmd) {
1122 c.params = "$fingerprint"
1123 c.help = `Get a TLS public key for a fingerprint.
1124
1125Prints the type, name, account and address for the key, and the certificate in
1126PEM format.
1127`
1128 args := c.Parse()
1129 if len(args) != 1 {
1130 c.Usage()
1131 }
1132
1133 mustLoadConfig()
1134 ctlcmdConfigTlspubkeyGet(xctl(), args[0])
1135}
1136
1137func ctlcmdConfigTlspubkeyGet(ctl *ctl, fingerprint string) {
1138 ctl.xwrite("tlspubkeyget")
1139 ctl.xwrite(fingerprint)
1140 ctl.xreadok()
1141 typ := ctl.xread()
1142 name := ctl.xread()
1143 account := ctl.xread()
1144 address := ctl.xread()
1145 noimappreauth := ctl.xread()
1146 var b bytes.Buffer
1147 ctl.xstreamto(&b)
1148 buf := b.Bytes()
1149 var block *pem.Block
1150 if len(buf) != 0 {
1151 block = &pem.Block{
1152 Type: "CERTIFICATE",
1153 Bytes: buf,
1154 }
1155 }
1156
1157 fmt.Printf("type: %s\nname: %s\naccount: %s\naddress: %s\nno imap preauth: %s\n", typ, name, account, address, noimappreauth)
1158 if block != nil {
1159 fmt.Printf("certificate:\n\n")
1160 if err := pem.Encode(os.Stdout, block); err != nil {
1161 log.Fatalf("pem encode: %v", err)
1162 }
1163 }
1164}
1165
1166func cmdConfigTlspubkeyAdd(c *cmd) {
1167 c.params = "$address [$name] < cert.pem"
1168 c.help = `Add a TLS public key to the account of the given address.
1169
1170The public key is read from the certificate.
1171
1172The optional name is a human-readable descriptive name of the key. If absent,
1173the CommonName from the certificate is used.
1174`
1175 var noimappreauth bool
1176 c.flag.BoolVar(&noimappreauth, "no-imap-preauth", false, "Don't automatically switch new IMAP connections authenticated with this key to \"authenticated\" state after the TLS handshake. For working around clients that ignore the untagged IMAP PREAUTH response and try to authenticate while already authenticated.")
1177 args := c.Parse()
1178 var address, name string
1179 if len(args) == 1 {
1180 address = args[0]
1181 } else if len(args) == 2 {
1182 address, name = args[0], args[1]
1183 } else {
1184 c.Usage()
1185 }
1186
1187 buf, err := io.ReadAll(os.Stdin)
1188 xcheckf(err, "reading from stdin")
1189 block, _ := pem.Decode(buf)
1190 if block == nil {
1191 err = errors.New("no pem block found")
1192 } else if block.Type != "CERTIFICATE" {
1193 err = fmt.Errorf("unexpected type %q, expected CERTIFICATE", block.Type)
1194 }
1195 xcheckf(err, "parsing pem")
1196
1197 mustLoadConfig()
1198 ctlcmdConfigTlspubkeyAdd(xctl(), address, name, noimappreauth, block.Bytes)
1199}
1200
1201func ctlcmdConfigTlspubkeyAdd(ctl *ctl, address, name string, noimappreauth bool, certDER []byte) {
1202 ctl.xwrite("tlspubkeyadd")
1203 ctl.xwrite(address)
1204 ctl.xwrite(name)
1205 ctl.xwrite(fmt.Sprintf("%v", noimappreauth))
1206 ctl.xstreamfrom(bytes.NewReader(certDER))
1207 ctl.xreadok()
1208}
1209
1210func cmdConfigTlspubkeyRemove(c *cmd) {
1211 c.params = "$fingerprint"
1212 c.help = `Remove TLS public key for fingerprint.`
1213 args := c.Parse()
1214 if len(args) != 1 {
1215 c.Usage()
1216 }
1217
1218 mustLoadConfig()
1219 ctlcmdConfigTlspubkeyRemove(xctl(), args[0])
1220}
1221
1222func ctlcmdConfigTlspubkeyRemove(ctl *ctl, fingerprint string) {
1223 ctl.xwrite("tlspubkeyrm")
1224 ctl.xwrite(fingerprint)
1225 ctl.xreadok()
1226}
1227
1228func cmdConfigTlspubkeyGen(c *cmd) {
1229 c.params = "$stem"
1230 c.help = `Generate an ed25519 private key and minimal certificate for use a TLS public key and write to files starting with stem.
1231
1232The private key is written to $stem.$timestamp.ed25519privatekey.pkcs8.pem.
1233The certificate is written to $stem.$timestamp.certificate.pem.
1234The private key and certificate are also written to
1235$stem.$timestamp.ed25519privatekey-certificate.pem.
1236
1237The certificate can be added to an account with "mox config account tlspubkey add".
1238
1239The combined file can be used with "mox sendmail".
1240
1241The private key is also written to standard error in raw-url-base64-encoded
1242form, also for use with "mox sendmail". The fingerprint is written to standard
1243error too, for reference.
1244`
1245 args := c.Parse()
1246 if len(args) != 1 {
1247 c.Usage()
1248 }
1249
1250 stem := args[0]
1251 timestamp := time.Now().Format("200601021504")
1252 prefix := stem + "." + timestamp
1253
1254 seed := make([]byte, ed25519.SeedSize)
1255 cryptorand.Read(seed)
1256 privKey := ed25519.NewKeyFromSeed(seed)
1257 privKeyBuf, err := x509.MarshalPKCS8PrivateKey(privKey)
1258 xcheckf(err, "marshal private key as pkcs8")
1259 var b bytes.Buffer
1260 err = pem.Encode(&b, &pem.Block{Type: "PRIVATE KEY", Bytes: privKeyBuf})
1261 xcheckf(err, "marshal pkcs8 private key to pem")
1262 privKeyBufPEM := b.Bytes()
1263
1264 certBuf, tlsCert := xminimalCert(privKey)
1265 b = bytes.Buffer{}
1266 err = pem.Encode(&b, &pem.Block{Type: "CERTIFICATE", Bytes: certBuf})
1267 xcheckf(err, "marshal certificate to pem")
1268 certBufPEM := b.Bytes()
1269
1270 xwriteFile := func(p string, data []byte, what string) {
1271 log.Printf("writing %s", p)
1272 err = os.WriteFile(p, data, 0600)
1273 xcheckf(err, "writing %s file: %v", what, err)
1274 }
1275
1276 xwriteFile(prefix+".ed25519privatekey.pkcs8.pem", privKeyBufPEM, "private key")
1277 xwriteFile(prefix+".certificate.pem", certBufPEM, "certificate")
1278 combinedPEM := slices.Concat(privKeyBufPEM, certBufPEM)
1279 xwriteFile(prefix+".ed25519privatekey-certificate.pem", combinedPEM, "combined private key and certificate")
1280
1281 shabuf := sha256.Sum256(tlsCert.Leaf.RawSubjectPublicKeyInfo)
1282
1283 _, err = fmt.Fprintf(os.Stderr, "ed25519 private key as raw-url-base64: %s\ned25519 public key fingerprint: %s\n",
1284 base64.RawURLEncoding.EncodeToString(seed),
1285 base64.RawURLEncoding.EncodeToString(shabuf[:]),
1286 )
1287 xcheckf(err, "write private key and public key fingerprint")
1288}
1289
1290func cmdConfigAddressAdd(c *cmd) {
1291 c.params = "$address $account"
1292 c.help = `Adds an address to an account and reloads the configuration.
1293
1294If address starts with a @ (i.e. a missing localpart), this is a catchall
1295address for the domain.
1296`
1297 args := c.Parse()
1298 if len(args) != 2 {
1299 c.Usage()
1300 }
1301
1302 mustLoadConfig()
1303 ctlcmdConfigAddressAdd(xctl(), args[0], args[1])
1304}
1305
1306func ctlcmdConfigAddressAdd(ctl *ctl, address, account string) {
1307 ctl.xwrite("addressadd")
1308 ctl.xwrite(address)
1309 ctl.xwrite(account)
1310 ctl.xreadok()
1311 fmt.Println("address added")
1312}
1313
1314func cmdConfigAddressRemove(c *cmd) {
1315 c.params = "$address"
1316 c.help = `Remove an address and reload the configuration.
1317
1318Incoming email for this address will be rejected after removing an address.
1319`
1320 args := c.Parse()
1321 if len(args) != 1 {
1322 c.Usage()
1323 }
1324
1325 mustLoadConfig()
1326 ctlcmdConfigAddressRemove(xctl(), args[0])
1327}
1328
1329func ctlcmdConfigAddressRemove(ctl *ctl, address string) {
1330 ctl.xwrite("addressrm")
1331 ctl.xwrite(address)
1332 ctl.xreadok()
1333 fmt.Println("address removed")
1334}
1335
1336func cmdConfigAddressAccount(c *cmd) {
1337 c.params = "$address"
1338 c.help = `Print the account an address belongs to.
1339
1340Catchall addresses and the account catch all separator are considered when
1341looking up the account.
1342
1343Does not check whether account is disabled.
1344`
1345 args := c.Parse()
1346 if len(args) != 1 {
1347 c.Usage()
1348 }
1349
1350 mustLoadConfig()
1351 ctlcmdConfigAddressAccount(xctl(), args[0])
1352}
1353
1354func ctlcmdConfigAddressAccount(ctl *ctl, address string) {
1355 ctl.xwrite("addressaccount")
1356 ctl.xwrite(address)
1357 ctl.xreadok()
1358 account := ctl.xread()
1359 fmt.Println(account)
1360}
1361
1362func cmdConfigDNSRecords(c *cmd) {
1363 c.params = "$domain"
1364 c.help = `Prints annotated DNS records as zone file that should be created for the domain.
1365
1366The zone file can be imported into existing DNS software. You should review the
1367DNS records, especially if your domain previously/currently has email
1368configured.
1369`
1370 args := c.Parse()
1371 if len(args) != 1 {
1372 c.Usage()
1373 }
1374
1375 d := xparseDomain(args[0], "domain")
1376 mustLoadConfig()
1377 domConf, ok := mox.Conf.Domain(d)
1378 if !ok {
1379 log.Fatalf("unknown domain")
1380 }
1381
1382 resolver := dns.StrictResolver{Pkg: "main"}
1383 _, result, err := resolver.LookupTXT(context.Background(), d.ASCII+".")
1384 if !dns.IsNotFound(err) {
1385 xcheckf(err, "looking up record for dnssec-status")
1386 }
1387
1388 var certIssuerDomainName, acmeAccountURI string
1389 public := mox.Conf.Static.Listeners["public"]
1390 if public.TLS != nil && public.TLS.ACME != "" {
1391 acme, ok := mox.Conf.Static.ACME[public.TLS.ACME]
1392 if ok && acme.Manager.Manager.Client != nil {
1393 certIssuerDomainName = acme.IssuerDomainName
1394 acc, err := acme.Manager.Manager.Client.GetReg(context.Background(), "")
1395 c.log.Check(err, "get public acme account")
1396 if err == nil {
1397 acmeAccountURI = acc.URI
1398 }
1399 }
1400 }
1401
1402 records, err := admin.DomainRecords(domConf, d, result.Authentic, certIssuerDomainName, acmeAccountURI)
1403 xcheckf(err, "records")
1404 fmt.Print(strings.Join(records, "\n") + "\n")
1405}
1406
1407func cmdConfigDNSCheck(c *cmd) {
1408 c.params = "$domain"
1409 c.help = "Check the DNS records with the configuration for the domain, and print any errors/warnings."
1410 args := c.Parse()
1411 if len(args) != 1 {
1412 c.Usage()
1413 }
1414
1415 d := xparseDomain(args[0], "domain")
1416 mustLoadConfig()
1417 _, ok := mox.Conf.Domain(d)
1418 if !ok {
1419 log.Fatalf("unknown domain")
1420 }
1421
1422 // todo future: move http.Admin.CheckDomain to mox- and make it return a regular error.
1423 defer func() {
1424 x := recover()
1425 if x == nil {
1426 return
1427 }
1428 err, ok := x.(*sherpa.Error)
1429 if !ok {
1430 panic(x)
1431 }
1432 log.Fatalf("%s", err)
1433 }()
1434
1435 printResult := func(name string, r webadmin.Result) {
1436 if len(r.Errors) == 0 && len(r.Warnings) == 0 {
1437 return
1438 }
1439 fmt.Printf("# %s\n", name)
1440 for _, s := range r.Errors {
1441 fmt.Printf("error: %s\n", s)
1442 }
1443 for _, s := range r.Warnings {
1444 fmt.Printf("warning: %s\n", s)
1445 }
1446 }
1447
1448 result := webadmin.Admin{}.CheckDomain(context.Background(), args[0])
1449 printResult("DNSSEC", result.DNSSEC.Result)
1450 printResult("IPRev", result.IPRev.Result)
1451 printResult("MX", result.MX.Result)
1452 printResult("TLS", result.TLS.Result)
1453 printResult("DANE", result.DANE.Result)
1454 printResult("SPF", result.SPF.Result)
1455 printResult("DKIM", result.DKIM.Result)
1456 printResult("DMARC", result.DMARC.Result)
1457 printResult("Host TLSRPT", result.HostTLSRPT.Result)
1458 printResult("Domain TLSRPT", result.DomainTLSRPT.Result)
1459 printResult("MTASTS", result.MTASTS.Result)
1460 printResult("SRV conf", result.SRVConf.Result)
1461 printResult("Autoconf", result.Autoconf.Result)
1462 printResult("Autodiscover", result.Autodiscover.Result)
1463}
1464
1465func cmdConfigEnsureACMEHostprivatekeys(c *cmd) {
1466 c.params = ""
1467 c.help = `Ensure host private keys exist for TLS listeners with ACME.
1468
1469In mox.conf, each listener can have TLS configured. Long-lived private key files
1470can be specified, which will be used when requesting ACME certificates.
1471Configuring these private keys makes it feasible to publish DANE TLSA records
1472for the corresponding public keys in DNS, protected with DNSSEC, allowing TLS
1473certificate verification without depending on a list of Certificate Authorities
1474(CAs). Previous versions of mox did not pre-generate private keys for use with
1475ACME certificates, but would generate private keys on-demand. By explicitly
1476configuring private keys, they will not change automatedly with new
1477certificates, and the DNS TLSA records stay valid.
1478
1479This command looks for listeners in mox.conf with TLS with ACME configured. For
1480each missing host private key (of type rsa-2048 and ecdsa-p256) a key is written
1481to config/hostkeys/. If a certificate exists in the ACME "cache", its private
1482key is copied. Otherwise a new private key is generated. Snippets for manually
1483updating/editing mox.conf are printed.
1484
1485After running this command, and updating mox.conf, run "mox config dnsrecords"
1486for a domain and create the TLSA DNS records it suggests to enable DANE.
1487`
1488 args := c.Parse()
1489 if len(args) != 0 {
1490 c.Usage()
1491 }
1492
1493 // Load a private key from p, in various forms. We only look at the first PEM
1494 // block. Files with only a private key, or with multiple blocks but private key
1495 // first like autocert does, can be loaded.
1496 loadPrivateKey := func(f *os.File) (any, error) {
1497 buf, err := io.ReadAll(f)
1498 if err != nil {
1499 return nil, fmt.Errorf("reading private key file: %v", err)
1500 }
1501 block, _ := pem.Decode(buf)
1502 if block == nil {
1503 return nil, fmt.Errorf("no pem block found in pem file")
1504 }
1505 var privKey any
1506 switch block.Type {
1507 case "EC PRIVATE KEY":
1508 privKey, err = x509.ParseECPrivateKey(block.Bytes)
1509 case "RSA PRIVATE KEY":
1510 privKey, err = x509.ParsePKCS1PrivateKey(block.Bytes)
1511 case "PRIVATE KEY":
1512 privKey, err = x509.ParsePKCS8PrivateKey(block.Bytes)
1513 default:
1514 return nil, fmt.Errorf("unrecognized pem block type %q", block.Type)
1515 }
1516 if err != nil {
1517 return nil, fmt.Errorf("parsing private key of type %q: %v", block.Type, err)
1518 }
1519 return privKey, nil
1520 }
1521
1522 // Either load a private key from file, or if it doesn't exist generate a new
1523 // private key.
1524 xtryLoadPrivateKey := func(kt autocert.KeyType, p string) any {
1525 f, err := os.Open(p)
1526 if err != nil && errors.Is(err, fs.ErrNotExist) {
1527 switch kt {
1528 case autocert.KeyRSA2048:
1529 privKey, err := rsa.GenerateKey(cryptorand.Reader, 2048)
1530 xcheckf(err, "generating new 2048-bit rsa private key")
1531 return privKey
1532 case autocert.KeyECDSAP256:
1533 privKey, err := ecdsa.GenerateKey(elliptic.P256(), cryptorand.Reader)
1534 xcheckf(err, "generating new ecdsa p-256 private key")
1535 return privKey
1536 }
1537 log.Fatalf("unexpected keytype %v", kt)
1538 return nil
1539 }
1540 xcheckf(err, "%s: open acme key and certificate file", p)
1541
1542 // Load private key from file. autocert stores a PEM file that starts with a
1543 // private key, followed by certificate(s). So we can just read it and should find
1544 // the private key we are looking for.
1545 privKey, err := loadPrivateKey(f)
1546 if xerr := f.Close(); xerr != nil {
1547 log.Printf("closing private key file: %v", xerr)
1548 }
1549 xcheckf(err, "parsing private key from acme key and certificate file")
1550
1551 switch k := privKey.(type) {
1552 case *rsa.PrivateKey:
1553 if k.N.BitLen() == 2048 {
1554 return privKey
1555 }
1556 log.Printf("warning: rsa private key in %s has %d bits, skipping and generating new 2048-bit rsa private key", p, k.N.BitLen())
1557 privKey, err := rsa.GenerateKey(cryptorand.Reader, 2048)
1558 xcheckf(err, "generating new 2048-bit rsa private key")
1559 return privKey
1560 case *ecdsa.PrivateKey:
1561 if k.Curve == elliptic.P256() {
1562 return privKey
1563 }
1564 log.Printf("warning: ecdsa private key in %s has curve %v, skipping and generating new p-256 ecdsa key", p, k.Curve.Params().Name)
1565 privKey, err := ecdsa.GenerateKey(elliptic.P256(), cryptorand.Reader)
1566 xcheckf(err, "generating new ecdsa p-256 private key")
1567 return privKey
1568 default:
1569 log.Fatalf("%s: unexpected private key file of type %T", p, privKey)
1570 return nil
1571 }
1572 }
1573
1574 // Write privKey as PKCS#8 private key to p. Only if file does not yet exist.
1575 writeHostPrivateKey := func(privKey any, p string) error {
1576 os.MkdirAll(filepath.Dir(p), 0700)
1577 f, err := os.OpenFile(p, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
1578 if err != nil {
1579 return fmt.Errorf("create: %v", err)
1580 }
1581 defer func() {
1582 if f != nil {
1583 if err := f.Close(); err != nil {
1584 log.Printf("closing new hostkey file %s after error: %v", p, err)
1585 }
1586 if err := os.Remove(p); err != nil {
1587 log.Printf("removing new hostkey file %s after error: %v", p, err)
1588 }
1589 }
1590 }()
1591 buf, err := x509.MarshalPKCS8PrivateKey(privKey)
1592 if err != nil {
1593 return fmt.Errorf("marshal private host key: %v", err)
1594 }
1595 block := pem.Block{
1596 Type: "PRIVATE KEY",
1597 Bytes: buf,
1598 }
1599 if err := pem.Encode(f, &block); err != nil {
1600 return fmt.Errorf("write as pem: %v", err)
1601 }
1602 if err := f.Close(); err != nil {
1603 return fmt.Errorf("close: %v", err)
1604 }
1605 f = nil
1606 return nil
1607 }
1608
1609 mustLoadConfig()
1610 timestamp := time.Now().Format("20060102T150405")
1611 didCreate := false
1612 for listenerName, l := range mox.Conf.Static.Listeners {
1613 if l.TLS == nil || l.TLS.ACME == "" {
1614 continue
1615 }
1616 haveKeyTypes := map[autocert.KeyType]bool{}
1617 for _, privKeyFile := range l.TLS.HostPrivateKeyFiles {
1618 p := mox.ConfigDirPath(privKeyFile)
1619 f, err := os.Open(p)
1620 xcheckf(err, "open host private key")
1621 privKey, err := loadPrivateKey(f)
1622 if err := f.Close(); err != nil {
1623 log.Printf("closing host private key file: %v", err)
1624 }
1625 xcheckf(err, "loading host private key")
1626 switch k := privKey.(type) {
1627 case *rsa.PrivateKey:
1628 if k.N.BitLen() == 2048 {
1629 haveKeyTypes[autocert.KeyRSA2048] = true
1630 }
1631 case *ecdsa.PrivateKey:
1632 if k.Curve == elliptic.P256() {
1633 haveKeyTypes[autocert.KeyECDSAP256] = true
1634 }
1635 }
1636 }
1637 created := []string{}
1638 for _, kt := range []autocert.KeyType{autocert.KeyRSA2048, autocert.KeyECDSAP256} {
1639 if haveKeyTypes[kt] {
1640 continue
1641 }
1642 // Lookup key in ACME cache.
1643 host := l.HostnameDomain
1644 if host.ASCII == "" {
1645 host = mox.Conf.Static.HostnameDomain
1646 }
1647 filename := host.ASCII
1648 kind := "ecdsap256"
1649 if kt == autocert.KeyRSA2048 {
1650 filename += "+rsa"
1651 kind = "rsa2048"
1652 }
1653 p := mox.DataDirPath(filepath.Join("acme", "keycerts", l.TLS.ACME, filename))
1654 privKey := xtryLoadPrivateKey(kt, p)
1655
1656 relPath := filepath.Join("hostkeys", fmt.Sprintf("%s.%s.%s.privatekey.pkcs8.pem", host.Name(), timestamp, kind))
1657 destPath := mox.ConfigDirPath(relPath)
1658 err := writeHostPrivateKey(privKey, destPath)
1659 xcheckf(err, "writing host private key file to %s: %v", destPath, err)
1660 created = append(created, relPath)
1661 fmt.Printf("Wrote host private key: %s\n", destPath)
1662 }
1663 didCreate = didCreate || len(created) > 0
1664 if len(created) > 0 {
1665 tls := config.TLS{
1666 HostPrivateKeyFiles: append(l.TLS.HostPrivateKeyFiles, created...),
1667 }
1668 fmt.Printf("\nEnsure Listener %q in %s has the following in its TLS section, below \"ACME: %s\" (don't forget to indent with tabs):\n\n", listenerName, mox.ConfigStaticPath, l.TLS.ACME)
1669 err := sconf.Write(os.Stdout, tls)
1670 xcheckf(err, "writing new TLS.HostPrivateKeyFiles section")
1671 fmt.Println()
1672 }
1673 }
1674 if didCreate {
1675 fmt.Printf(`
1676After updating mox.conf and restarting, run "mox config dnsrecords" for a
1677domain and create the TLSA DNS records it suggests to enable DANE.
1678`)
1679 }
1680}
1681
1682func cmdLoglevels(c *cmd) {
1683 c.params = "[$level [$pkg]]"
1684 c.help = `Print the log levels, or set a new default log level, or a level for the given package.
1685
1686By default, a single log level applies to all logging in mox. But for each
1687"pkg", an overriding log level can be configured. Examples of packages:
1688smtpserver, smtpclient, queue, imapserver, spf, dkim, dmarc, junk, message,
1689etc.
1690
1691Specify a pkg and an empty level to clear the configured level for a package.
1692
1693Valid labels: error, info, debug, trace, traceauth, tracedata.
1694`
1695 args := c.Parse()
1696 if len(args) > 2 {
1697 c.Usage()
1698 }
1699 mustLoadConfig()
1700
1701 if len(args) == 0 {
1702 ctlcmdLoglevels(xctl())
1703 } else {
1704 var pkg string
1705 if len(args) == 2 {
1706 pkg = args[1]
1707 }
1708 ctlcmdSetLoglevels(xctl(), pkg, args[0])
1709 }
1710}
1711
1712func ctlcmdLoglevels(ctl *ctl) {
1713 ctl.xwrite("loglevels")
1714 ctl.xreadok()
1715 ctl.xstreamto(os.Stdout)
1716}
1717
1718func ctlcmdSetLoglevels(ctl *ctl, pkg, level string) {
1719 ctl.xwrite("setloglevels")
1720 ctl.xwrite(pkg)
1721 ctl.xwrite(level)
1722 ctl.xreadok()
1723}
1724
1725func cmdStop(c *cmd) {
1726 c.help = `Shut mox down, giving connections maximum 3 seconds to stop before closing them.
1727
1728While shutting down, new IMAP and SMTP connections will get a status response
1729indicating temporary unavailability. Existing connections will get a 3 second
1730period to finish their transaction and shut down. Under normal circumstances,
1731only IMAP has long-living connections, with the IDLE command to get notified of
1732new mail deliveries.
1733`
1734 if len(c.Parse()) != 0 {
1735 c.Usage()
1736 }
1737 mustLoadConfig()
1738
1739 xctl := xctl()
1740 xctl.xwrite("stop")
1741 // Read will hang until remote has shut down.
1742 buf := make([]byte, 128)
1743 n, err := xctl.conn.Read(buf)
1744 if err == nil {
1745 log.Fatalf("expected eof after graceful shutdown, got data %q", buf[:n])
1746 } else if err != io.EOF {
1747 log.Fatalf("expected eof after graceful shutdown, got error %v", err)
1748 }
1749 fmt.Println("mox stopped")
1750}
1751
1752func cmdBackup(c *cmd) {
1753 c.params = "$destdir"
1754 c.help = `Creates a backup of the config and data directory.
1755
1756Backup copies the config directory to <destdir>/config, and creates
1757<destdir>/data with a consistent snapshot of the databases and message files
1758and copies other files from the data directory. Empty directories are not
1759copied. The backup can then be stored elsewhere for long-term storage, or used
1760to fall back to should an upgrade fail. Simply copying files in the data
1761directory while mox is running can result in unusable database files.
1762
1763Message files never change (they are read-only, though can be removed) and are
1764hard-linked so they don't consume additional space. If hardlinking fails, for
1765example when the backup destination directory is on a different file system, a
1766regular copy is made. Using a destination directory like "data/tmp/backup"
1767increases the odds hardlinking succeeds: the default systemd service file
1768specifically mounts the data directory, causing attempts to hardlink outside it
1769to fail with an error about cross-device linking.
1770
1771All files in the data directory that aren't recognized (i.e. other than known
1772database files, message files, an acme directory, the "tmp" directory, etc),
1773are stored, but with a warning.
1774
1775Remove files in the destination directory before doing another backup. The
1776backup command will not overwrite files, but print and return errors.
1777
1778Exit code 0 indicates the backup was successful. A clean successful backup does
1779not print any output, but may print warnings. Use the -verbose flag for
1780details, including timing.
1781
1782To restore a backup, first shut down mox, move away the old data directory and
1783move an earlier backed up directory in its place, run "mox verifydata
1784<datadir>", possibly with the "-fix" option, and restart mox. After the
1785restore, you may also want to run "mox bumpuidvalidity" for each account for
1786which messages in a mailbox changed, to force IMAP clients to synchronize
1787mailbox state.
1788
1789Before upgrading, to check if the upgrade will likely succeed, first make a
1790backup, then use the new mox binary to run "mox verifydata <backupdir>/data".
1791This can change the backup files (e.g. upgrade database files, move away
1792unrecognized message files), so you should make a new backup before actually
1793upgrading.
1794`
1795
1796 var verbose bool
1797 c.flag.BoolVar(&verbose, "verbose", false, "print progress")
1798 args := c.Parse()
1799 if len(args) != 1 {
1800 c.Usage()
1801 }
1802 mustLoadConfig()
1803
1804 dstDataDir, err := filepath.Abs(args[0])
1805 xcheckf(err, "making path absolute")
1806
1807 ctlcmdBackup(xctl(), dstDataDir, verbose)
1808}
1809
1810func ctlcmdBackup(ctl *ctl, dstDataDir string, verbose bool) {
1811 ctl.xwrite("backup")
1812 ctl.xwrite(dstDataDir)
1813 if verbose {
1814 ctl.xwrite("verbose")
1815 } else {
1816 ctl.xwrite("")
1817 }
1818 ctl.xstreamto(os.Stdout)
1819 ctl.xreadok()
1820}
1821
1822func cmdSetadminpassword(c *cmd) {
1823 c.help = `Set a new admin password, for the web interface.
1824
1825The password is read from stdin. Its bcrypt hash is stored in a file named
1826"adminpasswd" in the configuration directory.
1827`
1828 if len(c.Parse()) != 0 {
1829 c.Usage()
1830 }
1831 mustLoadConfig()
1832
1833 path := mox.ConfigDirPath(mox.Conf.Static.AdminPasswordFile)
1834 if path == "" {
1835 log.Fatal("no admin password file configured")
1836 }
1837
1838 pw := xreadpassword()
1839 pw, err := precis.OpaqueString.String(pw)
1840 xcheckf(err, `checking password with "precis" requirements`)
1841 hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
1842 xcheckf(err, "generating hash for password")
1843 err = os.WriteFile(path, hash, 0660)
1844 xcheckf(err, "writing hash to admin password file")
1845}
1846
1847func xreadpassword() string {
1848 fmt.Printf(`
1849Type new password. Password WILL echo.
1850
1851WARNING: Bots will try to bruteforce your password. Connections with failed
1852authentication attempts will be rate limited but attackers WILL find passwords
1853reused at other services and weak passwords. If your account is compromised,
1854spammers are likely to abuse your system, spamming your address and the wider
1855internet in your name. So please pick a random, unguessable password, preferably
1856at least 12 characters.
1857
1858`)
1859 fmt.Printf("password: ")
1860 scanner := bufio.NewScanner(os.Stdin)
1861 // The default splitter for scanners is one that splits by lines, so we
1862 // don't have to set up another one here.
1863
1864 // We discard the return value of Scan() since failing to tokenize could
1865 // either mean reaching EOF but no newline (which can be legitimate if the
1866 // CLI was programatically called to set the password, but with no trailing
1867 // newline), or an actual error. We can distinguish between the two by
1868 // calling Err() since it will return nil if it were EOF, but the actual
1869 // error if not.
1870 scanner.Scan()
1871 xcheckf(scanner.Err(), "reading stdin")
1872 // No need to trim, the scanner does not return the token in the output.
1873 pw := scanner.Text()
1874 if len(pw) < 8 {
1875 log.Fatal("password must be at least 8 characters")
1876 }
1877 return pw
1878}
1879
1880func cmdSetaccountpassword(c *cmd) {
1881 c.params = "$account"
1882 c.help = `Set new password an account.
1883
1884The password is read from stdin. Secrets derived from the password, but not the
1885password itself, are stored in the account database. The stored secrets are for
1886authentication with: scram-sha-256, scram-sha-1, cram-md5, plain text (bcrypt
1887hash).
1888
1889The parameter is an account name, as configured under Accounts in domains.conf
1890and as present in the data/accounts/ directory, not a configured email address
1891for an account.
1892`
1893 args := c.Parse()
1894 if len(args) != 1 {
1895 c.Usage()
1896 }
1897 mustLoadConfig()
1898
1899 pw := xreadpassword()
1900
1901 ctlcmdSetaccountpassword(xctl(), args[0], pw)
1902}
1903
1904func ctlcmdSetaccountpassword(ctl *ctl, account, password string) {
1905 ctl.xwrite("setaccountpassword")
1906 ctl.xwrite(account)
1907 ctl.xwrite(password)
1908 ctl.xreadok()
1909}
1910
1911func cmdDeliver(c *cmd) {
1912 c.unlisted = true
1913 c.params = "$address < message"
1914 c.help = "Deliver message to address."
1915 args := c.Parse()
1916 if len(args) != 1 {
1917 c.Usage()
1918 }
1919 mustLoadConfig()
1920 ctlcmdDeliver(xctl(), args[0])
1921}
1922
1923func ctlcmdDeliver(ctl *ctl, address string) {
1924 ctl.xwrite("deliver")
1925 ctl.xwrite(address)
1926 ctl.xreadok()
1927 ctl.xstreamfrom(os.Stdin)
1928 line := ctl.xread()
1929 if line == "ok" {
1930 fmt.Println("message delivered")
1931 } else {
1932 log.Fatalf("deliver: %s", line)
1933 }
1934}
1935
1936func cmdDKIMGenrsa(c *cmd) {
1937 c.params = ">$selector._domainkey.$domain.rsa2048.privatekey.pkcs8.pem"
1938 c.help = `Generate a new 2048 bit RSA private key for use with DKIM.
1939
1940The generated file is in PEM format, and has a comment it is generated for use
1941with DKIM, by mox.
1942`
1943 if len(c.Parse()) != 0 {
1944 c.Usage()
1945 }
1946
1947 buf, err := admin.MakeDKIMRSAKey(dns.Domain{}, dns.Domain{})
1948 xcheckf(err, "making rsa private key")
1949 _, err = os.Stdout.Write(buf)
1950 xcheckf(err, "writing rsa private key")
1951}
1952
1953// todo: options for specifying the domain this is the mx host of, and enabling dane and/or mta-sts verification
1954func cmdSMTPDial(c *cmd) {
1955 c.params = "$host[:$port]"
1956
1957 var tlsCerts, tlsCiphersuites, tlsCurves, tlsVersionMin, tlsVersionMax, tlsRenegotiation string
1958 var tlsVerify, noTLS, forceTLS, tlsNoSessionTickets, tlsNoDynamicRecordSizing bool
1959 var ehloHostnameStr, remoteHostnameStr string
1960
1961 ciphersuites := map[string]*tls.CipherSuite{}
1962 ciphersuitesInsecure := map[string]*tls.CipherSuite{}
1963 for _, v := range tls.CipherSuites() {
1964 if slices.Contains(v.SupportedVersions, tls.VersionTLS10) || slices.Contains(v.SupportedVersions, tls.VersionTLS11) || slices.Contains(v.SupportedVersions, tls.VersionTLS12) {
1965 ciphersuites[strings.ToLower(v.Name)] = v
1966 }
1967 }
1968 for _, v := range tls.InsecureCipherSuites() {
1969 if slices.Contains(v.SupportedVersions, tls.VersionTLS10) || slices.Contains(v.SupportedVersions, tls.VersionTLS11) || slices.Contains(v.SupportedVersions, tls.VersionTLS12) {
1970 ciphersuitesInsecure[strings.ToLower(v.Name)] = v
1971 }
1972 }
1973
1974 curves := map[string]tls.CurveID{}
1975 for _, a := range curvesList {
1976 curves[strings.ToLower(a.String())] = a
1977 }
1978
1979 c.flag.StringVar(&tlsCiphersuites, "tlsciphersuites", "", "ciphersuites to allow, comma-separated, order is ignored, only for TLS 1.2 and earlier, empty value uses TLS stack defaults; values: "+strings.Join(slices.Sorted(maps.Keys(ciphersuites)), ", ")+", and insecure: "+strings.Join(slices.Sorted(maps.Keys(ciphersuitesInsecure)), ", "))
1980 c.flag.StringVar(&tlsCurves, "tlscurves", "", "tls ecc key exchange mechanisms to allow, comma-separated, order is ignored, empty value uses TLS stack defaults; values: curvep256, curvep384, curvep521, x25519, x25519mlkem768")
1981 c.flag.StringVar(&tlsCerts, "tlscerts", "", "path to root ca certificates in pem form, for verification")
1982 c.flag.StringVar(&tlsVersionMin, "tlsversionmin", "", "minimum TLS version, empty value uses TLS stack default; values: tls1.2, etc.")
1983 c.flag.StringVar(&tlsVersionMax, "tlsversionmax", "", "maximum TLS version, empty value uses TLS stack default; values: tls1.2, etc.")
1984 c.flag.BoolVar(&tlsVerify, "tlsverify", false, "verify remote hostname during TLS")
1985 c.flag.BoolVar(&tlsNoSessionTickets, "tlsnosessiontickets", false, "disable TLS session tickets")
1986 c.flag.BoolVar(&tlsNoDynamicRecordSizing, "tlsnodynamicrecordsizing", false, "disable TLS dynamic record sizing")
1987 c.flag.BoolVar(&noTLS, "notls", false, "do not use TLS")
1988 c.flag.BoolVar(&forceTLS, "forcetls", false, "use TLS, even if remote SMTP server does not announce STARTTLS extension")
1989 c.flag.StringVar(&tlsRenegotiation, "tlsrenegotiation", "never", "when to allow renegotiation; only applies to tls1.2 and earlier, not tls1.3; values: never, once, always")
1990 c.flag.StringVar(&ehloHostnameStr, "ehlohostname", "", "our hostname to use during the SMTP EHLO command")
1991 c.flag.StringVar(&remoteHostnameStr, "remotehostname", "", "remote hostname to use for TLS verification, if enabled; the hostname from the parameter is used by default")
1992
1993 c.help = `Dial the address, initialize the SMTP session, including using STARTTLS to enable TLS if the server supports it.
1994
1995If no port is specified, SMTP port 25 is used.
1996
1997Data is copied between connection and stdin/stdout until either side closes the
1998connection.
1999
2000The flags influence the TLS configuration, useful for debugging interoperability
2001issues.
2002
2003No MTA-STS or DANE verification is done.
2004
2005Hint: Use "mox -loglevel trace smtp dial ..." to see the protocol messages
2006exchanged during connection set up.
2007`
2008 args := c.Parse()
2009 if len(args) != 1 {
2010 c.Usage()
2011 }
2012
2013 if noTLS && forceTLS {
2014 log.Fatalf("cannot have both -notls and -forcetls")
2015 }
2016
2017 parseTLSVersion := func(s string) uint16 {
2018 switch s {
2019 case "tls1.0":
2020 return tls.VersionTLS10
2021 case "tls1.1":
2022 return tls.VersionTLS11
2023 case "tls1.2":
2024 return tls.VersionTLS12
2025 case "tls1.3":
2026 return tls.VersionTLS13
2027 case "":
2028 return 0
2029 default:
2030 log.Fatalf("invalid tls version %q", s)
2031 panic("not reached")
2032 }
2033 }
2034 tlsConfig := tls.Config{
2035 MinVersion: parseTLSVersion(tlsVersionMin),
2036 MaxVersion: parseTLSVersion(tlsVersionMax),
2037 InsecureSkipVerify: !tlsVerify,
2038 SessionTicketsDisabled: tlsNoSessionTickets,
2039 DynamicRecordSizingDisabled: tlsNoDynamicRecordSizing,
2040 }
2041
2042 switch tlsRenegotiation {
2043 case "never":
2044 tlsConfig.Renegotiation = tls.RenegotiateNever
2045 case "once":
2046 tlsConfig.Renegotiation = tls.RenegotiateOnceAsClient
2047 case "always":
2048 tlsConfig.Renegotiation = tls.RenegotiateFreelyAsClient
2049 default:
2050 log.Fatalf("invalid value %q for -tlsrenegotation", tlsRenegotiation)
2051 }
2052 if tlsCerts != "" {
2053 pool := x509.NewCertPool()
2054 pembuf, err := os.ReadFile(tlsCerts)
2055 xcheckf(err, "reading tls certificates")
2056 ok := pool.AppendCertsFromPEM(pembuf)
2057 if !ok {
2058 c.log.Warn("no tls certificates found", slog.String("path", tlsCerts))
2059 }
2060 tlsConfig.RootCAs = pool
2061 }
2062 if tlsCiphersuites != "" {
2063 for s := range strings.SplitSeq(tlsCiphersuites, ",") {
2064 s = strings.TrimSpace(s)
2065 c, ok := ciphersuites[s]
2066 if !ok {
2067 c, ok = ciphersuitesInsecure[s]
2068 }
2069 if !ok {
2070 log.Fatalf("unknown ciphersuite %q", s)
2071 }
2072 tlsConfig.CipherSuites = append(tlsConfig.CipherSuites, c.ID)
2073 }
2074 }
2075 if tlsCurves != "" {
2076 for s := range strings.SplitSeq(tlsCurves, ",") {
2077 s = strings.TrimSpace(s)
2078 if c, ok := curves[s]; !ok {
2079 log.Fatalf("unknown ecc key exchange algorithm %q", s)
2080 } else {
2081 tlsConfig.CurvePreferences = append(tlsConfig.CurvePreferences, c)
2082 }
2083 }
2084 }
2085
2086 var host, portStr string
2087 var err error
2088 host, portStr, err = net.SplitHostPort(args[0])
2089 if err != nil {
2090 host = args[0]
2091 portStr = "25"
2092 }
2093 port, err := strconv.ParseInt(portStr, 10, 64)
2094 xcheckf(err, "parsing port %q", portStr)
2095
2096 if remoteHostnameStr == "" {
2097 remoteHostnameStr = host
2098 }
2099 remoteHostname, err := dns.ParseDomain(remoteHostnameStr)
2100 xcheckf(err, "parsing remote host")
2101 tlsConfig.ServerName = remoteHostname.Name()
2102
2103 resolver := dns.StrictResolver{Pkg: "smtpdial"}
2104 _, _, _, ips, _, err := smtpclient.GatherIPs(context.Background(), c.log.Logger, resolver, "ip", dns.IPDomain{Domain: remoteHostname}, nil)
2105 xcheckf(err, "resolve host")
2106 c.log.Info("resolved remote address", slog.Any("ips", ips))
2107
2108 dialer := &net.Dialer{Timeout: 5 * time.Second}
2109 dialedIPs := map[string][]net.IP{}
2110 conn, ip, err := smtpclient.Dial(context.Background(), c.log.Logger, dialer, dns.IPDomain{Domain: remoteHostname}, ips, int(port), dialedIPs, nil)
2111 xcheckf(err, "dial")
2112 c.log.Info("connected to remote host", slog.Any("ip", ip))
2113
2114 tlsMode := smtpclient.TLSOpportunistic
2115 if forceTLS {
2116 tlsMode = smtpclient.TLSRequiredStartTLS
2117 } else if noTLS {
2118 tlsMode = smtpclient.TLSSkip
2119 }
2120 var ehloHostname dns.Domain
2121 if ehloHostnameStr == "" {
2122 name, err := os.Hostname()
2123 xcheckf(err, "get hostname")
2124 ehloHostnameStr = name
2125 }
2126 ehloHostname, err = dns.ParseDomain(ehloHostnameStr)
2127 xcheckf(err, "parse hostname")
2128
2129 opts := smtpclient.Opts{
2130 TLSConfig: &tlsConfig,
2131 }
2132 client, err := smtpclient.New(context.Background(), c.log.Logger, conn, tlsMode, false, ehloHostname, dns.Domain{}, opts)
2133 xcheckf(err, "new smtp client")
2134
2135 cs := client.TLSConnectionState()
2136 if cs == nil {
2137 c.log.Info("smtp initialized without tls")
2138 } else {
2139 c.log.Info("smtp initialized with tls",
2140 slog.String("version", tls.VersionName(cs.Version)),
2141 slog.String("ciphersuite", strings.ToLower(tls.CipherSuiteName(cs.CipherSuite))),
2142 slog.String("sni", cs.ServerName),
2143 )
2144 for _, chain := range cs.VerifiedChains {
2145 var l []string
2146 for _, cert := range chain {
2147 s := fmt.Sprintf("dns names %q, common name %q, %s - %s, issuer %q)", strings.Join(cert.DNSNames, ","), cert.Subject.CommonName, cert.NotBefore.Format("2006-01-02T15:04:05"), cert.NotAfter.Format("2006-01-02T15:04:05"), cert.Issuer.CommonName)
2148 l = append(l, s)
2149 }
2150 c.log.Info("tls certificate verification chain", slog.String("chain", strings.Join(l, "; ")))
2151 }
2152 }
2153
2154 conn, err = client.Conn()
2155 xcheckf(err, "get smtp session connection")
2156
2157 go func() {
2158 _, err := io.Copy(os.Stdout, conn)
2159 xcheckf(err, "copy from connection to stdout")
2160 err = conn.Close()
2161 c.log.Check(err, "closing connection")
2162 }()
2163 _, err = io.Copy(conn, os.Stdin)
2164 xcheckf(err, "copy from stdin to connection")
2165}
2166
2167func cmdDANEDial(c *cmd) {
2168 c.params = "$host:$port"
2169 var usages string
2170 c.flag.StringVar(&usages, "usages", "pkix-ta,pkix-ee,dane-ta,dane-ee", "allowed usages for dane, comma-separated list")
2171 c.help = `Dial the address using TLS with certificate verification using DANE.
2172
2173Data is copied between connection and stdin/stdout until either side closes the
2174connection.
2175`
2176 args := c.Parse()
2177 if len(args) != 1 {
2178 c.Usage()
2179 }
2180
2181 allowedUsages := []adns.TLSAUsage{}
2182 if usages != "" {
2183 for s := range strings.SplitSeq(usages, ",") {
2184 var usage adns.TLSAUsage
2185 switch strings.ToLower(s) {
2186 case "pkix-ta", strconv.Itoa(int(adns.TLSAUsagePKIXTA)):
2187 usage = adns.TLSAUsagePKIXTA
2188 case "pkix-ee", strconv.Itoa(int(adns.TLSAUsagePKIXEE)):
2189 usage = adns.TLSAUsagePKIXEE
2190 case "dane-ta", strconv.Itoa(int(adns.TLSAUsageDANETA)):
2191 usage = adns.TLSAUsageDANETA
2192 case "dane-ee", strconv.Itoa(int(adns.TLSAUsageDANEEE)):
2193 usage = adns.TLSAUsageDANEEE
2194 default:
2195 log.Fatalf("unknown dane usage %q", s)
2196 }
2197 allowedUsages = append(allowedUsages, usage)
2198 }
2199 }
2200
2201 pkixRoots, err := x509.SystemCertPool()
2202 xcheckf(err, "get system pkix certificate pool")
2203
2204 resolver := dns.StrictResolver{Pkg: "danedial"}
2205 conn, record, err := dane.Dial(context.Background(), c.log.Logger, resolver, "tcp", args[0], allowedUsages, pkixRoots)
2206 xcheckf(err, "dial")
2207 log.Printf("(connected, verified with %s)", record)
2208
2209 go func() {
2210 _, err := io.Copy(os.Stdout, conn)
2211 xcheckf(err, "copy from connection to stdout")
2212 err = conn.Close()
2213 c.log.Check(err, "closing connection")
2214 }()
2215 _, err = io.Copy(conn, os.Stdin)
2216 xcheckf(err, "copy from stdin to connection")
2217}
2218
2219func cmdDANEDialmx(c *cmd) {
2220 c.params = "$domain [$desthost]"
2221 var ehloHostname string
2222 c.flag.StringVar(&ehloHostname, "ehlohostname", "localhost", "hostname to send in smtp ehlo command")
2223 c.help = `Connect to MX server for domain using STARTTLS verified with DANE.
2224
2225If no destination host is specified, regular delivery logic is used to find the
2226hosts to attempt delivery too. This involves following CNAMEs for the domain,
2227looking up MX records, and possibly falling back to the domain name itself as
2228host.
2229
2230If a destination host is specified, that is the only candidate host considered
2231for dialing.
2232
2233With a list of destinations gathered, each is dialed until a successful SMTP
2234session verified with DANE has been initialized, including EHLO and STARTTLS
2235commands.
2236
2237Once connected, data is copied between connection and stdin/stdout, until
2238either side closes the connection.
2239
2240This command follows the same logic as delivery attempts made from the queue,
2241sharing most of its code.
2242`
2243 args := c.Parse()
2244 if len(args) != 1 && len(args) != 2 {
2245 c.Usage()
2246 }
2247
2248 ehloDomain := xparseDomain(ehloHostname, "ehlo host name")
2249 origNextHop := xparseDomain(args[0], "domain")
2250
2251 ctxbg := context.Background()
2252
2253 resolver := dns.StrictResolver{}
2254 var haveMX bool
2255 var expandedNextHopAuthentic bool
2256 var expandedNextHop dns.Domain
2257 var hostPrefs []smtpclient.HostPref
2258 if len(args) == 1 {
2259 var permanent bool
2260 var origNextHopAuthentic bool
2261 var err error
2262 haveMX, origNextHopAuthentic, expandedNextHopAuthentic, expandedNextHop, hostPrefs, permanent, err = smtpclient.GatherDestinations(ctxbg, c.log.Logger, resolver, dns.IPDomain{Domain: origNextHop})
2263 status := "temporary"
2264 if permanent {
2265 status = "permanent"
2266 }
2267 if err != nil {
2268 log.Fatalf("gathering destinations: %v (%s)", err, status)
2269 }
2270 if expandedNextHop != origNextHop {
2271 log.Printf("followed cnames to %s", expandedNextHop)
2272 }
2273 if haveMX {
2274 log.Printf("found mx record, trying mx hosts")
2275 } else {
2276 log.Printf("no mx record found, will try to connect to domain directly")
2277 }
2278 if !origNextHopAuthentic {
2279 log.Fatalf("error: initial domain not dnssec-secure")
2280 }
2281 if !expandedNextHopAuthentic {
2282 log.Fatalf("error: expanded domain not dnssec-secure")
2283 }
2284
2285 l := []string{}
2286 for _, hp := range hostPrefs {
2287 s := hp.Host.String()
2288 if hp.Pref >= 0 {
2289 s += fmt.Sprintf(" (pref %d)", hp.Pref)
2290 }
2291 l = append(l, s)
2292 }
2293 log.Printf("destinations: %s", strings.Join(l, ", "))
2294 } else {
2295 d := xparseDomain(args[1], "destination host")
2296 log.Printf("skipping domain mx/cname lookups, assuming domain is dnssec-protected")
2297
2298 expandedNextHopAuthentic = true
2299 expandedNextHop = d
2300 hostPrefs = []smtpclient.HostPref{{Host: dns.IPDomain{Domain: d}, Pref: -1}}
2301 }
2302
2303 dialedIPs := map[string][]net.IP{}
2304 for _, hp := range hostPrefs {
2305 host := hp.Host
2306
2307 log.Printf("attempting to connect to %s (pref %d)", host, hp.Pref)
2308
2309 authentic, expandedAuthentic, expandedHost, ips, _, err := smtpclient.GatherIPs(ctxbg, c.log.Logger, resolver, "ip", host, dialedIPs)
2310 if err != nil {
2311 log.Printf("resolving ips for %s: %v, skipping", host, err)
2312 continue
2313 }
2314 if !authentic {
2315 log.Printf("no dnssec for ips of %s, skipping", host)
2316 continue
2317 }
2318 if !expandedAuthentic {
2319 log.Printf("no dnssec for cname-followed ips of %s, skipping", host)
2320 continue
2321 }
2322 if expandedHost != host.Domain {
2323 log.Printf("host %s cname-expanded to %s", host, expandedHost)
2324 }
2325 log.Printf("host %s resolved to ips %s, looking up tlsa records", host, ips)
2326
2327 daneRequired, daneRecords, tlsaBaseDomain, err := smtpclient.GatherTLSA(ctxbg, c.log.Logger, resolver, host.Domain, expandedAuthentic, expandedHost)
2328 if err != nil {
2329 log.Printf("looking up tlsa records: %s, skipping", err)
2330 continue
2331 }
2332 tlsMode := smtpclient.TLSRequiredStartTLS
2333 if len(daneRecords) == 0 {
2334 if !daneRequired {
2335 log.Printf("host %s has no tlsa records, skipping", expandedHost)
2336 continue
2337 }
2338 log.Printf("warning: only unusable tlsa records found, continuing with required tls without certificate verification")
2339 daneRecords = nil
2340 } else {
2341 var l []string
2342 for _, r := range daneRecords {
2343 l = append(l, r.String())
2344 }
2345 log.Printf("tlsa records: %s", strings.Join(l, "; "))
2346 }
2347
2348 tlsHostnames := smtpclient.GatherTLSANames(haveMX, expandedNextHopAuthentic, expandedAuthentic, origNextHop, expandedNextHop, host.Domain, tlsaBaseDomain)
2349 var l []string
2350 for _, name := range tlsHostnames {
2351 l = append(l, name.String())
2352 }
2353 log.Printf("gathered valid tls certificate names for potential verification with dane-ta: %s", strings.Join(l, ", "))
2354
2355 dialer := &net.Dialer{Timeout: 5 * time.Second}
2356 conn, _, err := smtpclient.Dial(ctxbg, c.log.Logger, dialer, dns.IPDomain{Domain: expandedHost}, ips, 25, dialedIPs, nil)
2357 if err != nil {
2358 log.Printf("dial %s: %v, skipping", expandedHost, err)
2359 continue
2360 }
2361 log.Printf("connected to %s, %s, starting smtp session with ehlo and starttls with dane verification", expandedHost, conn.RemoteAddr())
2362
2363 var verifiedRecord adns.TLSA
2364 opts := smtpclient.Opts{
2365 DANERecords: daneRecords,
2366 DANEMoreHostnames: tlsHostnames[1:],
2367 DANEVerifiedRecord: &verifiedRecord,
2368 RootCAs: mox.Conf.Static.TLS.CertPool,
2369 }
2370 tlsPKIX := false
2371 sc, err := smtpclient.New(ctxbg, c.log.Logger, conn, tlsMode, tlsPKIX, ehloDomain, tlsHostnames[0], opts)
2372 if err != nil {
2373 log.Printf("setting up smtp session: %v, skipping", err)
2374 if xerr := conn.Close(); xerr != nil {
2375 log.Printf("closing connection: %v", xerr)
2376 }
2377 continue
2378 }
2379
2380 smtpConn, err := sc.Conn()
2381 if err != nil {
2382 log.Fatalf("error: taking over smtp connection: %s", err)
2383 }
2384 log.Printf("tls verified with tlsa record: %s", verifiedRecord)
2385 log.Printf("smtp session initialized and connected to stdin/stdout")
2386
2387 go func() {
2388 _, err := io.Copy(os.Stdout, smtpConn)
2389 xcheckf(err, "copy from connection to stdout")
2390 if err := smtpConn.Close(); err != nil {
2391 log.Printf("closing smtp connection: %v", err)
2392 }
2393 }()
2394 _, err = io.Copy(smtpConn, os.Stdin)
2395 xcheckf(err, "copy from stdin to connection")
2396 }
2397
2398 log.Fatalf("no remaining destinations")
2399}
2400
2401func cmdDANEMakeRecord(c *cmd) {
2402 c.params = "$usage $selector $matchtype [certificate.pem | publickey.pem | privatekey.pem]"
2403 c.help = `Print TLSA record for given certificate/key and parameters.
2404
2405Valid values:
2406- usage: pkix-ta (0), pkix-ee (1), dane-ta (2), dane-ee (3)
2407- selector: cert (0), spki (1)
2408- matchtype: full (0), sha2-256 (1), sha2-512 (2)
2409
2410Common DANE TLSA record parameters are: dane-ee spki sha2-256, or 3 1 1,
2411followed by a sha2-256 hash of the DER-encoded "SPKI" (subject public key info)
2412from the certificate. An example DNS zone file entry:
2413
2414 _25._tcp.example.com. TLSA 3 1 1 133b919c9d65d8b1488157315327334ead8d83372db57465ecabf53ee5748aee
2415
2416The first usable information from the pem file is used to compose the TLSA
2417record. In case of selector "cert", a certificate is required. Otherwise the
2418"subject public key info" (spki) of the first certificate or public or private
2419key (pkcs#8, pkcs#1 or ec private key) is used.
2420`
2421
2422 args := c.Parse()
2423 if len(args) != 4 {
2424 c.Usage()
2425 }
2426
2427 var usage adns.TLSAUsage
2428 switch strings.ToLower(args[0]) {
2429 case "pkix-ta", strconv.Itoa(int(adns.TLSAUsagePKIXTA)):
2430 usage = adns.TLSAUsagePKIXTA
2431 case "pkix-ee", strconv.Itoa(int(adns.TLSAUsagePKIXEE)):
2432 usage = adns.TLSAUsagePKIXEE
2433 case "dane-ta", strconv.Itoa(int(adns.TLSAUsageDANETA)):
2434 usage = adns.TLSAUsageDANETA
2435 case "dane-ee", strconv.Itoa(int(adns.TLSAUsageDANEEE)):
2436 usage = adns.TLSAUsageDANEEE
2437 default:
2438 if v, err := strconv.ParseUint(args[0], 10, 16); err != nil {
2439 log.Fatalf("bad usage %q", args[0])
2440 } else {
2441 // Does not influence certificate association data, so we can accept other numbers.
2442 log.Printf("warning: continuing with unrecognized tlsa usage %d", v)
2443 usage = adns.TLSAUsage(v)
2444 }
2445 }
2446
2447 var selector adns.TLSASelector
2448 switch strings.ToLower(args[1]) {
2449 case "cert", strconv.Itoa(int(adns.TLSASelectorCert)):
2450 selector = adns.TLSASelectorCert
2451 case "spki", strconv.Itoa(int(adns.TLSASelectorSPKI)):
2452 selector = adns.TLSASelectorSPKI
2453 default:
2454 log.Fatalf("bad selector %q", args[1])
2455 }
2456
2457 var matchType adns.TLSAMatchType
2458 switch strings.ToLower(args[2]) {
2459 case "full", strconv.Itoa(int(adns.TLSAMatchTypeFull)):
2460 matchType = adns.TLSAMatchTypeFull
2461 case "sha2-256", strconv.Itoa(int(adns.TLSAMatchTypeSHA256)):
2462 matchType = adns.TLSAMatchTypeSHA256
2463 case "sha2-512", strconv.Itoa(int(adns.TLSAMatchTypeSHA512)):
2464 matchType = adns.TLSAMatchTypeSHA512
2465 default:
2466 log.Fatalf("bad matchtype %q", args[2])
2467 }
2468
2469 buf, err := os.ReadFile(args[3])
2470 xcheckf(err, "reading certificate")
2471 for {
2472 var block *pem.Block
2473 block, buf = pem.Decode(buf)
2474 if block == nil {
2475 extra := ""
2476 if len(buf) > 0 {
2477 extra = " (with leftover data from pem file)"
2478 }
2479 if selector == adns.TLSASelectorCert {
2480 log.Fatalf("no certificate found in pem file%s", extra)
2481 } else {
2482 log.Fatalf("no certificate or public or private key found in pem file%s", extra)
2483 }
2484 }
2485 var cert *x509.Certificate
2486 var data []byte
2487 if block.Type == "CERTIFICATE" {
2488 cert, err = x509.ParseCertificate(block.Bytes)
2489 xcheckf(err, "parse certificate")
2490 switch selector {
2491 case adns.TLSASelectorCert:
2492 data = cert.Raw
2493 case adns.TLSASelectorSPKI:
2494 data = cert.RawSubjectPublicKeyInfo
2495 }
2496 } else if selector == adns.TLSASelectorCert {
2497 // We need a certificate, just a public/private key won't do.
2498 log.Printf("skipping pem type %q, certificate is required", block.Type)
2499 continue
2500 } else {
2501 var privKey, pubKey any
2502 var err error
2503 switch block.Type {
2504 case "PUBLIC KEY":
2505 _, err := x509.ParsePKIXPublicKey(block.Bytes)
2506 xcheckf(err, "parse pkix subject public key info (spki)")
2507 data = block.Bytes
2508 case "EC PRIVATE KEY":
2509 privKey, err = x509.ParseECPrivateKey(block.Bytes)
2510 xcheckf(err, "parse ec private key")
2511 case "RSA PRIVATE KEY":
2512 privKey, err = x509.ParsePKCS1PrivateKey(block.Bytes)
2513 xcheckf(err, "parse pkcs#1 rsa private key")
2514 case "RSA PUBLIC KEY":
2515 pubKey, err = x509.ParsePKCS1PublicKey(block.Bytes)
2516 xcheckf(err, "parse pkcs#1 rsa public key")
2517 case "PRIVATE KEY":
2518 // PKCS#8 private key
2519 privKey, err = x509.ParsePKCS8PrivateKey(block.Bytes)
2520 xcheckf(err, "parse pkcs#8 private key")
2521 default:
2522 log.Printf("skipping unrecognized pem type %q", block.Type)
2523 continue
2524 }
2525 if data == nil {
2526 if pubKey == nil && privKey != nil {
2527 if signer, ok := privKey.(crypto.Signer); !ok {
2528 log.Fatalf("private key of type %T is not a signer, cannot get public key", privKey)
2529 } else {
2530 pubKey = signer.Public()
2531 }
2532 }
2533 if pubKey == nil {
2534 // Should not happen.
2535 log.Fatalf("internal error: did not find private or public key")
2536 }
2537 data, err = x509.MarshalPKIXPublicKey(pubKey)
2538 xcheckf(err, "marshal pkix subject public key info (spki)")
2539 }
2540 }
2541
2542 switch matchType {
2543 case adns.TLSAMatchTypeFull:
2544 case adns.TLSAMatchTypeSHA256:
2545 p := sha256.Sum256(data)
2546 data = p[:]
2547 case adns.TLSAMatchTypeSHA512:
2548 p := sha512.Sum512(data)
2549 data = p[:]
2550 }
2551 fmt.Printf("%d %d %d %x\n", usage, selector, matchType, data)
2552 break
2553 }
2554}
2555
2556func cmdDNSLookup(c *cmd) {
2557 c.params = "[ptr | mx | cname | ips | a | aaaa | ns | txt | srv | tlsa] $name"
2558 c.help = `Lookup DNS name of given type.
2559
2560Lookup always prints whether the response was DNSSEC-protected.
2561
2562Examples:
2563
2564mox dns lookup ptr 1.1.1.1
2565mox dns lookup mx xmox.nl
2566mox dns lookup txt _dmarc.xmox.nl.
2567mox dns lookup tlsa _25._tcp.xmox.nl
2568`
2569 args := c.Parse()
2570
2571 if len(args) != 2 {
2572 c.Usage()
2573 }
2574
2575 resolver := dns.StrictResolver{Pkg: "dns"}
2576
2577 // like xparseDomain, but treat unparseable domain as an ASCII name so names with
2578 // underscores are still looked up, e,g <selector>._domainkey.<host>.
2579 xdomain := func(s string) dns.Domain {
2580 d, err := dns.ParseDomain(s)
2581 if err != nil {
2582 return dns.Domain{ASCII: strings.TrimSuffix(s, ".")}
2583 }
2584 return d
2585 }
2586
2587 cmd, name := args[0], args[1]
2588
2589 switch cmd {
2590 case "ptr":
2591 ip := xparseIP(name, "ip")
2592 ptrs, result, err := resolver.LookupAddr(context.Background(), ip.String())
2593 if err != nil {
2594 log.Fatalf("dns lookup: %v (%s)", err, dnssecStatus(result.Authentic))
2595 }
2596 fmt.Printf("names (%d, %s):\n", len(ptrs), dnssecStatus(result.Authentic))
2597 for _, ptr := range ptrs {
2598 fmt.Printf("- %s\n", ptr)
2599 }
2600
2601 case "mx":
2602 name := xdomain(name)
2603 mxl, result, err := resolver.LookupMX(context.Background(), name.ASCII+".")
2604 if err != nil {
2605 log.Printf("dns lookup: %v (%s)", err, dnssecStatus(result.Authentic))
2606 // We can still have valid records...
2607 }
2608 fmt.Printf("mx records (%d, %s):\n", len(mxl), dnssecStatus(result.Authentic))
2609 for _, mx := range mxl {
2610 fmt.Printf("- %s, preference %d\n", mx.Host, mx.Pref)
2611 }
2612
2613 case "cname":
2614 name := xdomain(name)
2615 target, result, err := resolver.LookupCNAME(context.Background(), name.ASCII+".")
2616 if err != nil {
2617 log.Fatalf("dns lookup: %v (%s)", err, dnssecStatus(result.Authentic))
2618 }
2619 fmt.Printf("%s (%s)\n", target, dnssecStatus(result.Authentic))
2620
2621 case "ips", "a", "aaaa":
2622 network := "ip"
2623 if cmd == "a" {
2624 network = "ip4"
2625 } else if cmd == "aaaa" {
2626 network = "ip6"
2627 }
2628 name := xdomain(name)
2629 ips, result, err := resolver.LookupIP(context.Background(), network, name.ASCII+".")
2630 if err != nil {
2631 log.Fatalf("dns lookup: %v (%s)", err, dnssecStatus(result.Authentic))
2632 }
2633 fmt.Printf("records (%d, %s):\n", len(ips), dnssecStatus(result.Authentic))
2634 for _, ip := range ips {
2635 fmt.Printf("- %s\n", ip)
2636 }
2637
2638 case "ns":
2639 name := xdomain(name)
2640 nsl, result, err := resolver.LookupNS(context.Background(), name.ASCII+".")
2641 if err != nil {
2642 log.Fatalf("dns lookup: %v (%s)", err, dnssecStatus(result.Authentic))
2643 }
2644 fmt.Printf("ns records (%d, %s):\n", len(nsl), dnssecStatus(result.Authentic))
2645 for _, ns := range nsl {
2646 fmt.Printf("- %s\n", ns)
2647 }
2648
2649 case "txt":
2650 host := xdomain(name)
2651 l, result, err := resolver.LookupTXT(context.Background(), host.ASCII+".")
2652 if err != nil {
2653 log.Fatalf("dns lookup: %v (%s)", err, dnssecStatus(result.Authentic))
2654 }
2655 fmt.Printf("txt records (%d, %s):\n", len(l), dnssecStatus(result.Authentic))
2656 for _, txt := range l {
2657 fmt.Printf("- %s\n", txt)
2658 }
2659
2660 case "srv":
2661 host := xdomain(name)
2662 _, l, result, err := resolver.LookupSRV(context.Background(), "", "", host.ASCII+".")
2663 if err != nil {
2664 log.Fatalf("dns lookup: %v (%s)", err, dnssecStatus(result.Authentic))
2665 }
2666 fmt.Printf("srv records (%d, %s):\n", len(l), dnssecStatus(result.Authentic))
2667 for _, srv := range l {
2668 fmt.Printf("- host %s, port %d, priority %d, weight %d\n", srv.Target, srv.Port, srv.Priority, srv.Weight)
2669 }
2670
2671 case "tlsa":
2672 host := xdomain(name)
2673 l, result, err := resolver.LookupTLSA(context.Background(), 0, "", host.ASCII+".")
2674 if err != nil {
2675 log.Fatalf("dns lookup: %v (%s)", err, dnssecStatus(result.Authentic))
2676 }
2677 fmt.Printf("tlsa records (%d, %s):\n", len(l), dnssecStatus(result.Authentic))
2678 for _, tlsa := range l {
2679 fmt.Printf("- usage %q (%d), selector %q (%d), matchtype %q (%d), certificate association data %x\n", tlsa.Usage, tlsa.Usage, tlsa.Selector, tlsa.Selector, tlsa.MatchType, tlsa.MatchType, tlsa.CertAssoc)
2680 }
2681 default:
2682 log.Fatalf("unknown record type %q", args[0])
2683 }
2684}
2685
2686func cmdDKIMGened25519(c *cmd) {
2687 c.params = ">$selector._domainkey.$domain.ed25519.privatekey.pkcs8.pem"
2688 c.help = `Generate a new ed25519 key for use with DKIM.
2689
2690Ed25519 keys are much smaller than RSA keys of comparable cryptographic
2691strength. This is convenient because of maximum DNS message sizes. At the time
2692of writing, not many mail servers appear to support ed25519 DKIM keys though,
2693so it is recommended to sign messages with both RSA and ed25519 keys.
2694`
2695 if len(c.Parse()) != 0 {
2696 c.Usage()
2697 }
2698
2699 buf, err := admin.MakeDKIMEd25519Key(dns.Domain{}, dns.Domain{})
2700 xcheckf(err, "making dkim ed25519 key")
2701 _, err = os.Stdout.Write(buf)
2702 xcheckf(err, "writing dkim ed25519 key")
2703}
2704
2705func cmdDKIMTXT(c *cmd) {
2706 c.params = "<$selector._domainkey.$domain.key.pkcs8.pem"
2707 c.help = `Print a DKIM DNS TXT record with the public key derived from the private key read from stdin.
2708
2709The DNS should be configured as a TXT record at $selector._domainkey.$domain.
2710`
2711 if len(c.Parse()) != 0 {
2712 c.Usage()
2713 }
2714
2715 privKey, err := parseDKIMKey(os.Stdin)
2716 xcheckf(err, "reading dkim private key from stdin")
2717
2718 r := dkim.Record{
2719 Version: "DKIM1",
2720 Hashes: []string{"sha256"},
2721 Flags: []string{"s"},
2722 }
2723
2724 switch key := privKey.(type) {
2725 case *rsa.PrivateKey:
2726 r.PublicKey = key.Public()
2727 case ed25519.PrivateKey:
2728 r.PublicKey = key.Public()
2729 r.Key = "ed25519"
2730 default:
2731 log.Fatalf("unsupported private key type %T, must be rsa or ed25519", privKey)
2732 }
2733
2734 record, err := r.Record()
2735 xcheckf(err, "making record")
2736 fmt.Print("<selector>._domainkey.<your.domain.> TXT ")
2737 for record != "" {
2738 s := record
2739 if len(s) > 100 {
2740 s, record = record[:100], record[100:]
2741 } else {
2742 record = ""
2743 }
2744 fmt.Printf(`"%s" `, s)
2745 }
2746 fmt.Println("")
2747}
2748
2749func parseDKIMKey(r io.Reader) (any, error) {
2750 buf, err := io.ReadAll(r)
2751 if err != nil {
2752 return nil, fmt.Errorf("reading pem from stdin: %v", err)
2753 }
2754 b, _ := pem.Decode(buf)
2755 if b == nil {
2756 return nil, fmt.Errorf("decoding pem: %v", err)
2757 }
2758 privKey, err := x509.ParsePKCS8PrivateKey(b.Bytes)
2759 if err != nil {
2760 return nil, fmt.Errorf("parsing private key: %v", err)
2761 }
2762 return privKey, nil
2763}
2764
2765func cmdDKIMVerify(c *cmd) {
2766 c.params = "$messagefile"
2767 c.help = `Verify the DKIM signatures in a message and print the results.
2768
2769The message is parsed, and the DKIM-Signature headers are validated. Validation
2770of older messages may fail because the DNS records have been removed or changed
2771by now, or because the signature header may have specified an expiration time
2772that was passed.
2773`
2774 args := c.Parse()
2775 if len(args) != 1 {
2776 c.Usage()
2777 }
2778
2779 msgf, err := os.Open(args[0])
2780 xcheckf(err, "open message")
2781
2782 results, err := dkim.Verify(context.Background(), c.log.Logger, dns.StrictResolver{}, false, dkim.DefaultPolicy, msgf, true)
2783 xcheckf(err, "dkim verify")
2784
2785 for _, result := range results {
2786 var sigh string
2787 if result.Sig == nil {
2788 log.Printf("warning: could not parse signature")
2789 } else {
2790 sigh, err = result.Sig.Header()
2791 if err != nil {
2792 log.Printf("warning: packing signature: %s", err)
2793 }
2794 }
2795 var txt string
2796 if result.Record == nil {
2797 log.Printf("warning: missing DNS record")
2798 } else {
2799 txt, err = result.Record.Record()
2800 if err != nil {
2801 log.Printf("warning: packing record: %s", err)
2802 }
2803 }
2804 fmt.Printf("status %q, err %v\nrecord %q\nheader %s\n", result.Status, result.Err, txt, sigh)
2805 }
2806}
2807
2808func cmdDKIMSign(c *cmd) {
2809 c.params = "$messagefile"
2810 c.help = `Sign a message, adding DKIM-Signature headers based on the domain in the From header.
2811
2812The message is parsed, the domain looked up in the configuration files, and
2813DKIM-Signature headers generated. The message is printed with the DKIM-Signature
2814headers prepended.
2815`
2816 args := c.Parse()
2817 if len(args) != 1 {
2818 c.Usage()
2819 }
2820
2821 msgf, err := os.Open(args[0])
2822 xcheckf(err, "open message")
2823 defer func() {
2824 if err := msgf.Close(); err != nil {
2825 log.Printf("closing message file: %v", err)
2826 }
2827 }()
2828
2829 p, err := message.Parse(c.log.Logger, true, msgf)
2830 xcheckf(err, "parsing message")
2831
2832 if len(p.Envelope.From) != 1 {
2833 log.Fatalf("found %d from headers, need exactly 1", len(p.Envelope.From))
2834 }
2835 localpart, err := smtp.ParseLocalpart(p.Envelope.From[0].User)
2836 xcheckf(err, "parsing localpart of address in from-header")
2837 dom := xparseDomain(p.Envelope.From[0].Host, "domain of address in from-header")
2838
2839 mustLoadConfig()
2840
2841 domConf, ok := mox.Conf.Domain(dom)
2842 if !ok {
2843 log.Fatalf("domain %s not configured", dom)
2844 }
2845
2846 selectors := mox.DKIMSelectors(domConf.DKIM)
2847 headers, err := dkim.Sign(context.Background(), c.log.Logger, localpart, dom, selectors, false, msgf)
2848 xcheckf(err, "signing message with dkim")
2849 if headers == "" {
2850 log.Fatalf("no DKIM configured for domain %s", dom)
2851 }
2852 _, err = fmt.Fprint(os.Stdout, headers)
2853 xcheckf(err, "write headers")
2854 _, err = io.Copy(os.Stdout, msgf)
2855 xcheckf(err, "write message")
2856}
2857
2858func cmdDKIMLookup(c *cmd) {
2859 c.params = "$selector $domain"
2860 c.help = "Lookup and print the DKIM record for the selector at the domain."
2861 args := c.Parse()
2862 if len(args) != 2 {
2863 c.Usage()
2864 }
2865
2866 selector := xparseDomain(args[0], "selector")
2867 domain := xparseDomain(args[1], "domain")
2868
2869 status, record, txt, authentic, err := dkim.Lookup(context.Background(), c.log.Logger, dns.StrictResolver{}, selector, domain)
2870 if err != nil {
2871 fmt.Printf("error: %s\n", err)
2872 }
2873 if status != dkim.StatusNeutral {
2874 fmt.Printf("status: %s\n", status)
2875 }
2876 if txt != "" {
2877 fmt.Printf("TXT record: %s\n", txt)
2878 }
2879 if authentic {
2880 fmt.Println("dnssec-signed: yes")
2881 } else {
2882 fmt.Println("dnssec-signed: no")
2883 }
2884 if record != nil {
2885 fmt.Printf("Record:\n")
2886 pairs := []any{
2887 "version", record.Version,
2888 "hashes", record.Hashes,
2889 "key", record.Key,
2890 "notes", record.Notes,
2891 "services", record.Services,
2892 "flags", record.Flags,
2893 }
2894 for i := 0; i < len(pairs); i += 2 {
2895 fmt.Printf("\t%s: %v\n", pairs[i], pairs[i+1])
2896 }
2897 }
2898}
2899
2900func cmdDMARCLookup(c *cmd) {
2901 c.params = "$domain"
2902 c.help = "Lookup dmarc policy for domain, a DNS TXT record at _dmarc.<domain>, validate and print it."
2903 args := c.Parse()
2904 if len(args) != 1 {
2905 c.Usage()
2906 }
2907
2908 fromdomain := xparseDomain(args[0], "domain")
2909 _, domain, _, txt, authentic, err := dmarc.Lookup(context.Background(), c.log.Logger, dns.StrictResolver{}, fromdomain)
2910 xcheckf(err, "dmarc lookup domain %s", fromdomain)
2911 fmt.Printf("dmarc record at domain %s: %s\n", domain, txt)
2912 fmt.Printf("(%s)\n", dnssecStatus(authentic))
2913}
2914
2915func dnssecStatus(v bool) string {
2916 if v {
2917 return "with dnssec"
2918 }
2919 return "without dnssec"
2920}
2921
2922func cmdDMARCVerify(c *cmd) {
2923 c.params = "$remoteip $mailfromaddress $helodomain < messagefile"
2924 c.help = `Parse an email message and evaluate it against the DMARC policy of the domain in the From-header.
2925
2926mailfromaddress and helodomain are used for SPF validation. If both are empty,
2927SPF validation is skipped.
2928
2929mailfromaddress should be the address used as MAIL FROM in the SMTP session.
2930For DSN messages, that address may be empty. The helo domain was specified at
2931the beginning of the SMTP transaction that delivered the message. These values
2932can be found in message headers.
2933`
2934 args := c.Parse()
2935 if len(args) != 3 {
2936 c.Usage()
2937 }
2938
2939 var heloDomain *dns.Domain
2940
2941 remoteIP := xparseIP(args[0], "remoteip")
2942
2943 var mailfrom *smtp.Address
2944 if args[1] != "" {
2945 a, err := smtp.ParseAddress(args[1])
2946 xcheckf(err, "parsing mailfrom address")
2947 mailfrom = &a
2948 }
2949 if args[2] != "" {
2950 d := xparseDomain(args[2], "helo domain")
2951 heloDomain = &d
2952 }
2953 var received *spf.Received
2954 spfStatus := spf.StatusNone
2955 var spfIdentity *dns.Domain
2956 if mailfrom != nil || heloDomain != nil {
2957 spfArgs := spf.Args{
2958 RemoteIP: remoteIP,
2959 LocalIP: net.ParseIP("127.0.0.1"),
2960 LocalHostname: dns.Domain{ASCII: "localhost"},
2961 }
2962 if mailfrom != nil {
2963 spfArgs.MailFromLocalpart = mailfrom.Localpart
2964 spfArgs.MailFromDomain = mailfrom.Domain
2965 }
2966 if heloDomain != nil {
2967 spfArgs.HelloDomain = dns.IPDomain{Domain: *heloDomain}
2968 }
2969 rspf, spfDomain, expl, authentic, err := spf.Verify(context.Background(), c.log.Logger, dns.StrictResolver{}, spfArgs)
2970 if err != nil {
2971 log.Printf("spf verify: %v (explanation: %q, authentic %v)", err, expl, authentic)
2972 } else {
2973 received = &rspf
2974 spfStatus = received.Result
2975 // todo: should probably potentially do two separate spf validations
2976 if mailfrom != nil {
2977 spfIdentity = &mailfrom.Domain
2978 } else {
2979 spfIdentity = heloDomain
2980 }
2981 fmt.Printf("spf result: %s: %s (%s)\n", spfDomain, spfStatus, dnssecStatus(authentic))
2982 }
2983 }
2984
2985 data, err := io.ReadAll(os.Stdin)
2986 xcheckf(err, "read message")
2987 dmarcFrom, _, _, err := message.From(c.log.Logger, false, bytes.NewReader(data), nil)
2988 xcheckf(err, "extract dmarc from message")
2989
2990 const ignoreTestMode = false
2991 dkimResults, err := dkim.Verify(context.Background(), c.log.Logger, dns.StrictResolver{}, true, func(*dkim.Sig) error { return nil }, bytes.NewReader(data), ignoreTestMode)
2992 xcheckf(err, "dkim verify")
2993 for _, r := range dkimResults {
2994 fmt.Printf("dkim result: %q (err %v)\n", r.Status, r.Err)
2995 }
2996
2997 _, result := dmarc.Verify(context.Background(), c.log.Logger, dns.StrictResolver{}, dmarcFrom.Domain, dkimResults, spfStatus, spfIdentity, false)
2998 xcheckf(result.Err, "dmarc verify")
2999 fmt.Printf("dmarc from: %s\ndmarc status: %q\ndmarc reject: %v\ncmarc record: %s\n", dmarcFrom, result.Status, result.Reject, result.Record)
3000}
3001
3002func cmdDMARCCheckreportaddrs(c *cmd) {
3003 c.params = "$domain"
3004 c.help = `For each reporting address in the domain's DMARC record, check if it has opted into receiving reports (if needed).
3005
3006A DMARC record can request reports about DMARC evaluations to be sent to an
3007email/http address. If the organizational domains of that of the DMARC record
3008and that of the report destination address do not match, the destination
3009address must opt-in to receiving DMARC reports by creating a DMARC record at
3010<dmarcdomain>._report._dmarc.<reportdestdomain>.
3011`
3012 args := c.Parse()
3013 if len(args) != 1 {
3014 c.Usage()
3015 }
3016
3017 dom := xparseDomain(args[0], "domain")
3018 _, domain, record, txt, authentic, err := dmarc.Lookup(context.Background(), c.log.Logger, dns.StrictResolver{}, dom)
3019 xcheckf(err, "dmarc lookup domain %s", dom)
3020 fmt.Printf("dmarc record at domain %s: %q\n", domain, txt)
3021 fmt.Printf("(%s)\n", dnssecStatus(authentic))
3022
3023 check := func(kind, addr string) {
3024 var authentic bool
3025
3026 printResult := func(format string, args ...any) {
3027 fmt.Printf("%s %s: %s (%s)\n", kind, addr, fmt.Sprintf(format, args...), dnssecStatus(authentic))
3028 }
3029
3030 u, err := url.Parse(addr)
3031 if err != nil {
3032 printResult("parsing uri %s: %v (skipping)", addr, err)
3033 return
3034 }
3035 var destdom dns.Domain
3036 switch u.Scheme {
3037 case "mailto":
3038 a, err := smtp.ParseAddress(u.Opaque)
3039 if err != nil {
3040 printResult("parsing destination email address %s: %v (skipping)", u.Opaque, err)
3041 return
3042 }
3043 destdom = a.Domain
3044 default:
3045 printResult("unrecognized scheme in reporting address %s (skipping)", u.Scheme)
3046 return
3047 }
3048
3049 if publicsuffix.Lookup(context.Background(), c.log.Logger, dom) == publicsuffix.Lookup(context.Background(), c.log.Logger, destdom) {
3050 printResult("pass (same organizational domain)")
3051 return
3052 }
3053
3054 accepts, status, _, txts, authentic, err := dmarc.LookupExternalReportsAccepted(context.Background(), c.log.Logger, dns.StrictResolver{}, domain, destdom)
3055 var txtstr string
3056 txtaddr := fmt.Sprintf("%s._report._dmarc.%s", domain.ASCII, destdom.ASCII)
3057 if len(txts) == 0 {
3058 txtstr = fmt.Sprintf(" (no txt records %s)", txtaddr)
3059 } else {
3060 txtstr = fmt.Sprintf(" (txt record %s: %q)", txtaddr, txts)
3061 }
3062 if status != dmarc.StatusNone {
3063 printResult("fail: %s%s", err, txtstr)
3064 } else if accepts {
3065 printResult("pass%s", txtstr)
3066 } else if err != nil {
3067 printResult("fail: %s%s", err, txtstr)
3068 } else {
3069 printResult("fail%s", txtstr)
3070 }
3071 }
3072
3073 for _, uri := range record.AggregateReportAddresses {
3074 check("aggregate reporting", uri.Address)
3075 }
3076 for _, uri := range record.FailureReportAddresses {
3077 check("failure reporting", uri.Address)
3078 }
3079}
3080
3081func cmdDMARCParsereportmsg(c *cmd) {
3082 c.params = "$messagefile ..."
3083 c.help = `Parse a DMARC report from an email message, and print its extracted details.
3084
3085DMARC reports are periodically mailed, if requested in the DMARC DNS record of
3086a domain. Reports are sent by mail servers that received messages with our
3087domain in a From header. This may or may not be legatimate email. DMARC reports
3088contain summaries of evaluations of DMARC and DKIM/SPF, which can help
3089understand email deliverability problems.
3090`
3091 args := c.Parse()
3092 if len(args) == 0 {
3093 c.Usage()
3094 }
3095
3096 for _, arg := range args {
3097 f, err := os.Open(arg)
3098 xcheckf(err, "open %q", arg)
3099 feedback, err := dmarcrpt.ParseMessageReport(c.log.Logger, f)
3100 xcheckf(err, "parse report in %q", arg)
3101 meta := feedback.ReportMetadata
3102 fmt.Printf("Report: period %s-%s, organisation %q, reportID %q, %s\n", time.Unix(meta.DateRange.Begin, 0).UTC().String(), time.Unix(meta.DateRange.End, 0).UTC().String(), meta.OrgName, meta.ReportID, meta.Email)
3103 if len(meta.Errors) > 0 {
3104 fmt.Printf("Errors:\n")
3105 for _, s := range meta.Errors {
3106 fmt.Printf("\t- %s\n", s)
3107 }
3108 }
3109 pol := feedback.PolicyPublished
3110 fmt.Printf("Policy: domain %q, policy %q, subdomainpolicy %q, dkim %q, spf %q, percentage %d, options %q\n", pol.Domain, pol.Policy, pol.SubdomainPolicy, pol.ADKIM, pol.ASPF, pol.Percentage, pol.ReportingOptions)
3111 for _, record := range feedback.Records {
3112 idents := record.Identifiers
3113 fmt.Printf("\theaderfrom %q, envelopes from %q, to %q\n", idents.HeaderFrom, idents.EnvelopeFrom, idents.EnvelopeTo)
3114 eval := record.Row.PolicyEvaluated
3115 var reasons strings.Builder
3116 for _, reason := range eval.Reasons {
3117 reasons.WriteString("; " + string(reason.Type))
3118 if reason.Comment != "" {
3119 reasons.WriteString(fmt.Sprintf(": %q", reason.Comment))
3120 }
3121 }
3122 fmt.Printf("\tresult %s: dkim %s, spf %s; sourceIP %s, count %d%s\n", eval.Disposition, eval.DKIM, eval.SPF, record.Row.SourceIP, record.Row.Count, reasons.String())
3123 for _, dkim := range record.AuthResults.DKIM {
3124 var result string
3125 if dkim.HumanResult != "" {
3126 result = fmt.Sprintf(": %q", dkim.HumanResult)
3127 }
3128 fmt.Printf("\t\tdkim %s; domain %q selector %q%s\n", dkim.Result, dkim.Domain, dkim.Selector, result)
3129 }
3130 for _, spf := range record.AuthResults.SPF {
3131 fmt.Printf("\t\tspf %s; domain %q scope %q\n", spf.Result, spf.Domain, spf.Scope)
3132 }
3133 }
3134 }
3135}
3136
3137func cmdDMARCDBAddReport(c *cmd) {
3138 c.unlisted = true
3139 c.params = "$fromdomain < messagefile"
3140 c.help = "Add a DMARC report to the database."
3141 args := c.Parse()
3142 if len(args) != 1 {
3143 c.Usage()
3144 }
3145
3146 mustLoadConfig()
3147
3148 fromdomain := xparseDomain(args[0], "domain")
3149 fmt.Fprintln(os.Stderr, "reading report message from stdin")
3150 report, err := dmarcrpt.ParseMessageReport(c.log.Logger, os.Stdin)
3151 xcheckf(err, "parse message")
3152 err = dmarcdb.AddReport(context.Background(), report, fromdomain)
3153 xcheckf(err, "add dmarc report")
3154}
3155
3156func cmdTLSRPTLookup(c *cmd) {
3157 c.params = "$domain"
3158 c.help = `Lookup the TLSRPT record for the domain.
3159
3160A TLSRPT record typically contains an email address where reports about TLS
3161connectivity should be sent. Mail servers attempting delivery to our domain
3162should attempt to use TLS. TLSRPT lets them report how many connection
3163successfully used TLS, and how what kind of errors occurred otherwise.
3164`
3165 args := c.Parse()
3166 if len(args) != 1 {
3167 c.Usage()
3168 }
3169
3170 d := xparseDomain(args[0], "domain")
3171 _, txt, err := tlsrpt.Lookup(context.Background(), c.log.Logger, dns.StrictResolver{}, d)
3172 xcheckf(err, "tlsrpt lookup for %s", d)
3173 fmt.Println(txt)
3174}
3175
3176func cmdTLSRPTParsereportmsg(c *cmd) {
3177 c.params = "$messagefile ..."
3178 c.help = `Parse and print the TLSRPT in the message.
3179
3180The report is printed in formatted JSON.
3181`
3182 args := c.Parse()
3183 if len(args) == 0 {
3184 c.Usage()
3185 }
3186
3187 for _, arg := range args {
3188 f, err := os.Open(arg)
3189 xcheckf(err, "open %q", arg)
3190 reportJSON, err := tlsrpt.ParseMessage(c.log.Logger, f)
3191 xcheckf(err, "parse report in %q", arg)
3192 // todo future: only print the highlights?
3193 enc := json.NewEncoder(os.Stdout)
3194 enc.SetIndent("", "\t")
3195 enc.SetEscapeHTML(false)
3196 err = enc.Encode(reportJSON)
3197 xcheckf(err, "write report")
3198 }
3199}
3200
3201func cmdSPFCheck(c *cmd) {
3202 c.params = "$domain $ip"
3203 c.help = `Check the status of IP for the policy published in DNS for the domain.
3204
3205IPs may be allowed to send for a domain, or disallowed, and several shades in
3206between. If not allowed, an explanation may be provided by the policy. If so,
3207the explanation is printed. The SPF mechanism that matched (if any) is also
3208printed.
3209`
3210 args := c.Parse()
3211 if len(args) != 2 {
3212 c.Usage()
3213 }
3214
3215 domain := xparseDomain(args[0], "domain")
3216
3217 ip := xparseIP(args[1], "ip")
3218
3219 spfargs := spf.Args{
3220 RemoteIP: ip,
3221 MailFromLocalpart: "user",
3222 MailFromDomain: domain,
3223 HelloDomain: dns.IPDomain{Domain: domain},
3224 LocalIP: net.ParseIP("127.0.0.1"),
3225 LocalHostname: dns.Domain{ASCII: "localhost"},
3226 }
3227 r, _, explanation, authentic, err := spf.Verify(context.Background(), c.log.Logger, dns.StrictResolver{}, spfargs)
3228 if err != nil {
3229 fmt.Printf("error: %s\n", err)
3230 }
3231 if explanation != "" {
3232 fmt.Printf("explanation: %s\n", explanation)
3233 }
3234 fmt.Printf("status: %s (%s)\n", r.Result, dnssecStatus(authentic))
3235 if r.Mechanism != "" {
3236 fmt.Printf("mechanism: %s\n", r.Mechanism)
3237 }
3238}
3239
3240func cmdSPFParse(c *cmd) {
3241 c.params = "$txtrecord"
3242 c.help = "Parse the record as SPF record. If valid, nothing is printed."
3243 args := c.Parse()
3244 if len(args) != 1 {
3245 c.Usage()
3246 }
3247
3248 _, _, err := spf.ParseRecord(args[0])
3249 xcheckf(err, "parsing record")
3250}
3251
3252func cmdSPFLookup(c *cmd) {
3253 c.params = "$domain"
3254 c.help = "Lookup the SPF record for the domain and print it."
3255 args := c.Parse()
3256 if len(args) != 1 {
3257 c.Usage()
3258 }
3259
3260 domain := xparseDomain(args[0], "domain")
3261 _, txt, _, authentic, err := spf.Lookup(context.Background(), c.log.Logger, dns.StrictResolver{}, domain)
3262 xcheckf(err, "spf lookup for %s", domain)
3263 fmt.Println(txt)
3264 fmt.Printf("(%s)\n", dnssecStatus(authentic))
3265}
3266
3267func cmdMTASTSLookup(c *cmd) {
3268 c.params = "$domain"
3269 c.help = `Lookup the MTASTS record and policy for the domain.
3270
3271MTA-STS is a mechanism for a domain to specify if it requires TLS connections
3272for delivering email. If a domain has a valid MTA-STS DNS TXT record at
3273_mta-sts.<domain> it signals it implements MTA-STS. A policy can then be
3274fetched at https://mta-sts.<domain>/.well-known/mta-sts.txt. The policy
3275specifies the mode (enforce, testing, none), which MX servers support TLS and
3276should be used, and how long the policy can be cached.
3277`
3278 args := c.Parse()
3279 if len(args) != 1 {
3280 c.Usage()
3281 }
3282
3283 domain := xparseDomain(args[0], "domain")
3284
3285 record, policy, _, err := mtasts.Get(context.Background(), c.log.Logger, dns.StrictResolver{}, domain)
3286 if err != nil {
3287 fmt.Printf("error: %s\n", err)
3288 }
3289 if record != nil {
3290 fmt.Printf("DNS TXT record _mta-sts.%s: %s\n", domain.ASCII, record.String())
3291 }
3292 if policy != nil {
3293 fmt.Println("")
3294 fmt.Printf("policy at https://mta-sts.%s/.well-known/mta-sts.txt:\n", domain.ASCII)
3295 fmt.Printf("%s", policy.String())
3296 }
3297}
3298
3299func cmdRDAPDomainage(c *cmd) {
3300 c.params = "$domain"
3301 c.help = `Lookup the age of domain in RDAP based on latest registration.
3302
3303RDAP is the registration data access protocol. Registries run RDAP services for
3304their top level domains, providing information such as the registration date of
3305domains. This command looks up the "age" of a domain by looking at the most
3306recent "registration", "reregistration" or "reinstantiation" event.
3307
3308Email messages from recently registered domains are often treated with
3309suspicion, and some mail systems are more likely to classify them as junk.
3310
3311On each invocation, a bootstrap file with a list of registries (of top-level
3312domains) is retrieved, without caching. Do not run this command too often with
3313automation.
3314`
3315 args := c.Parse()
3316 if len(args) != 1 {
3317 c.Usage()
3318 }
3319
3320 domain := xparseDomain(args[0], "domain")
3321
3322 registration, err := rdap.LookupLastDomainRegistration(context.Background(), c.log, domain)
3323 xcheckf(err, "looking up domain in rdap")
3324
3325 age := time.Since(registration)
3326 const day = 24 * time.Hour
3327 const year = 365 * day
3328 years := age / year
3329 days := (age - years*year) / day
3330 var s string
3331 if years == 1 {
3332 s = "1 year, "
3333 } else if years > 0 {
3334 s = fmt.Sprintf("%d years, ", years)
3335 }
3336 if days == 1 {
3337 s += "1 day"
3338 } else {
3339 s += fmt.Sprintf("%d days", days)
3340 }
3341 fmt.Println(s)
3342}
3343
3344func cmdRetrain(c *cmd) {
3345 c.params = "[$accountname]"
3346 c.help = `Recreate and retrain the junk filter for the account or all accounts.
3347
3348Useful after having made changes to the junk filter configuration, or if the
3349implementation has changed.
3350`
3351 args := c.Parse()
3352 if len(args) > 1 {
3353 c.Usage()
3354 }
3355 var account string
3356 if len(args) == 1 {
3357 account = args[0]
3358 }
3359
3360 mustLoadConfig()
3361 ctlcmdRetrain(xctl(), account)
3362}
3363
3364func ctlcmdRetrain(ctl *ctl, account string) {
3365 ctl.xwrite("retrain")
3366 ctl.xwrite(account)
3367 ctl.xreadok()
3368}
3369
3370func cmdTLSRPTDBAddReport(c *cmd) {
3371 c.unlisted = true
3372 c.params = "< messagefile"
3373 c.help = "Parse a TLS report from the message and add it to the database."
3374 var hostReport bool
3375 c.flag.BoolVar(&hostReport, "hostreport", false, "report for a host instead of domain")
3376 args := c.Parse()
3377 if len(args) != 0 {
3378 c.Usage()
3379 }
3380
3381 mustLoadConfig()
3382
3383 // First read message, to get the From-header. Then parse it as TLSRPT.
3384 fmt.Fprintln(os.Stderr, "reading report message from stdin")
3385 buf, err := io.ReadAll(os.Stdin)
3386 xcheckf(err, "reading message")
3387 part, err := message.Parse(c.log.Logger, true, bytes.NewReader(buf))
3388 xcheckf(err, "parsing message")
3389 if part.Envelope == nil || len(part.Envelope.From) != 1 {
3390 log.Fatalf("message must have one From-header")
3391 }
3392 from := part.Envelope.From[0]
3393 domain := xparseDomain(from.Host, "domain")
3394
3395 reportJSON, err := tlsrpt.ParseMessage(c.log.Logger, bytes.NewReader(buf))
3396 xcheckf(err, "parsing tls report in message")
3397
3398 mailfrom := from.User + "@" + from.Host // todo future: should escape and such
3399 report := reportJSON.Convert()
3400 err = tlsrptdb.AddReport(context.Background(), c.log, domain, mailfrom, hostReport, &report)
3401 xcheckf(err, "add tls report to database")
3402}
3403
3404func cmdDNSBLCheck(c *cmd) {
3405 c.params = "$zone $ip"
3406 c.help = `Test if IP is in the DNS blocklist of the zone, e.g. bl.spamcop.net.
3407
3408If the IP is in the blocklist, an explanation is printed. This is typically a
3409URL with more information.
3410`
3411 args := c.Parse()
3412 if len(args) != 2 {
3413 c.Usage()
3414 }
3415
3416 zone := xparseDomain(args[0], "zone")
3417 ip := xparseIP(args[1], "ip")
3418
3419 status, explanation, err := dnsbl.Lookup(context.Background(), c.log.Logger, dns.StrictResolver{}, zone, ip)
3420 fmt.Printf("status: %s\n", status)
3421 if status == dnsbl.StatusFail {
3422 fmt.Printf("explanation: %q\n", explanation)
3423 }
3424 if err != nil {
3425 fmt.Printf("error: %s\n", err)
3426 }
3427}
3428
3429func cmdDNSBLCheckhealth(c *cmd) {
3430 c.params = "$zone"
3431 c.help = `Check the health of the DNS blocklist represented by zone, e.g. bl.spamcop.net.
3432
3433The health of a DNS blocklist can be checked by querying for 127.0.0.1 and
3434127.0.0.2. The second must and the first must not be present.
3435`
3436 args := c.Parse()
3437 if len(args) != 1 {
3438 c.Usage()
3439 }
3440
3441 zone := xparseDomain(args[0], "zone")
3442 err := dnsbl.CheckHealth(context.Background(), c.log.Logger, dns.StrictResolver{}, zone)
3443 xcheckf(err, "unhealthy")
3444 fmt.Println("healthy")
3445}
3446
3447func cmdCheckupdate(c *cmd) {
3448 c.help = `Check if a newer version of mox is available.
3449
3450A single DNS TXT lookup to _updates.xmox.nl tells if a new version is
3451available. If so, a changelog is fetched from https://updates.xmox.nl, and the
3452individual entries verified with a builtin public key. The changelog is
3453printed.
3454`
3455 if len(c.Parse()) != 0 {
3456 c.Usage()
3457 }
3458 mustLoadConfig()
3459
3460 current, lastknown, _, err := store.LastKnown()
3461 if err != nil {
3462 log.Printf("getting last known version: %s", err)
3463 } else {
3464 fmt.Printf("last known version: %s\n", lastknown)
3465 fmt.Printf("current version: %s\n", current)
3466 }
3467 latest, _, err := updates.Lookup(context.Background(), c.log.Logger, dns.StrictResolver{}, dns.Domain{ASCII: changelogDomain})
3468 xcheckf(err, "lookup of latest version")
3469 fmt.Printf("latest version: %s\n", latest)
3470
3471 if latest.After(current) {
3472 changelog, err := updates.FetchChangelog(context.Background(), c.log.Logger, changelogURL, current, changelogPubKey)
3473 xcheckf(err, "fetching changelog")
3474 if len(changelog.Changes) == 0 {
3475 log.Printf("no changes in changelog")
3476 return
3477 }
3478 fmt.Println("Changelog")
3479 for _, c := range changelog.Changes {
3480 fmt.Println("\n" + strings.TrimSpace(c.Text))
3481 }
3482 }
3483}
3484
3485func cmdCid(c *cmd) {
3486 c.params = "$cid"
3487 c.help = `Turn an ID from a Received header into a cid, for looking up in logs.
3488
3489A cid is essentially a connection counter initialized when mox starts. Each log
3490line contains a cid. Received headers added by mox contain a unique ID that can
3491be decrypted to a cid by admin of a mox instance only.
3492`
3493 args := c.Parse()
3494 if len(args) != 1 {
3495 c.Usage()
3496 }
3497
3498 mustLoadConfig()
3499 recvidpath := mox.DataDirPath("receivedid.key")
3500 recvidbuf, err := os.ReadFile(recvidpath)
3501 xcheckf(err, "reading %s", recvidpath)
3502 if len(recvidbuf) != 16+8 {
3503 log.Fatalf("bad data in %s: got %d bytes, expect 16+8=24", recvidpath, len(recvidbuf))
3504 }
3505 err = mox.ReceivedIDInit(recvidbuf[:16], recvidbuf[16:])
3506 xcheckf(err, "init receivedid")
3507
3508 cid, err := mox.ReceivedToCid(args[0])
3509 xcheckf(err, "received id to cid")
3510 fmt.Printf("%x\n", cid)
3511}
3512
3513func cmdVersion(c *cmd) {
3514 c.help = "Prints this mox version."
3515 if len(c.Parse()) != 0 {
3516 c.Usage()
3517 }
3518 fmt.Println(moxvar.Version)
3519 fmt.Printf("%s/%s\n", runtime.GOOS, runtime.GOARCH)
3520}
3521
3522func cmdWebapi(c *cmd) {
3523 c.params = "[$method [$baseurl-with-credentials]"
3524 c.help = "Lists available methods, prints request/response parameters for method, or calls a method with a request read from standard input."
3525 args := c.Parse()
3526 if len(args) > 2 {
3527 c.Usage()
3528 }
3529
3530 t := reflect.TypeFor[webapi.Methods]()
3531 methods := map[string]reflect.Type{}
3532 var ml []string
3533 for i := range t.NumMethod() {
3534 mt := t.Method(i)
3535 methods[mt.Name] = mt.Type
3536 ml = append(ml, mt.Name)
3537 }
3538
3539 if len(args) == 0 {
3540 fmt.Println(strings.Join(ml, "\n"))
3541 return
3542 }
3543
3544 mt, ok := methods[args[0]]
3545 if !ok {
3546 log.Fatalf("unknown method %q", args[0])
3547 }
3548 resultNotJSON := mt.Out(0).Kind() == reflect.Interface
3549
3550 if len(args) == 1 {
3551 fmt.Println("# Example request")
3552 fmt.Println()
3553 printJSON("\t", mox.FillExample(nil, reflect.New(mt.In(1))).Interface())
3554 fmt.Println()
3555 if resultNotJSON {
3556 fmt.Println("Output is non-JSON data.")
3557 return
3558 }
3559 fmt.Println("# Example response")
3560 fmt.Println()
3561 printJSON("\t", mox.FillExample(nil, reflect.New(mt.Out(0))).Interface())
3562 return
3563 }
3564
3565 var response any
3566 if !resultNotJSON {
3567 response = reflect.New(mt.Out(0))
3568 }
3569
3570 fmt.Fprintln(os.Stderr, "reading request from stdin...")
3571 request, err := io.ReadAll(os.Stdin)
3572 xcheckf(err, "read message")
3573
3574 dec := json.NewDecoder(bytes.NewReader(request))
3575 dec.DisallowUnknownFields()
3576 err = dec.Decode(reflect.New(mt.In(1)).Interface())
3577 xcheckf(err, "parsing request")
3578
3579 resp, err := http.PostForm(args[1]+args[0], url.Values{"request": []string{string(request)}})
3580 xcheckf(err, "http post")
3581 defer func() {
3582 if err := resp.Body.Close(); err != nil {
3583 log.Printf("closing http response body: %v", err)
3584 }
3585 }()
3586 if resp.StatusCode == http.StatusBadRequest {
3587 buf, err := io.ReadAll(&moxio.LimitReader{R: resp.Body, Limit: 10 * 1024})
3588 xcheckf(err, "reading response for 400 bad request error")
3589 err = json.Unmarshal(buf, &response)
3590 if err == nil {
3591 printJSON("", response)
3592 } else {
3593 fmt.Fprintf(os.Stderr, "(not json)\n")
3594 os.Stderr.Write(buf)
3595 }
3596 os.Exit(1)
3597 } else if resp.StatusCode != http.StatusOK {
3598 fmt.Fprintf(os.Stderr, "http response %s\n", resp.Status)
3599 _, err := io.Copy(os.Stderr, resp.Body)
3600 xcheckf(err, "copy body")
3601 } else {
3602 err := json.NewDecoder(resp.Body).Decode(&resp)
3603 xcheckf(err, "unmarshal response")
3604 printJSON("", response)
3605 }
3606}
3607
3608func printJSON(indent string, v any) {
3609 fmt.Printf("%s", indent)
3610 enc := json.NewEncoder(os.Stdout)
3611 enc.SetIndent(indent, "\t")
3612 enc.SetEscapeHTML(false)
3613 err := enc.Encode(v)
3614 xcheckf(err, "encode json")
3615}
3616
3617// todo: should make it possible to run this command against a running mox. it should disconnect existing clients for accounts with a bumped uidvalidity, so they will reconnect and refetch the data.
3618func cmdBumpUIDValidity(c *cmd) {
3619 c.params = "$account [$mailbox]"
3620 c.help = `Change the IMAP UID validity of the mailbox, causing IMAP clients to refetch messages.
3621
3622This can be useful after manually repairing metadata about the account/mailbox.
3623
3624Opens account database file directly. Ensure mox does not have the account
3625open, or is not running.
3626`
3627 args := c.Parse()
3628 if len(args) != 1 && len(args) != 2 {
3629 c.Usage()
3630 }
3631
3632 mustLoadConfig()
3633 a, err := store.OpenAccount(c.log, args[0], false)
3634 xcheckf(err, "open account")
3635 defer func() {
3636 if err := a.Close(); err != nil {
3637 log.Printf("closing account: %v", err)
3638 }
3639 }()
3640
3641 err = a.DB.Write(context.Background(), func(tx *bstore.Tx) error {
3642 uidvalidity, err := a.NextUIDValidity(tx)
3643 if err != nil {
3644 return fmt.Errorf("assigning next uid validity: %v", err)
3645 }
3646
3647 q := bstore.QueryTx[store.Mailbox](tx)
3648 q.FilterEqual("Expunged", false)
3649 if len(args) == 2 {
3650 q.FilterEqual("Name", args[1])
3651 }
3652 mbl, err := q.SortAsc("Name").List()
3653 if err != nil {
3654 return fmt.Errorf("looking up mailbox: %v", err)
3655 }
3656 if len(args) == 2 && len(mbl) != 1 {
3657 return fmt.Errorf("looking up mailbox %q, found %d mailboxes", args[1], len(mbl))
3658 }
3659 for _, mb := range mbl {
3660 mb.UIDValidity = uidvalidity
3661 err = tx.Update(&mb)
3662 if err != nil {
3663 return fmt.Errorf("updating uid validity for mailbox: %v", err)
3664 }
3665 fmt.Printf("uid validity for %q updated to %d\n", mb.Name, uidvalidity)
3666 }
3667 return nil
3668 })
3669 xcheckf(err, "updating database")
3670}
3671
3672func cmdReassignUIDs(c *cmd) {
3673 c.params = "$account [$mailboxid]"
3674 c.help = `Reassign UIDs in one mailbox or all mailboxes in an account and bump UID validity, causing IMAP clients to refetch messages.
3675
3676Opens account database file directly. Ensure mox does not have the account
3677open, or is not running.
3678`
3679 args := c.Parse()
3680 if len(args) != 1 && len(args) != 2 {
3681 c.Usage()
3682 }
3683
3684 var mailboxID int64
3685 if len(args) == 2 {
3686 var err error
3687 mailboxID, err = strconv.ParseInt(args[1], 10, 64)
3688 xcheckf(err, "parsing mailbox id")
3689 }
3690
3691 mustLoadConfig()
3692 a, err := store.OpenAccount(c.log, args[0], false)
3693 xcheckf(err, "open account")
3694 defer func() {
3695 if err := a.Close(); err != nil {
3696 log.Printf("closing account: %v", err)
3697 }
3698 }()
3699
3700 // Gather the last-assigned UIDs per mailbox.
3701 uidlasts := map[int64]store.UID{}
3702
3703 err = a.DB.Write(context.Background(), func(tx *bstore.Tx) error {
3704 // Reassign UIDs, going per mailbox. We assign starting at 1, only changing the
3705 // message if it isn't already at the intended UID. Doing it in this order ensures
3706 // we don't get into trouble with duplicate UIDs for a mailbox. We assign a new
3707 // modseq. Not strictly needed, but doesn't hurt. It's also why we assign a UID to
3708 // expunged messages.
3709 modseq, err := a.NextModSeq(tx)
3710 xcheckf(err, "assigning next modseq")
3711
3712 q := bstore.QueryTx[store.Message](tx)
3713 if len(args) == 2 {
3714 q.FilterNonzero(store.Message{MailboxID: mailboxID})
3715 }
3716 q.SortAsc("MailboxID", "UID")
3717 err = q.ForEach(func(m store.Message) error {
3718 uidlasts[m.MailboxID]++
3719 uid := uidlasts[m.MailboxID]
3720 if m.UID != uid {
3721 m.UID = uid
3722 m.ModSeq = modseq
3723 if err := tx.Update(&m); err != nil {
3724 return fmt.Errorf("updating uid for message: %v", err)
3725 }
3726 }
3727 return nil
3728 })
3729 if err != nil {
3730 return fmt.Errorf("reading through messages: %v", err)
3731 }
3732
3733 // Now update the uidnext, uidvalidity and modseq for each mailbox.
3734 err = bstore.QueryTx[store.Mailbox](tx).FilterEqual("Expunged", false).ForEach(func(mb store.Mailbox) error {
3735 // Assign each mailbox a completely new uidvalidity.
3736 uidvalidity, err := a.NextUIDValidity(tx)
3737 if err != nil {
3738 return fmt.Errorf("assigning next uid validity: %v", err)
3739 }
3740
3741 if mb.UIDValidity >= uidvalidity {
3742 // This should not happen, but since we're fixing things up after a hypothetical
3743 // mishap, might as well account for inconsistent uidvalidity.
3744 next := store.NextUIDValidity{ID: 1, Next: mb.UIDValidity + 2}
3745 if err := tx.Update(&next); err != nil {
3746 log.Printf("updating nextuidvalidity: %v, continuing", err)
3747 }
3748 mb.UIDValidity++
3749 } else {
3750 mb.UIDValidity = uidvalidity
3751 }
3752 mb.UIDNext = uidlasts[mb.ID] + 1
3753 mb.ModSeq = modseq
3754 if err := tx.Update(&mb); err != nil {
3755 return fmt.Errorf("updating uidvalidity and uidnext for mailbox: %v", err)
3756 }
3757 return nil
3758 })
3759 if err != nil {
3760 return fmt.Errorf("updating mailboxes: %v", err)
3761 }
3762 return nil
3763 })
3764 xcheckf(err, "updating database")
3765}
3766
3767func cmdFixUIDMeta(c *cmd) {
3768 c.params = "$account"
3769 c.help = `Fix inconsistent UIDVALIDITY and UIDNEXT in messages/mailboxes/account.
3770
3771The next UID to use for a message in a mailbox should always be higher than any
3772existing message UID in the mailbox. If it is not, the mailbox UIDNEXT is
3773updated.
3774
3775Each mailbox has a UIDVALIDITY sequence number, which should always be lower
3776than the per-account next UIDVALIDITY to use. If it is not, the account next
3777UIDVALIDITY is updated.
3778
3779Opens account database file directly. Ensure mox does not have the account
3780open, or is not running.
3781`
3782 args := c.Parse()
3783 if len(args) != 1 {
3784 c.Usage()
3785 }
3786
3787 mustLoadConfig()
3788 a, err := store.OpenAccount(c.log, args[0], false)
3789 xcheckf(err, "open account")
3790 defer func() {
3791 if err := a.Close(); err != nil {
3792 log.Printf("closing account: %v", err)
3793 }
3794 }()
3795
3796 var maxUIDValidity uint32
3797
3798 err = a.DB.Write(context.Background(), func(tx *bstore.Tx) error {
3799 // We look at each mailbox, retrieve its max UID and compare against the mailbox
3800 // UIDNEXT.
3801 err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Expunged", false).ForEach(func(mb store.Mailbox) error {
3802 if mb.UIDValidity > maxUIDValidity {
3803 maxUIDValidity = mb.UIDValidity
3804 }
3805 m, err := bstore.QueryTx[store.Message](tx).FilterNonzero(store.Message{MailboxID: mb.ID}).SortDesc("UID").Limit(1).Get()
3806 if err == bstore.ErrAbsent || err == nil && m.UID < mb.UIDNext {
3807 return nil
3808 } else if err != nil {
3809 return fmt.Errorf("finding message with max uid in mailbox: %w", err)
3810 }
3811 olduidnext := mb.UIDNext
3812 mb.UIDNext = m.UID + 1
3813 log.Printf("fixing uidnext to %d (max uid is %d, old uidnext was %d) for mailbox %q (id %d)", mb.UIDNext, m.UID, olduidnext, mb.Name, mb.ID)
3814 if err := tx.Update(&mb); err != nil {
3815 return fmt.Errorf("updating mailbox uidnext: %v", err)
3816 }
3817 return nil
3818 })
3819 if err != nil {
3820 return fmt.Errorf("processing mailboxes: %v", err)
3821 }
3822
3823 uidvalidity := store.NextUIDValidity{ID: 1}
3824 if err := tx.Get(&uidvalidity); err != nil {
3825 return fmt.Errorf("reading account next uidvalidity: %v", err)
3826 }
3827 if maxUIDValidity >= uidvalidity.Next {
3828 log.Printf("account next uidvalidity %d <= highest uidvalidity %d found in mailbox, resetting account next uidvalidity to %d", uidvalidity.Next, maxUIDValidity, maxUIDValidity+1)
3829 uidvalidity.Next = maxUIDValidity + 1
3830 if err := tx.Update(&uidvalidity); err != nil {
3831 return fmt.Errorf("updating account next uidvalidity: %v", err)
3832 }
3833 }
3834
3835 return nil
3836 })
3837 xcheckf(err, "updating database")
3838}
3839
3840func cmdFixmsgsize(c *cmd) {
3841 c.params = "[$account]"
3842 c.help = `Ensure message sizes in the database matching the sum of the message prefix length and on-disk file size.
3843
3844Messages with an inconsistent size are also parsed again.
3845
3846If an inconsistency is found, you should probably also run "mox
3847bumpuidvalidity" on the mailboxes or entire account to force IMAP clients to
3848refetch messages.
3849`
3850 args := c.Parse()
3851 if len(args) > 1 {
3852 c.Usage()
3853 }
3854
3855 mustLoadConfig()
3856 var account string
3857 if len(args) == 1 {
3858 account = args[0]
3859 }
3860 ctlcmdFixmsgsize(xctl(), account)
3861}
3862
3863func ctlcmdFixmsgsize(ctl *ctl, account string) {
3864 ctl.xwrite("fixmsgsize")
3865 ctl.xwrite(account)
3866 ctl.xreadok()
3867 ctl.xstreamto(os.Stdout)
3868}
3869
3870func cmdReparse(c *cmd) {
3871 c.params = "[$account]"
3872 c.help = `Parse all messages in the account or all accounts again.
3873
3874Can be useful after upgrading mox with improved message parsing. Messages are
3875parsed in batches, so other access to the mailboxes/messages are not blocked
3876while reparsing all messages.
3877`
3878 args := c.Parse()
3879 if len(args) > 1 {
3880 c.Usage()
3881 }
3882
3883 mustLoadConfig()
3884 var account string
3885 if len(args) == 1 {
3886 account = args[0]
3887 }
3888 ctlcmdReparse(xctl(), account)
3889}
3890
3891func ctlcmdReparse(ctl *ctl, account string) {
3892 ctl.xwrite("reparse")
3893 ctl.xwrite(account)
3894 ctl.xreadok()
3895 ctl.xstreamto(os.Stdout)
3896}
3897
3898func cmdEnsureParsed(c *cmd) {
3899 c.params = "$account"
3900 c.help = "Ensure messages in the database have a pre-parsed MIME form in the database."
3901 var all bool
3902 c.flag.BoolVar(&all, "all", false, "store new parsed message for all messages")
3903 args := c.Parse()
3904 if len(args) != 1 {
3905 c.Usage()
3906 }
3907
3908 mustLoadConfig()
3909 a, err := store.OpenAccount(c.log, args[0], false)
3910 xcheckf(err, "open account")
3911 defer func() {
3912 if err := a.Close(); err != nil {
3913 log.Printf("closing account: %v", err)
3914 }
3915 }()
3916
3917 n := 0
3918 err = a.DB.Write(context.Background(), func(tx *bstore.Tx) error {
3919 q := bstore.QueryTx[store.Message](tx)
3920 q.FilterEqual("Expunged", false)
3921 q.FilterFn(func(m store.Message) bool {
3922 return all || m.ParsedBuf == nil
3923 })
3924 l, err := q.List()
3925 if err != nil {
3926 return fmt.Errorf("list messages: %v", err)
3927 }
3928 for _, m := range l {
3929 mr := a.MessageReader(m)
3930 p, err := message.EnsurePart(c.log.Logger, false, mr, m.Size)
3931 if err != nil {
3932 log.Printf("parsing message %d: %v (continuing)", m.ID, err)
3933 }
3934 m.ParsedBuf, err = json.Marshal(p)
3935 if err != nil {
3936 return fmt.Errorf("marshal parsed message: %v", err)
3937 }
3938 if err := tx.Update(&m); err != nil {
3939 return fmt.Errorf("update message: %v", err)
3940 }
3941 n++
3942 }
3943 return nil
3944 })
3945 xcheckf(err, "update messages with parsed mime structure")
3946 fmt.Printf("%d messages updated\n", n)
3947}
3948
3949func cmdRecalculateMailboxCounts(c *cmd) {
3950 c.params = "$account"
3951 c.help = `Recalculate message counts for all mailboxes in the account, and total message size for quota.
3952
3953When a message is added to/removed from a mailbox, or when message flags change,
3954the total, unread, unseen and deleted messages are accounted, the total size of
3955the mailbox, and the total message size for the account. In case of a bug in
3956this accounting, the numbers could become incorrect. This command will find, fix
3957and print them.
3958`
3959 args := c.Parse()
3960 if len(args) != 1 {
3961 c.Usage()
3962 }
3963
3964 mustLoadConfig()
3965 ctlcmdRecalculateMailboxCounts(xctl(), args[0])
3966}
3967
3968func ctlcmdRecalculateMailboxCounts(ctl *ctl, account string) {
3969 ctl.xwrite("recalculatemailboxcounts")
3970 ctl.xwrite(account)
3971 ctl.xreadok()
3972 ctl.xstreamto(os.Stdout)
3973}
3974
3975func cmdMessageParse(c *cmd) {
3976 c.params = "$messagefile"
3977 c.help = "Parse message, print JSON representation."
3978
3979 var smtputf8 bool
3980 c.flag.BoolVar(&smtputf8, "smtputf8", false, "check if message needs smtputf8")
3981 args := c.Parse()
3982 if len(args) != 1 {
3983 c.Usage()
3984 }
3985
3986 f, err := os.Open(args[0])
3987 xcheckf(err, "open")
3988 defer func() {
3989 if err := f.Close(); err != nil {
3990 log.Printf("closing message file: %v", err)
3991 }
3992 }()
3993
3994 part, err := message.Parse(c.log.Logger, false, f)
3995 xcheckf(err, "parsing message")
3996 err = part.Walk(c.log.Logger, nil)
3997 xcheckf(err, "parsing nested parts")
3998 enc := json.NewEncoder(os.Stdout)
3999 enc.SetIndent("", "\t")
4000 enc.SetEscapeHTML(false)
4001 err = enc.Encode(part)
4002 xcheckf(err, "write")
4003
4004 if smtputf8 {
4005 needs, err := part.NeedsSMTPUTF8()
4006 xcheckf(err, "checking if message needs smtputf8")
4007 fmt.Println("message needs smtputf8:", needs)
4008 }
4009}
4010
4011func cmdOpenaccounts(c *cmd) {
4012 c.unlisted = true
4013 c.params = "$datadir $account ..."
4014 c.help = `Open and close accounts, for triggering data upgrades, for tests.
4015
4016Opens database files directly, not going through a running mox instance.
4017`
4018
4019 args := c.Parse()
4020 if len(args) <= 1 {
4021 c.Usage()
4022 }
4023
4024 dataDir := filepath.Clean(args[0])
4025 for _, accName := range args[1:] {
4026 accDir := filepath.Join(dataDir, "accounts", accName)
4027 log.Printf("opening account %s...", accDir)
4028 a, err := store.OpenAccountDB(c.log, accDir, accName)
4029 xcheckf(err, "open account %s", accName)
4030 err = a.ThreadingWait(c.log)
4031 xcheckf(err, "wait for threading upgrade to complete for %s", accName)
4032 err = a.Close()
4033 xcheckf(err, "close account %s", accName)
4034 }
4035}
4036
4037func cmdReassignthreads(c *cmd) {
4038 c.params = "[$account]"
4039 c.help = `Reassign message threads.
4040
4041For all accounts, or optionally only the specified account.
4042
4043Threading for all messages in an account is first reset, and new base subject
4044and normalized message-id saved with the message. Then all messages are
4045evaluated and matched against their parents/ancestors.
4046
4047Messages are matched based on the References header, with a fall-back to an
4048In-Reply-To header, and if neither is present/valid, based only on base
4049subject.
4050
4051A References header typically points to multiple previous messages in a
4052hierarchy. From oldest ancestor to most recent parent. An In-Reply-To header
4053would have only a message-id of the parent message.
4054
4055A message is only linked to a parent/ancestor if their base subject is the
4056same. This ensures unrelated replies, with a new subject, are placed in their
4057own thread.
4058
4059The base subject is lower cased, has whitespace collapsed to a single
4060space, and some components removed: leading "Re:", "Fwd:", "Fw:", or bracketed
4061tag (that mailing lists often add, e.g. "[listname]"), trailing "(fwd)", or
4062enclosing "[fwd: ...]".
4063
4064Messages are linked to all their ancestors. If an intermediate parent/ancestor
4065message is deleted in the future, the message can still be linked to the earlier
4066ancestors. If the direct parent already wasn't available while matching, this is
4067stored as the message having a "missing link" to its stored ancestors.
4068`
4069 args := c.Parse()
4070 if len(args) > 1 {
4071 c.Usage()
4072 }
4073
4074 mustLoadConfig()
4075 var account string
4076 if len(args) == 1 {
4077 account = args[0]
4078 }
4079 ctlcmdReassignthreads(xctl(), account)
4080}
4081
4082func ctlcmdReassignthreads(ctl *ctl, account string) {
4083 ctl.xwrite("reassignthreads")
4084 ctl.xwrite(account)
4085 ctl.xreadok()
4086 ctl.xstreamto(os.Stdout)
4087}
4088
4089func cmdIMAPServe(c *cmd) {
4090 c.params = "$preauthaddress"
4091 c.help = `Initiate a preauthenticated IMAP connection on file descriptor 0.
4092
4093For use with tools that can do IMAP over tunneled connections, e.g. with SSH
4094during migrations. TLS is not possible on the connection, and authentication
4095does not require TLS.
4096`
4097 var fd0 bool
4098 c.flag.BoolVar(&fd0, "fd0", false, "write IMAP to file descriptor 0 instead of stdout")
4099 args := c.Parse()
4100 if len(args) != 1 {
4101 c.Usage()
4102 }
4103
4104 address := args[0]
4105 output := os.Stdout
4106 if fd0 {
4107 output = os.Stdout
4108 }
4109 ctlcmdIMAPServe(xctl(), address, os.Stdin, output)
4110}
4111
4112func ctlcmdIMAPServe(ctl *ctl, address string, input io.ReadCloser, output io.WriteCloser) {
4113 ctl.xwrite("imapserve")
4114 ctl.xwrite(address)
4115 ctl.xreadok()
4116
4117 done := make(chan struct{}, 1)
4118 go func() {
4119 defer func() {
4120 done <- struct{}{}
4121 }()
4122 _, err := io.Copy(output, ctl.conn)
4123 if err == nil {
4124 err = io.EOF
4125 }
4126 log.Printf("reading from imap: %v", err)
4127 }()
4128 go func() {
4129 defer func() {
4130 done <- struct{}{}
4131 }()
4132 _, err := io.Copy(ctl.conn, input)
4133 if err == nil {
4134 err = io.EOF
4135 }
4136 log.Printf("writing to imap: %v", err)
4137 }()
4138 <-done
4139}
4140
4141func cmdReadmessages(c *cmd) {
4142 c.unlisted = true
4143 c.params = "$datadir $account ..."
4144 c.help = `Open account, parse several headers for all messages.
4145
4146For performance testing.
4147
4148Opens database files directly, not going through a running mox instance.
4149`
4150
4151 gomaxprocs := runtime.GOMAXPROCS(0)
4152 var procs, workqueuesize, limit int
4153 c.flag.IntVar(&procs, "procs", gomaxprocs, "number of goroutines for reading messages")
4154 c.flag.IntVar(&workqueuesize, "workqueuesize", 2*gomaxprocs, "number of messages to keep in work queue")
4155 c.flag.IntVar(&limit, "limit", 0, "number of messages to process if greater than zero")
4156 args := c.Parse()
4157 if len(args) <= 1 {
4158 c.Usage()
4159 }
4160
4161 type threadPrep struct {
4162 references []string
4163 inReplyTo []string
4164 }
4165
4166 threadingFields := [][]byte{
4167 []byte("references"),
4168 []byte("in-reply-to"),
4169 }
4170
4171 dataDir := filepath.Clean(args[0])
4172 for _, accName := range args[1:] {
4173 accDir := filepath.Join(dataDir, "accounts", accName)
4174 log.Printf("opening account %s...", accDir)
4175 a, err := store.OpenAccountDB(c.log, accDir, accName)
4176 xcheckf(err, "open account %s", accName)
4177
4178 prepareMessages := func(in, out chan moxio.Work[store.Message, threadPrep]) {
4179 headerbuf := make([]byte, 8*1024)
4180 scratch := make([]byte, 4*1024)
4181 for {
4182 w, ok := <-in
4183 if !ok {
4184 return
4185 }
4186
4187 m := w.In
4188 var partialPart struct {
4189 HeaderOffset int64
4190 BodyOffset int64
4191 }
4192 if err := json.Unmarshal(m.ParsedBuf, &partialPart); err != nil {
4193 w.Err = fmt.Errorf("unmarshal part: %v", err)
4194 } else {
4195 size := partialPart.BodyOffset - partialPart.HeaderOffset
4196 if int(size) > len(headerbuf) {
4197 headerbuf = make([]byte, size)
4198 }
4199 if size > 0 {
4200 buf := headerbuf[:int(size)]
4201 err := func() error {
4202 mr := a.MessageReader(m)
4203 defer func() {
4204 if err := mr.Close(); err != nil {
4205 log.Printf("closing message reader: %v", err)
4206 }
4207 }()
4208
4209 // ReadAt returns whole buffer or error. Single read should be fast.
4210 n, err := mr.ReadAt(buf, partialPart.HeaderOffset)
4211 if err != nil || n != len(buf) {
4212 return fmt.Errorf("read header: %v", err)
4213 }
4214 return nil
4215 }()
4216 if err != nil {
4217 w.Err = err
4218 } else if h, err := message.ParseHeaderFields(buf, scratch, threadingFields); err != nil {
4219 w.Err = err
4220 } else {
4221 w.Out.references = h["References"]
4222 w.Out.inReplyTo = h["In-Reply-To"]
4223 }
4224 }
4225 }
4226
4227 out <- w
4228 }
4229 }
4230
4231 n := 0
4232 t := time.Now()
4233 t0 := t
4234
4235 processMessage := func(m store.Message, prep threadPrep) error {
4236 if n%100000 == 0 {
4237 log.Printf("%d messages (delta %s)", n, time.Since(t))
4238 t = time.Now()
4239 }
4240 n++
4241 return nil
4242 }
4243
4244 wq := moxio.NewWorkQueue(procs, workqueuesize, prepareMessages, processMessage)
4245
4246 err = a.DB.Write(context.Background(), func(tx *bstore.Tx) error {
4247 q := bstore.QueryTx[store.Message](tx)
4248 q.FilterEqual("Expunged", false)
4249 q.SortAsc("ID")
4250 if limit > 0 {
4251 q.Limit(limit)
4252 }
4253 err = q.ForEach(wq.Add)
4254 if err == nil {
4255 err = wq.Finish()
4256 }
4257 wq.Stop()
4258
4259 return err
4260 })
4261 xcheckf(err, "processing message")
4262
4263 err = a.Close()
4264 xcheckf(err, "close account %s", accName)
4265 log.Printf("account %s, total time %s", accName, time.Since(t0))
4266 }
4267}
4268
4269func cmdQueueFillRetired(c *cmd) {
4270 c.unlisted = true
4271 c.help = `Fill retired messag and webhooks queue with testdata.
4272
4273For testing the pagination. Operates directly on queue database.
4274`
4275 var n int
4276 c.flag.IntVar(&n, "n", 10000, "retired messages and retired webhooks to insert")
4277 args := c.Parse()
4278 if len(args) != 0 {
4279 c.Usage()
4280 }
4281
4282 mustLoadConfig()
4283 err := queue.Init()
4284 xcheckf(err, "init queue")
4285 err = queue.DB.Write(context.Background(), func(tx *bstore.Tx) error {
4286 now := time.Now()
4287
4288 // Cause autoincrement ID for queue.Msg to be forwarded, and use the reserved ID
4289 // space for inserting retired messages.
4290 fm := queue.Msg{}
4291 err = tx.Insert(&fm)
4292 xcheckf(err, "temporarily insert message to get autoincrement sequence")
4293 err = tx.Delete(&fm)
4294 xcheckf(err, "removing temporary message for resetting autoincrement sequence")
4295 fm.ID += int64(n)
4296 err = tx.Insert(&fm)
4297 xcheckf(err, "temporarily insert message to forward autoincrement sequence")
4298 err = tx.Delete(&fm)
4299 xcheckf(err, "removing temporary message after forwarding autoincrement sequence")
4300 fm.ID -= int64(n)
4301
4302 // And likewise for webhooks.
4303 fh := queue.Hook{Account: "x", URL: "x", NextAttempt: time.Now()}
4304 err = tx.Insert(&fh)
4305 xcheckf(err, "temporarily insert webhook to get autoincrement sequence")
4306 err = tx.Delete(&fh)
4307 xcheckf(err, "removing temporary webhook for resetting autoincrement sequence")
4308 fh.ID += int64(n)
4309 err = tx.Insert(&fh)
4310 xcheckf(err, "temporarily insert webhook to forward autoincrement sequence")
4311 err = tx.Delete(&fh)
4312 xcheckf(err, "removing temporary webhook after forwarding autoincrement sequence")
4313 fh.ID -= int64(n)
4314
4315 for i := range n {
4316 t0 := now.Add(-time.Duration(i) * time.Second)
4317 last := now.Add(-time.Duration(i/10) * time.Second)
4318 mr := queue.MsgRetired{
4319 ID: fm.ID + int64(i),
4320 Queued: t0,
4321 SenderAccount: "test",
4322 SenderLocalpart: "mox",
4323 SenderDomainStr: "localhost",
4324 FromID: fmt.Sprintf("%016d", i),
4325 RecipientLocalpart: "mox",
4326 RecipientDomain: dns.IPDomain{Domain: dns.Domain{ASCII: "localhost"}},
4327 RecipientDomainStr: "localhost",
4328 Attempts: i % 6,
4329 LastAttempt: &last,
4330 Results: []queue.MsgResult{
4331 {
4332 Start: last,
4333 Duration: time.Millisecond,
4334 Success: i%10 != 0,
4335 Code: 250,
4336 },
4337 },
4338 Has8bit: i%2 == 0,
4339 SMTPUTF8: i%8 == 0,
4340 Size: int64(i * 100),
4341 MessageID: fmt.Sprintf("<msg%d@localhost>", i),
4342 Subject: fmt.Sprintf("test message %d", i),
4343 Extra: map[string]string{"i": fmt.Sprintf("%d", i)},
4344 LastActivity: last,
4345 RecipientAddress: "mox@localhost",
4346 Success: i%10 != 0,
4347 KeepUntil: now.Add(48 * time.Hour),
4348 }
4349 err := tx.Insert(&mr)
4350 xcheckf(err, "inserting retired message")
4351 }
4352
4353 for i := range n {
4354 t0 := now.Add(-time.Duration(i) * time.Second)
4355 last := now.Add(-time.Duration(i/10) * time.Second)
4356 var event string
4357 if i%10 != 0 {
4358 event = "delivered"
4359 }
4360 hr := queue.HookRetired{
4361 ID: fh.ID + int64(i),
4362 QueueMsgID: fm.ID + int64(i),
4363 FromID: fmt.Sprintf("%016d", i),
4364 MessageID: fmt.Sprintf("<msg%d@localhost>", i),
4365 Subject: fmt.Sprintf("test message %d", i),
4366 Extra: map[string]string{"i": fmt.Sprintf("%d", i)},
4367 Account: "test",
4368 URL: "http://localhost/hook",
4369 IsIncoming: i%10 == 0,
4370 OutgoingEvent: event,
4371 Payload: "{}",
4372
4373 Submitted: t0,
4374 Attempts: i % 6,
4375 Results: []queue.HookResult{
4376 {
4377 Start: t0,
4378 Duration: time.Millisecond,
4379 URL: "http://localhost/hook",
4380 Success: i%10 != 0,
4381 Code: 200,
4382 Response: "ok",
4383 },
4384 },
4385
4386 Success: i%10 != 0,
4387 LastActivity: last,
4388 KeepUntil: now.Add(48 * time.Hour),
4389 }
4390 err := tx.Insert(&hr)
4391 xcheckf(err, "inserting retired hook")
4392 }
4393
4394 return nil
4395 })
4396 xcheckf(err, "add to queue")
4397 log.Printf("added %d retired messages and %d retired webhooks", n, n)
4398}
4399