3// todo: if fetch fails part-way through the command, we wouldn't be storing the messages that were parsed. should we try harder to get parsed form of messages stored in db?
 
17	"github.com/mjl-/bstore"
 
19	"github.com/mjl-/mox/message"
 
20	"github.com/mjl-/mox/mlog"
 
21	"github.com/mjl-/mox/mox-"
 
22	"github.com/mjl-/mox/moxio"
 
23	"github.com/mjl-/mox/store"
 
26// functions to handle fetch attribute requests are defined on fetchCmd.
 
29	isUID           bool        // If this is a UID FETCH command.
 
30	rtx             *bstore.Tx  // Read-only transaction, kept open while processing all messages.
 
31	updateSeen      []store.UID // To mark as seen after processing all messages. UID instead of message ID since moved messages keep their ID and insert a new ID in the original mailbox.
 
32	hasChangedSince bool        // Whether CHANGEDSINCE was set. Enables MODSEQ in response.
 
33	expungeIssued   bool        // Set if any message has been expunged. Can happen for expunged messages.
 
35	// For message currently processing.
 
41	needModseq  bool                 // Whether untagged responses needs modseq.
 
42	newPreviews map[store.UID]string // Save with messages when done.
 
44	// Loaded when first needed, closed when message was processed.
 
45	m    *store.Message // Message currently being processed.
 
50// error when processing an attribute. we typically just don't respond with requested attributes that encounter a failure.
 
51type attrError struct{ err error }
 
53func (e attrError) Error() string {
 
57// raise error processing an attribute.
 
58func (cmd *fetchCmd) xerrorf(format string, args ...any) {
 
59	panic(attrError{fmt.Errorf(format, args...)})
 
62func (cmd *fetchCmd) xcheckf(err error, format string, args ...any) {
 
64		msg := fmt.Sprintf(format, args...)
 
65		cmd.xerrorf("%s: %w", msg, err)
 
69// Fetch returns information about messages, be it email envelopes, headers,
 
70// bodies, full messages, flags.
 
73func (c *conn) cmdxFetch(isUID bool, tag, cmdstr string, p *parser) {
 
82	atts := p.xfetchAtts()
 
83	var changedSince int64
 
84	var haveChangedSince bool
 
90		seen := map[string]bool{}
 
93			if isUID && p.conn.enabled[capQresync] {
 
95				w = p.xtakelist("CHANGEDSINCE", "VANISHED")
 
97				w = p.xtakelist("CHANGEDSINCE")
 
100				xsyntaxErrorf("duplicate fetch modifier %s", w)
 
106				changedSince = p.xnumber64()
 
107				// workaround: ios mail (16.5.1) was seen sending changedSince 0 on an existing account that got condstore enabled.
 
108				if changedSince == 0 && mox.Pedantic {
 
110					xsyntaxErrorf("changedsince modseq must be > 0")
 
113				p.conn.xensureCondstore(nil)
 
114				haveChangedSince = true
 
125		if vanished && !haveChangedSince {
 
126			xsyntaxErrorf("VANISHED can only be used with CHANGEDSINCE")
 
131	// We only keep a wlock, only for initial checks and listing the uids. Then we
 
132	// unlock and work without a lock. So changes to the store can happen, and we need
 
133	// to deal with that. If we need to mark messages as seen, we do so after
 
134	// processing the fetch for all messages, in a single write transaction. We don't
 
135	// send untagged changes for those \seen flag changes before finishing this
 
136	// command, because we have to sequence all changes properly, and since we don't
 
137	// (want to) hold a wlock while processing messages (can be many!), other changes
 
138	// may have happened to the store. So instead, we'll silently mark messages as seen
 
139	// (the client should know this is happening anyway!), then broadcast the changes
 
140	// to everyone, including ourselves. A noop/idle command that may come next will
 
141	// return the \seen flag changes, in the correct order, with the correct modseq. We
 
142	// also cannot just apply pending changes while processing. It is not allowed at
 
143	// all for non-uid-fetch. It would also make life more complicated, e.g. we would
 
144	// perhaps have to check if newly added messages also match uid fetch set that was
 
148	var vanishedUIDs []store.UID
 
150	cmd := &fetchCmd{conn: c, isUID: isUID, hasChangedSince: haveChangedSince, mailboxID: c.mailboxID, newPreviews: map[store.UID]string{}}
 
156		err := cmd.rtx.Rollback()
 
157		c.log.Check(err, "rollback rtx")
 
161	c.account.WithRLock(func() {
 
163		cmd.rtx, err = c.account.DB.Begin(context.TODO(), false)
 
164		cmd.xcheckf(err, "begin transaction")
 
166		// Ensure the mailbox still exists.
 
167		c.xmailboxID(cmd.rtx, c.mailboxID)
 
169		// With changedSince, the client is likely asking for a small set of changes. Use a
 
170		// database query to trim down the uids we need to look at. We need to go through
 
171		// the database for "VANISHED (EARLIER)" anyway, to see UIDs that aren't in the
 
173		if changedSince > 0 {
 
174			q := bstore.QueryTx[store.Message](cmd.rtx)
 
175			q.FilterNonzero(store.Message{MailboxID: c.mailboxID})
 
176			q.FilterGreater("ModSeq", store.ModSeqFromClient(changedSince))
 
178				q.FilterEqual("Expunged", false)
 
180			err := q.ForEach(func(m store.Message) error {
 
181				if m.UID >= c.uidnext {
 
185					if nums.xcontainsKnownUID(m.UID, c.searchResult, func() store.UID { return c.uidnext - 1 }) {
 
187							vanishedUIDs = append(vanishedUIDs, m.UID)
 
189							uids = append(uids, m.UID)
 
193					seq := c.sequence(m.UID)
 
194					if seq > 0 && nums.containsSeq(seq, c.uids, c.searchResult) {
 
195						uids = append(uids, m.UID)
 
200			xcheckf(err, "looking up messages with changedsince")
 
202			// In case of vanished where we don't have the full history, we must send VANISHED
 
204			delModSeq, err := c.account.HighestDeletedModSeq(cmd.rtx)
 
205			xcheckf(err, "looking up highest deleted modseq")
 
206			if !vanished || changedSince >= delModSeq.Client() {
 
210			// We'll iterate through all UIDs in the numset, and add anything that isn't
 
211			// already in uids and vanishedUIDs. First sort the uids we already found, for fast
 
212			// lookup. We'll gather new UIDs in more, so we don't break the binary search.
 
213			slices.Sort(vanishedUIDs)
 
216			more := map[store.UID]struct{}{} // We'll add them at the end.
 
217			checkVanished := func(uid store.UID) {
 
218				if uid < c.uidnext && uidSearch(uids, uid) <= 0 && uidSearch(vanishedUIDs, uid) <= 0 {
 
219					more[uid] = struct{}{}
 
223			// Now look through the requested uids. We may have a searchResult, handle it
 
224			// separately from a numset with potential stars, over which we can more easily
 
226			if nums.searchResult {
 
227				for _, uid := range c.searchResult {
 
231				xlastUID := c.newCachedLastUID(cmd.rtx, c.mailboxID, func(xerr error) { xuserErrorf("%s", xerr) })
 
232				iter := nums.xinterpretStar(xlastUID).newIter()
 
234					num, ok := iter.Next()
 
238					checkVanished(store.UID(num))
 
241			vanishedUIDs = slices.AppendSeq(vanishedUIDs, maps.Keys(more))
 
242			slices.Sort(vanishedUIDs)
 
244			uids = c.xnumSetEval(cmd.rtx, isUID, nums)
 
248	// We are continuing without a lock, working off our snapshot of uids to process.
 
251	if len(vanishedUIDs) > 0 {
 
252		// Mention all vanished UIDs in compact numset form.
 
254		// No hard limit on response sizes, but clients are recommended to not send more
 
255		// than 8k. We send a more conservative max 4k.
 
256		for _, s := range compactUIDSet(vanishedUIDs).Strings(4*1024 - 32) {
 
257			c.xbwritelinef("* VANISHED (EARLIER) %s", s)
 
261	defer cmd.msgclose() // In case of panic.
 
263	for _, cmd.uid = range uids {
 
264		cmd.conn.log.Debug("processing uid", slog.Any("uid", cmd.uid))
 
265		data, err := cmd.process(atts)
 
267			cmd.conn.log.Infox("processing fetch attribute", err, slog.Any("uid", cmd.uid))
 
268			xuserErrorf("processing fetch attribute: %v", err)
 
273			fmt.Fprintf(cmd.conn.xbw, "* %d UIDFETCH ", cmd.uid)
 
275			fmt.Fprintf(cmd.conn.xbw, "* %d FETCH ", cmd.conn.xsequence(cmd.uid))
 
277		data.xwriteTo(cmd.conn, cmd.conn.xbw)
 
278		cmd.conn.xbw.Write([]byte("\r\n"))
 
283	// We've returned all data. Now we mark messages as seen in one go, in a new write
 
284	// transaction. We don't send untagged messages for the changes, since there may be
 
285	// unprocessed pending changes. Instead, we broadcast them to ourselve too, so a
 
286	// next noop/idle will return the flags to the client.
 
288	err := cmd.rtx.Rollback()
 
289	c.log.Check(err, "fetch read tx rollback")
 
293	// command, in a single transaction.
 
294	if len(cmd.updateSeen) > 0 || len(cmd.newPreviews) > 0 {
 
295		c.account.WithWLock(func() {
 
296			changes := make([]store.Change, 0, len(cmd.updateSeen)+1)
 
298			c.xdbwrite(func(wtx *bstore.Tx) {
 
299				mb, err := store.MailboxID(wtx, c.mailboxID)
 
300				if err == store.ErrMailboxExpunged {
 
301					xusercodeErrorf("NONEXISTENT", "mailbox has been expunged")
 
303				xcheckf(err, "get mailbox for updating counts after marking as seen")
 
305				var modseq store.ModSeq
 
307				for _, uid := range cmd.updateSeen {
 
308					m, err := bstore.QueryTx[store.Message](wtx).FilterNonzero(store.Message{MailboxID: c.mailboxID, UID: uid}).Get()
 
309					xcheckf(err, "get message")
 
311						// Message has been deleted in the mean time.
 
312						cmd.expungeIssued = true
 
316						// Message already marked as seen by another process.
 
321						modseq, err = c.account.NextModSeq(wtx)
 
322						xcheckf(err, "get next mod seq")
 
326					mb.Sub(m.MailboxCounts())
 
328					mb.Add(m.MailboxCounts())
 
329					changes = append(changes, m.ChangeFlags(oldFlags, mb))
 
333					xcheckf(err, "mark message as seen")
 
336				changes = append(changes, mb.ChangeCounts())
 
338				for uid, s := range cmd.newPreviews {
 
339					m, err := bstore.QueryTx[store.Message](wtx).FilterNonzero(store.Message{MailboxID: c.mailboxID, UID: uid}).Get()
 
340					xcheckf(err, "get message")
 
342						// Message has been deleted in the mean time.
 
343						cmd.expungeIssued = true
 
347					// note: we are not updating modseq.
 
351					xcheckf(err, "saving preview with message")
 
356					err = wtx.Update(&mb)
 
357					xcheckf(err, "update mailbox with counts and modseq")
 
361			// Broadcast these changes also to ourselves, so we'll send the updated flags, but
 
362			// in the correct order, after other changes.
 
363			store.BroadcastChanges(c.account, changes)
 
367	if cmd.expungeIssued {
 
370		c.xwriteresultf("%s OK [EXPUNGEISSUED] at least one message was expunged", tag)
 
376func (cmd *fetchCmd) xensureMessage() *store.Message {
 
381	// We do not filter by Expunged, the message may have been deleted in other
 
382	// sessions, but not in ours.
 
383	q := bstore.QueryTx[store.Message](cmd.rtx)
 
384	q.FilterNonzero(store.Message{MailboxID: cmd.mailboxID, UID: cmd.uid})
 
386	cmd.xcheckf(err, "get message for uid %d", cmd.uid)
 
389		cmd.expungeIssued = true
 
394func (cmd *fetchCmd) xensureParsed() (*store.MsgReader, *message.Part) {
 
396		return cmd.msgr, cmd.part
 
399	m := cmd.xensureMessage()
 
401	cmd.msgr = cmd.conn.account.MessageReader(*m)
 
404			err := cmd.msgr.Close()
 
405			cmd.conn.xsanity(err, "closing messagereader")
 
410	p, err := m.LoadPart(cmd.msgr)
 
411	xcheckf(err, "load parsed message")
 
413	return cmd.msgr, cmd.part
 
416// msgclose must be called after processing a message (after having written/used
 
417// its data), even in the case of a panic.
 
418func (cmd *fetchCmd) msgclose() {
 
422		err := cmd.msgr.Close()
 
423		cmd.conn.xsanity(err, "closing messagereader")
 
428func (cmd *fetchCmd) process(atts []fetchAtt) (rdata listspace, rerr error) {
 
434		err, ok := x.(attrError)
 
437		} else if rerr == nil {
 
443	if !cmd.conn.uidonly {
 
444		data = append(data, bare("UID"), number(cmd.uid))
 
448	cmd.needFlags = false
 
449	cmd.needModseq = false
 
451	for _, a := range atts {
 
452		data = append(data, cmd.xprocessAtt(a)...)
 
456		cmd.updateSeen = append(cmd.updateSeen, cmd.uid)
 
460		m := cmd.xensureMessage()
 
461		data = append(data, bare("FLAGS"), flaglist(m.Flags, m.Keywords))
 
464	// The wording around when to include the MODSEQ attribute is hard to follow and is
 
465	// specified and refined in several places.
 
467	// An additional rule applies to "QRESYNC servers" (we'll assume it only applies
 
468	// when QRESYNC is enabled on a connection): setting the \Seen flag also triggers
 
472	// subsequent untagged fetch responses", then lists cases, but leaves out FETCH/UID
 
473	// FETCH. That appears intentional, it is not a list of examples, it is the full
 
474	// list, and the "all subsequent untagged fetch responses" doesn't mean "all", just
 
475	// those covering the listed cases. That makes sense, because otherwise all the
 
476	// other mentioning of cases elsewhere in the RFC would be too superfluous.
 
479	if cmd.needModseq || cmd.hasChangedSince || cmd.conn.enabled[capQresync] && cmd.isUID {
 
480		m := cmd.xensureMessage()
 
481		data = append(data, bare("MODSEQ"), listspace{bare(fmt.Sprintf("%d", m.ModSeq.Client()))})
 
487// result for one attribute. if processing fails, e.g. because data was requested
 
488// that doesn't exist and cannot be represented in imap, the attribute is simply
 
489// not returned to the user. in this case, the returned value is a nil list.
 
490func (cmd *fetchCmd) xprocessAtt(a fetchAtt) []token {
 
493		// Present by default without uidonly. For uidonly, we only add it when explicitly
 
495		if cmd.conn.uidonly {
 
496			return []token{bare("UID"), number(cmd.uid)}
 
500		_, part := cmd.xensureParsed()
 
501		envelope := xenvelope(part)
 
502		return []token{bare("ENVELOPE"), envelope}
 
506		m := cmd.xensureMessage()
 
507		return []token{bare("INTERNALDATE"), dquote(m.Received.Format("_2-Jan-2006 15:04:05 -0700"))}
 
510		m := cmd.xensureMessage()
 
511		// For messages in storage from before we implemented this extension, we don't have
 
512		// a savedate, and we return nil. This is normally meant to be per mailbox, but
 
514		var savedate token = nilt
 
515		if m.SaveDate != nil {
 
516			savedate = dquote(m.SaveDate.Format("_2-Jan-2006 15:04:05 -0700"))
 
518		return []token{bare("SAVEDATE"), savedate}
 
520	case "BODYSTRUCTURE":
 
521		_, part := cmd.xensureParsed()
 
522		bs := xbodystructure(cmd.conn.log, part, true)
 
523		return []token{bare("BODYSTRUCTURE"), bs}
 
526		respField, t := cmd.xbody(a)
 
530		return []token{bare(respField), t}
 
533		_, p := cmd.xensureParsed()
 
534		if len(a.sectionBinary) == 0 {
 
535			// Must return the size of the entire message but with decoded body.
 
536			// todo: make this less expensive and/or cache the result?
 
537			n, err := io.Copy(io.Discard, cmd.xbinaryMessageReader(p))
 
538			cmd.xcheckf(err, "reading message as binary for its size")
 
539			return []token{bare(cmd.sectionRespField(a)), number(uint32(n))}
 
541		p = cmd.xpartnumsDeref(a.sectionBinary, p)
 
542		if len(p.Parts) > 0 || p.Message != nil {
 
544			cmd.xerrorf("binary only allowed on leaf parts, not multipart/* or message/rfc822 or message/global")
 
546		return []token{bare(cmd.sectionRespField(a)), number(p.DecodedSize)}
 
549		respField, t := cmd.xbinary(a)
 
553		return []token{bare(respField), t}
 
556		m := cmd.xensureMessage()
 
557		return []token{bare("RFC822.SIZE"), number(m.Size)}
 
559	case "RFC822.HEADER":
 
563			section: §ionSpec{
 
564				msgtext: §ionMsgtext{s: "HEADER"},
 
567		respField, t := cmd.xbody(ba)
 
571		return []token{bare(a.field), t}
 
576			section: §ionSpec{},
 
578		respField, t := cmd.xbody(ba)
 
582		return []token{bare(a.field), t}
 
587			section: §ionSpec{
 
588				msgtext: §ionMsgtext{s: "TEXT"},
 
591		respField, t := cmd.xbody(ba)
 
595		return []token{bare(a.field), t}
 
601		cmd.needModseq = true
 
604		m := cmd.xensureMessage()
 
606		// We ignore "lazy", generating the preview is fast enough.
 
608			// Get the preview. We'll save all generated previews in a single transaction at
 
610			_, p := cmd.xensureParsed()
 
611			s, err := p.Preview(cmd.conn.log)
 
612			cmd.xcheckf(err, "generating preview")
 
614			cmd.newPreviews[m.UID] = s
 
629			s = strings.TrimSpace(s)
 
632		return []token{bare(a.field), t}
 
635		xserverErrorf("field %q not yet implemented", a.field)
 
641func xenvelope(p *message.Part) token {
 
642	var env message.Envelope
 
643	if p.Envelope != nil {
 
646	var date token = nilt
 
647	if !env.Date.IsZero() {
 
649		date = string0(env.Date.Format("Mon, 2 Jan 2006 15:04:05 -0700"))
 
651	var subject token = nilt
 
652	if env.Subject != "" {
 
653		subject = string0(env.Subject)
 
655	var inReplyTo token = nilt
 
656	if env.InReplyTo != "" {
 
657		inReplyTo = string0(env.InReplyTo)
 
659	var messageID token = nilt
 
660	if env.MessageID != "" {
 
661		messageID = string0(env.MessageID)
 
664	addresses := func(l []message.Address) token {
 
669		for _, a := range l {
 
670			var name token = nilt
 
672				name = string0(a.Name)
 
674			user := string0(a.User)
 
675			var host token = nilt
 
677				host = string0(a.Host)
 
679			r = append(r, listspace{name, nilt, user, host})
 
686	if len(sender) == 0 {
 
689	replyTo := env.ReplyTo
 
690	if len(replyTo) == 0 {
 
708func (cmd *fetchCmd) peekOrSeen(peek bool) {
 
709	if cmd.conn.readonly || peek {
 
712	m := cmd.xensureMessage()
 
719// reader that returns the message, but with header Content-Transfer-Encoding left out.
 
720func (cmd *fetchCmd) xbinaryMessageReader(p *message.Part) io.Reader {
 
721	hr := cmd.xmodifiedHeader(p, []string{"Content-Transfer-Encoding"}, true)
 
722	return io.MultiReader(hr, p.Reader())
 
725// return header with only fields, or with everything except fields if "not" is set.
 
726func (cmd *fetchCmd) xmodifiedHeader(p *message.Part, fields []string, not bool) io.Reader {
 
727	h, err := io.ReadAll(p.HeaderReader())
 
728	cmd.xcheckf(err, "reading header")
 
730	matchesFields := func(line []byte) bool {
 
731		k := bytes.TrimRight(bytes.SplitN(line, []byte(":"), 2)[0], " \t")
 
732		for _, f := range fields {
 
733			if bytes.EqualFold(k, []byte(f)) {
 
741	hb := &bytes.Buffer{}
 
744		i := bytes.Index(line, []byte("\r\n"))
 
750		match = matchesFields(line) || match && (bytes.HasPrefix(line, []byte(" ")) || bytes.HasPrefix(line, []byte("\t")))
 
751		if match != not || len(line) == 2 {
 
758func (cmd *fetchCmd) xbinary(a fetchAtt) (string, token) {
 
759	_, part := cmd.xensureParsed()
 
761	cmd.peekOrSeen(a.peek)
 
762	if len(a.sectionBinary) == 0 {
 
763		r := cmd.xbinaryMessageReader(part)
 
764		if a.partial != nil {
 
765			r = cmd.xpartialReader(a.partial, r)
 
767		return cmd.sectionRespField(a), readerSyncliteral{r}
 
771	if len(a.sectionBinary) > 0 {
 
772		p = cmd.xpartnumsDeref(a.sectionBinary, p)
 
774	if len(p.Parts) != 0 || p.Message != nil {
 
776		cmd.xerrorf("binary only allowed on leaf parts, not multipart/* or message/rfc822 or message/global")
 
780	if p.ContentTransferEncoding != nil {
 
781		cte = *p.ContentTransferEncoding
 
784	case "", "7BIT", "8BIT", "BINARY", "BASE64", "QUOTED-PRINTABLE":
 
787		xusercodeErrorf("UNKNOWN-CTE", "unknown Content-Transfer-Encoding %q", cte)
 
791	if a.partial != nil {
 
792		r = cmd.xpartialReader(a.partial, r)
 
794	return cmd.sectionRespField(a), readerSyncliteral{r}
 
797func (cmd *fetchCmd) xpartialReader(partial *partial, r io.Reader) io.Reader {
 
798	n, err := io.Copy(io.Discard, io.LimitReader(r, int64(partial.offset)))
 
799	cmd.xcheckf(err, "skipping to offset for partial")
 
800	if n != int64(partial.offset) {
 
803	return io.LimitReader(r, int64(partial.count))
 
806func (cmd *fetchCmd) xbody(a fetchAtt) (string, token) {
 
807	msgr, part := cmd.xensureParsed()
 
809	if a.section == nil {
 
810		// Non-extensible form of BODYSTRUCTURE.
 
811		return a.field, xbodystructure(cmd.conn.log, part, false)
 
814	cmd.peekOrSeen(a.peek)
 
816	respField := cmd.sectionRespField(a)
 
818	if a.section.msgtext == nil && a.section.part == nil {
 
819		m := cmd.xensureMessage()
 
822		if a.partial != nil {
 
823			offset = min(int64(a.partial.offset), m.Size)
 
824			count = int64(a.partial.count)
 
825			if offset+count > m.Size {
 
826				count = m.Size - offset
 
829		return respField, readerSizeSyncliteral{&moxio.AtReader{R: msgr, Offset: offset}, count, false}
 
832	sr := cmd.xsection(a.section, part)
 
834	if a.partial != nil {
 
835		n, err := io.Copy(io.Discard, io.LimitReader(sr, int64(a.partial.offset)))
 
836		cmd.xcheckf(err, "skipping to offset for partial")
 
837		if n != int64(a.partial.offset) {
 
840		return respField, readerSyncliteral{io.LimitReader(sr, int64(a.partial.count))}
 
842	return respField, readerSyncliteral{sr}
 
845func (cmd *fetchCmd) xpartnumsDeref(nums []uint32, p *message.Part) *message.Part {
 
847	if (len(p.Parts) == 0 && p.Message == nil) && len(nums) == 1 && nums[0] == 1 {
 
852	for i, num := range nums {
 
853		index := int(num - 1)
 
854		if p.Message != nil {
 
855			err := p.SetMessageReaderAt()
 
856			cmd.xcheckf(err, "preparing submessage")
 
857			return cmd.xpartnumsDeref(nums[i:], p.Message)
 
859		if index < 0 || index >= len(p.Parts) {
 
860			cmd.xerrorf("requested part does not exist")
 
867func (cmd *fetchCmd) xsection(section *sectionSpec, p *message.Part) io.Reader {
 
868	// msgtext is not nil, i.e. HEADER* or TEXT (not MIME), for the top-level part (a message).
 
869	if section.part == nil {
 
870		return cmd.xsectionMsgtext(section.msgtext, p)
 
873	p = cmd.xpartnumsDeref(section.part.part, p)
 
875	// If there is no sectionMsgText, then this isn't for HEADER*, TEXT or MIME, i.e. a
 
876	// part body, e.g. "BODY[1]".
 
877	if section.part.text == nil {
 
881	// MIME is defined for all parts. Otherwise it's HEADER* or TEXT, which is only
 
883	if !section.part.text.mime {
 
884		if p.Message == nil {
 
885			cmd.xerrorf("part is not a message, cannot request header* or text")
 
888		err := p.SetMessageReaderAt()
 
889		cmd.xcheckf(err, "preparing submessage")
 
892		return cmd.xsectionMsgtext(section.part.text.msgtext, p)
 
896	h, err := io.ReadAll(p.HeaderReader())
 
897	cmd.xcheckf(err, "reading header")
 
899	matchesFields := func(line []byte) bool {
 
900		k := textproto.CanonicalMIMEHeaderKey(string(bytes.TrimRight(bytes.SplitN(line, []byte(":"), 2)[0], " \t")))
 
901		return strings.HasPrefix(k, "Content-")
 
905	hb := &bytes.Buffer{}
 
908		i := bytes.Index(line, []byte("\r\n"))
 
914		match = matchesFields(line) || match && (bytes.HasPrefix(line, []byte(" ")) || bytes.HasPrefix(line, []byte("\t")))
 
922func (cmd *fetchCmd) xsectionMsgtext(smt *sectionMsgtext, p *message.Part) io.Reader {
 
925		return p.HeaderReader()
 
927	case "HEADER.FIELDS":
 
928		return cmd.xmodifiedHeader(p, smt.headers, false)
 
930	case "HEADER.FIELDS.NOT":
 
931		return cmd.xmodifiedHeader(p, smt.headers, true)
 
934		// TEXT the body (excluding headers) of a message, either the top-level message, or
 
938	panic(serverError{fmt.Errorf("missing case")})
 
941func (cmd *fetchCmd) sectionRespField(a fetchAtt) string {
 
943	if len(a.sectionBinary) > 0 {
 
944		s += fmt.Sprintf("%d", a.sectionBinary[0])
 
945		for _, v := range a.sectionBinary[1:] {
 
946			s += "." + fmt.Sprintf("%d", v)
 
948	} else if a.section != nil {
 
949		if a.section.part != nil {
 
951			s += fmt.Sprintf("%d", p.part[0])
 
952			for _, v := range p.part[1:] {
 
953				s += "." + fmt.Sprintf("%d", v)
 
959					s += "." + cmd.sectionMsgtextName(p.text.msgtext)
 
962		} else if a.section.msgtext != nil {
 
963			s += cmd.sectionMsgtextName(a.section.msgtext)
 
968	if a.field != "BINARY" && a.partial != nil {
 
969		s += fmt.Sprintf("<%d>", a.partial.offset)
 
974func (cmd *fetchCmd) sectionMsgtextName(smt *sectionMsgtext) string {
 
976	if strings.HasPrefix(smt.s, "HEADER.FIELDS") {
 
978		for _, h := range smt.headers {
 
979			l = append(l, astring(h))
 
981		s += " " + l.pack(cmd.conn)
 
986func bodyFldParams(p *message.Part) token {
 
987	if len(p.ContentTypeParams) == 0 {
 
990	params := make(listspace, 0, 2*len(p.ContentTypeParams))
 
991	// Ensure same ordering, easier for testing.
 
992	for _, k := range slices.Sorted(maps.Keys(p.ContentTypeParams)) {
 
993		v := p.ContentTypeParams[k]
 
994		params = append(params, string0(strings.ToUpper(k)), string0(v))
 
999func bodyFldEnc(cte *string) token {
 
1004	up := strings.ToUpper(s)
 
1006	case "7BIT", "8BIT", "BINARY", "BASE64", "QUOTED-PRINTABLE":
 
1012func bodyFldMd5(p *message.Part) token {
 
1013	if p.ContentMD5 == nil {
 
1016	return string0(*p.ContentMD5)
 
1019func bodyFldDisp(log mlog.Log, p *message.Part) token {
 
1020	if p.ContentDisposition == nil {
 
1025	// mime.ParseMediaType recombines parameter value continuations like "title*0" and
 
1027	// And decodes character sets and removes language tags, like
 
1030	disp, params, err := mime.ParseMediaType(*p.ContentDisposition)
 
1032		log.Debugx("parsing content-disposition, ignoring", err, slog.String("header", *p.ContentDisposition))
 
1034	} else if len(params) == 0 {
 
1035		log.Debug("content-disposition has no parameters, ignoring", slog.String("header", *p.ContentDisposition))
 
1038	var fields listspace
 
1039	for _, k := range slices.Sorted(maps.Keys(params)) {
 
1040		fields = append(fields, string0(k), string0(params[k]))
 
1042	return listspace{string0(disp), fields}
 
1045func bodyFldLang(p *message.Part) token {
 
1047	if p.ContentLanguage == nil {
 
1051	for _, s := range strings.Split(*p.ContentLanguage, ",") {
 
1052		s = strings.TrimSpace(s)
 
1054			return string0(*p.ContentLanguage)
 
1056		l = append(l, string0(s))
 
1061func bodyFldLoc(p *message.Part) token {
 
1062	if p.ContentLocation == nil {
 
1065	return string0(*p.ContentLocation)
 
1068// xbodystructure returns a "body".
 
1069// calls itself for multipart messages and message/{rfc822,global}.
 
1070func xbodystructure(log mlog.Log, p *message.Part, extensible bool) token {
 
1071	if p.MediaType == "MULTIPART" {
 
1074		for i := range p.Parts {
 
1075			bodies = append(bodies, xbodystructure(log, &p.Parts[i], extensible))
 
1077		r := listspace{bodies, string0(p.MediaSubType)}
 
1082				bodyFldDisp(log, p),
 
1092	if p.MediaType == "TEXT" {
 
1098			nilOrString(p.ContentID),
 
1099			nilOrString(p.ContentDescription),
 
1100			bodyFldEnc(p.ContentTransferEncoding),
 
1101			number(p.EndOffset - p.BodyOffset),
 
1102			number(p.RawLineCount),
 
1104	} else if p.MediaType == "MESSAGE" && (p.MediaSubType == "RFC822" || p.MediaSubType == "GLOBAL") {
 
1106		// note: we don't have to prepare p.Message for reading, because we aren't going to read from it.
 
1111			nilOrString(p.ContentID),
 
1112			nilOrString(p.ContentDescription),
 
1113			bodyFldEnc(p.ContentTransferEncoding),
 
1114			number(p.EndOffset - p.BodyOffset),
 
1115			xenvelope(p.Message),
 
1116			xbodystructure(log, p.Message, extensible),
 
1117			number(p.RawLineCount), // todo: or mp.RawLineCount?
 
1121		switch p.MediaType {
 
1122		case "APPLICATION", "AUDIO", "IMAGE", "FONT", "MESSAGE", "MODEL", "VIDEO":
 
1123			media = dquote(p.MediaType)
 
1125			media = string0(p.MediaType)
 
1132			nilOrString(p.ContentID),
 
1133			nilOrString(p.ContentDescription),
 
1134			bodyFldEnc(p.ContentTransferEncoding),
 
1135			number(p.EndOffset - p.BodyOffset),
 
1142			bodyFldDisp(log, p),