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