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