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