8 cryptorand "crypto/rand"
24 "golang.org/x/crypto/bcrypt"
26 "github.com/mjl-/sconf"
28 "github.com/mjl-/mox/admin"
29 "github.com/mjl-/mox/config"
30 "github.com/mjl-/mox/dkim"
31 "github.com/mjl-/mox/dns"
32 "github.com/mjl-/mox/junk"
33 "github.com/mjl-/mox/mlog"
34 "github.com/mjl-/mox/mox-"
35 "github.com/mjl-/mox/moxvar"
36 "github.com/mjl-/mox/queue"
37 "github.com/mjl-/mox/smtpserver"
38 "github.com/mjl-/mox/store"
41func cmdLocalserve(c *cmd) {
42 c.help = `Start a local SMTP/IMAP server that accepts all messages, useful when testing/developing software that sends email.
44Localserve starts mox with a configuration suitable for local email-related
45software development/testing. It listens for SMTP/Submission(s), IMAP(s) and
46HTTP(s), on the regular port numbers + 1000.
48Data is stored in the system user's configuration directory under
49"mox-localserve", e.g. $HOME/.config/mox-localserve/ on linux, but can be
50overridden with the -dir flag. If the directory does not yet exist, it is
51automatically initialized with configuration files, an account with email
52address mox@localhost and password moxmoxmox, and a newly generated self-signed
55Incoming messages are delivered as normal, falling back to accepting and
56delivering to the mox account for unknown addresses.
57Submitted messages are added to the queue, which delivers by ignoring the
58destination servers, always connecting to itself instead.
60Recipient addresses with the following localpart suffixes are handled specially:
62- "temperror": fail with a temporary error code
63- "permerror": fail with a permanent error code
64- [45][0-9][0-9]: fail with the specific error code
65- "timeout": no response (for an hour)
67If the localpart begins with "mailfrom" or "rcptto", the error is returned
68during those commands instead of during "data".
72 userConfDir, _ := os.UserConfigDir()
73 if userConfDir == "" {
76 // If we are being run to gather help output, show a placeholder directory
77 // instead of evaluating to the actual userconfigdir on the host os.
79 userConfDir = "$userconfigdir"
84 c.flag.StringVar(&dir, "dir", filepath.Join(userConfDir, "mox-localserve"), "configuration storage directory")
85 c.flag.StringVar(&ip, "ip", "", "serve on this ip instead of default 127.0.0.1 and ::1. only used when writing configuration, at first launch.")
86 c.flag.BoolVar(&initOnly, "initonly", false, "write configuration files and exit")
93 mox.FilesImmediate = true
96 if _, err := os.Stat(dir); err == nil {
97 log.Print("warning: directory for configuration files already exists, continuing")
99 log.Print("creating mox localserve config", slog.String("dir", dir))
100 err := writeLocalConfig(log, dir, ip)
102 log.Fatalx("creating mox localserve config", err, slog.String("dir", dir))
107 // Load config, creating a new one if needed.
108 var existingConfig bool
109 if _, err := os.Stat(dir); err != nil && os.IsNotExist(err) {
110 err := writeLocalConfig(log, dir, ip)
112 log.Fatalx("creating mox localserve config", err, slog.String("dir", dir))
114 } else if err != nil {
115 log.Fatalx("stat config dir", err, slog.String("dir", dir))
116 } else if err := localLoadConfig(log, dir); err != nil {
117 log.Fatalx("loading mox localserve config (hint: when creating a new config with -dir, the directory must not yet exist)", err, slog.String("dir", dir))
119 log.Fatal("can only use -ip when writing a new config file")
121 existingConfig = true
124 // For new configs, we keep the "info" loglevel set by writeLocalConfig until after
125 // initializing database files, to prevent lots of schema upgrade logging.
126 fallbackLevel := mox.Conf.Static.LogLevel
127 if fallbackLevel == "" {
128 fallbackLevel = "debug"
131 loadLoglevel(log, fallbackLevel)
134 // Initialize receivedid.
135 recvidbuf, err := os.ReadFile(filepath.Join(dir, "receivedid.key"))
136 if err == nil && len(recvidbuf) != 16+8 {
137 err = fmt.Errorf("bad length %d, need 16+8", len(recvidbuf))
140 log.Errorx("reading receivedid.key", err)
141 recvidbuf = make([]byte, 16+8)
142 _, err := cryptorand.Read(recvidbuf)
144 log.Fatalx("read random recvid key", err)
147 if err := mox.ReceivedIDInit(recvidbuf[:16], recvidbuf[16:]); err != nil {
148 log.Fatalx("init receivedid", err)
151 // Make smtp server accept all email and deliver to account "mox".
152 smtpserver.Localserve = true
153 // Tell queue it shouldn't be queuing/delivering.
154 queue.Localserve = true
155 // Tell DKIM not to fail signatures for TLD localhost.
156 dkim.Localserve = true
158 const mtastsdbRefresher = false
159 const sendDMARCReports = false
160 const sendTLSReports = false
161 const skipForkExec = true
162 if err := start(mtastsdbRefresher, sendDMARCReports, sendTLSReports, skipForkExec); err != nil {
163 log.Fatalx("starting mox", err)
166 loadLoglevel(log, fallbackLevel)
168 golog.Printf("mox, version %s %s/%s", moxvar.Version, runtime.GOOS, runtime.GOARCH)
170 golog.Printf("the default user is mox@localhost, with password moxmoxmox")
171 golog.Printf("the default admin password is moxadmin")
172 golog.Printf("port numbers are those common for the services + 1000")
173 golog.Printf("tls uses generated self-signed certificate %s", filepath.Join(dir, "localhost.crt"))
174 golog.Printf("all incoming email to any address is accepted (if checks pass), unless the recipient localpart ends with:")
176 golog.Printf(`- "temperror": fail with a temporary error code.`)
177 golog.Printf(`- "permerror": fail with a permanent error code.`)
178 golog.Printf(`- [45][0-9][0-9]: fail with the specific error code.`)
179 golog.Printf(`- "timeout": no response (for an hour).`)
181 golog.Print(`if the localpart begins with "mailfrom" or "rcptto", the error is returned`)
182 golog.Print(`during those commands instead of during "data". if the localpart begins with`)
183 golog.Print(`"queue", the submission is accepted but delivery from the queue will fail.`)
185 golog.Print(" smtp://localhost:1025 - receive email")
186 golog.Print("smtps://mox%40localhost:moxmoxmox@localhost:1465 - send email")
187 golog.Print(" smtp://mox%40localhost:moxmoxmox@localhost:1587 - send email (without tls)")
188 golog.Print("imaps://mox%40localhost:moxmoxmox@localhost:1993 - read email")
189 golog.Print(" imap://mox%40localhost:moxmoxmox@localhost:1143 - read email (without tls)")
190 golog.Print("https://localhost:1443/account/ - account https (email mox@localhost, password moxmoxmox)")
191 golog.Print(" http://localhost:1080/account/ - account http (without tls)")
192 golog.Print("https://localhost:1443/webmail/ - webmail https (email mox@localhost, password moxmoxmox)")
193 golog.Print(" http://localhost:1080/webmail/ - webmail http (without tls)")
194 golog.Print("https://localhost:1443/webapi/ - webmail https (email mox@localhost, password moxmoxmox)")
195 golog.Print(" http://localhost:1080/webapi/ - webmail http (without tls)")
196 golog.Print("https://localhost:1443/admin/ - admin https (password moxadmin)")
197 golog.Print(" http://localhost:1080/admin/ - admin http (without tls)")
200 golog.Printf("serving from existing config dir %s/", dir)
201 golog.Printf("if urls above don't work, consider resetting by removing config dir")
203 golog.Printf("serving from newly created config dir %s/", dir)
206 ctlpath := mox.DataDirPath("ctl")
207 _ = os.Remove(ctlpath)
208 ctl, err := net.Listen("unix", ctlpath)
210 log.Fatalx("listen on ctl unix domain socket", err)
214 conn, err := ctl.Accept()
216 log.Printx("accept for ctl", err)
220 ctx := context.WithValue(mox.Context, mlog.CidKey, cid)
221 go servectl(ctx, log.WithCid(cid), conn, func() { shutdown(log) })
225 // Graceful shutdown.
226 sigc := make(chan os.Signal, 1)
227 signal.Notify(sigc, os.Interrupt, syscall.SIGTERM)
229 log.Print("shutting down, waiting max 3s for existing connections", slog.Any("signal", sig))
231 if num, ok := sig.(syscall.Signal); ok {
238func writeLocalConfig(log mlog.Log, dir, ip string) (rerr error) {
242 if err, ok := x.(error); ok {
247 err := os.RemoveAll(dir)
248 log.Check(err, "removing config directory", slog.String("dir", dir))
252 xcheck := func(err error, msg string) {
254 panic(fmt.Errorf("%s: %s", msg, err))
258 os.MkdirAll(dir, 0770)
260 // Generate key and self-signed certificate for use with TLS.
261 privKey, err := ecdsa.GenerateKey(elliptic.P256(), cryptorand.Reader)
262 xcheck(err, "generating ecdsa key for self-signed certificate")
263 privKeyDER, err := x509.MarshalPKCS8PrivateKey(privKey)
264 xcheck(err, "marshal private key to pkcs8")
265 privBlock := &pem.Block{
267 Headers: map[string]string{
268 "Note": "ECDSA key generated by mox localserve for self-signed certificate.",
272 var privPEM bytes.Buffer
273 err = pem.Encode(&privPEM, privBlock)
274 xcheck(err, "pem-encoding private key")
275 err = os.WriteFile(filepath.Join(dir, "localhost.key"), privPEM.Bytes(), 0660)
276 xcheck(err, "writing private key for self-signed certificate")
278 // Now the certificate.
279 template := &x509.Certificate{
280 SerialNumber: big.NewInt(time.Now().Unix()), // Required field.
281 DNSNames: []string{"localhost"},
282 NotBefore: time.Now().Add(-time.Hour),
283 NotAfter: time.Now().Add(4 * 365 * 24 * time.Hour),
285 Organization: []string{"mox localserve"},
288 Organization: []string{"mox localserve"},
289 CommonName: "localhost",
292 certDER, err := x509.CreateCertificate(cryptorand.Reader, template, template, privKey.Public(), privKey)
293 xcheck(err, "making self-signed certificate")
295 pubBlock := &pem.Block{
297 // Comments (header) would cause failure to parse the certificate when we load the config.
300 var crtPEM bytes.Buffer
301 err = pem.Encode(&crtPEM, pubBlock)
302 xcheck(err, "pem-encoding self-signed certificate")
303 err = os.WriteFile(filepath.Join(dir, "localhost.crt"), crtPEM.Bytes(), 0660)
304 xcheck(err, "writing self-signed certificate")
306 // Write adminpasswd.
307 adminpw := "moxadmin"
308 adminpwhash, err := bcrypt.GenerateFromPassword([]byte(adminpw), bcrypt.DefaultCost)
309 xcheck(err, "generating hash for admin password")
310 err = os.WriteFile(filepath.Join(dir, "adminpasswd"), adminpwhash, 0660)
311 xcheck(err, "writing adminpasswd file")
314 ips := []string{"127.0.0.1", "::1"}
319 local := config.Listener{
322 KeyCerts: []config.KeyCert{
324 CertFile: "localhost.crt",
325 KeyFile: "localhost.key",
330 local.SMTP.Enabled = true
331 local.SMTP.Port = 1025
332 local.Submission.Enabled = true
333 local.Submission.Port = 1587
334 local.Submission.NoRequireSTARTTLS = true
335 local.Submissions.Enabled = true
336 local.Submissions.Port = 1465
337 local.IMAP.Enabled = true
338 local.IMAP.Port = 1143
339 local.IMAP.NoRequireSTARTTLS = true
340 local.IMAPS.Enabled = true
341 local.IMAPS.Port = 1993
342 local.AccountHTTP.Enabled = true
343 local.AccountHTTP.Port = 1080
344 local.AccountHTTP.Path = "/account/"
345 local.AccountHTTPS.Enabled = true
346 local.AccountHTTPS.Port = 1443
347 local.AccountHTTPS.Path = "/account/"
348 local.WebmailHTTP.Enabled = true
349 local.WebmailHTTP.Port = 1080
350 local.WebmailHTTP.Path = "/webmail/"
351 local.WebmailHTTPS.Enabled = true
352 local.WebmailHTTPS.Port = 1443
353 local.WebmailHTTPS.Path = "/webmail/"
354 local.WebAPIHTTP.Enabled = true
355 local.WebAPIHTTP.Port = 1080
356 local.WebAPIHTTP.Path = "/webapi/"
357 local.WebAPIHTTPS.Enabled = true
358 local.WebAPIHTTPS.Port = 1443
359 local.WebAPIHTTPS.Path = "/webapi/"
360 local.AdminHTTP.Enabled = true
361 local.AdminHTTP.Port = 1080
362 local.AdminHTTPS.Enabled = true
363 local.AdminHTTPS.Port = 1443
364 local.MetricsHTTP.Enabled = true
365 local.MetricsHTTP.Port = 1081
366 local.WebserverHTTP.Enabled = true
367 local.WebserverHTTP.Port = 1080
368 local.WebserverHTTPS.Enabled = true
369 local.WebserverHTTPS.Port = 1443
373 uid = 1 // For windows.
375 static := config.Static{
377 LogLevel: "traceauth",
378 Hostname: "localhost",
379 User: fmt.Sprintf("%d", uid),
380 AdminPasswordFile: "adminpasswd",
382 Listeners: map[string]config.Listener{
387 AdditionalToSystem bool `sconf:"optional"`
388 CertFiles []string `sconf:"optional"`
389 }{true, []string{"localhost.crt"}}
390 static.TLS.CA = &tlsca
391 static.Postmaster.Account = "mox"
392 static.Postmaster.Mailbox = "Inbox"
394 var moxconfBuf bytes.Buffer
395 err = sconf.WriteDocs(&moxconfBuf, static)
396 xcheck(err, "making mox.conf")
398 err = os.WriteFile(filepath.Join(dir, "mox.conf"), moxconfBuf.Bytes(), 0660)
399 xcheck(err, "writing mox.conf")
401 // Write domains.conf.
402 acc := config.Account{
403 KeepRetiredMessagePeriod: 72 * time.Hour,
404 KeepRetiredWebhookPeriod: 72 * time.Hour,
405 RejectsMailbox: "Rejects",
406 Destinations: map[string]config.Destination{
409 NoFirstTimeSenderDelay: true,
411 acc.AutomaticJunkFlags.Enabled = true
412 acc.AutomaticJunkFlags.JunkMailboxRegexp = "^(junk|spam)"
413 acc.AutomaticJunkFlags.NeutralMailboxRegexp = "^(inbox|neutral|postmaster|dmarc|tlsrpt|rejects)"
414 acc.JunkFilter = &config.JunkFilter{
425 dkimKeyBuf, err := admin.MakeDKIMEd25519Key(dns.Domain{ASCII: "localserve"}, dns.Domain{ASCII: "localhost"})
426 xcheck(err, "making dkim key")
427 dkimKeyPath := "dkim.localserve.privatekey.pkcs8.pem"
428 err = os.WriteFile(filepath.Join(dir, dkimKeyPath), dkimKeyBuf, 0660)
429 xcheck(err, "writing dkim key file")
431 dynamic := config.Dynamic{
432 Domains: map[string]config.Domain{
434 LocalpartCatchallSeparator: "+",
436 Sign: []string{"localserve"},
437 Selectors: map[string]config.Selector{
440 PrivateKeyFile: dkimKeyPath,
446 Accounts: map[string]config.Account{
449 WebHandlers: []config.WebHandler{
453 PathRegexp: "^/workdir/",
454 DontRedirectPlainHTTP: true,
455 WebStatic: &config.WebStatic{
456 StripPrefix: "/workdir/",
463 var domainsconfBuf bytes.Buffer
464 err = sconf.WriteDocs(&domainsconfBuf, dynamic)
465 xcheck(err, "making domains.conf")
467 err = os.WriteFile(filepath.Join(dir, "domains.conf"), domainsconfBuf.Bytes(), 0660)
468 xcheck(err, "writing domains.conf")
470 // Write receivedid.key.
471 recvidbuf := make([]byte, 16+8)
472 _, err = cryptorand.Read(recvidbuf)
473 xcheck(err, "reading random recvid data")
474 err = os.WriteFile(filepath.Join(dir, "receivedid.key"), recvidbuf, 0660)
475 xcheck(err, "writing receivedid.key")
477 // Load config, so we can access the account.
478 err = localLoadConfig(log, dir)
479 xcheck(err, "loading config")
481 // Info so we don't log lots about initializing database.
482 loadLoglevel(log, "info")
484 // Set password on account.
485 a, _, err := store.OpenEmail(log, "mox@localhost")
486 xcheck(err, "opening account to set password")
487 password := "moxmoxmox"
488 err = a.SetPassword(log, password)
489 xcheck(err, "setting password")
491 xcheck(err, "closing account")
493 golog.Printf("config created in %s", dir)
497func loadLoglevel(log mlog.Log, fallback string) {
502 if level, ok := mlog.Levels[ll]; ok {
503 mox.Conf.Log[""] = level
504 mlog.SetConfig(mox.Conf.Log)
506 log.Fatal("unknown loglevel", slog.String("loglevel", loglevel))
510func localLoadConfig(log mlog.Log, dir string) error {
511 mox.ConfigStaticPath = filepath.Join(dir, "mox.conf")
512 mox.ConfigDynamicPath = filepath.Join(dir, "domains.conf")
513 errs := mox.LoadConfig(context.Background(), log, true, false)
515 log.Error("loading config generated config file: multiple errors")
516 for _, err := range errs {
517 log.Errorx("config error", err)
519 return fmt.Errorf("stopping after multiple config errors")
520 } else if len(errs) == 1 {
521 return fmt.Errorf("loading config file: %v", errs[0])