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