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