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