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