1package webmail
2
3import (
4 "context"
5 cryptorand "crypto/rand"
6 "encoding/base64"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "log/slog"
12 "mime"
13 "mime/multipart"
14 "net"
15 "net/http"
16 "net/mail"
17 "net/textproto"
18 "os"
19 "regexp"
20 "runtime/debug"
21 "slices"
22 "sort"
23 "strings"
24 "sync"
25 "time"
26
27 _ "embed"
28
29 "golang.org/x/exp/maps"
30
31 "github.com/mjl-/bstore"
32 "github.com/mjl-/sherpa"
33 "github.com/mjl-/sherpadoc"
34 "github.com/mjl-/sherpaprom"
35
36 "github.com/mjl-/mox/config"
37 "github.com/mjl-/mox/dkim"
38 "github.com/mjl-/mox/dns"
39 "github.com/mjl-/mox/message"
40 "github.com/mjl-/mox/metrics"
41 "github.com/mjl-/mox/mlog"
42 "github.com/mjl-/mox/mox-"
43 "github.com/mjl-/mox/moxio"
44 "github.com/mjl-/mox/moxvar"
45 "github.com/mjl-/mox/mtasts"
46 "github.com/mjl-/mox/mtastsdb"
47 "github.com/mjl-/mox/queue"
48 "github.com/mjl-/mox/smtp"
49 "github.com/mjl-/mox/smtpclient"
50 "github.com/mjl-/mox/store"
51 "github.com/mjl-/mox/webauth"
52 "github.com/mjl-/mox/webops"
53)
54
55//go:embed api.json
56var webmailapiJSON []byte
57
58type Webmail struct {
59 maxMessageSize int64 // From listener.
60 cookiePath string // From listener.
61 isForwarded bool // From listener, whether we look at X-Forwarded-* headers.
62}
63
64func mustParseAPI(api string, buf []byte) (doc sherpadoc.Section) {
65 err := json.Unmarshal(buf, &doc)
66 if err != nil {
67 pkglog.Fatalx("parsing webmail api docs", err, slog.String("api", api))
68 }
69 return doc
70}
71
72var webmailDoc = mustParseAPI("webmail", webmailapiJSON)
73
74var sherpaHandlerOpts *sherpa.HandlerOpts
75
76func makeSherpaHandler(maxMessageSize int64, cookiePath string, isForwarded bool) (http.Handler, error) {
77 return sherpa.NewHandler("/api/", moxvar.Version, Webmail{maxMessageSize, cookiePath, isForwarded}, &webmailDoc, sherpaHandlerOpts)
78}
79
80func init() {
81 collector, err := sherpaprom.NewCollector("moxwebmail", nil)
82 if err != nil {
83 pkglog.Fatalx("creating sherpa prometheus collector", err)
84 }
85
86 sherpaHandlerOpts = &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none", NoCORS: true}
87 // Just to validate.
88 _, err = makeSherpaHandler(0, "", false)
89 if err != nil {
90 pkglog.Fatalx("sherpa handler", err)
91 }
92}
93
94// LoginPrep returns a login token, and also sets it as cookie. Both must be
95// present in the call to Login.
96func (w Webmail) LoginPrep(ctx context.Context) string {
97 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
98 log := reqInfo.Log
99
100 var data [8]byte
101 _, err := cryptorand.Read(data[:])
102 xcheckf(ctx, err, "generate token")
103 loginToken := base64.RawURLEncoding.EncodeToString(data[:])
104
105 webauth.LoginPrep(ctx, log, "webmail", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, loginToken)
106
107 return loginToken
108}
109
110// Login returns a session token for the credentials, or fails with error code
111// "user:badLogin". Call LoginPrep to get a loginToken.
112func (w Webmail) Login(ctx context.Context, loginToken, username, password string) store.CSRFToken {
113 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
114 log := reqInfo.Log
115
116 csrfToken, err := webauth.Login(ctx, log, webauth.Accounts, "webmail", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, loginToken, username, password)
117 if _, ok := err.(*sherpa.Error); ok {
118 panic(err)
119 }
120 xcheckf(ctx, err, "login")
121 return csrfToken
122}
123
124// Logout invalidates the session token.
125func (w Webmail) Logout(ctx context.Context) {
126 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
127 log := reqInfo.Log
128
129 err := webauth.Logout(ctx, log, webauth.Accounts, "webmail", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, reqInfo.Account.Name, reqInfo.SessionToken)
130 xcheckf(ctx, err, "logout")
131}
132
133// Token returns a token to use for an SSE connection. A token can only be used for
134// a single SSE connection. Tokens are stored in memory for a maximum of 1 minute,
135// with at most 10 unused tokens (the most recently created) per account.
136func (Webmail) Token(ctx context.Context) string {
137 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
138 return sseTokens.xgenerate(ctx, reqInfo.Account.Name, reqInfo.LoginAddress, reqInfo.SessionToken)
139}
140
141// Requests sends a new request for an open SSE connection. Any currently active
142// request for the connection will be canceled, but this is done asynchrously, so
143// the SSE connection may still send results for the previous request. Callers
144// should take care to ignore such results. If req.Cancel is set, no new request is
145// started.
146func (Webmail) Request(ctx context.Context, req Request) {
147 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
148
149 if !req.Cancel && req.Page.Count <= 0 {
150 xcheckuserf(ctx, errors.New("Page.Count must be >= 1"), "checking request")
151 }
152
153 sse, ok := sseGet(req.SSEID, reqInfo.Account.Name)
154 if !ok {
155 xcheckuserf(ctx, errors.New("unknown sseid"), "looking up connection")
156 }
157 sse.Request <- req
158}
159
160// ParsedMessage returns enough to render the textual body of a message. It is
161// assumed the client already has other fields through MessageItem.
162func (Webmail) ParsedMessage(ctx context.Context, msgID int64) (pm ParsedMessage) {
163 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
164 log := reqInfo.Log
165 acc := reqInfo.Account
166
167 xdbread(ctx, acc, func(tx *bstore.Tx) {
168 m := xmessageID(ctx, tx, msgID)
169
170 state := msgState{acc: acc}
171 defer state.clear()
172 var err error
173 pm, err = parsedMessage(log, m, &state, true, false)
174 xcheckf(ctx, err, "parsing message")
175
176 if len(pm.envelope.From) == 1 {
177 pm.ViewMode, err = fromAddrViewMode(tx, pm.envelope.From[0])
178 xcheckf(ctx, err, "looking up view mode for from address")
179 }
180 })
181 return
182}
183
184// fromAddrViewMode returns the view mode for a from address.
185func fromAddrViewMode(tx *bstore.Tx, from MessageAddress) (store.ViewMode, error) {
186 lp, err := smtp.ParseLocalpart(from.User)
187 if err != nil {
188 return store.ModeDefault, nil
189 }
190 fromAddr := smtp.Address{Localpart: lp, Domain: from.Domain}.Pack(true)
191 fas := store.FromAddressSettings{FromAddress: fromAddr}
192 err = tx.Get(&fas)
193 if err == bstore.ErrAbsent {
194 return store.ModeDefault, nil
195 }
196 return fas.ViewMode, err
197}
198
199// FromAddressSettingsSave saves per-"From"-address settings.
200func (Webmail) FromAddressSettingsSave(ctx context.Context, fas store.FromAddressSettings) {
201 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
202 acc := reqInfo.Account
203
204 if fas.FromAddress == "" {
205 xcheckuserf(ctx, errors.New("empty from address"), "checking address")
206 }
207
208 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
209 if tx.Get(&store.FromAddressSettings{FromAddress: fas.FromAddress}) == nil {
210 err := tx.Update(&fas)
211 xcheckf(ctx, err, "updating settings for from address")
212 } else {
213 err := tx.Insert(&fas)
214 xcheckf(ctx, err, "inserting settings for from address")
215 }
216 })
217}
218
219// MessageFindMessageID looks up a message by Message-Id header, and returns the ID
220// of the message in storage. Used when opening a previously saved draft message
221// for editing again.
222// If no message is find, zero is returned, not an error.
223func (Webmail) MessageFindMessageID(ctx context.Context, messageID string) (id int64) {
224 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
225 acc := reqInfo.Account
226
227 messageID, _, _ = message.MessageIDCanonical(messageID)
228 if messageID == "" {
229 xcheckuserf(ctx, errors.New("empty message-id"), "parsing message-id")
230 }
231
232 xdbread(ctx, acc, func(tx *bstore.Tx) {
233 m, err := bstore.QueryTx[store.Message](tx).FilterNonzero(store.Message{MessageID: messageID}).Get()
234 if err == bstore.ErrAbsent {
235 return
236 }
237 xcheckf(ctx, err, "looking up message by message-id")
238 id = m.ID
239 })
240 return
241}
242
243// ComposeMessage is a message to be composed, for saving draft messages.
244type ComposeMessage struct {
245 From string
246 To []string
247 Cc []string
248 Bcc []string
249 ReplyTo string // If non-empty, Reply-To header to add to message.
250 Subject string
251 TextBody string
252 ResponseMessageID int64 // If set, this was a reply or forward, based on IsForward.
253 DraftMessageID int64 // If set, previous draft message that will be removed after composing new message.
254}
255
256// MessageCompose composes a message and saves it to the mailbox. Used for
257// saving draft messages.
258func (w Webmail) MessageCompose(ctx context.Context, m ComposeMessage, mailboxID int64) (id int64) {
259 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
260 acc := reqInfo.Account
261 log := reqInfo.Log
262
263 log.Debug("message compose")
264
265 // Prevent any accidental control characters, or attempts at getting bare \r or \n
266 // into messages.
267 for _, l := range [][]string{m.To, m.Cc, m.Bcc, {m.From, m.Subject, m.ReplyTo}} {
268 for _, s := range l {
269 for _, c := range s {
270 if c < 0x20 {
271 xcheckuserf(ctx, errors.New("control characters not allowed"), "checking header values")
272 }
273 }
274 }
275 }
276
277 fromAddr, err := parseAddress(m.From)
278 xcheckuserf(ctx, err, "parsing From address")
279
280 var replyTo *message.NameAddress
281 if m.ReplyTo != "" {
282 addr, err := parseAddress(m.ReplyTo)
283 xcheckuserf(ctx, err, "parsing Reply-To address")
284 replyTo = &addr
285 }
286
287 var recipients []smtp.Address
288
289 var toAddrs []message.NameAddress
290 for _, s := range m.To {
291 addr, err := parseAddress(s)
292 xcheckuserf(ctx, err, "parsing To address")
293 toAddrs = append(toAddrs, addr)
294 recipients = append(recipients, addr.Address)
295 }
296
297 var ccAddrs []message.NameAddress
298 for _, s := range m.Cc {
299 addr, err := parseAddress(s)
300 xcheckuserf(ctx, err, "parsing Cc address")
301 ccAddrs = append(ccAddrs, addr)
302 recipients = append(recipients, addr.Address)
303 }
304
305 var bccAddrs []message.NameAddress
306 for _, s := range m.Bcc {
307 addr, err := parseAddress(s)
308 xcheckuserf(ctx, err, "parsing Bcc address")
309 bccAddrs = append(bccAddrs, addr)
310 recipients = append(recipients, addr.Address)
311 }
312
313 // We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
314 smtputf8 := false
315 for _, a := range recipients {
316 if a.Localpart.IsInternational() {
317 smtputf8 = true
318 break
319 }
320 }
321 if !smtputf8 && fromAddr.Address.Localpart.IsInternational() {
322 // todo: may want to warn user that they should consider sending with a ascii-only localpart, in case receiver doesn't support smtputf8.
323 smtputf8 = true
324 }
325 if !smtputf8 && replyTo != nil && replyTo.Address.Localpart.IsInternational() {
326 smtputf8 = true
327 }
328
329 // Create file to compose message into.
330 dataFile, err := store.CreateMessageTemp(log, "webmail-compose")
331 xcheckf(ctx, err, "creating temporary file for compose message")
332 defer store.CloseRemoveTempFile(log, dataFile, "compose message")
333
334 // If writing to the message file fails, we abort immediately.
335 xc := message.NewComposer(dataFile, w.maxMessageSize, smtputf8)
336 defer func() {
337 x := recover()
338 if x == nil {
339 return
340 }
341 if err, ok := x.(error); ok && errors.Is(err, message.ErrMessageSize) {
342 xcheckuserf(ctx, err, "making message")
343 } else if ok && errors.Is(err, message.ErrCompose) {
344 xcheckf(ctx, err, "making message")
345 }
346 panic(x)
347 }()
348
349 // Outer message headers.
350 xc.HeaderAddrs("From", []message.NameAddress{fromAddr})
351 if replyTo != nil {
352 xc.HeaderAddrs("Reply-To", []message.NameAddress{*replyTo})
353 }
354 xc.HeaderAddrs("To", toAddrs)
355 xc.HeaderAddrs("Cc", ccAddrs)
356 xc.HeaderAddrs("Bcc", bccAddrs)
357 if m.Subject != "" {
358 xc.Subject(m.Subject)
359 }
360
361 // Add In-Reply-To and References headers.
362 if m.ResponseMessageID > 0 {
363 xdbread(ctx, acc, func(tx *bstore.Tx) {
364 rm := xmessageID(ctx, tx, m.ResponseMessageID)
365 msgr := acc.MessageReader(rm)
366 defer func() {
367 err := msgr.Close()
368 log.Check(err, "closing message reader")
369 }()
370 rp, err := rm.LoadPart(msgr)
371 xcheckf(ctx, err, "load parsed message")
372 h, err := rp.Header()
373 xcheckf(ctx, err, "parsing header")
374
375 if rp.Envelope == nil {
376 return
377 }
378
379 if rp.Envelope.MessageID != "" {
380 xc.Header("In-Reply-To", rp.Envelope.MessageID)
381 }
382 refs := h.Values("References")
383 if len(refs) == 0 && rp.Envelope.InReplyTo != "" {
384 refs = []string{rp.Envelope.InReplyTo}
385 }
386 if rp.Envelope.MessageID != "" {
387 refs = append(refs, rp.Envelope.MessageID)
388 }
389 if len(refs) > 0 {
390 xc.Header("References", strings.Join(refs, "\r\n\t"))
391 }
392 })
393 }
394 xc.Header("MIME-Version", "1.0")
395 textBody, ct, cte := xc.TextPart("plain", m.TextBody)
396 xc.Header("Content-Type", ct)
397 xc.Header("Content-Transfer-Encoding", cte)
398 xc.Line()
399 xc.Write([]byte(textBody))
400 xc.Flush()
401
402 var nm store.Message
403
404 // Remove previous draft message, append message to destination mailbox.
405 acc.WithRLock(func() {
406 var changes []store.Change
407
408 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
409 var modseq store.ModSeq // Only set if needed.
410
411 if m.DraftMessageID > 0 {
412 var nchanges []store.Change
413 modseq, nchanges = xops.MessageDeleteTx(ctx, log, tx, acc, []int64{m.DraftMessageID}, modseq)
414 changes = append(changes, nchanges...)
415 // On-disk file is removed after lock.
416 }
417
418 // Find mailbox to write to.
419 mb := store.Mailbox{ID: mailboxID}
420 err := tx.Get(&mb)
421 if err == bstore.ErrAbsent {
422 xcheckuserf(ctx, err, "looking up mailbox")
423 }
424 xcheckf(ctx, err, "looking up mailbox")
425
426 if modseq == 0 {
427 modseq, err = acc.NextModSeq(tx)
428 xcheckf(ctx, err, "next modseq")
429 }
430
431 nm = store.Message{
432 CreateSeq: modseq,
433 ModSeq: modseq,
434 MailboxID: mb.ID,
435 MailboxOrigID: mb.ID,
436 Flags: store.Flags{Notjunk: true},
437 Size: xc.Size,
438 }
439
440 if ok, maxSize, err := acc.CanAddMessageSize(tx, nm.Size); err != nil {
441 xcheckf(ctx, err, "checking quota")
442 } else if !ok {
443 xcheckuserf(ctx, fmt.Errorf("account over maximum total message size %d", maxSize), "checking quota")
444 }
445
446 // Update mailbox before delivery, which changes uidnext.
447 mb.Add(nm.MailboxCounts())
448 err = tx.Update(&mb)
449 xcheckf(ctx, err, "updating sent mailbox for counts")
450
451 err = acc.DeliverMessage(log, tx, &nm, dataFile, true, false, false, true)
452 xcheckf(ctx, err, "storing message in mailbox")
453
454 changes = append(changes, nm.ChangeAddUID(), mb.ChangeCounts())
455 })
456
457 store.BroadcastChanges(acc, changes)
458 })
459
460 // Remove on-disk file for removed draft message.
461 if m.DraftMessageID > 0 {
462 p := acc.MessagePath(m.DraftMessageID)
463 err := os.Remove(p)
464 log.Check(err, "removing draft message file")
465 }
466
467 return nm.ID
468}
469
470// Attachment is a MIME part is an existing message that is not intended as
471// viewable text or HTML part.
472type Attachment struct {
473 Path []int // Indices into top-level message.Part.Parts.
474
475 // File name based on "name" attribute of "Content-Type", or the "filename"
476 // attribute of "Content-Disposition".
477 Filename string
478
479 Part message.Part
480}
481
482// SubmitMessage is an email message to be sent to one or more recipients.
483// Addresses are formatted as just email address, or with a name like "name
484// <user@host>".
485type SubmitMessage struct {
486 From string
487 To []string
488 Cc []string
489 Bcc []string
490 ReplyTo string // If non-empty, Reply-To header to add to message.
491 Subject string
492 TextBody string
493 Attachments []File
494 ForwardAttachments ForwardAttachments
495 IsForward bool
496 ResponseMessageID int64 // If set, this was a reply or forward, based on IsForward.
497 UserAgent string // User-Agent header added if not empty.
498 RequireTLS *bool // For "Require TLS" extension during delivery.
499 FutureRelease *time.Time // If set, time (in the future) when message should be delivered from queue.
500 ArchiveThread bool // If set, thread is archived after sending message.
501 DraftMessageID int64 // If set, draft message that will be removed after sending.
502}
503
504// ForwardAttachments references attachments by a list of message.Part paths.
505type ForwardAttachments struct {
506 MessageID int64 // Only relevant if MessageID is not 0.
507 Paths [][]int // List of attachments, each path is a list of indices into the top-level message.Part.Parts.
508}
509
510// File is a new attachment (not from an existing message that is being
511// forwarded) to send with a SubmitMessage.
512type File struct {
513 Filename string
514 DataURI string // Full data of the attachment, with base64 encoding and including content-type.
515}
516
517// parseAddress expects either a plain email address like "user@domain", or a
518// single address as used in a message header, like "name <user@domain>".
519func parseAddress(msghdr string) (message.NameAddress, error) {
520 a, err := mail.ParseAddress(msghdr)
521 if err != nil {
522 return message.NameAddress{}, err
523 }
524
525 // todo: parse more fully according to ../rfc/5322:959
526 path, err := smtp.ParseAddress(a.Address)
527 if err != nil {
528 return message.NameAddress{}, err
529 }
530 return message.NameAddress{DisplayName: a.Name, Address: path}, nil
531}
532
533func xmailboxID(ctx context.Context, tx *bstore.Tx, mailboxID int64) store.Mailbox {
534 if mailboxID == 0 {
535 xcheckuserf(ctx, errors.New("invalid zero mailbox ID"), "getting mailbox")
536 }
537 mb := store.Mailbox{ID: mailboxID}
538 err := tx.Get(&mb)
539 if err == bstore.ErrAbsent {
540 xcheckuserf(ctx, err, "getting mailbox")
541 }
542 xcheckf(ctx, err, "getting mailbox")
543 return mb
544}
545
546// xmessageID returns a non-expunged message or panics with a sherpa error.
547func xmessageID(ctx context.Context, tx *bstore.Tx, messageID int64) store.Message {
548 if messageID == 0 {
549 xcheckuserf(ctx, errors.New("invalid zero message id"), "getting message")
550 }
551 m := store.Message{ID: messageID}
552 err := tx.Get(&m)
553 if err == bstore.ErrAbsent {
554 xcheckuserf(ctx, errors.New("message does not exist"), "getting message")
555 } else if err == nil && m.Expunged {
556 xcheckuserf(ctx, errors.New("message was removed"), "getting message")
557 }
558 xcheckf(ctx, err, "getting message")
559 return m
560}
561
562func xrandomID(ctx context.Context, n int) string {
563 return base64.RawURLEncoding.EncodeToString(xrandom(ctx, n))
564}
565
566func xrandom(ctx context.Context, n int) []byte {
567 buf := make([]byte, n)
568 x, err := cryptorand.Read(buf)
569 xcheckf(ctx, err, "read random")
570 if x != n {
571 xcheckf(ctx, errors.New("short random read"), "read random")
572 }
573 return buf
574}
575
576// MessageSubmit sends a message by submitting it the outgoing email queue. The
577// message is sent to all addresses listed in the To, Cc and Bcc addresses, without
578// Bcc message header.
579//
580// If a Sent mailbox is configured, messages are added to it after submitting
581// to the delivery queue. If Bcc addresses were present, a header is prepended
582// to the message stored in the Sent mailbox.
583func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) {
584 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
585 acc := reqInfo.Account
586 log := reqInfo.Log
587
588 log.Debug("message submit")
589
590 // Similar between ../smtpserver/server.go:/submit\( and ../webmail/api.go:/MessageSubmit\( and ../webapisrv/server.go:/Send\(
591
592 // todo: consider making this an HTTP POST, so we can upload as regular form, which is probably more efficient for encoding for the client and we can stream the data in. also not unlike the webapi Submit method.
593
594 // Prevent any accidental control characters, or attempts at getting bare \r or \n
595 // into messages.
596 for _, l := range [][]string{m.To, m.Cc, m.Bcc, {m.From, m.Subject, m.ReplyTo, m.UserAgent}} {
597 for _, s := range l {
598 for _, c := range s {
599 if c < 0x20 {
600 xcheckuserf(ctx, errors.New("control characters not allowed"), "checking header values")
601 }
602 }
603 }
604 }
605
606 fromAddr, err := parseAddress(m.From)
607 xcheckuserf(ctx, err, "parsing From address")
608
609 var replyTo *message.NameAddress
610 if m.ReplyTo != "" {
611 a, err := parseAddress(m.ReplyTo)
612 xcheckuserf(ctx, err, "parsing Reply-To address")
613 replyTo = &a
614 }
615
616 var recipients []smtp.Address
617
618 var toAddrs []message.NameAddress
619 for _, s := range m.To {
620 addr, err := parseAddress(s)
621 xcheckuserf(ctx, err, "parsing To address")
622 toAddrs = append(toAddrs, addr)
623 recipients = append(recipients, addr.Address)
624 }
625
626 var ccAddrs []message.NameAddress
627 for _, s := range m.Cc {
628 addr, err := parseAddress(s)
629 xcheckuserf(ctx, err, "parsing Cc address")
630 ccAddrs = append(ccAddrs, addr)
631 recipients = append(recipients, addr.Address)
632 }
633
634 var bccAddrs []message.NameAddress
635 for _, s := range m.Bcc {
636 addr, err := parseAddress(s)
637 xcheckuserf(ctx, err, "parsing Bcc address")
638 bccAddrs = append(bccAddrs, addr)
639 recipients = append(recipients, addr.Address)
640 }
641
642 // Check if from address is allowed for account.
643 if !mox.AllowMsgFrom(reqInfo.Account.Name, fromAddr.Address) {
644 metricSubmission.WithLabelValues("badfrom").Inc()
645 xcheckuserf(ctx, errors.New("address not found"), `looking up "from" address for account`)
646 }
647
648 if len(recipients) == 0 {
649 xcheckuserf(ctx, errors.New("no recipients"), "composing message")
650 }
651
652 // Check outgoing message rate limit.
653 xdbread(ctx, acc, func(tx *bstore.Tx) {
654 rcpts := make([]smtp.Path, len(recipients))
655 for i, r := range recipients {
656 rcpts[i] = smtp.Path{Localpart: r.Localpart, IPDomain: dns.IPDomain{Domain: r.Domain}}
657 }
658 msglimit, rcptlimit, err := acc.SendLimitReached(tx, rcpts)
659 if msglimit >= 0 {
660 metricSubmission.WithLabelValues("messagelimiterror").Inc()
661 xcheckuserf(ctx, errors.New("message limit reached"), "checking outgoing rate")
662 } else if rcptlimit >= 0 {
663 metricSubmission.WithLabelValues("recipientlimiterror").Inc()
664 xcheckuserf(ctx, errors.New("recipient limit reached"), "checking outgoing rate")
665 }
666 xcheckf(ctx, err, "checking send limit")
667 })
668
669 // We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
670 smtputf8 := false
671 for _, a := range recipients {
672 if a.Localpart.IsInternational() {
673 smtputf8 = true
674 break
675 }
676 }
677 if !smtputf8 && fromAddr.Address.Localpart.IsInternational() {
678 // todo: may want to warn user that they should consider sending with a ascii-only localpart, in case receiver doesn't support smtputf8.
679 smtputf8 = true
680 }
681 if !smtputf8 && replyTo != nil && replyTo.Address.Localpart.IsInternational() {
682 smtputf8 = true
683 }
684
685 // Create file to compose message into.
686 dataFile, err := store.CreateMessageTemp(log, "webmail-submit")
687 xcheckf(ctx, err, "creating temporary file for message")
688 defer store.CloseRemoveTempFile(log, dataFile, "message to submit")
689
690 // If writing to the message file fails, we abort immediately.
691 xc := message.NewComposer(dataFile, w.maxMessageSize, smtputf8)
692 defer func() {
693 x := recover()
694 if x == nil {
695 return
696 }
697 if err, ok := x.(error); ok && errors.Is(err, message.ErrMessageSize) {
698 xcheckuserf(ctx, err, "making message")
699 } else if ok && errors.Is(err, message.ErrCompose) {
700 xcheckf(ctx, err, "making message")
701 }
702 panic(x)
703 }()
704
705 // todo spec: can we add an Authentication-Results header that indicates this is an authenticated message? the "auth" method is for SMTP AUTH, which this isn't. ../rfc/8601 https://www.iana.org/assignments/email-auth/email-auth.xhtml
706
707 // Each queued message gets a Received header.
708 // We don't have access to the local IP for adding.
709 // We cannot use VIA, because there is no registered method. We would like to use
710 // it to add the ascii domain name in case of smtputf8 and IDNA host name.
711 recvFrom := message.HeaderCommentDomain(mox.Conf.Static.HostnameDomain, smtputf8)
712 recvBy := mox.Conf.Static.HostnameDomain.XName(smtputf8)
713 recvID := mox.ReceivedID(mox.CidFromCtx(ctx))
714 recvHdrFor := func(rcptTo string) string {
715 recvHdr := &message.HeaderWriter{}
716 // For additional Received-header clauses, see:
717 // https://www.iana.org/assignments/mail-parameters/mail-parameters.xhtml#table-mail-parameters-8
718 // Note: we don't have "via" or "with", there is no registered for webmail.
719 recvHdr.Add(" ", "Received:", "from", recvFrom, "by", recvBy, "id", recvID) // ../rfc/5321:3158
720 if reqInfo.Request.TLS != nil {
721 recvHdr.Add(" ", mox.TLSReceivedComment(log, *reqInfo.Request.TLS)...)
722 }
723 recvHdr.Add(" ", "for", "<"+rcptTo+">;", time.Now().Format(message.RFC5322Z))
724 return recvHdr.String()
725 }
726
727 // Outer message headers.
728 xc.HeaderAddrs("From", []message.NameAddress{fromAddr})
729 if replyTo != nil {
730 xc.HeaderAddrs("Reply-To", []message.NameAddress{*replyTo})
731 }
732 xc.HeaderAddrs("To", toAddrs)
733 xc.HeaderAddrs("Cc", ccAddrs)
734 // We prepend Bcc headers to the message when adding to the Sent mailbox.
735 if m.Subject != "" {
736 xc.Subject(m.Subject)
737 }
738
739 messageID := fmt.Sprintf("<%s>", mox.MessageIDGen(smtputf8))
740 xc.Header("Message-Id", messageID)
741 xc.Header("Date", time.Now().Format(message.RFC5322Z))
742 // Add In-Reply-To and References headers.
743 if m.ResponseMessageID > 0 {
744 xdbread(ctx, acc, func(tx *bstore.Tx) {
745 rm := xmessageID(ctx, tx, m.ResponseMessageID)
746 msgr := acc.MessageReader(rm)
747 defer func() {
748 err := msgr.Close()
749 log.Check(err, "closing message reader")
750 }()
751 rp, err := rm.LoadPart(msgr)
752 xcheckf(ctx, err, "load parsed message")
753 h, err := rp.Header()
754 xcheckf(ctx, err, "parsing header")
755
756 if rp.Envelope == nil {
757 return
758 }
759
760 if rp.Envelope.MessageID != "" {
761 xc.Header("In-Reply-To", rp.Envelope.MessageID)
762 }
763 refs := h.Values("References")
764 if len(refs) == 0 && rp.Envelope.InReplyTo != "" {
765 refs = []string{rp.Envelope.InReplyTo}
766 }
767 if rp.Envelope.MessageID != "" {
768 refs = append(refs, rp.Envelope.MessageID)
769 }
770 if len(refs) > 0 {
771 xc.Header("References", strings.Join(refs, "\r\n\t"))
772 }
773 })
774 }
775 if m.UserAgent != "" {
776 xc.Header("User-Agent", m.UserAgent)
777 }
778 if m.RequireTLS != nil && !*m.RequireTLS {
779 xc.Header("TLS-Required", "No")
780 }
781 xc.Header("MIME-Version", "1.0")
782
783 if len(m.Attachments) > 0 || len(m.ForwardAttachments.Paths) > 0 {
784 mp := multipart.NewWriter(xc)
785 xc.Header("Content-Type", fmt.Sprintf(`multipart/mixed; boundary="%s"`, mp.Boundary()))
786 xc.Line()
787
788 textBody, ct, cte := xc.TextPart("plain", m.TextBody)
789 textHdr := textproto.MIMEHeader{}
790 textHdr.Set("Content-Type", ct)
791 textHdr.Set("Content-Transfer-Encoding", cte)
792
793 textp, err := mp.CreatePart(textHdr)
794 xcheckf(ctx, err, "adding text part to message")
795 _, err = textp.Write(textBody)
796 xcheckf(ctx, err, "writing text part")
797
798 xaddPart := func(ct, filename string) io.Writer {
799 ahdr := textproto.MIMEHeader{}
800 cd := mime.FormatMediaType("attachment", map[string]string{"filename": filename})
801
802 ahdr.Set("Content-Type", ct)
803 ahdr.Set("Content-Transfer-Encoding", "base64")
804 ahdr.Set("Content-Disposition", cd)
805 ap, err := mp.CreatePart(ahdr)
806 xcheckf(ctx, err, "adding attachment part to message")
807 return ap
808 }
809
810 xaddAttachmentBase64 := func(ct, filename string, base64Data []byte) {
811 ap := xaddPart(ct, filename)
812
813 for len(base64Data) > 0 {
814 line := base64Data
815 n := len(line)
816 if n > 78 {
817 n = 78
818 }
819 line, base64Data = base64Data[:n], base64Data[n:]
820 _, err := ap.Write(line)
821 xcheckf(ctx, err, "writing attachment")
822 _, err = ap.Write([]byte("\r\n"))
823 xcheckf(ctx, err, "writing attachment")
824 }
825 }
826
827 xaddAttachment := func(ct, filename string, r io.Reader) {
828 ap := xaddPart(ct, filename)
829 wc := moxio.Base64Writer(ap)
830 _, err := io.Copy(wc, r)
831 xcheckf(ctx, err, "adding attachment")
832 err = wc.Close()
833 xcheckf(ctx, err, "flushing attachment")
834 }
835
836 for _, a := range m.Attachments {
837 s := a.DataURI
838 if !strings.HasPrefix(s, "data:") {
839 xcheckuserf(ctx, errors.New("missing data: in datauri"), "parsing attachment")
840 }
841 s = s[len("data:"):]
842 t := strings.SplitN(s, ",", 2)
843 if len(t) != 2 {
844 xcheckuserf(ctx, errors.New("missing comma in datauri"), "parsing attachment")
845 }
846 if !strings.HasSuffix(t[0], "base64") {
847 xcheckuserf(ctx, errors.New("missing base64 in datauri"), "parsing attachment")
848 }
849 ct := strings.TrimSuffix(t[0], "base64")
850 ct = strings.TrimSuffix(ct, ";")
851 if ct == "" {
852 ct = "application/octet-stream"
853 }
854 filename := a.Filename
855 if filename == "" {
856 filename = "unnamed.bin"
857 }
858 params := map[string]string{"name": filename}
859 ct = mime.FormatMediaType(ct, params)
860
861 // Ensure base64 is valid, then we'll write the original string.
862 _, err := io.Copy(io.Discard, base64.NewDecoder(base64.StdEncoding, strings.NewReader(t[1])))
863 xcheckuserf(ctx, err, "parsing attachment as base64")
864
865 xaddAttachmentBase64(ct, filename, []byte(t[1]))
866 }
867
868 if len(m.ForwardAttachments.Paths) > 0 {
869 acc.WithRLock(func() {
870 xdbread(ctx, acc, func(tx *bstore.Tx) {
871 fm := xmessageID(ctx, tx, m.ForwardAttachments.MessageID)
872 msgr := acc.MessageReader(fm)
873 defer func() {
874 err := msgr.Close()
875 log.Check(err, "closing message reader")
876 }()
877
878 fp, err := fm.LoadPart(msgr)
879 xcheckf(ctx, err, "load parsed message")
880
881 for _, path := range m.ForwardAttachments.Paths {
882 ap := fp
883 for _, xp := range path {
884 if xp < 0 || xp >= len(ap.Parts) {
885 xcheckuserf(ctx, errors.New("unknown part"), "looking up attachment")
886 }
887 ap = ap.Parts[xp]
888 }
889
890 filename := tryDecodeParam(log, ap.ContentTypeParams["name"])
891 if filename == "" {
892 filename = "unnamed.bin"
893 }
894 params := map[string]string{"name": filename}
895 if pcharset := ap.ContentTypeParams["charset"]; pcharset != "" {
896 params["charset"] = pcharset
897 }
898 ct := strings.ToLower(ap.MediaType + "/" + ap.MediaSubType)
899 ct = mime.FormatMediaType(ct, params)
900 xaddAttachment(ct, filename, ap.Reader())
901 }
902 })
903 })
904 }
905
906 err = mp.Close()
907 xcheckf(ctx, err, "writing mime multipart")
908 } else {
909 textBody, ct, cte := xc.TextPart("plain", m.TextBody)
910 xc.Header("Content-Type", ct)
911 xc.Header("Content-Transfer-Encoding", cte)
912 xc.Line()
913 xc.Write([]byte(textBody))
914 }
915
916 xc.Flush()
917
918 // Add DKIM-Signature headers.
919 var msgPrefix string
920 fd := fromAddr.Address.Domain
921 confDom, _ := mox.Conf.Domain(fd)
922 selectors := mox.DKIMSelectors(confDom.DKIM)
923 if len(selectors) > 0 {
924 dkimHeaders, err := dkim.Sign(ctx, log.Logger, fromAddr.Address.Localpart, fd, selectors, smtputf8, dataFile)
925 if err != nil {
926 metricServerErrors.WithLabelValues("dkimsign").Inc()
927 }
928 xcheckf(ctx, err, "sign dkim")
929
930 msgPrefix = dkimHeaders
931 }
932
933 accConf, _ := acc.Conf()
934 loginAddr, err := smtp.ParseAddress(reqInfo.LoginAddress)
935 xcheckf(ctx, err, "parsing login address")
936 useFromID := slices.Contains(accConf.ParsedFromIDLoginAddresses, loginAddr)
937 fromPath := fromAddr.Address.Path()
938 var localpartBase string
939 if useFromID {
940 localpartBase = strings.SplitN(string(fromPath.Localpart), confDom.LocalpartCatchallSeparator, 2)[0]
941 }
942 qml := make([]queue.Msg, len(recipients))
943 now := time.Now()
944 for i, rcpt := range recipients {
945 fp := fromPath
946 var fromID string
947 if useFromID {
948 fromID = xrandomID(ctx, 16)
949 fp.Localpart = smtp.Localpart(localpartBase + confDom.LocalpartCatchallSeparator + fromID)
950 }
951
952 // Don't use per-recipient unique message prefix when multiple recipients are
953 // present, or the queue cannot deliver it in a single smtp transaction.
954 var recvRcpt string
955 if len(recipients) == 1 {
956 recvRcpt = rcpt.Pack(smtputf8)
957 }
958 rcptMsgPrefix := recvHdrFor(recvRcpt) + msgPrefix
959 msgSize := int64(len(rcptMsgPrefix)) + xc.Size
960 toPath := smtp.Path{
961 Localpart: rcpt.Localpart,
962 IPDomain: dns.IPDomain{Domain: rcpt.Domain},
963 }
964 qm := queue.MakeMsg(fp, toPath, xc.Has8bit, xc.SMTPUTF8, msgSize, messageID, []byte(rcptMsgPrefix), m.RequireTLS, now, m.Subject)
965 if m.FutureRelease != nil {
966 ival := time.Until(*m.FutureRelease)
967 if ival < 0 {
968 xcheckuserf(ctx, errors.New("date/time is in the past"), "scheduling delivery")
969 } else if ival > queue.FutureReleaseIntervalMax {
970 xcheckuserf(ctx, fmt.Errorf("date/time can not be further than %v in the future", queue.FutureReleaseIntervalMax), "scheduling delivery")
971 }
972 qm.NextAttempt = *m.FutureRelease
973 qm.FutureReleaseRequest = "until;" + m.FutureRelease.Format(time.RFC3339)
974 // todo: possibly add a header to the message stored in the Sent mailbox to indicate it was scheduled for later delivery.
975 }
976 qm.FromID = fromID
977 // no qm.Extra from webmail
978 qml[i] = qm
979 }
980 err = queue.Add(ctx, log, reqInfo.Account.Name, dataFile, qml...)
981 if err != nil {
982 metricSubmission.WithLabelValues("queueerror").Inc()
983 }
984 xcheckf(ctx, err, "adding messages to the delivery queue")
985 metricSubmission.WithLabelValues("ok").Inc()
986
987 var modseq store.ModSeq // Only set if needed.
988
989 // Append message to Sent mailbox, mark original messages as answered/forwarded,
990 // remove any draft message.
991 acc.WithRLock(func() {
992 var changes []store.Change
993
994 metricked := false
995 defer func() {
996 if x := recover(); x != nil {
997 if !metricked {
998 metricServerErrors.WithLabelValues("submit").Inc()
999 }
1000 panic(x)
1001 }
1002 }()
1003 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1004 if m.DraftMessageID > 0 {
1005 var nchanges []store.Change
1006 modseq, nchanges = xops.MessageDeleteTx(ctx, log, tx, acc, []int64{m.DraftMessageID}, modseq)
1007 changes = append(changes, nchanges...)
1008 // On-disk file is removed after lock.
1009 }
1010
1011 if m.ResponseMessageID > 0 {
1012 rm := xmessageID(ctx, tx, m.ResponseMessageID)
1013 oflags := rm.Flags
1014 if m.IsForward {
1015 rm.Forwarded = true
1016 } else {
1017 rm.Answered = true
1018 }
1019 if !rm.Junk && !rm.Notjunk {
1020 rm.Notjunk = true
1021 }
1022 if rm.Flags != oflags {
1023 modseq, err = acc.NextModSeq(tx)
1024 xcheckf(ctx, err, "next modseq")
1025 rm.ModSeq = modseq
1026 err := tx.Update(&rm)
1027 xcheckf(ctx, err, "updating flags of replied/forwarded message")
1028 changes = append(changes, rm.ChangeFlags(oflags))
1029
1030 err = acc.RetrainMessages(ctx, log, tx, []store.Message{rm}, false)
1031 xcheckf(ctx, err, "retraining messages after reply/forward")
1032 }
1033
1034 // Move messages from this thread still in this mailbox to the designated Archive
1035 // mailbox.
1036 if m.ArchiveThread {
1037 mbArchive, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Archive", true).Get()
1038 if err == bstore.ErrAbsent {
1039 xcheckuserf(ctx, errors.New("not configured"), "looking up designated archive mailbox")
1040 }
1041 xcheckf(ctx, err, "looking up designated archive mailbox")
1042
1043 var msgIDs []int64
1044 q := bstore.QueryTx[store.Message](tx)
1045 q.FilterNonzero(store.Message{ThreadID: rm.ThreadID, MailboxID: rm.MailboxID})
1046 q.FilterEqual("Expunged", false)
1047 err = q.IDs(&msgIDs)
1048 xcheckf(ctx, err, "listing messages in thread to archive")
1049 if len(msgIDs) > 0 {
1050 var nchanges []store.Change
1051 modseq, nchanges = xops.MessageMoveTx(ctx, log, acc, tx, msgIDs, mbArchive, modseq)
1052 changes = append(changes, nchanges...)
1053 }
1054 }
1055 }
1056
1057 sentmb, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Sent", true).Get()
1058 if err == bstore.ErrAbsent {
1059 // There is no mailbox designated as Sent mailbox, so we're done.
1060 return
1061 }
1062 xcheckf(ctx, err, "message submitted to queue, adding to Sent mailbox")
1063
1064 if modseq == 0 {
1065 modseq, err = acc.NextModSeq(tx)
1066 xcheckf(ctx, err, "next modseq")
1067 }
1068
1069 // If there were bcc headers, prepend those to the stored message only, before the
1070 // DKIM signature. The DKIM-signature oversigns the bcc header, so this stored
1071 // message won't validate with DKIM anymore, which is fine.
1072 if len(bccAddrs) > 0 {
1073 var sb strings.Builder
1074 xbcc := message.NewComposer(&sb, 100*1024, smtputf8)
1075 xbcc.HeaderAddrs("Bcc", bccAddrs)
1076 xbcc.Flush()
1077 msgPrefix = sb.String() + msgPrefix
1078 }
1079
1080 sentm := store.Message{
1081 CreateSeq: modseq,
1082 ModSeq: modseq,
1083 MailboxID: sentmb.ID,
1084 MailboxOrigID: sentmb.ID,
1085 Flags: store.Flags{Notjunk: true, Seen: true},
1086 Size: int64(len(msgPrefix)) + xc.Size,
1087 MsgPrefix: []byte(msgPrefix),
1088 }
1089
1090 if ok, maxSize, err := acc.CanAddMessageSize(tx, sentm.Size); err != nil {
1091 xcheckf(ctx, err, "checking quota")
1092 } else if !ok {
1093 xcheckuserf(ctx, fmt.Errorf("account over maximum total message size %d", maxSize), "checking quota")
1094 }
1095
1096 // Update mailbox before delivery, which changes uidnext.
1097 sentmb.Add(sentm.MailboxCounts())
1098 err = tx.Update(&sentmb)
1099 xcheckf(ctx, err, "updating sent mailbox for counts")
1100
1101 err = acc.DeliverMessage(log, tx, &sentm, dataFile, true, false, false, true)
1102 if err != nil {
1103 metricSubmission.WithLabelValues("storesenterror").Inc()
1104 metricked = true
1105 }
1106 xcheckf(ctx, err, "message submitted to queue, appending message to Sent mailbox")
1107
1108 changes = append(changes, sentm.ChangeAddUID(), sentmb.ChangeCounts())
1109 })
1110
1111 store.BroadcastChanges(acc, changes)
1112 })
1113
1114 // Remove on-disk file for removed draft message.
1115 if m.DraftMessageID > 0 {
1116 p := acc.MessagePath(m.DraftMessageID)
1117 err := os.Remove(p)
1118 log.Check(err, "removing draft message file")
1119 }
1120}
1121
1122// MessageMove moves messages to another mailbox. If the message is already in
1123// the mailbox an error is returned.
1124func (Webmail) MessageMove(ctx context.Context, messageIDs []int64, mailboxID int64) {
1125 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1126 acc := reqInfo.Account
1127 log := reqInfo.Log
1128
1129 xops.MessageMove(ctx, log, acc, messageIDs, "", mailboxID)
1130}
1131
1132var xops = webops.XOps{
1133 DBWrite: xdbwrite,
1134 Checkf: xcheckf,
1135 Checkuserf: xcheckuserf,
1136}
1137
1138// MessageDelete permanently deletes messages, without moving them to the Trash mailbox.
1139func (Webmail) MessageDelete(ctx context.Context, messageIDs []int64) {
1140 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1141 acc := reqInfo.Account
1142 log := reqInfo.Log
1143
1144 if len(messageIDs) == 0 {
1145 return
1146 }
1147
1148 xops.MessageDelete(ctx, log, acc, messageIDs)
1149}
1150
1151// FlagsAdd adds flags, either system flags like \Seen or custom keywords. The
1152// flags should be lower-case, but will be converted and verified.
1153func (Webmail) FlagsAdd(ctx context.Context, messageIDs []int64, flaglist []string) {
1154 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1155 acc := reqInfo.Account
1156 log := reqInfo.Log
1157
1158 xops.MessageFlagsAdd(ctx, log, acc, messageIDs, flaglist)
1159}
1160
1161// FlagsClear clears flags, either system flags like \Seen or custom keywords.
1162func (Webmail) FlagsClear(ctx context.Context, messageIDs []int64, flaglist []string) {
1163 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1164 acc := reqInfo.Account
1165 log := reqInfo.Log
1166
1167 xops.MessageFlagsClear(ctx, log, acc, messageIDs, flaglist)
1168}
1169
1170// MailboxCreate creates a new mailbox.
1171func (Webmail) MailboxCreate(ctx context.Context, name string) {
1172 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1173 acc := reqInfo.Account
1174
1175 var err error
1176 name, _, err = store.CheckMailboxName(name, false)
1177 xcheckuserf(ctx, err, "checking mailbox name")
1178
1179 acc.WithWLock(func() {
1180 var changes []store.Change
1181 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1182 var exists bool
1183 var err error
1184 changes, _, exists, err = acc.MailboxCreate(tx, name)
1185 if exists {
1186 xcheckuserf(ctx, errors.New("mailbox already exists"), "creating mailbox")
1187 }
1188 xcheckf(ctx, err, "creating mailbox")
1189 })
1190
1191 store.BroadcastChanges(acc, changes)
1192 })
1193}
1194
1195// MailboxDelete deletes a mailbox and all its messages.
1196func (Webmail) MailboxDelete(ctx context.Context, mailboxID int64) {
1197 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1198 acc := reqInfo.Account
1199 log := reqInfo.Log
1200
1201 // Messages to remove after having broadcasted the removal of messages.
1202 var removeMessageIDs []int64
1203
1204 acc.WithWLock(func() {
1205 var changes []store.Change
1206
1207 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1208 mb := xmailboxID(ctx, tx, mailboxID)
1209 if mb.Name == "Inbox" {
1210 // Inbox is special in IMAP and cannot be removed.
1211 xcheckuserf(ctx, errors.New("cannot remove special Inbox"), "checking mailbox")
1212 }
1213
1214 var hasChildren bool
1215 var err error
1216 changes, removeMessageIDs, hasChildren, err = acc.MailboxDelete(ctx, log, tx, mb)
1217 if hasChildren {
1218 xcheckuserf(ctx, errors.New("mailbox has children"), "deleting mailbox")
1219 }
1220 xcheckf(ctx, err, "deleting mailbox")
1221 })
1222
1223 store.BroadcastChanges(acc, changes)
1224 })
1225
1226 for _, mID := range removeMessageIDs {
1227 p := acc.MessagePath(mID)
1228 err := os.Remove(p)
1229 log.Check(err, "removing message file for mailbox delete", slog.String("path", p))
1230 }
1231}
1232
1233// MailboxEmpty empties a mailbox, removing all messages from the mailbox, but not
1234// its child mailboxes.
1235func (Webmail) MailboxEmpty(ctx context.Context, mailboxID int64) {
1236 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1237 acc := reqInfo.Account
1238 log := reqInfo.Log
1239
1240 var expunged []store.Message
1241
1242 acc.WithWLock(func() {
1243 var changes []store.Change
1244
1245 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1246 mb := xmailboxID(ctx, tx, mailboxID)
1247
1248 modseq, err := acc.NextModSeq(tx)
1249 xcheckf(ctx, err, "next modseq")
1250
1251 // Mark messages as expunged.
1252 qm := bstore.QueryTx[store.Message](tx)
1253 qm.FilterNonzero(store.Message{MailboxID: mb.ID})
1254 qm.FilterEqual("Expunged", false)
1255 qm.SortAsc("UID")
1256 qm.Gather(&expunged)
1257 _, err = qm.UpdateNonzero(store.Message{ModSeq: modseq, Expunged: true})
1258 xcheckf(ctx, err, "deleting messages")
1259
1260 // Remove Recipients.
1261 anyIDs := make([]any, len(expunged))
1262 for i, m := range expunged {
1263 anyIDs[i] = m.ID
1264 }
1265 qmr := bstore.QueryTx[store.Recipient](tx)
1266 qmr.FilterEqual("MessageID", anyIDs...)
1267 _, err = qmr.Delete()
1268 xcheckf(ctx, err, "removing message recipients")
1269
1270 // Adjust mailbox counts, gather UIDs for broadcasted change, prepare for untraining.
1271 var totalSize int64
1272 uids := make([]store.UID, len(expunged))
1273 for i, m := range expunged {
1274 m.Expunged = false // Gather returns updated values.
1275 mb.Sub(m.MailboxCounts())
1276 totalSize += m.Size
1277 uids[i] = m.UID
1278
1279 expunged[i].Junk = false
1280 expunged[i].Notjunk = false
1281 }
1282
1283 err = tx.Update(&mb)
1284 xcheckf(ctx, err, "updating mailbox for counts")
1285
1286 err = acc.AddMessageSize(log, tx, -totalSize)
1287 xcheckf(ctx, err, "updating disk usage")
1288
1289 err = acc.RetrainMessages(ctx, log, tx, expunged, true)
1290 xcheckf(ctx, err, "retraining expunged messages")
1291
1292 chremove := store.ChangeRemoveUIDs{MailboxID: mb.ID, UIDs: uids, ModSeq: modseq}
1293 changes = []store.Change{chremove, mb.ChangeCounts()}
1294 })
1295
1296 store.BroadcastChanges(acc, changes)
1297 })
1298
1299 for _, m := range expunged {
1300 p := acc.MessagePath(m.ID)
1301 err := os.Remove(p)
1302 log.Check(err, "removing message file after emptying mailbox", slog.String("path", p))
1303 }
1304}
1305
1306// MailboxRename renames a mailbox, possibly moving it to a new parent. The mailbox
1307// ID and its messages are unchanged.
1308func (Webmail) MailboxRename(ctx context.Context, mailboxID int64, newName string) {
1309 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1310 acc := reqInfo.Account
1311
1312 // Renaming Inbox is special for IMAP. For IMAP we have to implement it per the
1313 // standard. We can just say no.
1314 var err error
1315 newName, _, err = store.CheckMailboxName(newName, false)
1316 xcheckuserf(ctx, err, "checking new mailbox name")
1317
1318 acc.WithWLock(func() {
1319 var changes []store.Change
1320
1321 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1322 mbsrc := xmailboxID(ctx, tx, mailboxID)
1323 var err error
1324 var isInbox, notExists, alreadyExists bool
1325 changes, isInbox, notExists, alreadyExists, err = acc.MailboxRename(tx, mbsrc, newName)
1326 if isInbox || notExists || alreadyExists {
1327 xcheckuserf(ctx, err, "renaming mailbox")
1328 }
1329 xcheckf(ctx, err, "renaming mailbox")
1330 })
1331
1332 store.BroadcastChanges(acc, changes)
1333 })
1334}
1335
1336// CompleteRecipient returns autocomplete matches for a recipient, returning the
1337// matches, most recently used first, and whether this is the full list and further
1338// requests for longer prefixes aren't necessary.
1339func (Webmail) CompleteRecipient(ctx context.Context, search string) ([]string, bool) {
1340 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1341 acc := reqInfo.Account
1342
1343 search = strings.ToLower(search)
1344
1345 var matches []string
1346 all := true
1347 acc.WithRLock(func() {
1348 xdbread(ctx, acc, func(tx *bstore.Tx) {
1349 type key struct {
1350 localpart string
1351 domain string
1352 }
1353 seen := map[key]bool{}
1354
1355 q := bstore.QueryTx[store.Recipient](tx)
1356 q.SortDesc("Sent")
1357 err := q.ForEach(func(r store.Recipient) error {
1358 k := key{r.Localpart, r.Domain}
1359 if seen[k] {
1360 return nil
1361 }
1362 // todo: we should have the address including name available in the database for searching. Will result in better matching, and also for the name.
1363 address := fmt.Sprintf("<%s@%s>", r.Localpart, r.Domain)
1364 if !strings.Contains(strings.ToLower(address), search) {
1365 return nil
1366 }
1367 if len(matches) >= 20 {
1368 all = false
1369 return bstore.StopForEach
1370 }
1371
1372 // Look in the message that was sent for a name along with the address.
1373 m := store.Message{ID: r.MessageID}
1374 err := tx.Get(&m)
1375 xcheckf(ctx, err, "get sent message")
1376 if !m.Expunged && m.ParsedBuf != nil {
1377 var part message.Part
1378 err := json.Unmarshal(m.ParsedBuf, &part)
1379 xcheckf(ctx, err, "parsing part")
1380
1381 dom, err := dns.ParseDomain(r.Domain)
1382 xcheckf(ctx, err, "parsing domain of recipient")
1383
1384 var found bool
1385 lp := r.Localpart
1386 checkAddrs := func(l []message.Address) {
1387 if found {
1388 return
1389 }
1390 for _, a := range l {
1391 if a.Name != "" && a.User == lp && strings.EqualFold(a.Host, dom.ASCII) {
1392 found = true
1393 address = addressString(a, false)
1394 return
1395 }
1396 }
1397 }
1398 if part.Envelope != nil {
1399 env := part.Envelope
1400 checkAddrs(env.To)
1401 checkAddrs(env.CC)
1402 checkAddrs(env.BCC)
1403 }
1404 }
1405
1406 matches = append(matches, address)
1407 seen[k] = true
1408 return nil
1409 })
1410 xcheckf(ctx, err, "listing recipients")
1411 })
1412 })
1413 return matches, all
1414}
1415
1416// addressString returns an address into a string as it could be used in a message header.
1417func addressString(a message.Address, smtputf8 bool) string {
1418 host := a.Host
1419 dom, err := dns.ParseDomain(a.Host)
1420 if err == nil {
1421 if smtputf8 && dom.Unicode != "" {
1422 host = dom.Unicode
1423 } else {
1424 host = dom.ASCII
1425 }
1426 }
1427 s := "<" + a.User + "@" + host + ">"
1428 if a.Name != "" {
1429 // todo: properly encoded/escaped name
1430 s = a.Name + " " + s
1431 }
1432 return s
1433}
1434
1435// MailboxSetSpecialUse sets the special use flags of a mailbox.
1436func (Webmail) MailboxSetSpecialUse(ctx context.Context, mb store.Mailbox) {
1437 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1438 acc := reqInfo.Account
1439
1440 acc.WithWLock(func() {
1441 var changes []store.Change
1442
1443 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1444 xmb := xmailboxID(ctx, tx, mb.ID)
1445
1446 // We only allow a single mailbox for each flag (JMAP requirement). So for any flag
1447 // we set, we clear it for the mailbox(es) that had it, if any.
1448 clearPrevious := func(clear bool, specialUse string) {
1449 if !clear {
1450 return
1451 }
1452 var ombl []store.Mailbox
1453 q := bstore.QueryTx[store.Mailbox](tx)
1454 q.FilterNotEqual("ID", mb.ID)
1455 q.FilterEqual(specialUse, true)
1456 q.Gather(&ombl)
1457 _, err := q.UpdateField(specialUse, false)
1458 xcheckf(ctx, err, "updating previous special-use mailboxes")
1459
1460 for _, omb := range ombl {
1461 changes = append(changes, omb.ChangeSpecialUse())
1462 }
1463 }
1464 clearPrevious(mb.Archive, "Archive")
1465 clearPrevious(mb.Draft, "Draft")
1466 clearPrevious(mb.Junk, "Junk")
1467 clearPrevious(mb.Sent, "Sent")
1468 clearPrevious(mb.Trash, "Trash")
1469
1470 xmb.SpecialUse = mb.SpecialUse
1471 err := tx.Update(&xmb)
1472 xcheckf(ctx, err, "updating special-use flags for mailbox")
1473 changes = append(changes, xmb.ChangeSpecialUse())
1474 })
1475
1476 store.BroadcastChanges(acc, changes)
1477 })
1478}
1479
1480// ThreadCollapse saves the ThreadCollapse field for the messages and its
1481// children. The messageIDs are typically thread roots. But not all roots
1482// (without parent) of a thread need to have the same collapsed state.
1483func (Webmail) ThreadCollapse(ctx context.Context, messageIDs []int64, collapse bool) {
1484 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1485 acc := reqInfo.Account
1486
1487 if len(messageIDs) == 0 {
1488 xcheckuserf(ctx, errors.New("no messages"), "setting collapse")
1489 }
1490
1491 acc.WithWLock(func() {
1492 changes := make([]store.Change, 0, len(messageIDs))
1493 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1494 // Gather ThreadIDs to list all potential messages, for a way to get all potential
1495 // (child) messages. Further refined in FilterFn.
1496 threadIDs := map[int64]struct{}{}
1497 msgIDs := map[int64]struct{}{}
1498 for _, id := range messageIDs {
1499 m := store.Message{ID: id}
1500 err := tx.Get(&m)
1501 if err == bstore.ErrAbsent {
1502 xcheckuserf(ctx, err, "get message")
1503 }
1504 xcheckf(ctx, err, "get message")
1505 threadIDs[m.ThreadID] = struct{}{}
1506 msgIDs[id] = struct{}{}
1507 }
1508
1509 var updated []store.Message
1510 q := bstore.QueryTx[store.Message](tx)
1511 q.FilterEqual("ThreadID", slicesAny(maps.Keys(threadIDs))...)
1512 q.FilterNotEqual("ThreadCollapsed", collapse)
1513 q.FilterFn(func(tm store.Message) bool {
1514 for _, id := range tm.ThreadParentIDs {
1515 if _, ok := msgIDs[id]; ok {
1516 return true
1517 }
1518 }
1519 _, ok := msgIDs[tm.ID]
1520 return ok
1521 })
1522 q.Gather(&updated)
1523 q.SortAsc("ID") // Consistent order for testing.
1524 _, err := q.UpdateFields(map[string]any{"ThreadCollapsed": collapse})
1525 xcheckf(ctx, err, "updating collapse in database")
1526
1527 for _, m := range updated {
1528 changes = append(changes, m.ChangeThread())
1529 }
1530 })
1531 store.BroadcastChanges(acc, changes)
1532 })
1533}
1534
1535// ThreadMute saves the ThreadMute field for the messages and their children.
1536// If messages are muted, they are also marked collapsed.
1537func (Webmail) ThreadMute(ctx context.Context, messageIDs []int64, mute bool) {
1538 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1539 acc := reqInfo.Account
1540
1541 if len(messageIDs) == 0 {
1542 xcheckuserf(ctx, errors.New("no messages"), "setting mute")
1543 }
1544
1545 acc.WithWLock(func() {
1546 changes := make([]store.Change, 0, len(messageIDs))
1547 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1548 threadIDs := map[int64]struct{}{}
1549 msgIDs := map[int64]struct{}{}
1550 for _, id := range messageIDs {
1551 m := store.Message{ID: id}
1552 err := tx.Get(&m)
1553 if err == bstore.ErrAbsent {
1554 xcheckuserf(ctx, err, "get message")
1555 }
1556 xcheckf(ctx, err, "get message")
1557 threadIDs[m.ThreadID] = struct{}{}
1558 msgIDs[id] = struct{}{}
1559 }
1560
1561 var updated []store.Message
1562
1563 q := bstore.QueryTx[store.Message](tx)
1564 q.FilterEqual("ThreadID", slicesAny(maps.Keys(threadIDs))...)
1565 q.FilterFn(func(tm store.Message) bool {
1566 if tm.ThreadMuted == mute && (!mute || tm.ThreadCollapsed) {
1567 return false
1568 }
1569 for _, id := range tm.ThreadParentIDs {
1570 if _, ok := msgIDs[id]; ok {
1571 return true
1572 }
1573 }
1574 _, ok := msgIDs[tm.ID]
1575 return ok
1576 })
1577 q.Gather(&updated)
1578 fields := map[string]any{"ThreadMuted": mute}
1579 if mute {
1580 fields["ThreadCollapsed"] = true
1581 }
1582 _, err := q.UpdateFields(fields)
1583 xcheckf(ctx, err, "updating mute in database")
1584
1585 for _, m := range updated {
1586 changes = append(changes, m.ChangeThread())
1587 }
1588 })
1589 store.BroadcastChanges(acc, changes)
1590 })
1591}
1592
1593// SecurityResult indicates whether a security feature is supported.
1594type SecurityResult string
1595
1596const (
1597 SecurityResultError SecurityResult = "error"
1598 SecurityResultNo SecurityResult = "no"
1599 SecurityResultYes SecurityResult = "yes"
1600 // Unknown whether supported. Finding out may only be (reasonably) possible when
1601 // trying (e.g. SMTP STARTTLS). Once tried, the result may be cached for future
1602 // lookups.
1603 SecurityResultUnknown SecurityResult = "unknown"
1604)
1605
1606// RecipientSecurity is a quick analysis of the security properties of delivery to
1607// the recipient (domain).
1608type RecipientSecurity struct {
1609 // Whether recipient domain supports (opportunistic) STARTTLS, as seen during most
1610 // recent delivery attempt. Will be "unknown" if no delivery to the domain has been
1611 // attempted yet.
1612 STARTTLS SecurityResult
1613
1614 // Whether we have a stored enforced MTA-STS policy, or domain has MTA-STS DNS
1615 // record.
1616 MTASTS SecurityResult
1617
1618 // Whether MX lookup response was DNSSEC-signed.
1619 DNSSEC SecurityResult
1620
1621 // Whether first delivery destination has DANE records.
1622 DANE SecurityResult
1623
1624 // Whether recipient domain is known to implement the REQUIRETLS SMTP extension.
1625 // Will be "unknown" if no delivery to the domain has been attempted yet.
1626 RequireTLS SecurityResult
1627}
1628
1629// RecipientSecurity looks up security properties of the address in the
1630// single-address message addressee (as it appears in a To/Cc/Bcc/etc header).
1631func (Webmail) RecipientSecurity(ctx context.Context, messageAddressee string) (RecipientSecurity, error) {
1632 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1633 log := reqInfo.Log
1634
1635 resolver := dns.StrictResolver{Pkg: "webmail", Log: log.Logger}
1636 return recipientSecurity(ctx, log, resolver, messageAddressee)
1637}
1638
1639// logPanic can be called with a defer from a goroutine to prevent the entire program from being shutdown in case of a panic.
1640func logPanic(ctx context.Context) {
1641 x := recover()
1642 if x == nil {
1643 return
1644 }
1645 log := pkglog.WithContext(ctx)
1646 log.Error("recover from panic", slog.Any("panic", x))
1647 debug.PrintStack()
1648 metrics.PanicInc(metrics.Webmail)
1649}
1650
1651// separate function for testing with mocked resolver.
1652func recipientSecurity(ctx context.Context, log mlog.Log, resolver dns.Resolver, messageAddressee string) (RecipientSecurity, error) {
1653 rs := RecipientSecurity{
1654 SecurityResultUnknown,
1655 SecurityResultUnknown,
1656 SecurityResultUnknown,
1657 SecurityResultUnknown,
1658 SecurityResultUnknown,
1659 }
1660
1661 msgAddr, err := mail.ParseAddress(messageAddressee)
1662 if err != nil {
1663 return rs, fmt.Errorf("parsing message addressee: %v", err)
1664 }
1665
1666 addr, err := smtp.ParseAddress(msgAddr.Address)
1667 if err != nil {
1668 return rs, fmt.Errorf("parsing address: %v", err)
1669 }
1670
1671 var wg sync.WaitGroup
1672
1673 // MTA-STS.
1674 wg.Add(1)
1675 go func() {
1676 defer logPanic(ctx)
1677 defer wg.Done()
1678
1679 policy, _, _, err := mtastsdb.Get(ctx, log.Logger, resolver, addr.Domain)
1680 if policy != nil && policy.Mode == mtasts.ModeEnforce {
1681 rs.MTASTS = SecurityResultYes
1682 } else if err == nil {
1683 rs.MTASTS = SecurityResultNo
1684 } else {
1685 rs.MTASTS = SecurityResultError
1686 }
1687 }()
1688
1689 // DNSSEC and DANE.
1690 wg.Add(1)
1691 go func() {
1692 defer logPanic(ctx)
1693 defer wg.Done()
1694
1695 _, origNextHopAuthentic, expandedNextHopAuthentic, _, hosts, _, err := smtpclient.GatherDestinations(ctx, log.Logger, resolver, dns.IPDomain{Domain: addr.Domain})
1696 if err != nil {
1697 rs.DNSSEC = SecurityResultError
1698 return
1699 }
1700 if origNextHopAuthentic && expandedNextHopAuthentic {
1701 rs.DNSSEC = SecurityResultYes
1702 } else {
1703 rs.DNSSEC = SecurityResultNo
1704 }
1705
1706 if !origNextHopAuthentic {
1707 rs.DANE = SecurityResultNo
1708 return
1709 }
1710
1711 // We're only looking at the first host to deliver to (typically first mx destination).
1712 if len(hosts) == 0 || hosts[0].Domain.IsZero() {
1713 return // Should not happen.
1714 }
1715 host := hosts[0]
1716
1717 // Resolve the IPs. Required for DANE to prevent bad DNS servers from causing an
1718 // error result instead of no-DANE result.
1719 authentic, expandedAuthentic, expandedHost, _, _, err := smtpclient.GatherIPs(ctx, log.Logger, resolver, "ip", host, map[string][]net.IP{})
1720 if err != nil {
1721 rs.DANE = SecurityResultError
1722 return
1723 }
1724 if !authentic {
1725 rs.DANE = SecurityResultNo
1726 return
1727 }
1728
1729 daneRequired, _, _, err := smtpclient.GatherTLSA(ctx, log.Logger, resolver, host.Domain, expandedAuthentic, expandedHost)
1730 if err != nil {
1731 rs.DANE = SecurityResultError
1732 return
1733 } else if daneRequired {
1734 rs.DANE = SecurityResultYes
1735 } else {
1736 rs.DANE = SecurityResultNo
1737 }
1738 }()
1739
1740 // STARTTLS and RequireTLS
1741 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1742 acc := reqInfo.Account
1743
1744 err = acc.DB.Read(ctx, func(tx *bstore.Tx) error {
1745 q := bstore.QueryTx[store.RecipientDomainTLS](tx)
1746 q.FilterNonzero(store.RecipientDomainTLS{Domain: addr.Domain.Name()})
1747 rd, err := q.Get()
1748 if err == bstore.ErrAbsent {
1749 return nil
1750 } else if err != nil {
1751 rs.STARTTLS = SecurityResultError
1752 rs.RequireTLS = SecurityResultError
1753 log.Errorx("looking up recipient domain", err, slog.Any("domain", addr.Domain))
1754 return nil
1755 }
1756 if rd.STARTTLS {
1757 rs.STARTTLS = SecurityResultYes
1758 } else {
1759 rs.STARTTLS = SecurityResultNo
1760 }
1761 if rd.RequireTLS {
1762 rs.RequireTLS = SecurityResultYes
1763 } else {
1764 rs.RequireTLS = SecurityResultNo
1765 }
1766 return nil
1767 })
1768 xcheckf(ctx, err, "lookup recipient domain")
1769
1770 wg.Wait()
1771
1772 return rs, nil
1773}
1774
1775// DecodeMIMEWords decodes Q/B-encoded words for a mime headers into UTF-8 text.
1776func (Webmail) DecodeMIMEWords(ctx context.Context, text string) string {
1777 s, err := wordDecoder.DecodeHeader(text)
1778 xcheckuserf(ctx, err, "decoding mime q/b-word encoded header")
1779 return s
1780}
1781
1782// SettingsSave saves settings, e.g. for composing.
1783func (Webmail) SettingsSave(ctx context.Context, settings store.Settings) {
1784 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1785 acc := reqInfo.Account
1786
1787 settings.ID = 1
1788 err := acc.DB.Update(ctx, &settings)
1789 xcheckf(ctx, err, "save settings")
1790}
1791
1792func (Webmail) RulesetSuggestMove(ctx context.Context, msgID, mbSrcID, mbDstID int64) (listID string, msgFrom string, isRemove bool, rcptTo string, ruleset *config.Ruleset) {
1793 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1794 acc := reqInfo.Account
1795 log := reqInfo.Log
1796
1797 xdbread(ctx, acc, func(tx *bstore.Tx) {
1798 m := xmessageID(ctx, tx, msgID)
1799 mbSrc := xmailboxID(ctx, tx, mbSrcID)
1800 mbDst := xmailboxID(ctx, tx, mbDstID)
1801
1802 if m.RcptToLocalpart == "" && m.RcptToDomain == "" {
1803 return
1804 }
1805 rcptTo = m.RcptToLocalpart.String() + "@" + m.RcptToDomain
1806
1807 conf, _ := acc.Conf()
1808 dest := conf.Destinations[rcptTo] // May not be present.
1809 defaultMailbox := "Inbox"
1810 if dest.Mailbox != "" {
1811 defaultMailbox = dest.Mailbox
1812 }
1813
1814 // Only suggest rules for messages moved into/out of the default mailbox (Inbox).
1815 if mbSrc.Name != defaultMailbox && mbDst.Name != defaultMailbox {
1816 return
1817 }
1818
1819 // Check if we have a previous answer "No" answer for moving from/to mailbox.
1820 exists, err := bstore.QueryTx[store.RulesetNoMailbox](tx).FilterNonzero(store.RulesetNoMailbox{MailboxID: mbSrcID}).FilterEqual("ToMailbox", false).Exists()
1821 xcheckf(ctx, err, "looking up previous response for source mailbox")
1822 if exists {
1823 return
1824 }
1825 exists, err = bstore.QueryTx[store.RulesetNoMailbox](tx).FilterNonzero(store.RulesetNoMailbox{MailboxID: mbDstID}).FilterEqual("ToMailbox", true).Exists()
1826 xcheckf(ctx, err, "looking up previous response for destination mailbox")
1827 if exists {
1828 return
1829 }
1830
1831 // Parse message for List-Id header.
1832 state := msgState{acc: acc}
1833 defer state.clear()
1834 pm, err := parsedMessage(log, m, &state, true, false)
1835 xcheckf(ctx, err, "parsing message")
1836
1837 // The suggested ruleset. Once all is checked, we'll return it.
1838 var nrs *config.Ruleset
1839
1840 // If List-Id header is present, we'll treat it as a (mailing) list message.
1841 if l, ok := pm.Headers["List-Id"]; ok {
1842 if len(l) != 1 {
1843 log.Debug("not exactly one list-id header", slog.Any("listid", l))
1844 return
1845 }
1846 var listIDDom dns.Domain
1847 listID, listIDDom = parseListID(l[0])
1848 if listID == "" {
1849 log.Debug("invalid list-id header", slog.String("listid", l[0]))
1850 return
1851 }
1852
1853 // Check if we have a previous "No" answer for this list-id.
1854 no := store.RulesetNoListID{
1855 RcptToAddress: rcptTo,
1856 ListID: listID,
1857 ToInbox: mbDst.Name == "Inbox",
1858 }
1859 exists, err = bstore.QueryTx[store.RulesetNoListID](tx).FilterNonzero(no).Exists()
1860 xcheckf(ctx, err, "looking up previous response for list-id")
1861 if exists {
1862 return
1863 }
1864
1865 // Find the "ListAllowDomain" to use. We only match and move messages with verified
1866 // SPF/DKIM. Otherwise spammers could add a list-id headers for mailing lists you
1867 // are subscribed to, and take advantage of any reduced junk filtering.
1868 listIDDomStr := listIDDom.Name()
1869
1870 doms := m.DKIMDomains
1871 if m.MailFromValidated {
1872 doms = append(doms, m.MailFromDomain)
1873 }
1874 // Sort, we prefer the shortest name, e.g. DKIM signature on whole domain instead
1875 // of SPF verification of one host.
1876 sort.Slice(doms, func(i, j int) bool {
1877 return len(doms[i]) < len(doms[j])
1878 })
1879 var listAllowDom string
1880 for _, dom := range doms {
1881 if dom == listIDDomStr || strings.HasSuffix(listIDDomStr, "."+dom) {
1882 listAllowDom = dom
1883 break
1884 }
1885 }
1886 if listAllowDom == "" {
1887 return
1888 }
1889
1890 listIDRegExp := regexp.QuoteMeta(fmt.Sprintf("<%s>", listID)) + "$"
1891 nrs = &config.Ruleset{
1892 HeadersRegexp: map[string]string{"^list-id$": listIDRegExp},
1893 ListAllowDomain: listAllowDom,
1894 Mailbox: mbDst.Name,
1895 }
1896 } else {
1897 // Otherwise, try to make a rule based on message "From" address.
1898 if m.MsgFromLocalpart == "" && m.MsgFromDomain == "" {
1899 return
1900 }
1901 msgFrom = m.MsgFromLocalpart.String() + "@" + m.MsgFromDomain
1902
1903 no := store.RulesetNoMsgFrom{
1904 RcptToAddress: rcptTo,
1905 MsgFromAddress: msgFrom,
1906 ToInbox: mbDst.Name == "Inbox",
1907 }
1908 exists, err = bstore.QueryTx[store.RulesetNoMsgFrom](tx).FilterNonzero(no).Exists()
1909 xcheckf(ctx, err, "looking up previous response for message from address")
1910 if exists {
1911 return
1912 }
1913
1914 nrs = &config.Ruleset{
1915 MsgFromRegexp: "^" + regexp.QuoteMeta(msgFrom) + "$",
1916 Mailbox: mbDst.Name,
1917 }
1918 }
1919
1920 // Only suggest adding/removing rule if it isn't/is present.
1921 var have bool
1922 for _, rs := range dest.Rulesets {
1923 xrs := config.Ruleset{
1924 MsgFromRegexp: rs.MsgFromRegexp,
1925 HeadersRegexp: rs.HeadersRegexp,
1926 ListAllowDomain: rs.ListAllowDomain,
1927 Mailbox: nrs.Mailbox,
1928 }
1929 if xrs.Equal(*nrs) {
1930 have = true
1931 break
1932 }
1933 }
1934 isRemove = mbDst.Name == defaultMailbox
1935 if isRemove {
1936 nrs.Mailbox = mbSrc.Name
1937 }
1938 if isRemove && !have || !isRemove && have {
1939 return
1940 }
1941
1942 // We'll be returning a suggested ruleset.
1943 nrs.Comment = "by webmail on " + time.Now().Format("2006-01-02")
1944 ruleset = nrs
1945 })
1946 return
1947}
1948
1949// Parse the list-id value (the value between <>) from a list-id header.
1950// Returns an empty string if it couldn't be parsed.
1951func parseListID(s string) (listID string, dom dns.Domain) {
1952 // ../rfc/2919:198
1953 s = strings.TrimRight(s, " \t")
1954 if !strings.HasSuffix(s, ">") {
1955 return "", dns.Domain{}
1956 }
1957 s = s[:len(s)-1]
1958 t := strings.Split(s, "<")
1959 if len(t) == 1 {
1960 return "", dns.Domain{}
1961 }
1962 s = t[len(t)-1]
1963 dom, err := dns.ParseDomain(s)
1964 if err != nil {
1965 return "", dom
1966 }
1967 return s, dom
1968}
1969
1970func (Webmail) RulesetAdd(ctx context.Context, rcptTo string, ruleset config.Ruleset) {
1971 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1972
1973 err := mox.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) {
1974 dest, ok := acc.Destinations[rcptTo]
1975 if !ok {
1976 // todo: we could find the catchall address and add the rule, or add the address explicitly.
1977 xcheckuserf(ctx, errors.New("destination address not found in account (hint: if this is a catchall address, configure the address explicitly to configure rulesets)"), "looking up address")
1978 }
1979
1980 nd := map[string]config.Destination{}
1981 for addr, d := range acc.Destinations {
1982 nd[addr] = d
1983 }
1984 dest.Rulesets = append(slices.Clone(dest.Rulesets), ruleset)
1985 nd[rcptTo] = dest
1986 acc.Destinations = nd
1987 })
1988 xcheckf(ctx, err, "saving account with new ruleset")
1989}
1990
1991func (Webmail) RulesetRemove(ctx context.Context, rcptTo string, ruleset config.Ruleset) {
1992 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1993
1994 err := mox.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) {
1995 dest, ok := acc.Destinations[rcptTo]
1996 if !ok {
1997 xcheckuserf(ctx, errors.New("destination address not found in account"), "looking up address")
1998 }
1999
2000 nd := map[string]config.Destination{}
2001 for addr, d := range acc.Destinations {
2002 nd[addr] = d
2003 }
2004 var l []config.Ruleset
2005 skipped := 0
2006 for _, rs := range dest.Rulesets {
2007 if rs.Equal(ruleset) {
2008 skipped++
2009 } else {
2010 l = append(l, rs)
2011 }
2012 }
2013 if skipped != 1 {
2014 xcheckuserf(ctx, fmt.Errorf("affected %d configured rulesets, expected 1", skipped), "changing rulesets")
2015 }
2016 dest.Rulesets = l
2017 nd[rcptTo] = dest
2018 acc.Destinations = nd
2019 })
2020 xcheckf(ctx, err, "saving account with new ruleset")
2021}
2022
2023func (Webmail) RulesetMessageNever(ctx context.Context, rcptTo, listID, msgFrom string, toInbox bool) {
2024 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2025 acc := reqInfo.Account
2026
2027 var err error
2028 if listID != "" {
2029 err = acc.DB.Insert(ctx, &store.RulesetNoListID{RcptToAddress: rcptTo, ListID: listID, ToInbox: toInbox})
2030 } else {
2031 err = acc.DB.Insert(ctx, &store.RulesetNoMsgFrom{RcptToAddress: rcptTo, MsgFromAddress: msgFrom, ToInbox: toInbox})
2032 }
2033 xcheckf(ctx, err, "storing user response")
2034}
2035
2036func (Webmail) RulesetMailboxNever(ctx context.Context, mailboxID int64, toMailbox bool) {
2037 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2038 acc := reqInfo.Account
2039
2040 err := acc.DB.Insert(ctx, &store.RulesetNoMailbox{MailboxID: mailboxID, ToMailbox: toMailbox})
2041 xcheckf(ctx, err, "storing user response")
2042}
2043
2044func slicesAny[T any](l []T) []any {
2045 r := make([]any, len(l))
2046 for i, v := range l {
2047 r[i] = v
2048 }
2049 return r
2050}
2051
2052// SSETypes exists to ensure the generated API contains the types, for use in SSE events.
2053func (Webmail) SSETypes() (start EventStart, viewErr EventViewErr, viewReset EventViewReset, viewMsgs EventViewMsgs, viewChanges EventViewChanges, msgAdd ChangeMsgAdd, msgRemove ChangeMsgRemove, msgFlags ChangeMsgFlags, msgThread ChangeMsgThread, mailboxRemove ChangeMailboxRemove, mailboxAdd ChangeMailboxAdd, mailboxRename ChangeMailboxRename, mailboxCounts ChangeMailboxCounts, mailboxSpecialUse ChangeMailboxSpecialUse, mailboxKeywords ChangeMailboxKeywords, flags store.Flags) {
2054 return
2055}
2056