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 cryptorand.Read(recvidbuf)
144 if err := mox.ReceivedIDInit(recvidbuf[:16], recvidbuf[16:]); err != nil {
145 log.Fatalx("init receivedid", err)
148 // Make smtp server accept all email and deliver to account "mox".
149 smtpserver.Localserve = true
150 // Tell queue it shouldn't be queuing/delivering.
151 queue.Localserve = true
152 // Tell DKIM not to fail signatures for TLD localhost.
153 dkim.Localserve = true
155 const mtastsdbRefresher = false
156 const sendDMARCReports = false
157 const sendTLSReports = false
158 const skipForkExec = true
159 if err := start(mtastsdbRefresher, sendDMARCReports, sendTLSReports, skipForkExec); err != nil {
160 log.Fatalx("starting mox", err)
163 loadLoglevel(log, fallbackLevel)
165 golog.Printf("mox, version %s %s/%s", moxvar.Version, runtime.GOOS, runtime.GOARCH)
167 golog.Printf("the default user is mox@localhost, with password moxmoxmox")
168 golog.Printf("the default admin password is moxadmin")
169 golog.Printf("port numbers are those common for the services + 1000")
170 golog.Printf("tls uses generated self-signed certificate %s", filepath.Join(dir, "localhost.crt"))
171 golog.Printf("all incoming email to any address is accepted (if checks pass), unless the recipient localpart ends with:")
173 golog.Printf(`- "temperror": fail with a temporary error code.`)
174 golog.Printf(`- "permerror": fail with a permanent error code.`)
175 golog.Printf(`- [45][0-9][0-9]: fail with the specific error code.`)
176 golog.Printf(`- "timeout": no response (for an hour).`)
178 golog.Print(`if the localpart begins with "mailfrom" or "rcptto", the error is returned`)
179 golog.Print(`during those commands instead of during "data". if the localpart begins with`)
180 golog.Print(`"queue", the submission is accepted but delivery from the queue will fail.`)
182 golog.Print(" smtp://localhost:1025 - receive email")
183 golog.Print("smtps://mox%40localhost:moxmoxmox@localhost:1465 - send email")
184 golog.Print(" smtp://mox%40localhost:moxmoxmox@localhost:1587 - send email (without tls)")
185 golog.Print("imaps://mox%40localhost:moxmoxmox@localhost:1993 - read email")
186 golog.Print(" imap://mox%40localhost:moxmoxmox@localhost:1143 - read email (without tls)")
187 golog.Print("https://localhost:1443/account/ - account https (email mox@localhost, password moxmoxmox)")
188 golog.Print(" http://localhost:1080/account/ - account http (without tls)")
189 golog.Print("https://localhost:1443/webmail/ - webmail https (email mox@localhost, password moxmoxmox)")
190 golog.Print(" http://localhost:1080/webmail/ - webmail http (without tls)")
191 golog.Print("https://localhost:1443/webapi/ - webmail https (email mox@localhost, password moxmoxmox)")
192 golog.Print(" http://localhost:1080/webapi/ - webmail http (without tls)")
193 golog.Print("https://localhost:1443/admin/ - admin https (password moxadmin)")
194 golog.Print(" http://localhost:1080/admin/ - admin http (without tls)")
197 golog.Printf("serving from existing config dir %s/", dir)
198 golog.Printf("if urls above don't work, consider resetting by removing config dir")
200 golog.Printf("serving from newly created config dir %s/", dir)
203 ctlpath := mox.DataDirPath("ctl")
204 _ = os.Remove(ctlpath)
205 ctl, err := net.Listen("unix", ctlpath)
207 log.Fatalx("listen on ctl unix domain socket", err)
211 conn, err := ctl.Accept()
213 log.Printx("accept for ctl", err)
217 ctx := context.WithValue(mox.Context, mlog.CidKey, cid)
218 go servectl(ctx, cid, log.WithCid(cid), conn, func() { shutdown(log) })
222 // Graceful shutdown.
223 sigc := make(chan os.Signal, 1)
224 signal.Notify(sigc, os.Interrupt, syscall.SIGTERM)
226 log.Print("shutting down, waiting max 3s for existing connections", slog.Any("signal", sig))
228 if num, ok := sig.(syscall.Signal); ok {
235func writeLocalConfig(log mlog.Log, dir, ip string) (rerr error) {
239 if err, ok := x.(error); ok {
244 err := os.RemoveAll(dir)
245 log.Check(err, "removing config directory", slog.String("dir", dir))
249 xcheck := func(err error, msg string) {
251 panic(fmt.Errorf("%s: %s", msg, err))
255 os.MkdirAll(dir, 0770)
257 // Generate key and self-signed certificate for use with TLS.
258 privKey, err := ecdsa.GenerateKey(elliptic.P256(), cryptorand.Reader)
259 xcheck(err, "generating ecdsa key for self-signed certificate")
260 privKeyDER, err := x509.MarshalPKCS8PrivateKey(privKey)
261 xcheck(err, "marshal private key to pkcs8")
262 privBlock := &pem.Block{
264 Headers: map[string]string{
265 "Note": "ECDSA key generated by mox localserve for self-signed certificate.",
269 var privPEM bytes.Buffer
270 err = pem.Encode(&privPEM, privBlock)
271 xcheck(err, "pem-encoding private key")
272 err = os.WriteFile(filepath.Join(dir, "localhost.key"), privPEM.Bytes(), 0660)
273 xcheck(err, "writing private key for self-signed certificate")
275 // Now the certificate.
276 template := &x509.Certificate{
277 SerialNumber: big.NewInt(time.Now().Unix()), // Required field.
278 DNSNames: []string{"localhost"},
279 NotBefore: time.Now().Add(-time.Hour),
280 NotAfter: time.Now().Add(4 * 365 * 24 * time.Hour),
282 Organization: []string{"mox localserve"},
285 Organization: []string{"mox localserve"},
286 CommonName: "localhost",
289 certDER, err := x509.CreateCertificate(cryptorand.Reader, template, template, privKey.Public(), privKey)
290 xcheck(err, "making self-signed certificate")
292 pubBlock := &pem.Block{
294 // Comments (header) would cause failure to parse the certificate when we load the config.
297 var crtPEM bytes.Buffer
298 err = pem.Encode(&crtPEM, pubBlock)
299 xcheck(err, "pem-encoding self-signed certificate")
300 err = os.WriteFile(filepath.Join(dir, "localhost.crt"), crtPEM.Bytes(), 0660)
301 xcheck(err, "writing self-signed certificate")
303 // Write adminpasswd.
304 adminpw := "moxadmin"
305 adminpwhash, err := bcrypt.GenerateFromPassword([]byte(adminpw), bcrypt.DefaultCost)
306 xcheck(err, "generating hash for admin password")
307 err = os.WriteFile(filepath.Join(dir, "adminpasswd"), adminpwhash, 0660)
308 xcheck(err, "writing adminpasswd file")
311 ips := []string{"127.0.0.1", "::1"}
316 local := config.Listener{
319 KeyCerts: []config.KeyCert{
321 CertFile: "localhost.crt",
322 KeyFile: "localhost.key",
327 local.SMTP.Enabled = true
328 local.SMTP.Port = 1025
329 local.Submission.Enabled = true
330 local.Submission.Port = 1587
331 local.Submission.NoRequireSTARTTLS = true
332 local.Submissions.Enabled = true
333 local.Submissions.Port = 1465
334 local.IMAP.Enabled = true
335 local.IMAP.Port = 1143
336 local.IMAP.NoRequireSTARTTLS = true
337 local.IMAPS.Enabled = true
338 local.IMAPS.Port = 1993
339 local.AccountHTTP.Enabled = true
340 local.AccountHTTP.Port = 1080
341 local.AccountHTTP.Path = "/account/"
342 local.AccountHTTPS.Enabled = true
343 local.AccountHTTPS.Port = 1443
344 local.AccountHTTPS.Path = "/account/"
345 local.WebmailHTTP.Enabled = true
346 local.WebmailHTTP.Port = 1080
347 local.WebmailHTTP.Path = "/webmail/"
348 local.WebmailHTTPS.Enabled = true
349 local.WebmailHTTPS.Port = 1443
350 local.WebmailHTTPS.Path = "/webmail/"
351 local.WebAPIHTTP.Enabled = true
352 local.WebAPIHTTP.Port = 1080
353 local.WebAPIHTTP.Path = "/webapi/"
354 local.WebAPIHTTPS.Enabled = true
355 local.WebAPIHTTPS.Port = 1443
356 local.WebAPIHTTPS.Path = "/webapi/"
357 local.AdminHTTP.Enabled = true
358 local.AdminHTTP.Port = 1080
359 local.AdminHTTPS.Enabled = true
360 local.AdminHTTPS.Port = 1443
361 local.MetricsHTTP.Enabled = true
362 local.MetricsHTTP.Port = 1081
363 local.WebserverHTTP.Enabled = true
364 local.WebserverHTTP.Port = 1080
365 local.WebserverHTTPS.Enabled = true
366 local.WebserverHTTPS.Port = 1443
370 uid = 1 // For windows.
372 static := config.Static{
374 LogLevel: "traceauth",
375 Hostname: "localhost",
376 User: fmt.Sprintf("%d", uid),
377 AdminPasswordFile: "adminpasswd",
379 Listeners: map[string]config.Listener{
384 AdditionalToSystem bool `sconf:"optional"`
385 CertFiles []string `sconf:"optional"`
386 }{true, []string{"localhost.crt"}}
387 static.TLS.CA = &tlsca
388 static.Postmaster.Account = "mox"
389 static.Postmaster.Mailbox = "Inbox"
391 var moxconfBuf bytes.Buffer
392 err = sconf.WriteDocs(&moxconfBuf, static)
393 xcheck(err, "making mox.conf")
395 err = os.WriteFile(filepath.Join(dir, "mox.conf"), moxconfBuf.Bytes(), 0660)
396 xcheck(err, "writing mox.conf")
398 // Write domains.conf.
399 acc := config.Account{
400 KeepRetiredMessagePeriod: 72 * time.Hour,
401 KeepRetiredWebhookPeriod: 72 * time.Hour,
402 RejectsMailbox: "Rejects",
403 Destinations: map[string]config.Destination{
406 NoFirstTimeSenderDelay: true,
408 acc.AutomaticJunkFlags.Enabled = true
409 acc.AutomaticJunkFlags.JunkMailboxRegexp = "^(junk|spam)"
410 acc.AutomaticJunkFlags.NeutralMailboxRegexp = "^(inbox|neutral|postmaster|dmarc|tlsrpt|rejects)"
411 acc.JunkFilter = &config.JunkFilter{
422 dkimKeyBuf, err := admin.MakeDKIMEd25519Key(dns.Domain{ASCII: "localserve"}, dns.Domain{ASCII: "localhost"})
423 xcheck(err, "making dkim key")
424 dkimKeyPath := "dkim.localserve.privatekey.pkcs8.pem"
425 err = os.WriteFile(filepath.Join(dir, dkimKeyPath), dkimKeyBuf, 0660)
426 xcheck(err, "writing dkim key file")
428 dynamic := config.Dynamic{
429 Domains: map[string]config.Domain{
431 LocalpartCatchallSeparator: "+",
433 Sign: []string{"localserve"},
434 Selectors: map[string]config.Selector{
437 PrivateKeyFile: dkimKeyPath,
443 Accounts: map[string]config.Account{
446 WebHandlers: []config.WebHandler{
450 PathRegexp: "^/workdir/",
451 DontRedirectPlainHTTP: true,
452 WebStatic: &config.WebStatic{
453 StripPrefix: "/workdir/",
460 var domainsconfBuf bytes.Buffer
461 err = sconf.WriteDocs(&domainsconfBuf, dynamic)
462 xcheck(err, "making domains.conf")
464 err = os.WriteFile(filepath.Join(dir, "domains.conf"), domainsconfBuf.Bytes(), 0660)
465 xcheck(err, "writing domains.conf")
467 // Write receivedid.key.
468 recvidbuf := make([]byte, 16+8)
469 cryptorand.Read(recvidbuf)
470 err = os.WriteFile(filepath.Join(dir, "receivedid.key"), recvidbuf, 0660)
471 xcheck(err, "writing receivedid.key")
473 // Load config, so we can access the account.
474 err = localLoadConfig(log, dir)
475 xcheck(err, "loading config")
477 // Info so we don't log lots about initializing database.
478 loadLoglevel(log, "info")
480 // Set password on account.
481 a, _, _, err := store.OpenEmail(log, "mox@localhost", false)
482 xcheck(err, "opening account to set password")
483 password := "moxmoxmox"
484 err = a.SetPassword(log, password)
485 xcheck(err, "setting password")
487 xcheck(err, "closing account")
489 golog.Printf("config created in %s", dir)
493func loadLoglevel(log mlog.Log, fallback string) {
498 if level, ok := mlog.Levels[ll]; ok {
499 mox.Conf.Log[""] = level
500 mlog.SetConfig(mox.Conf.Log)
502 log.Fatal("unknown loglevel", slog.String("loglevel", loglevel))
506func localLoadConfig(log mlog.Log, dir string) error {
507 mox.ConfigStaticPath = filepath.Join(dir, "mox.conf")
508 mox.ConfigDynamicPath = filepath.Join(dir, "domains.conf")
509 errs := mox.LoadConfig(context.Background(), log, true, false)
511 log.Error("loading config generated config file: multiple errors")
512 for _, err := range errs {
513 log.Errorx("config error", err)
515 return fmt.Errorf("stopping after multiple config errors")
516 } else if len(errs) == 1 {
517 return fmt.Errorf("loading config file: %v", errs[0])