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, cid, 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", false)
 
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])