8 cryptorand "crypto/rand"
24 "golang.org/x/crypto/bcrypt"
26 "github.com/mjl-/sconf"
28 "github.com/mjl-/mox/config"
29 "github.com/mjl-/mox/dkim"
30 "github.com/mjl-/mox/dns"
31 "github.com/mjl-/mox/junk"
32 "github.com/mjl-/mox/mlog"
33 "github.com/mjl-/mox/mox-"
34 "github.com/mjl-/mox/moxvar"
35 "github.com/mjl-/mox/queue"
36 "github.com/mjl-/mox/smtpserver"
37 "github.com/mjl-/mox/store"
40func cmdLocalserve(c *cmd) {
41 c.help = `Start a local SMTP/IMAP server that accepts all messages, useful when testing/developing software that sends email.
43Localserve starts mox with a configuration suitable for local email-related
44software development/testing. It listens for SMTP/Submission(s), IMAP(s) and
45HTTP(s), on the regular port numbers + 1000.
47Data is stored in the system user's configuration directory under
48"mox-localserve", e.g. $HOME/.config/mox-localserve/ on linux, but can be
49overridden with the -dir flag. If the directory does not yet exist, it is
50automatically initialized with configuration files, an account with email
51address mox@localhost and password moxmoxmox, and a newly generated self-signed
54Incoming messages are delivered as normal, falling back to accepting and
55delivering to the mox account for unknown addresses.
56Submitted messages are added to the queue, which delivers by ignoring the
57destination servers, always connecting to itself instead.
59Recipient addresses with the following localpart suffixes are handled specially:
61- "temperror": fail with a temporary error code
62- "permerror": fail with a permanent error code
63- [45][0-9][0-9]: fail with the specific error code
64- "timeout": no response (for an hour)
66If the localpart begins with "mailfrom" or "rcptto", the error is returned
67during those commands instead of during "data".
71 userConfDir, _ := os.UserConfigDir()
72 if userConfDir == "" {
75 // If we are being run to gather help output, show a placeholder directory
76 // instead of evaluating to the actual userconfigdir on the host os.
78 userConfDir = "$userconfigdir"
83 c.flag.StringVar(&dir, "dir", filepath.Join(userConfDir, "mox-localserve"), "configuration storage directory")
84 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.")
85 c.flag.BoolVar(&initOnly, "initonly", false, "write configuration files and exit")
92 mox.FilesImmediate = true
95 if _, err := os.Stat(dir); err == nil {
96 log.Print("warning: directory for configuration files already exists, continuing")
98 log.Print("creating mox localserve config", slog.String("dir", dir))
99 err := writeLocalConfig(log, dir, ip)
101 log.Fatalx("creating mox localserve config", err, slog.String("dir", dir))
106 // Load config, creating a new one if needed.
107 var existingConfig bool
108 if _, err := os.Stat(dir); err != nil && os.IsNotExist(err) {
109 err := writeLocalConfig(log, dir, ip)
111 log.Fatalx("creating mox localserve config", err, slog.String("dir", dir))
113 } else if err != nil {
114 log.Fatalx("stat config dir", err, slog.String("dir", dir))
115 } else if err := localLoadConfig(log, dir); err != nil {
116 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))
118 log.Fatal("can only use -ip when writing a new config file")
120 existingConfig = true
123 // For new configs, we keep the "info" loglevel set by writeLocalConfig until after
124 // initializing database files, to prevent lots of schema upgrade logging.
125 fallbackLevel := mox.Conf.Static.LogLevel
126 if fallbackLevel == "" {
127 fallbackLevel = "debug"
130 loadLoglevel(log, fallbackLevel)
133 // Initialize receivedid.
134 recvidbuf, err := os.ReadFile(filepath.Join(dir, "receivedid.key"))
135 if err == nil && len(recvidbuf) != 16+8 {
136 err = fmt.Errorf("bad length %d, need 16+8", len(recvidbuf))
139 log.Errorx("reading receivedid.key", err)
140 recvidbuf = make([]byte, 16+8)
141 _, err := cryptorand.Read(recvidbuf)
143 log.Fatalx("read random recvid key", err)
146 if err := mox.ReceivedIDInit(recvidbuf[:16], recvidbuf[16:]); err != nil {
147 log.Fatalx("init receivedid", err)
150 // Make smtp server accept all email and deliver to account "mox".
151 smtpserver.Localserve = true
152 // Tell queue it shouldn't be queuing/delivering.
153 queue.Localserve = true
154 // Tell DKIM not to fail signatures for TLD localhost.
155 dkim.Localserve = true
157 const mtastsdbRefresher = false
158 const sendDMARCReports = false
159 const sendTLSReports = false
160 const skipForkExec = true
161 if err := start(mtastsdbRefresher, sendDMARCReports, sendTLSReports, skipForkExec); err != nil {
162 log.Fatalx("starting mox", err)
165 loadLoglevel(log, fallbackLevel)
167 golog.Printf("mox, version %s, %s %s/%s", moxvar.Version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
169 golog.Printf("the default user is mox@localhost, with password moxmoxmox")
170 golog.Printf("the default admin password is moxadmin")
171 golog.Printf("port numbers are those common for the services + 1000")
172 golog.Printf("tls uses generated self-signed certificate %s", filepath.Join(dir, "localhost.crt"))
173 golog.Printf("all incoming email to any address is accepted (if checks pass), unless the recipient localpart ends with:")
175 golog.Printf(`- "temperror": fail with a temporary error code.`)
176 golog.Printf(`- "permerror": fail with a permanent error code.`)
177 golog.Printf(`- [45][0-9][0-9]: fail with the specific error code.`)
178 golog.Printf(`- "timeout": no response (for an hour).`)
180 golog.Print(`if the localpart begins with "mailfrom" or "rcptto", the error is returned`)
181 golog.Print(`during those commands instead of during "data". if the localpart begins with`)
182 golog.Print(`"queue", the submission is accepted but delivery from the queue will fail.`)
184 golog.Print(" smtp://localhost:1025 - receive email")
185 golog.Print("smtps://mox%40localhost:moxmoxmox@localhost:1465 - send email")
186 golog.Print(" smtp://mox%40localhost:moxmoxmox@localhost:1587 - send email (without tls)")
187 golog.Print("imaps://mox%40localhost:moxmoxmox@localhost:1993 - read email")
188 golog.Print(" imap://mox%40localhost:moxmoxmox@localhost:1143 - read email (without tls)")
189 golog.Print("https://localhost:1443/account/ - account https (email mox@localhost, password moxmoxmox)")
190 golog.Print(" http://localhost:1080/account/ - account http (without tls)")
191 golog.Print("https://localhost:1443/webmail/ - webmail https (email mox@localhost, password moxmoxmox)")
192 golog.Print(" http://localhost:1080/webmail/ - webmail http (without tls)")
193 golog.Print("https://localhost:1443/webapi/ - webmail https (email mox@localhost, password moxmoxmox)")
194 golog.Print(" http://localhost:1080/webapi/ - webmail http (without tls)")
195 golog.Print("https://localhost:1443/admin/ - admin https (password moxadmin)")
196 golog.Print(" http://localhost:1080/admin/ - admin http (without tls)")
199 golog.Printf("serving from existing config dir %s/", dir)
200 golog.Printf("if urls above don't work, consider resetting by removing config dir")
202 golog.Printf("serving from newly created config dir %s/", dir)
205 ctlpath := mox.DataDirPath("ctl")
206 _ = os.Remove(ctlpath)
207 ctl, err := net.Listen("unix", ctlpath)
209 log.Fatalx("listen on ctl unix domain socket", err)
213 conn, err := ctl.Accept()
215 log.Printx("accept for ctl", err)
219 ctx := context.WithValue(mox.Context, mlog.CidKey, cid)
220 go servectl(ctx, log.WithCid(cid), conn, func() { shutdown(log) })
224 // Graceful shutdown.
225 sigc := make(chan os.Signal, 1)
226 signal.Notify(sigc, os.Interrupt, syscall.SIGTERM)
228 log.Print("shutting down, waiting max 3s for existing connections", slog.Any("signal", sig))
230 if num, ok := sig.(syscall.Signal); ok {
237func writeLocalConfig(log mlog.Log, dir, ip string) (rerr error) {
241 if err, ok := x.(error); ok {
246 err := os.RemoveAll(dir)
247 log.Check(err, "removing config directory", slog.String("dir", dir))
251 xcheck := func(err error, msg string) {
253 panic(fmt.Errorf("%s: %s", msg, err))
257 os.MkdirAll(dir, 0770)
259 // Generate key and self-signed certificate for use with TLS.
260 privKey, err := ecdsa.GenerateKey(elliptic.P256(), cryptorand.Reader)
261 xcheck(err, "generating ecdsa key for self-signed certificate")
262 privKeyDER, err := x509.MarshalPKCS8PrivateKey(privKey)
263 xcheck(err, "marshal private key to pkcs8")
264 privBlock := &pem.Block{
266 Headers: map[string]string{
267 "Note": "ECDSA key generated by mox localserve for self-signed certificate.",
271 var privPEM bytes.Buffer
272 err = pem.Encode(&privPEM, privBlock)
273 xcheck(err, "pem-encoding private key")
274 err = os.WriteFile(filepath.Join(dir, "localhost.key"), privPEM.Bytes(), 0660)
275 xcheck(err, "writing private key for self-signed certificate")
277 // Now the certificate.
278 template := &x509.Certificate{
279 SerialNumber: big.NewInt(time.Now().Unix()), // Required field.
280 DNSNames: []string{"localhost"},
281 NotBefore: time.Now().Add(-time.Hour),
282 NotAfter: time.Now().Add(4 * 365 * 24 * time.Hour),
284 Organization: []string{"mox localserve"},
287 Organization: []string{"mox localserve"},
288 CommonName: "localhost",
291 certDER, err := x509.CreateCertificate(cryptorand.Reader, template, template, privKey.Public(), privKey)
292 xcheck(err, "making self-signed certificate")
294 pubBlock := &pem.Block{
296 // Comments (header) would cause failure to parse the certificate when we load the config.
299 var crtPEM bytes.Buffer
300 err = pem.Encode(&crtPEM, pubBlock)
301 xcheck(err, "pem-encoding self-signed certificate")
302 err = os.WriteFile(filepath.Join(dir, "localhost.crt"), crtPEM.Bytes(), 0660)
303 xcheck(err, "writing self-signed certificate")
305 // Write adminpasswd.
306 adminpw := "moxadmin"
307 adminpwhash, err := bcrypt.GenerateFromPassword([]byte(adminpw), bcrypt.DefaultCost)
308 xcheck(err, "generating hash for admin password")
309 err = os.WriteFile(filepath.Join(dir, "adminpasswd"), adminpwhash, 0660)
310 xcheck(err, "writing adminpasswd file")
313 ips := []string{"127.0.0.1", "::1"}
318 local := config.Listener{
321 KeyCerts: []config.KeyCert{
323 CertFile: "localhost.crt",
324 KeyFile: "localhost.key",
329 local.SMTP.Enabled = true
330 local.SMTP.Port = 1025
331 local.Submission.Enabled = true
332 local.Submission.Port = 1587
333 local.Submission.NoRequireSTARTTLS = true
334 local.Submissions.Enabled = true
335 local.Submissions.Port = 1465
336 local.IMAP.Enabled = true
337 local.IMAP.Port = 1143
338 local.IMAP.NoRequireSTARTTLS = true
339 local.IMAPS.Enabled = true
340 local.IMAPS.Port = 1993
341 local.AccountHTTP.Enabled = true
342 local.AccountHTTP.Port = 1080
343 local.AccountHTTP.Path = "/account/"
344 local.AccountHTTPS.Enabled = true
345 local.AccountHTTPS.Port = 1443
346 local.AccountHTTPS.Path = "/account/"
347 local.WebmailHTTP.Enabled = true
348 local.WebmailHTTP.Port = 1080
349 local.WebmailHTTP.Path = "/webmail/"
350 local.WebmailHTTPS.Enabled = true
351 local.WebmailHTTPS.Port = 1443
352 local.WebmailHTTPS.Path = "/webmail/"
353 local.WebAPIHTTP.Enabled = true
354 local.WebAPIHTTP.Port = 1080
355 local.WebAPIHTTP.Path = "/webapi/"
356 local.WebAPIHTTPS.Enabled = true
357 local.WebAPIHTTPS.Port = 1443
358 local.WebAPIHTTPS.Path = "/webapi/"
359 local.AdminHTTP.Enabled = true
360 local.AdminHTTP.Port = 1080
361 local.AdminHTTPS.Enabled = true
362 local.AdminHTTPS.Port = 1443
363 local.MetricsHTTP.Enabled = true
364 local.MetricsHTTP.Port = 1081
365 local.WebserverHTTP.Enabled = true
366 local.WebserverHTTP.Port = 1080
367 local.WebserverHTTPS.Enabled = true
368 local.WebserverHTTPS.Port = 1443
372 uid = 1 // For windows.
374 static := config.Static{
376 LogLevel: "traceauth",
377 Hostname: "localhost",
378 User: fmt.Sprintf("%d", uid),
379 AdminPasswordFile: "adminpasswd",
381 Listeners: map[string]config.Listener{
386 AdditionalToSystem bool `sconf:"optional"`
387 CertFiles []string `sconf:"optional"`
388 }{true, []string{"localhost.crt"}}
389 static.TLS.CA = &tlsca
390 static.Postmaster.Account = "mox"
391 static.Postmaster.Mailbox = "Inbox"
393 var moxconfBuf bytes.Buffer
394 err = sconf.WriteDocs(&moxconfBuf, static)
395 xcheck(err, "making mox.conf")
397 err = os.WriteFile(filepath.Join(dir, "mox.conf"), moxconfBuf.Bytes(), 0660)
398 xcheck(err, "writing mox.conf")
400 // Write domains.conf.
401 acc := config.Account{
402 KeepRetiredMessagePeriod: 72 * time.Hour,
403 KeepRetiredWebhookPeriod: 72 * time.Hour,
404 RejectsMailbox: "Rejects",
405 Destinations: map[string]config.Destination{
408 NoFirstTimeSenderDelay: true,
410 acc.AutomaticJunkFlags.Enabled = true
411 acc.AutomaticJunkFlags.JunkMailboxRegexp = "^(junk|spam)"
412 acc.AutomaticJunkFlags.NeutralMailboxRegexp = "^(inbox|neutral|postmaster|dmarc|tlsrpt|rejects)"
413 acc.JunkFilter = &config.JunkFilter{
424 dkimKeyBuf, err := mox.MakeDKIMEd25519Key(dns.Domain{ASCII: "localserve"}, dns.Domain{ASCII: "localhost"})
425 xcheck(err, "making dkim key")
426 dkimKeyPath := "dkim.localserve.privatekey.pkcs8.pem"
427 err = os.WriteFile(filepath.Join(dir, dkimKeyPath), dkimKeyBuf, 0660)
428 xcheck(err, "writing dkim key file")
430 dynamic := config.Dynamic{
431 Domains: map[string]config.Domain{
433 LocalpartCatchallSeparator: "+",
435 Sign: []string{"localserve"},
436 Selectors: map[string]config.Selector{
439 PrivateKeyFile: dkimKeyPath,
445 Accounts: map[string]config.Account{
448 WebHandlers: []config.WebHandler{
452 PathRegexp: "^/workdir/",
453 DontRedirectPlainHTTP: true,
454 WebStatic: &config.WebStatic{
455 StripPrefix: "/workdir/",
462 var domainsconfBuf bytes.Buffer
463 err = sconf.WriteDocs(&domainsconfBuf, dynamic)
464 xcheck(err, "making domains.conf")
466 err = os.WriteFile(filepath.Join(dir, "domains.conf"), domainsconfBuf.Bytes(), 0660)
467 xcheck(err, "writing domains.conf")
469 // Write receivedid.key.
470 recvidbuf := make([]byte, 16+8)
471 _, err = cryptorand.Read(recvidbuf)
472 xcheck(err, "reading random recvid data")
473 err = os.WriteFile(filepath.Join(dir, "receivedid.key"), recvidbuf, 0660)
474 xcheck(err, "writing receivedid.key")
476 // Load config, so we can access the account.
477 err = localLoadConfig(log, dir)
478 xcheck(err, "loading config")
480 // Info so we don't log lots about initializing database.
481 loadLoglevel(log, "info")
483 // Set password on account.
484 a, _, err := store.OpenEmail(log, "mox@localhost")
485 xcheck(err, "opening account to set password")
486 password := "moxmoxmox"
487 err = a.SetPassword(log, password)
488 xcheck(err, "setting password")
490 xcheck(err, "closing account")
492 golog.Printf("config created in %s", dir)
496func loadLoglevel(log mlog.Log, fallback string) {
501 if level, ok := mlog.Levels[ll]; ok {
502 mox.Conf.Log[""] = level
503 mlog.SetConfig(mox.Conf.Log)
505 log.Fatal("unknown loglevel", slog.String("loglevel", loglevel))
509func localLoadConfig(log mlog.Log, dir string) error {
510 mox.ConfigStaticPath = filepath.Join(dir, "mox.conf")
511 mox.ConfigDynamicPath = filepath.Join(dir, "domains.conf")
512 errs := mox.LoadConfig(context.Background(), log, true, false)
514 log.Error("loading config generated config file: multiple errors")
515 for _, err := range errs {
516 log.Errorx("config error", err)
518 return fmt.Errorf("stopping after multiple config errors")
519 } else if len(errs) == 1 {
520 return fmt.Errorf("loading config file: %v", errs[0])