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