1package webmail
2
3import (
4 "bufio"
5 "context"
6 "encoding/base64"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "mime"
12 "mime/multipart"
13 "mime/quotedprintable"
14 "net/http"
15 "net/mail"
16 "net/textproto"
17 "os"
18 "sort"
19 "strings"
20 "time"
21
22 _ "embed"
23
24 "golang.org/x/exp/maps"
25
26 "github.com/mjl-/bstore"
27 "github.com/mjl-/sherpa"
28 "github.com/mjl-/sherpadoc"
29 "github.com/mjl-/sherpaprom"
30
31 "github.com/mjl-/mox/dkim"
32 "github.com/mjl-/mox/dns"
33 "github.com/mjl-/mox/message"
34 "github.com/mjl-/mox/mlog"
35 "github.com/mjl-/mox/mox-"
36 "github.com/mjl-/mox/moxio"
37 "github.com/mjl-/mox/moxvar"
38 "github.com/mjl-/mox/queue"
39 "github.com/mjl-/mox/smtp"
40 "github.com/mjl-/mox/store"
41)
42
43//go:embed api.json
44var webmailapiJSON []byte
45
46type Webmail struct {
47 maxMessageSize int64 // From listener.
48}
49
50func mustParseAPI(api string, buf []byte) (doc sherpadoc.Section) {
51 err := json.Unmarshal(buf, &doc)
52 if err != nil {
53 xlog.Fatalx("parsing api docs", err, mlog.Field("api", api))
54 }
55 return doc
56}
57
58var webmailDoc = mustParseAPI("webmail", webmailapiJSON)
59
60var sherpaHandlerOpts *sherpa.HandlerOpts
61
62func makeSherpaHandler(maxMessageSize int64) (http.Handler, error) {
63 return sherpa.NewHandler("/api/", moxvar.Version, Webmail{maxMessageSize}, &webmailDoc, sherpaHandlerOpts)
64}
65
66func init() {
67 collector, err := sherpaprom.NewCollector("moxwebmail", nil)
68 if err != nil {
69 xlog.Fatalx("creating sherpa prometheus collector", err)
70 }
71
72 sherpaHandlerOpts = &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none"}
73 // Just to validate.
74 _, err = makeSherpaHandler(0)
75 if err != nil {
76 xlog.Fatalx("sherpa handler", err)
77 }
78}
79
80// Token returns a token to use for an SSE connection. A token can only be used for
81// a single SSE connection. Tokens are stored in memory for a maximum of 1 minute,
82// with at most 10 unused tokens (the most recently created) per account.
83func (Webmail) Token(ctx context.Context) string {
84 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
85 return sseTokens.xgenerate(ctx, reqInfo.AccountName, reqInfo.LoginAddress)
86}
87
88// Requests sends a new request for an open SSE connection. Any currently active
89// request for the connection will be canceled, but this is done asynchrously, so
90// the SSE connection may still send results for the previous request. Callers
91// should take care to ignore such results. If req.Cancel is set, no new request is
92// started.
93func (Webmail) Request(ctx context.Context, req Request) {
94 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
95
96 if !req.Cancel && req.Page.Count <= 0 {
97 xcheckuserf(ctx, errors.New("Page.Count must be >= 1"), "checking request")
98 }
99
100 sse, ok := sseGet(req.SSEID, reqInfo.AccountName)
101 if !ok {
102 xcheckuserf(ctx, errors.New("unknown sseid"), "looking up connection")
103 }
104 sse.Request <- req
105}
106
107// ParsedMessage returns enough to render the textual body of a message. It is
108// assumed the client already has other fields through MessageItem.
109func (Webmail) ParsedMessage(ctx context.Context, msgID int64) (pm ParsedMessage) {
110 log := xlog.WithContext(ctx)
111 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
112 acc, err := store.OpenAccount(reqInfo.AccountName)
113 xcheckf(ctx, err, "open account")
114 defer func() {
115 err := acc.Close()
116 log.Check(err, "closing account")
117 }()
118
119 var m store.Message
120 xdbread(ctx, acc, func(tx *bstore.Tx) {
121 m = xmessageID(ctx, tx, msgID)
122 })
123
124 state := msgState{acc: acc}
125 defer state.clear()
126 pm, err = parsedMessage(log, m, &state, true, false)
127 xcheckf(ctx, err, "parsing message")
128 return
129}
130
131// Attachment is a MIME part is an existing message that is not intended as
132// viewable text or HTML part.
133type Attachment struct {
134 Path []int // Indices into top-level message.Part.Parts.
135
136 // File name based on "name" attribute of "Content-Type", or the "filename"
137 // attribute of "Content-Disposition".
138 // todo: decode non-ascii character sets
139 Filename string
140
141 Part message.Part
142}
143
144// SubmitMessage is an email message to be sent to one or more recipients.
145// Addresses are formatted as just email address, or with a name like "name
146// <user@host>".
147type SubmitMessage struct {
148 From string
149 To []string
150 Cc []string
151 Bcc []string
152 Subject string
153 TextBody string
154 Attachments []File
155 ForwardAttachments ForwardAttachments
156 IsForward bool
157 ResponseMessageID int64 // If set, this was a reply or forward, based on IsForward.
158 ReplyTo string // If non-empty, Reply-To header to add to message.
159 UserAgent string // User-Agent header added if not empty.
160}
161
162// ForwardAttachments references attachments by a list of message.Part paths.
163type ForwardAttachments struct {
164 MessageID int64 // Only relevant if MessageID is not 0.
165 Paths [][]int // List of attachments, each path is a list of indices into the top-level message.Part.Parts.
166}
167
168// File is a new attachment (not from an existing message that is being
169// forwarded) to send with a SubmitMessage.
170type File struct {
171 Filename string
172 DataURI string // Full data of the attachment, with base64 encoding and including content-type.
173}
174
175// xerrWriter is an io.Writer that panics with a *sherpa.Error when Write
176// returns an error.
177type xerrWriter struct {
178 ctx context.Context
179 w *bufio.Writer
180 size int64
181 max int64
182}
183
184// Write implements io.Writer, but calls panic (that is handled higher up) on
185// i/o errors.
186func (w *xerrWriter) Write(buf []byte) (int, error) {
187 n, err := w.w.Write(buf)
188 xcheckf(w.ctx, err, "writing message file")
189 if n > 0 {
190 w.size += int64(n)
191 if w.size > w.max {
192 xcheckuserf(w.ctx, errors.New("max message size reached"), "writing message file")
193 }
194 }
195 return n, err
196}
197
198type nameAddress struct {
199 Name string
200 Address smtp.Address
201}
202
203// parseAddress expects either a plain email address like "user@domain", or a
204// single address as used in a message header, like "name <user@domain>".
205func parseAddress(msghdr string) (nameAddress, error) {
206 a, err := mail.ParseAddress(msghdr)
207 if err != nil {
208 return nameAddress{}, nil
209 }
210
211 // todo: parse more fully according to ../rfc/5322:959
212 path, err := smtp.ParseAddress(a.Address)
213 if err != nil {
214 return nameAddress{}, err
215 }
216 return nameAddress{a.Name, path}, nil
217}
218
219func xmailboxID(ctx context.Context, tx *bstore.Tx, mailboxID int64) store.Mailbox {
220 if mailboxID == 0 {
221 xcheckuserf(ctx, errors.New("invalid zero mailbox ID"), "getting mailbox")
222 }
223 mb := store.Mailbox{ID: mailboxID}
224 err := tx.Get(&mb)
225 if err == bstore.ErrAbsent {
226 xcheckuserf(ctx, err, "getting mailbox")
227 }
228 xcheckf(ctx, err, "getting mailbox")
229 return mb
230}
231
232// xmessageID returns a non-expunged message or panics with a sherpa error.
233func xmessageID(ctx context.Context, tx *bstore.Tx, messageID int64) store.Message {
234 if messageID == 0 {
235 xcheckuserf(ctx, errors.New("invalid zero message id"), "getting message")
236 }
237 m := store.Message{ID: messageID}
238 err := tx.Get(&m)
239 if err == bstore.ErrAbsent {
240 xcheckuserf(ctx, errors.New("message does not exist"), "getting message")
241 } else if err == nil && m.Expunged {
242 xcheckuserf(ctx, errors.New("message was removed"), "getting message")
243 }
244 xcheckf(ctx, err, "getting message")
245 return m
246}
247
248// MessageSubmit sends a message by submitting it the outgoing email queue. The
249// message is sent to all addresses listed in the To, Cc and Bcc addresses, without
250// Bcc message header.
251//
252// If a Sent mailbox is configured, messages are added to it after submitting
253// to the delivery queue.
254func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) {
255 // Similar between ../smtpserver/server.go:/submit\( and ../webmail/webmail.go:/MessageSubmit\(
256
257 // 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.
258
259 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
260 log := xlog.WithContext(ctx).Fields(mlog.Field("account", reqInfo.AccountName))
261 acc, err := store.OpenAccount(reqInfo.AccountName)
262 xcheckf(ctx, err, "open account")
263 defer func() {
264 err := acc.Close()
265 log.Check(err, "closing account")
266 }()
267
268 log.Debug("message submit")
269
270 fromAddr, err := parseAddress(m.From)
271 xcheckuserf(ctx, err, "parsing From address")
272
273 var replyTo *nameAddress
274 if m.ReplyTo != "" {
275 a, err := parseAddress(m.ReplyTo)
276 xcheckuserf(ctx, err, "parsing Reply-To address")
277 replyTo = &a
278 }
279
280 var recipients []smtp.Address
281
282 var toAddrs []nameAddress
283 for _, s := range m.To {
284 addr, err := parseAddress(s)
285 xcheckuserf(ctx, err, "parsing To address")
286 toAddrs = append(toAddrs, addr)
287 recipients = append(recipients, addr.Address)
288 }
289
290 var ccAddrs []nameAddress
291 for _, s := range m.Cc {
292 addr, err := parseAddress(s)
293 xcheckuserf(ctx, err, "parsing Cc address")
294 ccAddrs = append(ccAddrs, addr)
295 recipients = append(recipients, addr.Address)
296 }
297
298 for _, s := range m.Bcc {
299 addr, err := parseAddress(s)
300 xcheckuserf(ctx, err, "parsing Bcc address")
301 recipients = append(recipients, addr.Address)
302 }
303
304 // Check if from address is allowed for account.
305 fromAccName, _, _, err := mox.FindAccount(fromAddr.Address.Localpart, fromAddr.Address.Domain, false)
306 if err == nil && fromAccName != reqInfo.AccountName {
307 err = mox.ErrAccountNotFound
308 }
309 if err != nil && (errors.Is(err, mox.ErrAccountNotFound) || errors.Is(err, mox.ErrDomainNotFound)) {
310 metricSubmission.WithLabelValues("badfrom").Inc()
311 xcheckuserf(ctx, errors.New("address not found"), "looking from address for account")
312 }
313 xcheckf(ctx, err, "checking if from address is allowed")
314
315 if len(recipients) == 0 {
316 xcheckuserf(ctx, fmt.Errorf("no recipients"), "composing message")
317 }
318
319 // Check outgoing message rate limit.
320 xdbread(ctx, acc, func(tx *bstore.Tx) {
321 rcpts := make([]smtp.Path, len(recipients))
322 for i, r := range recipients {
323 rcpts[i] = smtp.Path{Localpart: r.Localpart, IPDomain: dns.IPDomain{Domain: r.Domain}}
324 }
325 msglimit, rcptlimit, err := acc.SendLimitReached(tx, rcpts)
326 if msglimit >= 0 {
327 metricSubmission.WithLabelValues("messagelimiterror").Inc()
328 xcheckuserf(ctx, errors.New("send message limit reached"), "checking outgoing rate limit")
329 } else if rcptlimit >= 0 {
330 metricSubmission.WithLabelValues("recipientlimiterror").Inc()
331 xcheckuserf(ctx, errors.New("send message limit reached"), "checking outgoing rate limit")
332 }
333 xcheckf(ctx, err, "checking send limit")
334 })
335
336 has8bit := false // We update this later on.
337
338 // We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
339 smtputf8 := false
340 for _, a := range recipients {
341 if a.Localpart.IsInternational() {
342 smtputf8 = true
343 break
344 }
345 }
346 if !smtputf8 && fromAddr.Address.Localpart.IsInternational() {
347 // todo: may want to warn user that they should consider sending with a ascii-only localpart, in case receiver doesn't support smtputf8.
348 smtputf8 = true
349 }
350
351 // Create file to compose message into.
352 dataFile, err := store.CreateMessageTemp("webmail-submit")
353 xcheckf(ctx, err, "creating temporary file for message")
354 defer func() {
355 if dataFile != nil {
356 err := dataFile.Close()
357 log.Check(err, "closing submit message file")
358 err = os.Remove(dataFile.Name())
359 log.Check(err, "removing temporary submit message file")
360 }
361 }()
362
363 // If writing to the message file fails, we abort immediately.
364 xmsgw := &xerrWriter{ctx, bufio.NewWriter(dataFile), 0, w.maxMessageSize}
365
366 isASCII := func(s string) bool {
367 for _, c := range s {
368 if c >= 0x80 {
369 return false
370 }
371 }
372 return true
373 }
374
375 header := func(k, v string) {
376 fmt.Fprintf(xmsgw, "%s: %s\r\n", k, v)
377 }
378
379 headerAddrs := func(k string, l []nameAddress) {
380 if len(l) == 0 {
381 return
382 }
383 v := ""
384 linelen := len(k) + len(": ")
385 for _, a := range l {
386 if v != "" {
387 v += ","
388 linelen++
389 }
390 addr := mail.Address{Name: a.Name, Address: a.Address.Pack(smtputf8)}
391 s := addr.String()
392 if v != "" && linelen+1+len(s) > 77 {
393 v += "\r\n\t"
394 linelen = 1
395 } else if v != "" {
396 v += " "
397 linelen++
398 }
399 v += s
400 linelen += len(s)
401 }
402 fmt.Fprintf(xmsgw, "%s: %s\r\n", k, v)
403 }
404
405 line := func(w io.Writer) {
406 _, _ = w.Write([]byte("\r\n"))
407 }
408
409 text := m.TextBody
410 if !strings.HasSuffix(text, "\n") {
411 text += "\n"
412 }
413 text = strings.ReplaceAll(text, "\n", "\r\n")
414
415 charset := "us-ascii"
416 if !isASCII(text) {
417 charset = "utf-8"
418 }
419
420 var cte string
421 if message.NeedsQuotedPrintable(text) {
422 var sb strings.Builder
423 _, err := io.Copy(quotedprintable.NewWriter(&sb), strings.NewReader(text))
424 xcheckf(ctx, err, "converting text to quoted printable")
425 text = sb.String()
426 cte = "quoted-printable"
427 } else if has8bit || charset == "utf-8" {
428 cte = "8bit"
429 } else {
430 cte = "7bit"
431 }
432
433 // 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
434
435 // Each queued message gets a Received header.
436 // We don't have access to the local IP for adding.
437 // We cannot use VIA, because there is no registered method. We would like to use
438 // it to add the ascii domain name in case of smtputf8 and IDNA host name.
439 recvFrom := message.HeaderCommentDomain(mox.Conf.Static.HostnameDomain, smtputf8)
440 recvBy := mox.Conf.Static.HostnameDomain.XName(smtputf8)
441 recvID := mox.ReceivedID(mox.CidFromCtx(ctx))
442 recvHdrFor := func(rcptTo string) string {
443 recvHdr := &message.HeaderWriter{}
444 // For additional Received-header clauses, see:
445 // https://www.iana.org/assignments/mail-parameters/mail-parameters.xhtml#table-mail-parameters-8
446 // Note: we don't have "via" or "with", there is no registered for webmail.
447 recvHdr.Add(" ", "Received:", "from", recvFrom, "by", recvBy, "id", recvID) // ../rfc/5321:3158
448 if reqInfo.Request.TLS != nil {
449 recvHdr.Add(" ", message.TLSReceivedComment(log, *reqInfo.Request.TLS)...)
450 }
451 recvHdr.Add(" ", "for", "<"+rcptTo+">;", time.Now().Format(message.RFC5322Z))
452 return recvHdr.String()
453 }
454
455 // Outer message headers.
456 headerAddrs("From", []nameAddress{fromAddr})
457 if replyTo != nil {
458 headerAddrs("Reply-To", []nameAddress{*replyTo})
459 }
460 headerAddrs("To", toAddrs)
461 headerAddrs("Cc", ccAddrs)
462
463 var subjectValue string
464 subjectLineLen := len("Subject: ")
465 subjectWord := false
466 for i, word := range strings.Split(m.Subject, " ") {
467 if !smtputf8 && !isASCII(word) {
468 word = mime.QEncoding.Encode("utf-8", word)
469 }
470 if i > 0 {
471 subjectValue += " "
472 subjectLineLen++
473 }
474 if subjectWord && subjectLineLen+len(word) > 77 {
475 subjectValue += "\r\n\t"
476 subjectLineLen = 1
477 }
478 subjectValue += word
479 subjectLineLen += len(word)
480 subjectWord = true
481 }
482 if subjectValue != "" {
483 header("Subject", subjectValue)
484 }
485
486 messageID := fmt.Sprintf("<%s>", mox.MessageIDGen(smtputf8))
487 header("Message-Id", messageID)
488 header("Date", time.Now().Format(message.RFC5322Z))
489 // Add In-Reply-To and References headers.
490 if m.ResponseMessageID > 0 {
491 xdbread(ctx, acc, func(tx *bstore.Tx) {
492 rm := xmessageID(ctx, tx, m.ResponseMessageID)
493 msgr := acc.MessageReader(rm)
494 defer func() {
495 err := msgr.Close()
496 log.Check(err, "closing message reader")
497 }()
498 rp, err := rm.LoadPart(msgr)
499 xcheckf(ctx, err, "load parsed message")
500 h, err := rp.Header()
501 xcheckf(ctx, err, "parsing header")
502
503 if rp.Envelope == nil {
504 return
505 }
506 header("In-Reply-To", rp.Envelope.MessageID)
507 ref := h.Get("References")
508 if ref == "" {
509 ref = h.Get("In-Reply-To")
510 }
511 if ref != "" {
512 header("References", ref+"\r\n\t"+rp.Envelope.MessageID)
513 } else {
514 header("References", rp.Envelope.MessageID)
515 }
516 })
517 }
518 if m.UserAgent != "" {
519 header("User-Agent", m.UserAgent)
520 }
521 header("MIME-Version", "1.0")
522
523 if len(m.Attachments) > 0 || len(m.ForwardAttachments.Paths) > 0 {
524 mp := multipart.NewWriter(xmsgw)
525 header("Content-Type", fmt.Sprintf(`multipart/mixed; boundary="%s"`, mp.Boundary()))
526 line(xmsgw)
527
528 textHdr := textproto.MIMEHeader{}
529 textHdr.Set("Content-Type", "text/plain; charset="+escapeParam(charset))
530 textHdr.Set("Content-Transfer-Encoding", cte)
531 textp, err := mp.CreatePart(textHdr)
532 xcheckf(ctx, err, "adding text part to message")
533 _, err = textp.Write([]byte(text))
534 xcheckf(ctx, err, "writing text part")
535
536 xaddPart := func(ct, filename string) io.Writer {
537 ahdr := textproto.MIMEHeader{}
538 if ct == "" {
539 ct = "application/octet-stream"
540 }
541 ct += fmt.Sprintf(`; name="%s"`, filename)
542 ahdr.Set("Content-Type", ct)
543 ahdr.Set("Content-Transfer-Encoding", "base64")
544 ahdr.Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%s`, escapeParam(filename)))
545 ap, err := mp.CreatePart(ahdr)
546 xcheckf(ctx, err, "adding attachment part to message")
547 return ap
548 }
549
550 xaddAttachmentBase64 := func(ct, filename string, base64Data []byte) {
551 ap := xaddPart(ct, filename)
552
553 for len(base64Data) > 0 {
554 line := base64Data
555 n := len(line)
556 if n > 78 {
557 n = 78
558 }
559 line, base64Data = base64Data[:n], base64Data[n:]
560 _, err := ap.Write(line)
561 xcheckf(ctx, err, "writing attachment")
562 _, err = ap.Write([]byte("\r\n"))
563 xcheckf(ctx, err, "writing attachment")
564 }
565 }
566
567 xaddAttachment := func(ct, filename string, r io.Reader) {
568 ap := xaddPart(ct, filename)
569 wc := moxio.Base64Writer(ap)
570 _, err := io.Copy(wc, r)
571 xcheckf(ctx, err, "adding attachment")
572 err = wc.Close()
573 xcheckf(ctx, err, "flushing attachment")
574 }
575
576 for _, a := range m.Attachments {
577 s := a.DataURI
578 if !strings.HasPrefix(s, "data:") {
579 xcheckuserf(ctx, errors.New("missing data: in datauri"), "parsing attachment")
580 }
581 s = s[len("data:"):]
582 t := strings.SplitN(s, ",", 2)
583 if len(t) != 2 {
584 xcheckuserf(ctx, errors.New("missing comma in datauri"), "parsing attachment")
585 }
586 if !strings.HasSuffix(t[0], "base64") {
587 xcheckuserf(ctx, errors.New("missing base64 in datauri"), "parsing attachment")
588 }
589 ct := strings.TrimSuffix(t[0], "base64")
590 ct = strings.TrimSuffix(ct, ";")
591
592 // Ensure base64 is valid, then we'll write the original string.
593 _, err := io.Copy(io.Discard, base64.NewDecoder(base64.StdEncoding, strings.NewReader(t[1])))
594 xcheckuserf(ctx, err, "parsing attachment as base64")
595
596 xaddAttachmentBase64(ct, a.Filename, []byte(t[1]))
597 }
598
599 if len(m.ForwardAttachments.Paths) > 0 {
600 acc.WithRLock(func() {
601 xdbread(ctx, acc, func(tx *bstore.Tx) {
602 fm := xmessageID(ctx, tx, m.ForwardAttachments.MessageID)
603 msgr := acc.MessageReader(fm)
604 defer func() {
605 err := msgr.Close()
606 log.Check(err, "closing message reader")
607 }()
608
609 fp, err := fm.LoadPart(msgr)
610 xcheckf(ctx, err, "load parsed message")
611
612 for _, path := range m.ForwardAttachments.Paths {
613 ap := fp
614 for _, xp := range path {
615 if xp < 0 || xp >= len(ap.Parts) {
616 xcheckuserf(ctx, errors.New("unknown part"), "looking up attachment")
617 }
618 ap = ap.Parts[xp]
619 }
620
621 filename := ap.ContentTypeParams["name"]
622 if filename == "" {
623 filename = "unnamed.bin"
624 }
625 ct := strings.ToLower(ap.MediaType + "/" + ap.MediaSubType)
626 if pcharset := ap.ContentTypeParams["charset"]; pcharset != "" {
627 ct += "; charset=" + escapeParam(pcharset)
628 }
629 xaddAttachment(ct, filename, ap.Reader())
630 }
631 })
632 })
633 }
634
635 err = mp.Close()
636 xcheckf(ctx, err, "writing mime multipart")
637 } else {
638 header("Content-Type", "text/plain; charset="+escapeParam(charset))
639 header("Content-Transfer-Encoding", cte)
640 line(xmsgw)
641 xmsgw.Write([]byte(text))
642 }
643
644 err = xmsgw.w.Flush()
645 xcheckf(ctx, err, "writing message")
646
647 // Add DKIM-Signature headers.
648 var msgPrefix string
649 fd := fromAddr.Address.Domain
650 confDom, _ := mox.Conf.Domain(fd)
651 if len(confDom.DKIM.Sign) > 0 {
652 dkimHeaders, err := dkim.Sign(ctx, fromAddr.Address.Localpart, fd, confDom.DKIM, smtputf8, dataFile)
653 if err != nil {
654 metricServerErrors.WithLabelValues("dkimsign").Inc()
655 }
656 xcheckf(ctx, err, "sign dkim")
657
658 msgPrefix = dkimHeaders
659 }
660
661 fromPath := smtp.Path{
662 Localpart: fromAddr.Address.Localpart,
663 IPDomain: dns.IPDomain{Domain: fromAddr.Address.Domain},
664 }
665 for _, rcpt := range recipients {
666 rcptMsgPrefix := recvHdrFor(rcpt.Pack(smtputf8)) + msgPrefix
667 msgSize := int64(len(rcptMsgPrefix)) + xmsgw.size
668 toPath := smtp.Path{
669 Localpart: rcpt.Localpart,
670 IPDomain: dns.IPDomain{Domain: rcpt.Domain},
671 }
672 _, err := queue.Add(ctx, log, reqInfo.AccountName, fromPath, toPath, has8bit, smtputf8, msgSize, messageID, []byte(rcptMsgPrefix), dataFile, nil, false)
673 if err != nil {
674 metricSubmission.WithLabelValues("queueerror").Inc()
675 }
676 xcheckf(ctx, err, "adding message to the delivery queue")
677 metricSubmission.WithLabelValues("ok").Inc()
678 }
679
680 var modseq store.ModSeq // Only set if needed.
681
682 // Append message to Sent mailbox and mark original messages as answered/forwarded.
683 acc.WithRLock(func() {
684 var changes []store.Change
685
686 metricked := false
687 defer func() {
688 if x := recover(); x != nil {
689 if !metricked {
690 metricServerErrors.WithLabelValues("submit").Inc()
691 }
692 panic(x)
693 }
694 }()
695 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
696 if m.ResponseMessageID > 0 {
697 rm := xmessageID(ctx, tx, m.ResponseMessageID)
698 oflags := rm.Flags
699 if m.IsForward {
700 rm.Forwarded = true
701 } else {
702 rm.Answered = true
703 }
704 if !rm.Junk && !rm.Notjunk {
705 rm.Notjunk = true
706 }
707 if rm.Flags != oflags {
708 modseq, err = acc.NextModSeq(tx)
709 xcheckf(ctx, err, "next modseq")
710 rm.ModSeq = modseq
711 err := tx.Update(&rm)
712 xcheckf(ctx, err, "updating flags of replied/forwarded message")
713 changes = append(changes, rm.ChangeFlags(oflags))
714
715 err = acc.RetrainMessages(ctx, log, tx, []store.Message{rm}, false)
716 xcheckf(ctx, err, "retraining messages after reply/forward")
717 }
718 }
719
720 sentmb, err := bstore.QueryDB[store.Mailbox](ctx, acc.DB).FilterEqual("Sent", true).Get()
721 if err == bstore.ErrAbsent {
722 // There is no mailbox designated as Sent mailbox, so we're done.
723 err := os.Remove(dataFile.Name())
724 log.Check(err, "removing submitmessage file")
725 err = dataFile.Close()
726 log.Check(err, "closing submitmessage file")
727 dataFile = nil
728 return
729 }
730 xcheckf(ctx, err, "message submitted to queue, adding to Sent mailbox")
731
732 if modseq == 0 {
733 modseq, err = acc.NextModSeq(tx)
734 xcheckf(ctx, err, "next modseq")
735 }
736
737 sentm := store.Message{
738 CreateSeq: modseq,
739 ModSeq: modseq,
740 MailboxID: sentmb.ID,
741 MailboxOrigID: sentmb.ID,
742 Flags: store.Flags{Notjunk: true, Seen: true},
743 Size: int64(len(msgPrefix)) + xmsgw.size,
744 MsgPrefix: []byte(msgPrefix),
745 }
746
747 // Update mailbox before delivery, which changes uidnext.
748 sentmb.Add(sentm.MailboxCounts())
749 err = tx.Update(&sentmb)
750 xcheckf(ctx, err, "updating sent mailbox for counts")
751
752 err = acc.DeliverMessage(log, tx, &sentm, dataFile, true, true, false, false)
753 if err != nil {
754 metricSubmission.WithLabelValues("storesenterror").Inc()
755 metricked = true
756 }
757 xcheckf(ctx, err, "message submitted to queue, appending message to Sent mailbox")
758
759 changes = append(changes, sentm.ChangeAddUID(), sentmb.ChangeCounts())
760
761 err = dataFile.Close()
762 log.Check(err, "closing submit message file")
763 dataFile = nil
764 })
765
766 store.BroadcastChanges(acc, changes)
767 })
768}
769
770// MessageMove moves messages to another mailbox. If the message is already in
771// the mailbox an error is returned.
772func (Webmail) MessageMove(ctx context.Context, messageIDs []int64, mailboxID int64) {
773 log := xlog.WithContext(ctx)
774 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
775 acc, err := store.OpenAccount(reqInfo.AccountName)
776 xcheckf(ctx, err, "open account")
777 defer func() {
778 err := acc.Close()
779 log.Check(err, "closing account")
780 }()
781
782 acc.WithRLock(func() {
783 retrain := make([]store.Message, 0, len(messageIDs))
784 removeChanges := map[int64]store.ChangeRemoveUIDs{}
785 // n adds, 1 remove, 2 mailboxcounts, optimistic and at least for a single message.
786 changes := make([]store.Change, 0, len(messageIDs)+3)
787
788 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
789 var mbSrc store.Mailbox
790 var modseq store.ModSeq
791
792 mbDst := xmailboxID(ctx, tx, mailboxID)
793
794 if len(messageIDs) == 0 {
795 return
796 }
797
798 keywords := map[string]struct{}{}
799
800 for _, mid := range messageIDs {
801 m := xmessageID(ctx, tx, mid)
802
803 // We may have loaded this mailbox in the previous iteration of this loop.
804 if m.MailboxID != mbSrc.ID {
805 if mbSrc.ID != 0 {
806 err = tx.Update(&mbSrc)
807 xcheckf(ctx, err, "updating source mailbox counts")
808 changes = append(changes, mbSrc.ChangeCounts())
809 }
810 mbSrc = xmailboxID(ctx, tx, m.MailboxID)
811 }
812
813 if mbSrc.ID == mailboxID {
814 // Client should filter out messages that are already in mailbox.
815 xcheckuserf(ctx, errors.New("already in destination mailbox"), "moving message")
816 }
817
818 if modseq == 0 {
819 modseq, err = acc.NextModSeq(tx)
820 xcheckf(ctx, err, "assigning next modseq")
821 }
822
823 ch := removeChanges[m.MailboxID]
824 ch.UIDs = append(ch.UIDs, m.UID)
825 ch.ModSeq = modseq
826 ch.MailboxID = m.MailboxID
827 removeChanges[m.MailboxID] = ch
828
829 // Copy of message record that we'll insert when UID is freed up.
830 om := m
831 om.PrepareExpunge()
832 om.ID = 0 // Assign new ID.
833 om.ModSeq = modseq
834
835 mbSrc.Sub(m.MailboxCounts())
836
837 if mbDst.Trash {
838 m.Seen = true
839 }
840 conf, _ := acc.Conf()
841 m.MailboxID = mbDst.ID
842 if m.IsReject && m.MailboxDestinedID != 0 {
843 // Incorrectly delivered to Rejects mailbox. Adjust MailboxOrigID so this message
844 // is used for reputation calculation during future deliveries.
845 m.MailboxOrigID = m.MailboxDestinedID
846 m.IsReject = false
847 m.Seen = false
848 }
849 m.UID = mbDst.UIDNext
850 m.ModSeq = modseq
851 mbDst.UIDNext++
852 m.JunkFlagsForMailbox(mbDst, conf)
853 err = tx.Update(&m)
854 xcheckf(ctx, err, "updating moved message in database")
855
856 // Now that UID is unused, we can insert the old record again.
857 err = tx.Insert(&om)
858 xcheckf(ctx, err, "inserting record for expunge after moving message")
859
860 mbDst.Add(m.MailboxCounts())
861
862 changes = append(changes, m.ChangeAddUID())
863 retrain = append(retrain, m)
864
865 for _, kw := range m.Keywords {
866 keywords[kw] = struct{}{}
867 }
868 }
869
870 err = tx.Update(&mbSrc)
871 xcheckf(ctx, err, "updating source mailbox counts")
872
873 changes = append(changes, mbSrc.ChangeCounts(), mbDst.ChangeCounts())
874
875 // Ensure destination mailbox has keywords of the moved messages.
876 var mbKwChanged bool
877 mbDst.Keywords, mbKwChanged = store.MergeKeywords(mbDst.Keywords, maps.Keys(keywords))
878 if mbKwChanged {
879 changes = append(changes, mbDst.ChangeKeywords())
880 }
881
882 err = tx.Update(&mbDst)
883 xcheckf(ctx, err, "updating mailbox with uidnext")
884
885 err = acc.RetrainMessages(ctx, log, tx, retrain, false)
886 xcheckf(ctx, err, "retraining messages after move")
887 })
888
889 // Ensure UIDs of the removed message are in increasing order. It is quite common
890 // for all messages to be from a single source mailbox, meaning this is just one
891 // change, for which we preallocated space.
892 for _, ch := range removeChanges {
893 sort.Slice(ch.UIDs, func(i, j int) bool {
894 return ch.UIDs[i] < ch.UIDs[j]
895 })
896 changes = append(changes, ch)
897 }
898 store.BroadcastChanges(acc, changes)
899 })
900}
901
902// MessageDelete permanently deletes messages, without moving them to the Trash mailbox.
903func (Webmail) MessageDelete(ctx context.Context, messageIDs []int64) {
904 log := xlog.WithContext(ctx)
905 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
906 acc, err := store.OpenAccount(reqInfo.AccountName)
907 xcheckf(ctx, err, "open account")
908 defer func() {
909 err := acc.Close()
910 log.Check(err, "closing account")
911 }()
912
913 if len(messageIDs) == 0 {
914 return
915 }
916
917 acc.WithWLock(func() {
918 removeChanges := map[int64]store.ChangeRemoveUIDs{}
919 changes := make([]store.Change, 0, len(messageIDs)+1) // n remove, 1 mailbox counts
920
921 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
922 var modseq store.ModSeq
923 var mb store.Mailbox
924 remove := make([]store.Message, 0, len(messageIDs))
925
926 for _, mid := range messageIDs {
927 m := xmessageID(ctx, tx, mid)
928
929 if m.MailboxID != mb.ID {
930 if mb.ID != 0 {
931 err := tx.Update(&mb)
932 xcheckf(ctx, err, "updating mailbox counts")
933 changes = append(changes, mb.ChangeCounts())
934 }
935 mb = xmailboxID(ctx, tx, m.MailboxID)
936 }
937
938 qmr := bstore.QueryTx[store.Recipient](tx)
939 qmr.FilterEqual("MessageID", m.ID)
940 _, err = qmr.Delete()
941 xcheckf(ctx, err, "removing message recipients")
942
943 mb.Sub(m.MailboxCounts())
944
945 if modseq == 0 {
946 modseq, err = acc.NextModSeq(tx)
947 xcheckf(ctx, err, "assigning next modseq")
948 }
949 m.Expunged = true
950 m.ModSeq = modseq
951 err = tx.Update(&m)
952 xcheckf(ctx, err, "marking message as expunged")
953
954 ch := removeChanges[m.MailboxID]
955 ch.UIDs = append(ch.UIDs, m.UID)
956 ch.MailboxID = m.MailboxID
957 ch.ModSeq = modseq
958 removeChanges[m.MailboxID] = ch
959 remove = append(remove, m)
960 }
961
962 if mb.ID != 0 {
963 err := tx.Update(&mb)
964 xcheckf(ctx, err, "updating count in mailbox")
965 changes = append(changes, mb.ChangeCounts())
966 }
967
968 // Mark removed messages as not needing training, then retrain them, so if they
969 // were trained, they get untrained.
970 for i := range remove {
971 remove[i].Junk = false
972 remove[i].Notjunk = false
973 }
974 err = acc.RetrainMessages(ctx, log, tx, remove, true)
975 xcheckf(ctx, err, "untraining deleted messages")
976 })
977
978 for _, ch := range removeChanges {
979 sort.Slice(ch.UIDs, func(i, j int) bool {
980 return ch.UIDs[i] < ch.UIDs[j]
981 })
982 changes = append(changes, ch)
983 }
984 store.BroadcastChanges(acc, changes)
985 })
986
987 for _, mID := range messageIDs {
988 p := acc.MessagePath(mID)
989 err := os.Remove(p)
990 log.Check(err, "removing message file for expunge")
991 }
992}
993
994// FlagsAdd adds flags, either system flags like \Seen or custom keywords. The
995// flags should be lower-case, but will be converted and verified.
996func (Webmail) FlagsAdd(ctx context.Context, messageIDs []int64, flaglist []string) {
997 log := xlog.WithContext(ctx)
998 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
999 acc, err := store.OpenAccount(reqInfo.AccountName)
1000 xcheckf(ctx, err, "open account")
1001 defer func() {
1002 err := acc.Close()
1003 log.Check(err, "closing account")
1004 }()
1005
1006 flags, keywords, err := store.ParseFlagsKeywords(flaglist)
1007 xcheckuserf(ctx, err, "parsing flags")
1008
1009 acc.WithRLock(func() {
1010 var changes []store.Change
1011
1012 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1013 var modseq store.ModSeq
1014 var retrain []store.Message
1015 var mb, origmb store.Mailbox
1016
1017 for _, mid := range messageIDs {
1018 m := xmessageID(ctx, tx, mid)
1019
1020 if mb.ID != m.MailboxID {
1021 if mb.ID != 0 {
1022 err := tx.Update(&mb)
1023 xcheckf(ctx, err, "updating mailbox")
1024 if mb.MailboxCounts != origmb.MailboxCounts {
1025 changes = append(changes, mb.ChangeCounts())
1026 }
1027 if mb.KeywordsChanged(origmb) {
1028 changes = append(changes, mb.ChangeKeywords())
1029 }
1030 }
1031 mb = xmailboxID(ctx, tx, m.MailboxID)
1032 origmb = mb
1033 }
1034 mb.Keywords, _ = store.MergeKeywords(mb.Keywords, keywords)
1035
1036 mb.Sub(m.MailboxCounts())
1037 oflags := m.Flags
1038 m.Flags = m.Flags.Set(flags, flags)
1039 var kwChanged bool
1040 m.Keywords, kwChanged = store.MergeKeywords(m.Keywords, keywords)
1041 mb.Add(m.MailboxCounts())
1042
1043 if m.Flags == oflags && !kwChanged {
1044 continue
1045 }
1046
1047 if modseq == 0 {
1048 modseq, err = acc.NextModSeq(tx)
1049 xcheckf(ctx, err, "assigning next modseq")
1050 }
1051 m.ModSeq = modseq
1052 err = tx.Update(&m)
1053 xcheckf(ctx, err, "updating message")
1054
1055 changes = append(changes, m.ChangeFlags(oflags))
1056 retrain = append(retrain, m)
1057 }
1058
1059 if mb.ID != 0 {
1060 err := tx.Update(&mb)
1061 xcheckf(ctx, err, "updating mailbox")
1062 if mb.MailboxCounts != origmb.MailboxCounts {
1063 changes = append(changes, mb.ChangeCounts())
1064 }
1065 if mb.KeywordsChanged(origmb) {
1066 changes = append(changes, mb.ChangeKeywords())
1067 }
1068 }
1069
1070 err = acc.RetrainMessages(ctx, log, tx, retrain, false)
1071 xcheckf(ctx, err, "retraining messages")
1072 })
1073
1074 store.BroadcastChanges(acc, changes)
1075 })
1076}
1077
1078// FlagsClear clears flags, either system flags like \Seen or custom keywords.
1079func (Webmail) FlagsClear(ctx context.Context, messageIDs []int64, flaglist []string) {
1080 log := xlog.WithContext(ctx)
1081 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1082 acc, err := store.OpenAccount(reqInfo.AccountName)
1083 xcheckf(ctx, err, "open account")
1084 defer func() {
1085 err := acc.Close()
1086 log.Check(err, "closing account")
1087 }()
1088
1089 flags, keywords, err := store.ParseFlagsKeywords(flaglist)
1090 xcheckuserf(ctx, err, "parsing flags")
1091
1092 acc.WithRLock(func() {
1093 var retrain []store.Message
1094 var changes []store.Change
1095
1096 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1097 var modseq store.ModSeq
1098 var mb, origmb store.Mailbox
1099
1100 for _, mid := range messageIDs {
1101 m := xmessageID(ctx, tx, mid)
1102
1103 if mb.ID != m.MailboxID {
1104 if mb.ID != 0 {
1105 err := tx.Update(&mb)
1106 xcheckf(ctx, err, "updating counts for mailbox")
1107 if mb.MailboxCounts != origmb.MailboxCounts {
1108 changes = append(changes, mb.ChangeCounts())
1109 }
1110 // note: cannot remove keywords from mailbox by removing keywords from message.
1111 }
1112 mb = xmailboxID(ctx, tx, m.MailboxID)
1113 origmb = mb
1114 }
1115
1116 oflags := m.Flags
1117 mb.Sub(m.MailboxCounts())
1118 m.Flags = m.Flags.Set(flags, store.Flags{})
1119 var changed bool
1120 m.Keywords, changed = store.RemoveKeywords(m.Keywords, keywords)
1121 mb.Add(m.MailboxCounts())
1122
1123 if m.Flags == oflags && !changed {
1124 continue
1125 }
1126
1127 if modseq == 0 {
1128 modseq, err = acc.NextModSeq(tx)
1129 xcheckf(ctx, err, "assigning next modseq")
1130 }
1131 m.ModSeq = modseq
1132 err = tx.Update(&m)
1133 xcheckf(ctx, err, "updating message")
1134
1135 changes = append(changes, m.ChangeFlags(oflags))
1136 retrain = append(retrain, m)
1137 }
1138
1139 if mb.ID != 0 {
1140 err := tx.Update(&mb)
1141 xcheckf(ctx, err, "updating keywords in mailbox")
1142 if mb.MailboxCounts != origmb.MailboxCounts {
1143 changes = append(changes, mb.ChangeCounts())
1144 }
1145 // note: cannot remove keywords from mailbox by removing keywords from message.
1146 }
1147
1148 err = acc.RetrainMessages(ctx, log, tx, retrain, false)
1149 xcheckf(ctx, err, "retraining messages")
1150 })
1151
1152 store.BroadcastChanges(acc, changes)
1153 })
1154}
1155
1156// MailboxCreate creates a new mailbox.
1157func (Webmail) MailboxCreate(ctx context.Context, name string) {
1158 log := xlog.WithContext(ctx)
1159 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1160 acc, err := store.OpenAccount(reqInfo.AccountName)
1161 xcheckf(ctx, err, "open account")
1162 defer func() {
1163 err := acc.Close()
1164 log.Check(err, "closing account")
1165 }()
1166
1167 name, _, err = store.CheckMailboxName(name, false)
1168 xcheckuserf(ctx, err, "checking mailbox name")
1169
1170 acc.WithWLock(func() {
1171 var changes []store.Change
1172 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1173 var exists bool
1174 var err error
1175 changes, _, exists, err = acc.MailboxCreate(tx, name)
1176 if exists {
1177 xcheckuserf(ctx, errors.New("mailbox already exists"), "creating mailbox")
1178 }
1179 xcheckf(ctx, err, "creating mailbox")
1180 })
1181
1182 store.BroadcastChanges(acc, changes)
1183 })
1184}
1185
1186// MailboxDelete deletes a mailbox and all its messages.
1187func (Webmail) MailboxDelete(ctx context.Context, mailboxID int64) {
1188 log := xlog.WithContext(ctx)
1189 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1190 acc, err := store.OpenAccount(reqInfo.AccountName)
1191 xcheckf(ctx, err, "open account")
1192 defer func() {
1193 err := acc.Close()
1194 log.Check(err, "closing account")
1195 }()
1196
1197 // Messages to remove after having broadcasted the removal of messages.
1198 var removeMessageIDs []int64
1199
1200 acc.WithWLock(func() {
1201 var changes []store.Change
1202
1203 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1204 mb := xmailboxID(ctx, tx, mailboxID)
1205 if mb.Name == "Inbox" {
1206 // Inbox is special in IMAP and cannot be removed.
1207 xcheckuserf(ctx, errors.New("cannot remove special Inbox"), "checking mailbox")
1208 }
1209
1210 var hasChildren bool
1211 var err error
1212 changes, removeMessageIDs, hasChildren, err = acc.MailboxDelete(ctx, log, tx, mb)
1213 if hasChildren {
1214 xcheckuserf(ctx, errors.New("mailbox has children"), "deleting mailbox")
1215 }
1216 xcheckf(ctx, err, "deleting mailbox")
1217 })
1218
1219 store.BroadcastChanges(acc, changes)
1220 })
1221
1222 for _, mID := range removeMessageIDs {
1223 p := acc.MessagePath(mID)
1224 err := os.Remove(p)
1225 log.Check(err, "removing message file for mailbox delete", mlog.Field("path", p))
1226 }
1227}
1228
1229// MailboxEmpty empties a mailbox, removing all messages from the mailbox, but not
1230// its child mailboxes.
1231func (Webmail) MailboxEmpty(ctx context.Context, mailboxID int64) {
1232 log := xlog.WithContext(ctx)
1233 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1234 acc, err := store.OpenAccount(reqInfo.AccountName)
1235 xcheckf(ctx, err, "open account")
1236 defer func() {
1237 err := acc.Close()
1238 log.Check(err, "closing account")
1239 }()
1240
1241 var expunged []store.Message
1242
1243 acc.WithWLock(func() {
1244 var changes []store.Change
1245
1246 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1247 mb := xmailboxID(ctx, tx, mailboxID)
1248
1249 modseq, err := acc.NextModSeq(tx)
1250 xcheckf(ctx, err, "next modseq")
1251
1252 // Mark messages as expunged.
1253 qm := bstore.QueryTx[store.Message](tx)
1254 qm.FilterNonzero(store.Message{MailboxID: mb.ID})
1255 qm.FilterEqual("Expunged", false)
1256 qm.SortAsc("UID")
1257 qm.Gather(&expunged)
1258 _, err = qm.UpdateNonzero(store.Message{ModSeq: modseq, Expunged: true})
1259 xcheckf(ctx, err, "deleting messages")
1260
1261 // Remove Recipients.
1262 anyIDs := make([]any, len(expunged))
1263 for i, m := range expunged {
1264 anyIDs[i] = m.ID
1265 }
1266 qmr := bstore.QueryTx[store.Recipient](tx)
1267 qmr.FilterEqual("MessageID", anyIDs...)
1268 _, err = qmr.Delete()
1269 xcheckf(ctx, err, "removing message recipients")
1270
1271 // Adjust mailbox counts, gather UIDs for broadcasted change, prepare for untraining.
1272 uids := make([]store.UID, len(expunged))
1273 for i, m := range expunged {
1274 m.Expunged = false // Gather returns updated values.
1275 mb.Sub(m.MailboxCounts())
1276 uids[i] = m.UID
1277
1278 expunged[i].Junk = false
1279 expunged[i].Notjunk = false
1280 }
1281
1282 err = tx.Update(&mb)
1283 xcheckf(ctx, err, "updating mailbox for counts")
1284
1285 err = acc.RetrainMessages(ctx, log, tx, expunged, true)
1286 xcheckf(ctx, err, "retraining expunged messages")
1287
1288 chremove := store.ChangeRemoveUIDs{MailboxID: mb.ID, UIDs: uids, ModSeq: modseq}
1289 changes = []store.Change{chremove, mb.ChangeCounts()}
1290 })
1291
1292 store.BroadcastChanges(acc, changes)
1293 })
1294
1295 for _, m := range expunged {
1296 p := acc.MessagePath(m.ID)
1297 err := os.Remove(p)
1298 log.Check(err, "removing message file after emptying mailbox", mlog.Field("path", p))
1299 }
1300}
1301
1302// MailboxRename renames a mailbox, possibly moving it to a new parent. The mailbox
1303// ID and its messages are unchanged.
1304func (Webmail) MailboxRename(ctx context.Context, mailboxID int64, newName string) {
1305 log := xlog.WithContext(ctx)
1306 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1307 acc, err := store.OpenAccount(reqInfo.AccountName)
1308 xcheckf(ctx, err, "open account")
1309 defer func() {
1310 err := acc.Close()
1311 log.Check(err, "closing account")
1312 }()
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 newName, _, err = store.CheckMailboxName(newName, false)
1317 xcheckuserf(ctx, err, "checking new mailbox name")
1318
1319 acc.WithWLock(func() {
1320 var changes []store.Change
1321
1322 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1323 mbsrc := xmailboxID(ctx, tx, mailboxID)
1324 var err error
1325 var isInbox, notExists, alreadyExists bool
1326 changes, isInbox, notExists, alreadyExists, err = acc.MailboxRename(tx, mbsrc, newName)
1327 if isInbox || notExists || alreadyExists {
1328 xcheckuserf(ctx, err, "renaming mailbox")
1329 }
1330 xcheckf(ctx, err, "renaming mailbox")
1331 })
1332
1333 store.BroadcastChanges(acc, changes)
1334 })
1335}
1336
1337// CompleteRecipient returns autocomplete matches for a recipient, returning the
1338// matches, most recently used first, and whether this is the full list and further
1339// requests for longer prefixes aren't necessary.
1340func (Webmail) CompleteRecipient(ctx context.Context, search string) ([]string, bool) {
1341 log := xlog.WithContext(ctx)
1342 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1343 acc, err := store.OpenAccount(reqInfo.AccountName)
1344 xcheckf(ctx, err, "open account")
1345 defer func() {
1346 err := acc.Close()
1347 log.Check(err, "closing account")
1348 }()
1349
1350 search = strings.ToLower(search)
1351
1352 var matches []string
1353 all := true
1354 acc.WithRLock(func() {
1355 xdbread(ctx, acc, func(tx *bstore.Tx) {
1356 type key struct {
1357 localpart smtp.Localpart
1358 domain string
1359 }
1360 seen := map[key]bool{}
1361
1362 q := bstore.QueryTx[store.Recipient](tx)
1363 q.SortDesc("Sent")
1364 err := q.ForEach(func(r store.Recipient) error {
1365 k := key{r.Localpart, r.Domain}
1366 if seen[k] {
1367 return nil
1368 }
1369 // todo: we should have the address including name available in the database for searching. Will result in better matching, and also for the name.
1370 address := fmt.Sprintf("<%s@%s>", r.Localpart.String(), r.Domain)
1371 if !strings.Contains(strings.ToLower(address), search) {
1372 return nil
1373 }
1374 if len(matches) >= 20 {
1375 all = false
1376 return bstore.StopForEach
1377 }
1378
1379 // Look in the message that was sent for a name along with the address.
1380 m := store.Message{ID: r.MessageID}
1381 err := tx.Get(&m)
1382 xcheckf(ctx, err, "get sent message")
1383 if !m.Expunged && m.ParsedBuf != nil {
1384 var part message.Part
1385 err := json.Unmarshal(m.ParsedBuf, &part)
1386 xcheckf(ctx, err, "parsing part")
1387
1388 dom, err := dns.ParseDomain(r.Domain)
1389 xcheckf(ctx, err, "parsing domain of recipient")
1390
1391 var found bool
1392 lp := r.Localpart.String()
1393 checkAddrs := func(l []message.Address) {
1394 if found {
1395 return
1396 }
1397 for _, a := range l {
1398 if a.Name != "" && a.User == lp && strings.EqualFold(a.Host, dom.ASCII) {
1399 found = true
1400 address = addressString(a, false)
1401 return
1402 }
1403 }
1404 }
1405 if part.Envelope != nil {
1406 env := part.Envelope
1407 checkAddrs(env.To)
1408 checkAddrs(env.CC)
1409 checkAddrs(env.BCC)
1410 }
1411 }
1412
1413 matches = append(matches, address)
1414 seen[k] = true
1415 return nil
1416 })
1417 xcheckf(ctx, err, "listing recipients")
1418 })
1419 })
1420 return matches, all
1421}
1422
1423// addressString returns an address into a string as it could be used in a message header.
1424func addressString(a message.Address, smtputf8 bool) string {
1425 host := a.Host
1426 dom, err := dns.ParseDomain(a.Host)
1427 if err == nil {
1428 if smtputf8 && dom.Unicode != "" {
1429 host = dom.Unicode
1430 } else {
1431 host = dom.ASCII
1432 }
1433 }
1434 s := "<" + a.User + "@" + host + ">"
1435 if a.Name != "" {
1436 // todo: properly encoded/escaped name
1437 s = a.Name + " " + s
1438 }
1439 return s
1440}
1441
1442// MailboxSetSpecialUse sets the special use flags of a mailbox.
1443func (Webmail) MailboxSetSpecialUse(ctx context.Context, mb store.Mailbox) {
1444 log := xlog.WithContext(ctx)
1445 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1446 acc, err := store.OpenAccount(reqInfo.AccountName)
1447 xcheckf(ctx, err, "open account")
1448 defer func() {
1449 err := acc.Close()
1450 log.Check(err, "closing account")
1451 }()
1452
1453 acc.WithWLock(func() {
1454 var changes []store.Change
1455
1456 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1457 xmb := xmailboxID(ctx, tx, mb.ID)
1458
1459 // We only allow a single mailbox for each flag (JMAP requirement). So for any flag
1460 // we set, we clear it for the mailbox(es) that had it, if any.
1461 clearPrevious := func(clear bool, specialUse string) {
1462 if !clear {
1463 return
1464 }
1465 var ombl []store.Mailbox
1466 q := bstore.QueryTx[store.Mailbox](tx)
1467 q.FilterNotEqual("ID", mb.ID)
1468 q.FilterEqual(specialUse, true)
1469 q.Gather(&ombl)
1470 _, err := q.UpdateField(specialUse, false)
1471 xcheckf(ctx, err, "updating previous special-use mailboxes")
1472
1473 for _, omb := range ombl {
1474 changes = append(changes, omb.ChangeSpecialUse())
1475 }
1476 }
1477 clearPrevious(mb.Archive, "Archive")
1478 clearPrevious(mb.Draft, "Draft")
1479 clearPrevious(mb.Junk, "Junk")
1480 clearPrevious(mb.Sent, "Sent")
1481 clearPrevious(mb.Trash, "Trash")
1482
1483 xmb.SpecialUse = mb.SpecialUse
1484 err = tx.Update(&xmb)
1485 xcheckf(ctx, err, "updating special-use flags for mailbox")
1486 changes = append(changes, xmb.ChangeSpecialUse())
1487 })
1488
1489 store.BroadcastChanges(acc, changes)
1490 })
1491}
1492
1493// ThreadCollapse saves the ThreadCollapse field for the messages and its
1494// children. The messageIDs are typically thread roots. But not all roots
1495// (without parent) of a thread need to have the same collapsed state.
1496func (Webmail) ThreadCollapse(ctx context.Context, messageIDs []int64, collapse bool) {
1497 log := xlog.WithContext(ctx)
1498 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1499 acc, err := store.OpenAccount(reqInfo.AccountName)
1500 xcheckf(ctx, err, "open account")
1501 defer func() {
1502 err := acc.Close()
1503 log.Check(err, "closing account")
1504 }()
1505
1506 if len(messageIDs) == 0 {
1507 xcheckuserf(ctx, errors.New("no messages"), "setting collapse")
1508 }
1509
1510 acc.WithWLock(func() {
1511 changes := make([]store.Change, 0, len(messageIDs))
1512 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1513 // Gather ThreadIDs to list all potential messages, for a way to get all potential
1514 // (child) messages. Further refined in FilterFn.
1515 threadIDs := map[int64]struct{}{}
1516 msgIDs := map[int64]struct{}{}
1517 for _, id := range messageIDs {
1518 m := store.Message{ID: id}
1519 err := tx.Get(&m)
1520 if err == bstore.ErrAbsent {
1521 xcheckuserf(ctx, err, "get message")
1522 }
1523 xcheckf(ctx, err, "get message")
1524 threadIDs[m.ThreadID] = struct{}{}
1525 msgIDs[id] = struct{}{}
1526 }
1527
1528 var updated []store.Message
1529 q := bstore.QueryTx[store.Message](tx)
1530 q.FilterEqual("ThreadID", slicesAny(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 log := xlog.WithContext(ctx)
1558 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1559 acc, err := store.OpenAccount(reqInfo.AccountName)
1560 xcheckf(ctx, err, "open account")
1561 defer func() {
1562 err := acc.Close()
1563 log.Check(err, "closing account")
1564 }()
1565
1566 if len(messageIDs) == 0 {
1567 xcheckuserf(ctx, errors.New("no messages"), "setting mute")
1568 }
1569
1570 acc.WithWLock(func() {
1571 changes := make([]store.Change, 0, len(messageIDs))
1572 xdbwrite(ctx, acc, func(tx *bstore.Tx) {
1573 threadIDs := map[int64]struct{}{}
1574 msgIDs := map[int64]struct{}{}
1575 for _, id := range messageIDs {
1576 m := store.Message{ID: id}
1577 err := tx.Get(&m)
1578 if err == bstore.ErrAbsent {
1579 xcheckuserf(ctx, err, "get message")
1580 }
1581 xcheckf(ctx, err, "get message")
1582 threadIDs[m.ThreadID] = struct{}{}
1583 msgIDs[id] = struct{}{}
1584 }
1585
1586 var updated []store.Message
1587
1588 q := bstore.QueryTx[store.Message](tx)
1589 q.FilterEqual("ThreadID", slicesAny(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
1618func slicesAny[T any](l []T) []any {
1619 r := make([]any, len(l))
1620 for i, v := range l {
1621 r[i] = v
1622 }
1623 return r
1624}
1625
1626// SSETypes exists to ensure the generated API contains the types, for use in SSE events.
1627func (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) {
1628 return
1629}
1630