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 cryptorand.Read(recvidbuf)
143 }
144 if err := mox.ReceivedIDInit(recvidbuf[:16], recvidbuf[16:]); err != nil {
145 log.Fatalx("init receivedid", err)
146 }
147
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
154
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)
161 }
162
163 loadLoglevel(log, fallbackLevel)
164
165 golog.Printf("mox, version %s %s/%s", moxvar.Version, runtime.GOOS, runtime.GOARCH)
166 golog.Print("")
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:")
172 golog.Print("")
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).`)
177 golog.Print("")
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.`)
181 golog.Print("")
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)")
195 golog.Print("")
196 if existingConfig {
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")
199 } else {
200 golog.Printf("serving from newly created config dir %s/", dir)
201 }
202
203 ctlpath := mox.DataDirPath("ctl")
204 _ = os.Remove(ctlpath)
205 ctl, err := net.Listen("unix", ctlpath)
206 if err != nil {
207 log.Fatalx("listen on ctl unix domain socket", err)
208 }
209 go func() {
210 for {
211 conn, err := ctl.Accept()
212 if err != nil {
213 log.Printx("accept for ctl", err)
214 continue
215 }
216 cid := mox.Cid()
217 ctx := context.WithValue(mox.Context, mlog.CidKey, cid)
218 go servectl(ctx, cid, log.WithCid(cid), conn, func() { shutdown(log) })
219 }
220 }()
221
222 // Graceful shutdown.
223 sigc := make(chan os.Signal, 1)
224 signal.Notify(sigc, os.Interrupt, syscall.SIGTERM)
225 sig := <-sigc
226 log.Print("shutting down, waiting max 3s for existing connections", slog.Any("signal", sig))
227 shutdown(log)
228 if num, ok := sig.(syscall.Signal); ok {
229 os.Exit(int(num))
230 } else {
231 os.Exit(1)
232 }
233}
234
235func writeLocalConfig(log mlog.Log, dir, ip string) (rerr error) {
236 defer func() {
237 x := recover()
238 if x != nil {
239 if err, ok := x.(error); ok {
240 rerr = err
241 }
242 }
243 if rerr != nil {
244 err := os.RemoveAll(dir)
245 log.Check(err, "removing config directory", slog.String("dir", dir))
246 }
247 }()
248
249 xcheck := func(err error, msg string) {
250 if err != nil {
251 panic(fmt.Errorf("%s: %s", msg, err))
252 }
253 }
254
255 os.MkdirAll(dir, 0770)
256
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{
263 Type: "PRIVATE KEY",
264 Headers: map[string]string{
265 "Note": "ECDSA key generated by mox localserve for self-signed certificate.",
266 },
267 Bytes: privKeyDER,
268 }
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")
274
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),
281 Issuer: pkix.Name{
282 Organization: []string{"mox localserve"},
283 },
284 Subject: pkix.Name{
285 Organization: []string{"mox localserve"},
286 CommonName: "localhost",
287 },
288 }
289 certDER, err := x509.CreateCertificate(cryptorand.Reader, template, template, privKey.Public(), privKey)
290 xcheck(err, "making self-signed certificate")
291
292 pubBlock := &pem.Block{
293 Type: "CERTIFICATE",
294 // Comments (header) would cause failure to parse the certificate when we load the config.
295 Bytes: certDER,
296 }
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")
302
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")
309
310 // Write mox.conf.
311 ips := []string{"127.0.0.1", "::1"}
312 if ip != "" {
313 ips = []string{ip}
314 }
315
316 local := config.Listener{
317 IPs: ips,
318 TLS: &config.TLS{
319 KeyCerts: []config.KeyCert{
320 {
321 CertFile: "localhost.crt",
322 KeyFile: "localhost.key",
323 },
324 },
325 },
326 }
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
367
368 uid := os.Getuid()
369 if uid < 0 {
370 uid = 1 // For windows.
371 }
372 static := config.Static{
373 DataDir: ".",
374 LogLevel: "traceauth",
375 Hostname: "localhost",
376 User: fmt.Sprintf("%d", uid),
377 AdminPasswordFile: "adminpasswd",
378 Pedantic: true,
379 Listeners: map[string]config.Listener{
380 "local": local,
381 },
382 }
383 tlsca := struct {
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"
390
391 var moxconfBuf bytes.Buffer
392 err = sconf.WriteDocs(&moxconfBuf, static)
393 xcheck(err, "making mox.conf")
394
395 err = os.WriteFile(filepath.Join(dir, "mox.conf"), moxconfBuf.Bytes(), 0660)
396 xcheck(err, "writing mox.conf")
397
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{
404 "mox@localhost": {},
405 },
406 NoFirstTimeSenderDelay: true,
407 }
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{
412 Threshold: 0.95,
413 Params: junk.Params{
414 Onegrams: true,
415 MaxPower: .01,
416 TopWords: 10,
417 IgnoreWords: .1,
418 RareWords: 2,
419 },
420 }
421
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")
427
428 dynamic := config.Dynamic{
429 Domains: map[string]config.Domain{
430 "localhost": {
431 LocalpartCatchallSeparator: "+",
432 DKIM: config.DKIM{
433 Sign: []string{"localserve"},
434 Selectors: map[string]config.Selector{
435 "localserve": {
436 Expiration: "72h",
437 PrivateKeyFile: dkimKeyPath,
438 },
439 },
440 },
441 },
442 },
443 Accounts: map[string]config.Account{
444 "mox": acc,
445 },
446 WebHandlers: []config.WebHandler{
447 {
448 LogName: "workdir",
449 Domain: "localhost",
450 PathRegexp: "^/workdir/",
451 DontRedirectPlainHTTP: true,
452 WebStatic: &config.WebStatic{
453 StripPrefix: "/workdir/",
454 Root: ".",
455 ListFiles: true,
456 },
457 },
458 },
459 }
460 var domainsconfBuf bytes.Buffer
461 err = sconf.WriteDocs(&domainsconfBuf, dynamic)
462 xcheck(err, "making domains.conf")
463
464 err = os.WriteFile(filepath.Join(dir, "domains.conf"), domainsconfBuf.Bytes(), 0660)
465 xcheck(err, "writing domains.conf")
466
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")
472
473 // Load config, so we can access the account.
474 err = localLoadConfig(log, dir)
475 xcheck(err, "loading config")
476
477 // Info so we don't log lots about initializing database.
478 loadLoglevel(log, "info")
479
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")
486 err = a.Close()
487 xcheck(err, "closing account")
488
489 golog.Printf("config created in %s", dir)
490 return nil
491}
492
493func loadLoglevel(log mlog.Log, fallback string) {
494 ll := loglevel
495 if ll == "" {
496 ll = fallback
497 }
498 if level, ok := mlog.Levels[ll]; ok {
499 mox.Conf.Log[""] = level
500 mlog.SetConfig(mox.Conf.Log)
501 } else {
502 log.Fatal("unknown loglevel", slog.String("loglevel", loglevel))
503 }
504}
505
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)
510 if len(errs) > 1 {
511 log.Error("loading config generated config file: multiple errors")
512 for _, err := range errs {
513 log.Errorx("config error", err)
514 }
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])
518 }
519 return nil
520}
521