1package main
2
3import (
4 "bytes"
5 "context"
6 "crypto/ecdsa"
7 "crypto/elliptic"
8 cryptorand "crypto/rand"
9 "crypto/x509"
10 "crypto/x509/pkix"
11 "encoding/pem"
12 "fmt"
13 golog "log"
14 "log/slog"
15 "math/big"
16 "net"
17 "os"
18 "os/signal"
19 "path/filepath"
20 "runtime"
21 "syscall"
22 "time"
23
24 "golang.org/x/crypto/bcrypt"
25
26 "github.com/mjl-/sconf"
27
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"
39)
40
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.
43
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.
47
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
53TLS certificate.
54
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.
59
60Recipient addresses with the following localpart suffixes are handled specially:
61
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)
66
67If the localpart begins with "mailfrom" or "rcptto", the error is returned
68during those commands instead of during "data".
69`
70 golog.SetFlags(0)
71
72 userConfDir, _ := os.UserConfigDir()
73 if userConfDir == "" {
74 userConfDir = "."
75 }
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.
78 if c._gather {
79 userConfDir = "$userconfigdir"
80 }
81
82 var dir, ip string
83 var initOnly bool
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")
87 args := c.Parse()
88 if len(args) != 0 {
89 c.Usage()
90 }
91
92 log := c.log
93 mox.FilesImmediate = true
94
95 if initOnly {
96 if _, err := os.Stat(dir); err == nil {
97 log.Print("warning: directory for configuration files already exists, continuing")
98 }
99 log.Print("creating mox localserve config", slog.String("dir", dir))
100 err := writeLocalConfig(log, dir, ip)
101 if err != nil {
102 log.Fatalx("creating mox localserve config", err, slog.String("dir", dir))
103 }
104 return
105 }
106
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)
111 if err != nil {
112 log.Fatalx("creating mox localserve config", err, slog.String("dir", dir))
113 }
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))
118 } else if ip != "" {
119 log.Fatal("can only use -ip when writing a new config file")
120 } else {
121 existingConfig = true
122 }
123
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"
129 }
130 if existingConfig {
131 loadLoglevel(log, fallbackLevel)
132 }
133
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))
138 }
139 if err != nil {
140 log.Errorx("reading receivedid.key", err)
141 recvidbuf = make([]byte, 16+8)
142 _, err := cryptorand.Read(recvidbuf)
143 if err != nil {
144 log.Fatalx("read random recvid key", err)
145 }
146 }
147 if err := mox.ReceivedIDInit(recvidbuf[:16], recvidbuf[16:]); err != nil {
148 log.Fatalx("init receivedid", err)
149 }
150
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
157
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)
164 }
165
166 loadLoglevel(log, fallbackLevel)
167
168 golog.Printf("mox, version %s %s/%s", moxvar.Version, runtime.GOOS, runtime.GOARCH)
169 golog.Print("")
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:")
175 golog.Print("")
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).`)
180 golog.Print("")
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.`)
184 golog.Print("")
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)")
198 golog.Print("")
199 if existingConfig {
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")
202 } else {
203 golog.Printf("serving from newly created config dir %s/", dir)
204 }
205
206 ctlpath := mox.DataDirPath("ctl")
207 _ = os.Remove(ctlpath)
208 ctl, err := net.Listen("unix", ctlpath)
209 if err != nil {
210 log.Fatalx("listen on ctl unix domain socket", err)
211 }
212 go func() {
213 for {
214 conn, err := ctl.Accept()
215 if err != nil {
216 log.Printx("accept for ctl", err)
217 continue
218 }
219 cid := mox.Cid()
220 ctx := context.WithValue(mox.Context, mlog.CidKey, cid)
221 go servectl(ctx, log.WithCid(cid), conn, func() { shutdown(log) })
222 }
223 }()
224
225 // Graceful shutdown.
226 sigc := make(chan os.Signal, 1)
227 signal.Notify(sigc, os.Interrupt, syscall.SIGTERM)
228 sig := <-sigc
229 log.Print("shutting down, waiting max 3s for existing connections", slog.Any("signal", sig))
230 shutdown(log)
231 if num, ok := sig.(syscall.Signal); ok {
232 os.Exit(int(num))
233 } else {
234 os.Exit(1)
235 }
236}
237
238func writeLocalConfig(log mlog.Log, dir, ip string) (rerr error) {
239 defer func() {
240 x := recover()
241 if x != nil {
242 if err, ok := x.(error); ok {
243 rerr = err
244 }
245 }
246 if rerr != nil {
247 err := os.RemoveAll(dir)
248 log.Check(err, "removing config directory", slog.String("dir", dir))
249 }
250 }()
251
252 xcheck := func(err error, msg string) {
253 if err != nil {
254 panic(fmt.Errorf("%s: %s", msg, err))
255 }
256 }
257
258 os.MkdirAll(dir, 0770)
259
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{
266 Type: "PRIVATE KEY",
267 Headers: map[string]string{
268 "Note": "ECDSA key generated by mox localserve for self-signed certificate.",
269 },
270 Bytes: privKeyDER,
271 }
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")
277
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),
284 Issuer: pkix.Name{
285 Organization: []string{"mox localserve"},
286 },
287 Subject: pkix.Name{
288 Organization: []string{"mox localserve"},
289 CommonName: "localhost",
290 },
291 }
292 certDER, err := x509.CreateCertificate(cryptorand.Reader, template, template, privKey.Public(), privKey)
293 xcheck(err, "making self-signed certificate")
294
295 pubBlock := &pem.Block{
296 Type: "CERTIFICATE",
297 // Comments (header) would cause failure to parse the certificate when we load the config.
298 Bytes: certDER,
299 }
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")
305
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")
312
313 // Write mox.conf.
314 ips := []string{"127.0.0.1", "::1"}
315 if ip != "" {
316 ips = []string{ip}
317 }
318
319 local := config.Listener{
320 IPs: ips,
321 TLS: &config.TLS{
322 KeyCerts: []config.KeyCert{
323 {
324 CertFile: "localhost.crt",
325 KeyFile: "localhost.key",
326 },
327 },
328 },
329 }
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
370
371 uid := os.Getuid()
372 if uid < 0 {
373 uid = 1 // For windows.
374 }
375 static := config.Static{
376 DataDir: ".",
377 LogLevel: "traceauth",
378 Hostname: "localhost",
379 User: fmt.Sprintf("%d", uid),
380 AdminPasswordFile: "adminpasswd",
381 Pedantic: true,
382 Listeners: map[string]config.Listener{
383 "local": local,
384 },
385 }
386 tlsca := struct {
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"
393
394 var moxconfBuf bytes.Buffer
395 err = sconf.WriteDocs(&moxconfBuf, static)
396 xcheck(err, "making mox.conf")
397
398 err = os.WriteFile(filepath.Join(dir, "mox.conf"), moxconfBuf.Bytes(), 0660)
399 xcheck(err, "writing mox.conf")
400
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{
407 "mox@localhost": {},
408 },
409 NoFirstTimeSenderDelay: true,
410 }
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{
415 Threshold: 0.95,
416 Params: junk.Params{
417 Onegrams: true,
418 MaxPower: .01,
419 TopWords: 10,
420 IgnoreWords: .1,
421 RareWords: 2,
422 },
423 }
424
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")
430
431 dynamic := config.Dynamic{
432 Domains: map[string]config.Domain{
433 "localhost": {
434 LocalpartCatchallSeparator: "+",
435 DKIM: config.DKIM{
436 Sign: []string{"localserve"},
437 Selectors: map[string]config.Selector{
438 "localserve": {
439 Expiration: "72h",
440 PrivateKeyFile: dkimKeyPath,
441 },
442 },
443 },
444 },
445 },
446 Accounts: map[string]config.Account{
447 "mox": acc,
448 },
449 WebHandlers: []config.WebHandler{
450 {
451 LogName: "workdir",
452 Domain: "localhost",
453 PathRegexp: "^/workdir/",
454 DontRedirectPlainHTTP: true,
455 WebStatic: &config.WebStatic{
456 StripPrefix: "/workdir/",
457 Root: ".",
458 ListFiles: true,
459 },
460 },
461 },
462 }
463 var domainsconfBuf bytes.Buffer
464 err = sconf.WriteDocs(&domainsconfBuf, dynamic)
465 xcheck(err, "making domains.conf")
466
467 err = os.WriteFile(filepath.Join(dir, "domains.conf"), domainsconfBuf.Bytes(), 0660)
468 xcheck(err, "writing domains.conf")
469
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")
476
477 // Load config, so we can access the account.
478 err = localLoadConfig(log, dir)
479 xcheck(err, "loading config")
480
481 // Info so we don't log lots about initializing database.
482 loadLoglevel(log, "info")
483
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")
490 err = a.Close()
491 xcheck(err, "closing account")
492
493 golog.Printf("config created in %s", dir)
494 return nil
495}
496
497func loadLoglevel(log mlog.Log, fallback string) {
498 ll := loglevel
499 if ll == "" {
500 ll = fallback
501 }
502 if level, ok := mlog.Levels[ll]; ok {
503 mox.Conf.Log[""] = level
504 mlog.SetConfig(mox.Conf.Log)
505 } else {
506 log.Fatal("unknown loglevel", slog.String("loglevel", loglevel))
507 }
508}
509
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)
514 if len(errs) > 1 {
515 log.Error("loading config generated config file: multiple errors")
516 for _, err := range errs {
517 log.Errorx("config error", err)
518 }
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])
522 }
523 return nil
524}
525