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), 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), 76) // ../rfc/2045:1372
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
1067 // Update modseq of mailbox of replied/forwarded message.
1068 rmb, err := store.MailboxID(tx, rm.MailboxID)
1069 xcheckf(ctx, err, "get mailbox of replied/forwarded message for modseq update")
1070 rmb.ModSeq = modseq
1071 err = tx.Update(&rmb)
1072 xcheckf(ctx, err, "update modseq of mailbox of replied/forwarded message")
1073
1074 changes = append(changes, rm.ChangeFlags(oflags, rmb))
1075
1076 err = acc.RetrainMessages(ctx, log, tx, []store.Message{rm})
1077 xcheckf(ctx, err, "retraining messages after reply/forward")
1078 }
1079
1080 // Move messages from this thread still in this mailbox to the designated Archive
1081 // mailbox.
1082 if m.ArchiveThread {
1083 mbArchive, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Expunged", false).FilterEqual("Archive", true).Get()
1084 if err == bstore.ErrAbsent || err == store.ErrMailboxExpunged {
1085 xcheckuserf(ctx, errors.New("not configured"), "looking up designated archive mailbox")
1086 }
1087 xcheckf(ctx, err, "looking up designated archive mailbox")
1088
1089 var msgIDs []int64
1090 q := bstore.QueryTx[store.Message](tx)
1091 q.FilterNonzero(store.Message{ThreadID: rm.ThreadID, MailboxID: m.ArchiveReferenceMailboxID})
1092 q.FilterEqual("Expunged", false)
1093 err = q.IDs(&msgIDs)
1094 xcheckf(ctx, err, "listing messages in thread to archive")
1095 if len(msgIDs) > 0 {
1096 ids, nchanges := xops.MessageMoveTx(ctx, log, acc, tx, msgIDs, mbArchive, &modseq)
1097 newIDs = append(newIDs, ids...)
1098 changes = append(changes, nchanges...)
1099 }
1100 }
1101 }
1102
1103 sentmb, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Expunged", false).FilterEqual("Sent", true).Get()
1104 if err == bstore.ErrAbsent || err == store.ErrMailboxExpunged {
1105 // There is no mailbox designated as Sent mailbox, so we're done.
1106 return
1107 }
1108 xcheckf(ctx, err, "message submitted to queue, adding to Sent mailbox")
1109
1110 if modseq == 0 {
1111 modseq, err = acc.NextModSeq(tx)
1112 xcheckf(ctx, err, "next modseq")
1113 }
1114
1115 // If there were bcc headers, prepend those to the stored message only, before the
1116 // DKIM signature. The DKIM-signature oversigns the bcc header, so this stored
1117 // message won't validate with DKIM anymore, which is fine.
1118 if len(bccAddrs) > 0 {
1119 var sb strings.Builder
1120 xbcc := message.NewComposer(&sb, 100*1024, smtputf8)
1121 xbcc.HeaderAddrs("Bcc", bccAddrs)
1122 xbcc.Flush()
1123 msgPrefix = sb.String() + msgPrefix
1124 }
1125
1126 sentm := store.Message{
1127 CreateSeq: modseq,
1128 ModSeq: modseq,
1129 MailboxID: sentmb.ID,
1130 MailboxOrigID: sentmb.ID,
1131 Flags: store.Flags{Notjunk: true, Seen: true},
1132 Size: int64(len(msgPrefix)) + xc.Size,
1133 MsgPrefix: []byte(msgPrefix),
1134 }
1135
1136 err = acc.MessageAdd(log, tx, &sentmb, &sentm, dataFile, store.AddOpts{})
1137 if err != nil && errors.Is(err, store.ErrOverQuota) {
1138 xcheckuserf(ctx, err, "checking quota")
1139 } else if err != nil {
1140 metricSubmission.WithLabelValues("storesenterror").Inc()
1141 metricked = true
1142 }
1143 xcheckf(ctx, err, "message submitted to queue, appending message to Sent mailbox")
1144 newIDs = append(newIDs, sentm.ID)
1145
1146 err = tx.Update(&sentmb)
1147 xcheckf(ctx, err, "updating sent mailbox for counts")
1148
1149 changes = append(changes, sentm.ChangeAddUID(sentmb), sentmb.ChangeCounts())
1150 })
1151 newIDs = nil
1152
1153 store.BroadcastChanges(acc, changes)
1154 })
1155}
1156
1157// MessageMove moves messages to another mailbox. If the message is already in
1158// the mailbox an error is returned.
1159func (Webmail) MessageMove(ctx context.Context, messageIDs []int64, mailboxID int64) {
1160 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1161 acc := reqInfo.Account
1162 log := reqInfo.Log
1163
1164 xops.MessageMove(ctx, log, acc, messageIDs, "", mailboxID)
1165}
1166
1167var xops = webops.XOps{
1168 DBWrite: xdbwrite,
1169 Checkf: xcheckf,
1170 Checkuserf: xcheckuserf,
1171}
1172
1173// MessageDelete permanently deletes messages, without moving them to the Trash mailbox.
1174func (Webmail) MessageDelete(ctx context.Context, messageIDs []int64) {
1175 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1176 acc := reqInfo.Account
1177 log := reqInfo.Log
1178
1179 if len(messageIDs) == 0 {
1180 return
1181 }
1182
1183 xops.MessageDelete(ctx, log, acc, messageIDs)
1184}
1185
1186// FlagsAdd adds flags, either system flags like \Seen or custom keywords. The
1187// flags should be lower-case, but will be converted and verified.
1188func (Webmail) FlagsAdd(ctx context.Context, messageIDs []int64, flaglist []string) {
1189 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1190 acc := reqInfo.Account
1191 log := reqInfo.Log
1192
1193 xops.MessageFlagsAdd(ctx, log, acc, messageIDs, flaglist)
1194}
1195
1196// FlagsClear clears flags, either system flags like \Seen or custom keywords.
1197func (Webmail) FlagsClear(ctx context.Context, messageIDs []int64, flaglist []string) {
1198 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1199 acc := reqInfo.Account
1200 log := reqInfo.Log
1201
1202 xops.MessageFlagsClear(ctx, log, acc, messageIDs, flaglist)
1203}
1204
1205// MailboxesMarkRead marks all messages in mailboxes as read. Child mailboxes are
1206// not automatically included, they must explicitly be included in the list of IDs.
1207func (Webmail) MailboxesMarkRead(ctx context.Context, mailboxIDs []int64) {
1208 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1209 acc := reqInfo.Account
1210 log := reqInfo.Log
1211
1212 xops.MailboxesMarkRead(ctx, log, acc, mailboxIDs)
1213}
1214
1215// MailboxCreate creates a new mailbox.
1216func (Webmail) MailboxCreate(ctx context.Context, name string) {
1217 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1218 acc := reqInfo.Account
1219
1220 var err error
1221 name, _, err = store.CheckMailboxName(name, false)
1222 xcheckuserf(ctx, err, "checking mailbox name")
1223
1224 acc.WithWLock(func() {
1225 var changes []store.Change
1226 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1227 var exists bool
1228 var err error
1229 _, changes, _, exists, err = acc.MailboxCreate(tx, name, store.SpecialUse{})
1230 if exists {
1231 xcheckuserf(ctx, errors.New("mailbox already exists"), "creating mailbox")
1232 }
1233 xcheckf(ctx, err, "creating mailbox")
1234 })
1235
1236 store.BroadcastChanges(acc, changes)
1237 })
1238}
1239
1240// MailboxDelete deletes a mailbox and all its messages and annotations.
1241func (Webmail) MailboxDelete(ctx context.Context, mailboxID int64) {
1242 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1243 acc := reqInfo.Account
1244 log := reqInfo.Log
1245
1246 acc.WithWLock(func() {
1247 var changes []store.Change
1248
1249 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1250 mb := xmailboxID(ctx, tx, mailboxID)
1251 if mb.Name == "Inbox" {
1252 // Inbox is special in IMAP and cannot be removed.
1253 xcheckuserf(ctx, errors.New("cannot remove special Inbox"), "checking mailbox")
1254 }
1255
1256 var hasChildren bool
1257 var err error
1258 changes, hasChildren, err = acc.MailboxDelete(ctx, log, tx, &mb)
1259 if hasChildren {
1260 xcheckuserf(ctx, errors.New("mailbox has children"), "deleting mailbox")
1261 }
1262 xcheckf(ctx, err, "deleting mailbox")
1263 })
1264
1265 store.BroadcastChanges(acc, changes)
1266 })
1267}
1268
1269// MailboxEmpty empties a mailbox, removing all messages from the mailbox, but not
1270// its child mailboxes.
1271func (Webmail) MailboxEmpty(ctx context.Context, mailboxID int64) {
1272 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1273 acc := reqInfo.Account
1274 log := reqInfo.Log
1275
1276 acc.WithWLock(func() {
1277 var changes []store.Change
1278
1279 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1280 mb := xmailboxID(ctx, tx, mailboxID)
1281
1282 qm := bstore.QueryTx[store.Message](tx)
1283 qm.FilterNonzero(store.Message{MailboxID: mb.ID})
1284 qm.FilterEqual("Expunged", false)
1285 qm.SortAsc("UID")
1286 l, err := qm.List()
1287 xcheckf(ctx, err, "listing messages to remove")
1288
1289 if len(l) == 0 {
1290 xcheckuserf(ctx, errors.New("no messages in mailbox"), "emptying mailbox")
1291 }
1292
1293 modseq, err := acc.NextModSeq(tx)
1294 xcheckf(ctx, err, "next modseq")
1295
1296 chrem, chmbcounts, err := acc.MessageRemove(log, tx, modseq, &mb, store.RemoveOpts{}, l...)
1297 xcheckf(ctx, err, "expunge messages")
1298 changes = append(changes, chrem, chmbcounts)
1299
1300 err = tx.Update(&mb)
1301 xcheckf(ctx, err, "updating mailbox for counts")
1302 })
1303
1304 store.BroadcastChanges(acc, changes)
1305 })
1306}
1307
1308// MailboxRename renames a mailbox, possibly moving it to a new parent. The mailbox
1309// ID and its messages are unchanged.
1310func (Webmail) MailboxRename(ctx context.Context, mailboxID int64, newName string) {
1311 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1312 acc := reqInfo.Account
1313
1314 // Renaming Inbox is special for IMAP. For IMAP we have to implement it per the
1315 // standard. We can just say no.
1316 var err error
1317 newName, _, err = store.CheckMailboxName(newName, false)
1318 xcheckuserf(ctx, err, "checking new mailbox name")
1319
1320 acc.WithWLock(func() {
1321 var changes []store.Change
1322
1323 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1324 mbsrc := xmailboxID(ctx, tx, mailboxID)
1325 var err error
1326 var isInbox, alreadyExists bool
1327 var modseq store.ModSeq
1328 changes, isInbox, alreadyExists, err = acc.MailboxRename(tx, &mbsrc, newName, &modseq)
1329 if isInbox || alreadyExists {
1330 xcheckuserf(ctx, err, "renaming mailbox")
1331 }
1332 xcheckf(ctx, err, "renaming mailbox")
1333 })
1334
1335 store.BroadcastChanges(acc, changes)
1336 })
1337}
1338
1339// CompleteRecipient returns autocomplete matches for a recipient, returning the
1340// matches, most recently used first, and whether this is the full list and further
1341// requests for longer prefixes aren't necessary.
1342func (Webmail) CompleteRecipient(ctx context.Context, search string) ([]string, bool) {
1343 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1344 acc := reqInfo.Account
1345
1346 search = strings.ToLower(search)
1347
1348 var matches []string
1349 all := true
1350 acc.WithRLock(func() {
1351 xdbread(ctx, acc, func(tx *bstore.Tx) {
1352 type key struct {
1353 localpart string
1354 domain string
1355 }
1356 seen := map[key]bool{}
1357
1358 q := bstore.QueryTx[store.Recipient](tx)
1359 q.SortDesc("Sent")
1360 err := q.ForEach(func(r store.Recipient) error {
1361 k := key{r.Localpart, r.Domain}
1362 if seen[k] {
1363 return nil
1364 }
1365 // todo: we should have the address including name available in the database for searching. Will result in better matching, and also for the name.
1366 address := fmt.Sprintf("<%s@%s>", r.Localpart, r.Domain)
1367 if !strings.Contains(strings.ToLower(address), search) {
1368 return nil
1369 }
1370 if len(matches) >= 20 {
1371 all = false
1372 return bstore.StopForEach
1373 }
1374
1375 // Look in the message that was sent for a name along with the address.
1376 m := store.Message{ID: r.MessageID}
1377 err := tx.Get(&m)
1378 xcheckf(ctx, err, "get sent message")
1379 if !m.Expunged && m.ParsedBuf != nil {
1380 var part message.Part
1381 err := json.Unmarshal(m.ParsedBuf, &part)
1382 xcheckf(ctx, err, "parsing part")
1383
1384 dom, err := dns.ParseDomain(r.Domain)
1385 xcheckf(ctx, err, "parsing domain of recipient")
1386
1387 var found bool
1388 lp := r.Localpart
1389 checkAddrs := func(l []message.Address) {
1390 if found {
1391 return
1392 }
1393 for _, a := range l {
1394 if a.Name != "" && a.User == lp && strings.EqualFold(a.Host, dom.ASCII) {
1395 found = true
1396 address = addressString(a, false)
1397 return
1398 }
1399 }
1400 }
1401 if part.Envelope != nil {
1402 env := part.Envelope
1403 checkAddrs(env.To)
1404 checkAddrs(env.CC)
1405 checkAddrs(env.BCC)
1406 }
1407 }
1408
1409 matches = append(matches, address)
1410 seen[k] = true
1411 return nil
1412 })
1413 xcheckf(ctx, err, "listing recipients")
1414 })
1415 })
1416 return matches, all
1417}
1418
1419// addressString returns an address into a string as it could be used in a message header.
1420func addressString(a message.Address, smtputf8 bool) string {
1421 host := a.Host
1422 dom, err := dns.ParseDomain(a.Host)
1423 if err == nil {
1424 if smtputf8 && dom.Unicode != "" {
1425 host = dom.Unicode
1426 } else {
1427 host = dom.ASCII
1428 }
1429 }
1430 if a.Name == "" {
1431 return "<" + a.User + "@" + host + ">"
1432 }
1433 // We only quote the name if we have to. ../rfc/5322:679
1434 const atom = "!#$%&'*+-/=?^_`{|}~"
1435 name := a.Name
1436 for _, c := range a.Name {
1437 if c == '\t' || c == ' ' || c >= 0x80 || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsAny(string(c), atom) {
1438 continue
1439 }
1440 // We need to quote.
1441 q := `"`
1442 for _, c := range a.Name {
1443 if c == '\\' || c == '"' {
1444 q += `\`
1445 }
1446 q += string(c)
1447 }
1448 q += `"`
1449 name = q
1450 }
1451 return name + " <" + a.User + "@" + host + ">"
1452}
1453
1454// MailboxSetSpecialUse sets the special use flags of a mailbox.
1455func (Webmail) MailboxSetSpecialUse(ctx context.Context, mb store.Mailbox) {
1456 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1457 acc := reqInfo.Account
1458
1459 acc.WithWLock(func() {
1460 var changes []store.Change
1461
1462 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1463 xmb := xmailboxID(ctx, tx, mb.ID)
1464
1465 modseq, err := acc.NextModSeq(tx)
1466 xcheckf(ctx, err, "get next modseq")
1467
1468 // We only allow a single mailbox for each flag (JMAP requirement). So for any flag
1469 // we set, we clear it for the mailbox(es) that had it, if any.
1470 clearPrevious := func(clear bool, specialUse string) {
1471 if !clear {
1472 return
1473 }
1474 var ombl []store.Mailbox
1475 q := bstore.QueryTx[store.Mailbox](tx)
1476 q.FilterNotEqual("ID", mb.ID)
1477 q.FilterEqual(specialUse, true)
1478 q.Gather(&ombl)
1479 _, err := q.UpdateFields(map[string]any{specialUse: false, "ModSeq": modseq})
1480 xcheckf(ctx, err, "updating previous special-use mailboxes")
1481
1482 for _, omb := range ombl {
1483 changes = append(changes, omb.ChangeSpecialUse())
1484 }
1485 }
1486 clearPrevious(mb.Archive, "Archive")
1487 clearPrevious(mb.Draft, "Draft")
1488 clearPrevious(mb.Junk, "Junk")
1489 clearPrevious(mb.Sent, "Sent")
1490 clearPrevious(mb.Trash, "Trash")
1491
1492 xmb.SpecialUse = mb.SpecialUse
1493 xmb.ModSeq = modseq
1494 err = tx.Update(&xmb)
1495 xcheckf(ctx, err, "updating special-use flags for mailbox")
1496 changes = append(changes, xmb.ChangeSpecialUse())
1497 })
1498
1499 store.BroadcastChanges(acc, changes)
1500 })
1501}
1502
1503// ThreadCollapse saves the ThreadCollapse field for the messages and its
1504// children. The messageIDs are typically thread roots. But not all roots
1505// (without parent) of a thread need to have the same collapsed state.
1506func (Webmail) ThreadCollapse(ctx context.Context, messageIDs []int64, collapse bool) {
1507 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1508 acc := reqInfo.Account
1509
1510 if len(messageIDs) == 0 {
1511 xcheckuserf(ctx, errors.New("no messages"), "setting collapse")
1512 }
1513
1514 acc.WithWLock(func() {
1515 changes := make([]store.Change, 0, len(messageIDs))
1516 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1517 // Gather ThreadIDs to list all potential messages, for a way to get all potential
1518 // (child) messages. Further refined in FilterFn.
1519 threadIDs := map[int64]struct{}{}
1520 msgIDs := map[int64]struct{}{}
1521 for _, id := range messageIDs {
1522 m := store.Message{ID: id}
1523 err := tx.Get(&m)
1524 if err == bstore.ErrAbsent || err == nil && m.Expunged {
1525 xcheckuserf(ctx, bstore.ErrAbsent, "get message")
1526 }
1527 xcheckf(ctx, err, "get message")
1528 threadIDs[m.ThreadID] = struct{}{}
1529 msgIDs[id] = struct{}{}
1530 }
1531
1532 var updated []store.Message
1533 q := bstore.QueryTx[store.Message](tx)
1534 q.FilterEqual("Expunged", false)
1535 q.FilterEqual("ThreadID", slicesAny(slices.Sorted(maps.Keys(threadIDs)))...)
1536 q.FilterNotEqual("ThreadCollapsed", collapse)
1537 q.FilterFn(func(tm store.Message) bool {
1538 for _, id := range tm.ThreadParentIDs {
1539 if _, ok := msgIDs[id]; ok {
1540 return true
1541 }
1542 }
1543 _, ok := msgIDs[tm.ID]
1544 return ok
1545 })
1546 q.Gather(&updated)
1547 q.SortAsc("ID") // Consistent order for testing.
1548 _, err := q.UpdateFields(map[string]any{"ThreadCollapsed": collapse})
1549 xcheckf(ctx, err, "updating collapse in database")
1550
1551 for _, m := range updated {
1552 changes = append(changes, m.ChangeThread())
1553 }
1554 })
1555 store.BroadcastChanges(acc, changes)
1556 })
1557}
1558
1559// ThreadMute saves the ThreadMute field for the messages and their children.
1560// If messages are muted, they are also marked collapsed.
1561func (Webmail) ThreadMute(ctx context.Context, messageIDs []int64, mute bool) {
1562 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1563 acc := reqInfo.Account
1564
1565 if len(messageIDs) == 0 {
1566 xcheckuserf(ctx, errors.New("no messages"), "setting mute")
1567 }
1568
1569 acc.WithWLock(func() {
1570 changes := make([]store.Change, 0, len(messageIDs))
1571 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1572 threadIDs := map[int64]struct{}{}
1573 msgIDs := map[int64]struct{}{}
1574 for _, id := range messageIDs {
1575 m := store.Message{ID: id}
1576 err := tx.Get(&m)
1577 if err == bstore.ErrAbsent || err == nil && m.Expunged {
1578 xcheckuserf(ctx, bstore.ErrAbsent, "get message")
1579 }
1580 xcheckf(ctx, err, "get message")
1581 threadIDs[m.ThreadID] = struct{}{}
1582 msgIDs[id] = struct{}{}
1583 }
1584
1585 var updated []store.Message
1586
1587 q := bstore.QueryTx[store.Message](tx)
1588 q.FilterEqual("Expunged", false)
1589 q.FilterEqual("ThreadID", slicesAny(slices.Sorted(maps.Keys(threadIDs)))...)
1590 q.FilterFn(func(tm store.Message) bool {
1591 if tm.ThreadMuted == mute && (!mute || tm.ThreadCollapsed) {
1592 return false
1593 }
1594 for _, id := range tm.ThreadParentIDs {
1595 if _, ok := msgIDs[id]; ok {
1596 return true
1597 }
1598 }
1599 _, ok := msgIDs[tm.ID]
1600 return ok
1601 })
1602 q.Gather(&updated)
1603 fields := map[string]any{"ThreadMuted": mute}
1604 if mute {
1605 fields["ThreadCollapsed"] = true
1606 }
1607 _, err := q.UpdateFields(fields)
1608 xcheckf(ctx, err, "updating mute in database")
1609
1610 for _, m := range updated {
1611 changes = append(changes, m.ChangeThread())
1612 }
1613 })
1614 store.BroadcastChanges(acc, changes)
1615 })
1616}
1617
1618// SecurityResult indicates whether a security feature is supported.
1619type SecurityResult string
1620
1621const (
1622 SecurityResultError SecurityResult = "error"
1623 SecurityResultNo SecurityResult = "no"
1624 SecurityResultYes SecurityResult = "yes"
1625 // Unknown whether supported. Finding out may only be (reasonably) possible when
1626 // trying (e.g. SMTP STARTTLS). Once tried, the result may be cached for future
1627 // lookups.
1628 SecurityResultUnknown SecurityResult = "unknown"
1629)
1630
1631// RecipientSecurity is a quick analysis of the security properties of delivery to
1632// the recipient (domain).
1633type RecipientSecurity struct {
1634 // Whether recipient domain supports (opportunistic) STARTTLS, as seen during most
1635 // recent delivery attempt. Will be "unknown" if no delivery to the domain has been
1636 // attempted yet.
1637 STARTTLS SecurityResult
1638
1639 // Whether we have a stored enforced MTA-STS policy, or domain has MTA-STS DNS
1640 // record.
1641 MTASTS SecurityResult
1642
1643 // Whether MX lookup response was DNSSEC-signed.
1644 DNSSEC SecurityResult
1645
1646 // Whether first delivery destination has DANE records.
1647 DANE SecurityResult
1648
1649 // Whether recipient domain is known to implement the REQUIRETLS SMTP extension.
1650 // Will be "unknown" if no delivery to the domain has been attempted yet.
1651 RequireTLS SecurityResult
1652}
1653
1654// RecipientSecurity looks up security properties of the address in the
1655// single-address message addressee (as it appears in a To/Cc/Bcc/etc header).
1656func (Webmail) RecipientSecurity(ctx context.Context, messageAddressee string) (RecipientSecurity, error) {
1657 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1658 log := reqInfo.Log
1659
1660 resolver := dns.StrictResolver{Pkg: "webmail", Log: log.Logger}
1661 return recipientSecurity(ctx, log, resolver, messageAddressee)
1662}
1663
1664// logPanic can be called with a defer from a goroutine to prevent the entire program from being shutdown in case of a panic.
1665func logPanic(ctx context.Context) {
1666 x := recover()
1667 if x == nil {
1668 return
1669 }
1670 log := pkglog.WithContext(ctx)
1671 log.Error("recover from panic", slog.Any("panic", x))
1672 debug.PrintStack()
1673 metrics.PanicInc(metrics.Webmail)
1674}
1675
1676// separate function for testing with mocked resolver.
1677func recipientSecurity(ctx context.Context, log mlog.Log, resolver dns.Resolver, messageAddressee string) (RecipientSecurity, error) {
1678 rs := RecipientSecurity{
1679 SecurityResultUnknown,
1680 SecurityResultUnknown,
1681 SecurityResultUnknown,
1682 SecurityResultUnknown,
1683 SecurityResultUnknown,
1684 }
1685
1686 parser := mail.AddressParser{WordDecoder: &wordDecoder}
1687 msgAddr, err := parser.Parse(messageAddressee)
1688 if err != nil {
1689 return rs, fmt.Errorf("parsing addressee: %v", err)
1690 }
1691 addr, err := smtp.ParseNetMailAddress(msgAddr.Address)
1692 if err != nil {
1693 return rs, fmt.Errorf("parsing address: %v", err)
1694 }
1695
1696 var wg sync.WaitGroup
1697
1698 // MTA-STS.
1699 wg.Add(1)
1700 go func() {
1701 defer logPanic(ctx)
1702 defer wg.Done()
1703
1704 policy, _, _, err := mtastsdb.Get(ctx, log.Logger, resolver, addr.Domain)
1705 if policy != nil && policy.Mode == mtasts.ModeEnforce {
1706 rs.MTASTS = SecurityResultYes
1707 } else if err == nil {
1708 rs.MTASTS = SecurityResultNo
1709 } else {
1710 rs.MTASTS = SecurityResultError
1711 }
1712 }()
1713
1714 // DNSSEC and DANE.
1715 wg.Add(1)
1716 go func() {
1717 defer logPanic(ctx)
1718 defer wg.Done()
1719
1720 _, origNextHopAuthentic, expandedNextHopAuthentic, _, hosts, _, err := smtpclient.GatherDestinations(ctx, log.Logger, resolver, dns.IPDomain{Domain: addr.Domain})
1721 if err != nil {
1722 rs.DNSSEC = SecurityResultError
1723 return
1724 }
1725 if origNextHopAuthentic && expandedNextHopAuthentic {
1726 rs.DNSSEC = SecurityResultYes
1727 } else {
1728 rs.DNSSEC = SecurityResultNo
1729 }
1730
1731 if !origNextHopAuthentic {
1732 rs.DANE = SecurityResultNo
1733 return
1734 }
1735
1736 // We're only looking at the first host to deliver to (typically first mx destination).
1737 if len(hosts) == 0 || hosts[0].Domain.IsZero() {
1738 return // Should not happen.
1739 }
1740 host := hosts[0]
1741
1742 // Resolve the IPs. Required for DANE to prevent bad DNS servers from causing an
1743 // error result instead of no-DANE result.
1744 authentic, expandedAuthentic, expandedHost, _, _, err := smtpclient.GatherIPs(ctx, log.Logger, resolver, "ip", host, map[string][]net.IP{})
1745 if err != nil {
1746 rs.DANE = SecurityResultError
1747 return
1748 }
1749 if !authentic {
1750 rs.DANE = SecurityResultNo
1751 return
1752 }
1753
1754 daneRequired, _, _, err := smtpclient.GatherTLSA(ctx, log.Logger, resolver, host.Domain, expandedAuthentic, expandedHost)
1755 if err != nil {
1756 rs.DANE = SecurityResultError
1757 return
1758 } else if daneRequired {
1759 rs.DANE = SecurityResultYes
1760 } else {
1761 rs.DANE = SecurityResultNo
1762 }
1763 }()
1764
1765 // STARTTLS and RequireTLS
1766 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1767 acc := reqInfo.Account
1768
1769 err = acc.DB.Read(ctx, func(tx *bstore.Tx) error {
1770 q := bstore.QueryTx[store.RecipientDomainTLS](tx)
1771 q.FilterNonzero(store.RecipientDomainTLS{Domain: addr.Domain.Name()})
1772 rd, err := q.Get()
1773 if err == bstore.ErrAbsent {
1774 return nil
1775 } else if err != nil {
1776 rs.STARTTLS = SecurityResultError
1777 rs.RequireTLS = SecurityResultError
1778 log.Errorx("looking up recipient domain", err, slog.Any("domain", addr.Domain))
1779 return nil
1780 }
1781 if rd.STARTTLS {
1782 rs.STARTTLS = SecurityResultYes
1783 } else {
1784 rs.STARTTLS = SecurityResultNo
1785 }
1786 if rd.RequireTLS {
1787 rs.RequireTLS = SecurityResultYes
1788 } else {
1789 rs.RequireTLS = SecurityResultNo
1790 }
1791 return nil
1792 })
1793 xcheckf(ctx, err, "lookup recipient domain")
1794
1795 wg.Wait()
1796
1797 return rs, nil
1798}
1799
1800// DecodeMIMEWords decodes Q/B-encoded words for a mime headers into UTF-8 text.
1801func (Webmail) DecodeMIMEWords(ctx context.Context, text string) string {
1802 s, err := wordDecoder.DecodeHeader(text)
1803 xcheckuserf(ctx, err, "decoding mime q/b-word encoded header")
1804 return s
1805}
1806
1807// SettingsSave saves settings, e.g. for composing.
1808func (Webmail) SettingsSave(ctx context.Context, settings store.Settings) {
1809 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1810 acc := reqInfo.Account
1811
1812 settings.ID = 1
1813 err := acc.DB.Update(ctx, &settings)
1814 xcheckf(ctx, err, "save settings")
1815}
1816
1817func (Webmail) RulesetSuggestMove(ctx context.Context, msgID, mbSrcID, mbDstID int64) (listID string, msgFrom string, isRemove bool, rcptTo string, ruleset *config.Ruleset) {
1818 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1819 acc := reqInfo.Account
1820 log := reqInfo.Log
1821
1822 xdbread(ctx, acc, func(tx *bstore.Tx) {
1823 m := xmessageID(ctx, tx, msgID)
1824 mbSrc := xmailboxID(ctx, tx, mbSrcID)
1825 mbDst := xmailboxID(ctx, tx, mbDstID)
1826
1827 if m.RcptToLocalpart == "" && m.RcptToDomain == "" {
1828 return
1829 }
1830 rcptTo = m.RcptToLocalpart.String() + "@" + m.RcptToDomain
1831
1832 conf, _ := acc.Conf()
1833 dest := conf.Destinations[rcptTo] // May not be present.
1834 defaultMailbox := "Inbox"
1835 if dest.Mailbox != "" {
1836 defaultMailbox = dest.Mailbox
1837 }
1838
1839 // Only suggest rules for messages moved into/out of the default mailbox (Inbox).
1840 if mbSrc.Name != defaultMailbox && mbDst.Name != defaultMailbox {
1841 return
1842 }
1843
1844 // Check if we have a previous answer "No" answer for moving from/to mailbox.
1845 exists, err := bstore.QueryTx[store.RulesetNoMailbox](tx).FilterNonzero(store.RulesetNoMailbox{MailboxID: mbSrcID}).FilterEqual("ToMailbox", false).Exists()
1846 xcheckf(ctx, err, "looking up previous response for source mailbox")
1847 if exists {
1848 return
1849 }
1850 exists, err = bstore.QueryTx[store.RulesetNoMailbox](tx).FilterNonzero(store.RulesetNoMailbox{MailboxID: mbDstID}).FilterEqual("ToMailbox", true).Exists()
1851 xcheckf(ctx, err, "looking up previous response for destination mailbox")
1852 if exists {
1853 return
1854 }
1855
1856 // Parse message for List-Id header.
1857 state := msgState{acc: acc}
1858 defer state.clear()
1859 pm, err := parsedMessage(log, &m, &state, true, false, false)
1860 xcheckf(ctx, err, "parsing message")
1861
1862 // The suggested ruleset. Once all is checked, we'll return it.
1863 var nrs *config.Ruleset
1864
1865 // If List-Id header is present, we'll treat it as a (mailing) list message.
1866 if l, ok := pm.Headers["List-Id"]; ok {
1867 if len(l) != 1 {
1868 log.Debug("not exactly one list-id header", slog.Any("listid", l))
1869 return
1870 }
1871 var listIDDom dns.Domain
1872 listID, listIDDom = parseListID(l[0])
1873 if listID == "" {
1874 log.Debug("invalid list-id header", slog.String("listid", l[0]))
1875 return
1876 }
1877
1878 // Check if we have a previous "No" answer for this list-id.
1879 no := store.RulesetNoListID{
1880 RcptToAddress: rcptTo,
1881 ListID: listID,
1882 ToInbox: mbDst.Name == "Inbox",
1883 }
1884 exists, err = bstore.QueryTx[store.RulesetNoListID](tx).FilterNonzero(no).Exists()
1885 xcheckf(ctx, err, "looking up previous response for list-id")
1886 if exists {
1887 return
1888 }
1889
1890 // Find the "ListAllowDomain" to use. We only match and move messages with verified
1891 // SPF/DKIM. Otherwise spammers could add a list-id headers for mailing lists you
1892 // are subscribed to, and take advantage of any reduced junk filtering.
1893 listIDDomStr := listIDDom.Name()
1894
1895 doms := m.DKIMDomains
1896 if m.MailFromValidated {
1897 doms = append(doms, m.MailFromDomain)
1898 }
1899 // Sort, we prefer the shortest name, e.g. DKIM signature on whole domain instead
1900 // of SPF verification of one host.
1901 sort.Slice(doms, func(i, j int) bool {
1902 return len(doms[i]) < len(doms[j])
1903 })
1904 var listAllowDom string
1905 for _, dom := range doms {
1906 if dom == listIDDomStr || strings.HasSuffix(listIDDomStr, "."+dom) {
1907 listAllowDom = dom
1908 break
1909 }
1910 }
1911 if listAllowDom == "" {
1912 return
1913 }
1914
1915 listIDRegExp := regexp.QuoteMeta(fmt.Sprintf("<%s>", listID)) + "$"
1916 nrs = &config.Ruleset{
1917 HeadersRegexp: map[string]string{"^list-id$": listIDRegExp},
1918 ListAllowDomain: listAllowDom,
1919 Mailbox: mbDst.Name,
1920 }
1921 } else {
1922 // Otherwise, try to make a rule based on message "From" address.
1923 if m.MsgFromLocalpart == "" && m.MsgFromDomain == "" {
1924 return
1925 }
1926 msgFrom = m.MsgFromLocalpart.String() + "@" + m.MsgFromDomain
1927
1928 no := store.RulesetNoMsgFrom{
1929 RcptToAddress: rcptTo,
1930 MsgFromAddress: msgFrom,
1931 ToInbox: mbDst.Name == "Inbox",
1932 }
1933 exists, err = bstore.QueryTx[store.RulesetNoMsgFrom](tx).FilterNonzero(no).Exists()
1934 xcheckf(ctx, err, "looking up previous response for message from address")
1935 if exists {
1936 return
1937 }
1938
1939 nrs = &config.Ruleset{
1940 MsgFromRegexp: "^" + regexp.QuoteMeta(msgFrom) + "$",
1941 Mailbox: mbDst.Name,
1942 }
1943 }
1944
1945 // Only suggest adding/removing rule if it isn't/is present.
1946 var have bool
1947 for _, rs := range dest.Rulesets {
1948 xrs := config.Ruleset{
1949 MsgFromRegexp: rs.MsgFromRegexp,
1950 HeadersRegexp: rs.HeadersRegexp,
1951 ListAllowDomain: rs.ListAllowDomain,
1952 Mailbox: nrs.Mailbox,
1953 }
1954 if xrs.Equal(*nrs) {
1955 have = true
1956 break
1957 }
1958 }
1959 isRemove = mbDst.Name == defaultMailbox
1960 if isRemove {
1961 nrs.Mailbox = mbSrc.Name
1962 }
1963 if isRemove && !have || !isRemove && have {
1964 return
1965 }
1966
1967 // We'll be returning a suggested ruleset.
1968 nrs.Comment = "by webmail on " + time.Now().Format("2006-01-02")
1969 ruleset = nrs
1970 })
1971 return
1972}
1973
1974// Parse the list-id value (the value between <>) from a list-id header.
1975// Returns an empty string if it couldn't be parsed.
1976func parseListID(s string) (listID string, dom dns.Domain) {
1977 // ../rfc/2919:198
1978 s = strings.TrimRight(s, " \t")
1979 if !strings.HasSuffix(s, ">") {
1980 return "", dns.Domain{}
1981 }
1982 s = s[:len(s)-1]
1983 t := strings.Split(s, "<")
1984 if len(t) == 1 {
1985 return "", dns.Domain{}
1986 }
1987 s = t[len(t)-1]
1988 dom, err := dns.ParseDomain(s)
1989 if err != nil {
1990 return "", dom
1991 }
1992 return s, dom
1993}
1994
1995func (Webmail) RulesetAdd(ctx context.Context, rcptTo string, ruleset config.Ruleset) {
1996 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1997
1998 err := admin.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) {
1999 dest, ok := acc.Destinations[rcptTo]
2000 if !ok {
2001 // todo: we could find the catchall address and add the rule, or add the address explicitly.
2002 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")
2003 }
2004
2005 nd := map[string]config.Destination{}
2006 for addr, d := range acc.Destinations {
2007 nd[addr] = d
2008 }
2009 dest.Rulesets = append(slices.Clone(dest.Rulesets), ruleset)
2010 nd[rcptTo] = dest
2011 acc.Destinations = nd
2012 })
2013 xcheckf(ctx, err, "saving account with new ruleset")
2014}
2015
2016func (Webmail) RulesetRemove(ctx context.Context, rcptTo string, ruleset config.Ruleset) {
2017 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2018
2019 err := admin.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) {
2020 dest, ok := acc.Destinations[rcptTo]
2021 if !ok {
2022 xcheckuserf(ctx, errors.New("destination address not found in account"), "looking up address")
2023 }
2024
2025 nd := map[string]config.Destination{}
2026 for addr, d := range acc.Destinations {
2027 nd[addr] = d
2028 }
2029 var l []config.Ruleset
2030 skipped := 0
2031 for _, rs := range dest.Rulesets {
2032 if rs.Equal(ruleset) {
2033 skipped++
2034 } else {
2035 l = append(l, rs)
2036 }
2037 }
2038 if skipped != 1 {
2039 xcheckuserf(ctx, fmt.Errorf("affected %d configured rulesets, expected 1", skipped), "changing rulesets")
2040 }
2041 dest.Rulesets = l
2042 nd[rcptTo] = dest
2043 acc.Destinations = nd
2044 })
2045 xcheckf(ctx, err, "saving account with new ruleset")
2046}
2047
2048func (Webmail) RulesetMessageNever(ctx context.Context, rcptTo, listID, msgFrom string, toInbox bool) {
2049 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2050 acc := reqInfo.Account
2051
2052 var err error
2053 if listID != "" {
2054 err = acc.DB.Insert(ctx, &store.RulesetNoListID{RcptToAddress: rcptTo, ListID: listID, ToInbox: toInbox})
2055 } else {
2056 err = acc.DB.Insert(ctx, &store.RulesetNoMsgFrom{RcptToAddress: rcptTo, MsgFromAddress: msgFrom, ToInbox: toInbox})
2057 }
2058 xcheckf(ctx, err, "storing user response")
2059}
2060
2061func (Webmail) RulesetMailboxNever(ctx context.Context, mailboxID int64, toMailbox bool) {
2062 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
2063 acc := reqInfo.Account
2064
2065 err := acc.DB.Insert(ctx, &store.RulesetNoMailbox{MailboxID: mailboxID, ToMailbox: toMailbox})
2066 xcheckf(ctx, err, "storing user response")
2067}
2068
2069func slicesAny[T any](l []T) []any {
2070 r := make([]any, len(l))
2071 for i, v := range l {
2072 r[i] = v
2073 }
2074 return r
2075}
2076
2077// SSETypes exists to ensure the generated API contains the types, for use in SSE events.
2078func (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) {
2079 return
2080}
2081