imapserver: return proper response for FETCH of "BODY[1.MIME]" where 1 is a message

MIME returns the part headers. If 1 is a message, i.e. a message/rfc822 or
message/global, for example when top-level is a multipart/mixed, we were
returning the MIME headers from the message, not from the part.

We also shouldn't be returning a MIME-Version header or the separating newline
for MIME. Those are for MIME headers of a message, but the "MIME" fetch body
part is always about the part.

Found after looking into FETCH BODY handling for issue #327.
This commit is contained in:
Mechiel Lukkien 2025-04-07 12:15:13 +02:00
parent 462568d878
commit 39c21f80cd
No known key found for this signature in database
2 changed files with 35 additions and 29 deletions

View File

@ -826,35 +826,40 @@ func (cmd *fetchCmd) xpartnumsDeref(nums []uint32, p *message.Part) *message.Par
} }
func (cmd *fetchCmd) xsection(section *sectionSpec, p *message.Part) io.Reader { func (cmd *fetchCmd) xsection(section *sectionSpec, p *message.Part) io.Reader {
// msgtext is not nil, i.e. HEADER* or TEXT (not MIME), for the top-level part (a message).
if section.part == nil { if section.part == nil {
return cmd.xsectionMsgtext(section.msgtext, p) return cmd.xsectionMsgtext(section.msgtext, p)
} }
p = cmd.xpartnumsDeref(section.part.part, p) p = cmd.xpartnumsDeref(section.part.part, p)
// If there is no sectionMsgText, then this isn't for HEADER*, TEXT or MIME, i.e. a
// part body, e.g. "BODY[1]".
if section.part.text == nil { if section.part.text == nil {
return p.RawReader() return p.RawReader()
} }
// ../rfc/9051:4535 // MIME is defined for all parts. Otherwise it's HEADER* or TEXT, which is only
if p.Message != nil { // defined for parts that are messages. ../rfc/9051:4500 ../rfc/9051:4517
if !section.part.text.mime {
if p.Message == nil {
cmd.xerrorf("part is not a message, cannot request header* or text")
}
err := p.SetMessageReaderAt() err := p.SetMessageReaderAt()
cmd.xcheckf(err, "preparing submessage") cmd.xcheckf(err, "preparing submessage")
p = p.Message p = p.Message
}
if !section.part.text.mime {
return cmd.xsectionMsgtext(section.part.text.msgtext, p) return cmd.xsectionMsgtext(section.part.text.msgtext, p)
} }
// MIME header, see ../rfc/9051:4534 ../rfc/2045:1645 // MIME header, see ../rfc/9051:4514 ../rfc/2045:1652
h, err := io.ReadAll(p.HeaderReader()) h, err := io.ReadAll(p.HeaderReader())
cmd.xcheckf(err, "reading header") cmd.xcheckf(err, "reading header")
matchesFields := func(line []byte) bool { matchesFields := func(line []byte) bool {
k := textproto.CanonicalMIMEHeaderKey(string(bytes.TrimRight(bytes.SplitN(line, []byte(":"), 2)[0], " \t"))) k := textproto.CanonicalMIMEHeaderKey(string(bytes.TrimRight(bytes.SplitN(line, []byte(":"), 2)[0], " \t")))
// Only add MIME-Version and additional CRLF for messages, not other parts. ../rfc/2045:1645 ../rfc/2045:1652 return strings.HasPrefix(k, "Content-")
return (p.Envelope != nil && k == "Mime-Version") || strings.HasPrefix(k, "Content-")
} }
var match bool var match bool
@ -868,7 +873,7 @@ func (cmd *fetchCmd) xsection(section *sectionSpec, p *message.Part) io.Reader {
h = h[len(line):] h = h[len(line):]
match = matchesFields(line) || match && (bytes.HasPrefix(line, []byte(" ")) || bytes.HasPrefix(line, []byte("\t"))) match = matchesFields(line) || match && (bytes.HasPrefix(line, []byte(" ")) || bytes.HasPrefix(line, []byte("\t")))
if match || len(line) == 2 { if match {
hb.Write(line) hb.Write(line)
} }
} }
@ -876,11 +881,10 @@ func (cmd *fetchCmd) xsection(section *sectionSpec, p *message.Part) io.Reader {
} }
func (cmd *fetchCmd) xsectionMsgtext(smt *sectionMsgtext, p *message.Part) io.Reader { func (cmd *fetchCmd) xsectionMsgtext(smt *sectionMsgtext, p *message.Part) io.Reader {
if smt.s == "HEADER" {
return p.HeaderReader()
}
switch smt.s { switch smt.s {
case "HEADER":
return p.HeaderReader()
case "HEADER.FIELDS": case "HEADER.FIELDS":
return cmd.xmodifiedHeader(p, smt.headers, false) return cmd.xmodifiedHeader(p, smt.headers, false)
@ -888,8 +892,8 @@ func (cmd *fetchCmd) xsectionMsgtext(smt *sectionMsgtext, p *message.Part) io.Re
return cmd.xmodifiedHeader(p, smt.headers, true) return cmd.xmodifiedHeader(p, smt.headers, true)
case "TEXT": case "TEXT":
// It appears imap clients expect to get the body of the message, not a "text body" // TEXT the body (excluding headers) of a message, either the top-level message, or
// which sounds like it means a text/* part of a message. ../rfc/9051:4517 // a nested as message/rfc822 or message/global. ../rfc/9051:4517
return p.RawReader() return p.RawReader()
} }
panic(serverError{fmt.Errorf("missing case")}) panic(serverError{fmt.Errorf("missing case")})

View File

@ -78,9 +78,7 @@ func TestFetch(t *testing.T) {
headerSplit := strings.SplitN(exampleMsgHeader, "\r\n", 2) headerSplit := strings.SplitN(exampleMsgHeader, "\r\n", 2)
dateheader1 := imapclient.FetchBody{RespAttr: "BODY[HEADER.FIELDS (Date)]", Section: "HEADER.FIELDS (Date)", Body: headerSplit[0] + "\r\n\r\n"} dateheader1 := imapclient.FetchBody{RespAttr: "BODY[HEADER.FIELDS (Date)]", Section: "HEADER.FIELDS (Date)", Body: headerSplit[0] + "\r\n\r\n"}
nodateheader1 := imapclient.FetchBody{RespAttr: "BODY[HEADER.FIELDS.NOT (Date)]", Section: "HEADER.FIELDS.NOT (Date)", Body: headerSplit[1]} nodateheader1 := imapclient.FetchBody{RespAttr: "BODY[HEADER.FIELDS.NOT (Date)]", Section: "HEADER.FIELDS.NOT (Date)", Body: headerSplit[1]}
date1header1 := imapclient.FetchBody{RespAttr: "BODY[1.HEADER.FIELDS (Date)]", Section: "1.HEADER.FIELDS (Date)", Body: headerSplit[0] + "\r\n\r\n"} mime1 := imapclient.FetchBody{RespAttr: "BODY[1.MIME]", Section: "1.MIME", Body: "Content-Type: TEXT/PLAIN; CHARSET=US-ASCII\r\n"}
nodate1header1 := imapclient.FetchBody{RespAttr: "BODY[1.HEADER.FIELDS.NOT (Date)]", Section: "1.HEADER.FIELDS.NOT (Date)", Body: headerSplit[1]}
mime1 := imapclient.FetchBody{RespAttr: "BODY[1.MIME]", Section: "1.MIME", Body: "MIME-Version: 1.0\r\nContent-Type: TEXT/PLAIN; CHARSET=US-ASCII\r\n\r\n"}
flagsSeen := imapclient.FetchFlags{`\Seen`} flagsSeen := imapclient.FetchFlags{`\Seen`}
@ -212,11 +210,10 @@ func TestFetch(t *testing.T) {
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, dateheader1}}) tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, dateheader1}})
tc.transactf("ok", "fetch 1 body.peek[header.fields.not (date)]") tc.transactf("ok", "fetch 1 body.peek[header.fields.not (date)]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, nodateheader1}}) tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, nodateheader1}})
// For non-multipart messages, 1 means the whole message. ../rfc/9051:4481 // For non-multipart messages, 1 means the whole message, but since it's not of
tc.transactf("ok", "fetch 1 body.peek[1.header.fields (date)]") // type message/{rfc822,global} (a message), you can't get the message headers.
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, date1header1}}) // ../rfc/9051:4481
tc.transactf("ok", "fetch 1 body.peek[1.header.fields.not (date)]") tc.transactf("no", "fetch 1 body.peek[1.header]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{uid1, nodate1header1}})
// MIME, part 1 for non-multipart messages is the message itself. ../rfc/9051:4481 // MIME, part 1 for non-multipart messages is the message itself. ../rfc/9051:4481
tc.transactf("ok", "fetch 1 body.peek[1.mime]") tc.transactf("ok", "fetch 1 body.peek[1.mime]")
@ -407,9 +404,7 @@ Content-Disposition: inline; filename=image.jpg
tc.transactf("ok", "fetch 2 body.peek[3]") tc.transactf("ok", "fetch 2 body.peek[3]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[3]", Section: "3", Body: part3}}}) tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[3]", Section: "3", Body: part3}}})
part2mime := tocrlf(`Content-type: text/plain; charset=US-ASCII part2mime := "Content-type: text/plain; charset=US-ASCII\r\n"
`)
tc.transactf("ok", "fetch 2 body.peek[2.mime]") tc.transactf("ok", "fetch 2 body.peek[2.mime]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[2.MIME]", Section: "2.MIME", Body: part2mime}}}) tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[2.MIME]", Section: "2.MIME", Body: part2mime}}})
@ -434,19 +429,26 @@ Content-Transfer-Encoding: Quoted-printable
tc.transactf("ok", "fetch 2 body.peek[5.header]") tc.transactf("ok", "fetch 2 body.peek[5.header]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[5.HEADER]", Section: "5.HEADER", Body: part5header}}}) tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[5.HEADER]", Section: "5.HEADER", Body: part5header}}})
part5mime := tocrlf(`Content-Type: Text/plain; charset=ISO-8859-1 part5mime := tocrlf(`Content-Type: message/rfc822
Content-Transfer-Encoding: Quoted-printable Content-MD5: MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=
Content-Language: en,de
Content-Location: http://localhost
`) `)
tc.transactf("ok", "fetch 2 body.peek[5.mime]") tc.transactf("ok", "fetch 2 body.peek[5.mime]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[5.MIME]", Section: "5.MIME", Body: part5mime}}}) tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[5.MIME]", Section: "5.MIME", Body: part5mime}}})
part5text := " ... Additional text in ISO-8859-1 goes here ...\r\n" part5text := " ... Additional text in ISO-8859-1 goes here ...\r\n"
tc.transactf("ok", "fetch 2 body.peek[5.text]") tc.transactf("ok", "fetch 2 body.peek[5.text]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[5.TEXT]", Section: "5.TEXT", Body: part5text}}}) tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[5.TEXT]", Section: "5.TEXT", Body: part5text}}})
part5body := " ... Additional text in ISO-8859-1 goes here ...\r\n"
tc.transactf("ok", "fetch 2 body.peek[5.1]") tc.transactf("ok", "fetch 2 body.peek[5.1]")
tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[5.1]", Section: "5.1", Body: part5text}}}) tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, imapclient.FetchBody{RespAttr: "BODY[5.1]", Section: "5.1", Body: part5body}}})
// 5.1 is the part that is the sub message, but not as message/rfc822, but as part,
// so we cannot request a header.
tc.transactf("no", "fetch 2 body.peek[5.1.header]")
// In case of EXAMINE instead of SELECT, we should not be seeing any changed \Seen flags for non-peek commands. // In case of EXAMINE instead of SELECT, we should not be seeing any changed \Seen flags for non-peek commands.
tc.client.StoreFlagsClear("1", true, `\Seen`) tc.client.StoreFlagsClear("1", true, `\Seen`)