This is the v1 version, had the v2 before.

This commit is contained in:
2025-05-01 12:02:19 +03:00
parent bcc3f95e8e
commit 0e253ba422
130 changed files with 18061 additions and 2179 deletions

View File

@@ -0,0 +1,2 @@
// Package backendutil provides utility functions to implement IMAP backends.
package backendutil

View File

@@ -0,0 +1,79 @@
package backendutil
import (
"time"
)
var testDate, _ = time.Parse(time.RFC1123Z, "Sat, 18 Jun 2016 12:00:00 +0900")
const testHeaderString = "Content-Type: multipart/mixed; boundary=message-boundary\r\n" +
"Date: Sat, 18 Jun 2016 12:00:00 +0900\r\n" +
"Date: Sat, 19 Jun 2016 12:00:00 +0900\r\n" +
"From: Mitsuha Miyamizu <mitsuha.miyamizu@example.org>\r\n" +
"Reply-To: Mitsuha Miyamizu <mitsuha.miyamizu+replyto@example.org>\r\n" +
"Message-Id: 42@example.org\r\n" +
"Subject: Your Name.\r\n" +
"To: Taki Tachibana <taki.tachibana@example.org>\r\n" +
"\r\n"
const testHeaderFromToString = "From: Mitsuha Miyamizu <mitsuha.miyamizu@example.org>\r\n" +
"To: Taki Tachibana <taki.tachibana@example.org>\r\n" +
"\r\n"
const testHeaderDateString = "Date: Sat, 18 Jun 2016 12:00:00 +0900\r\n" +
"Date: Sat, 19 Jun 2016 12:00:00 +0900\r\n" +
"\r\n"
const testHeaderNoFromToString = "Content-Type: multipart/mixed; boundary=message-boundary\r\n" +
"Date: Sat, 18 Jun 2016 12:00:00 +0900\r\n" +
"Date: Sat, 19 Jun 2016 12:00:00 +0900\r\n" +
"Reply-To: Mitsuha Miyamizu <mitsuha.miyamizu+replyto@example.org>\r\n" +
"Message-Id: 42@example.org\r\n" +
"Subject: Your Name.\r\n" +
"\r\n"
const testAltHeaderString = "Content-Type: multipart/alternative; boundary=b2\r\n" +
"\r\n"
const testTextHeaderString = "Content-Disposition: inline\r\n" +
"Content-Type: text/plain\r\n" +
"\r\n"
const testTextContentTypeString = "Content-Type: text/plain\r\n" +
"\r\n"
const testTextNoContentTypeString = "Content-Disposition: inline\r\n" +
"\r\n"
const testTextBodyString = "What's your name?"
const testTextString = testTextHeaderString + testTextBodyString
const testHTMLHeaderString = "Content-Disposition: inline\r\n" +
"Content-Type: text/html\r\n" +
"\r\n"
const testHTMLBodyString = "<div>What's <i>your\r\n</i> name?</div>"
const testHTMLString = testHTMLHeaderString + testHTMLBodyString
const testAttachmentHeaderString = "Content-Disposition: attachment; filename=note.txt\r\n" +
"Content-Type: text/plain\r\n" +
"\r\n"
const testAttachmentBodyString = "My name is Mitsuha."
const testAttachmentString = testAttachmentHeaderString + testAttachmentBodyString
const testBodyString = "--message-boundary\r\n" +
testAltHeaderString +
"\r\n--b2\r\n" +
testTextString +
"\r\n--b2\r\n" +
testHTMLString +
"\r\n--b2--\r\n" +
"\r\n--message-boundary\r\n" +
testAttachmentString +
"\r\n--message-boundary--\r\n"
const testMailString = testHeaderString + testBodyString

121
backend/backendutil/body.go Normal file
View File

@@ -0,0 +1,121 @@
package backendutil
import (
"bytes"
"errors"
"io"
"mime"
nettextproto "net/textproto"
"strings"
"github.com/emersion/go-imap"
"github.com/emersion/go-message/textproto"
)
var errNoSuchPart = errors.New("backendutil: no such message body part")
func multipartReader(header textproto.Header, body io.Reader) *textproto.MultipartReader {
contentType := header.Get("Content-Type")
if !strings.HasPrefix(strings.ToLower(contentType), "multipart/") {
return nil
}
_, params, err := mime.ParseMediaType(contentType)
if err != nil {
return nil
}
return textproto.NewMultipartReader(body, params["boundary"])
}
// FetchBodySection extracts a body section from a message.
func FetchBodySection(header textproto.Header, body io.Reader, section *imap.BodySectionName) (imap.Literal, error) {
// First, find the requested part using the provided path
for i := 0; i < len(section.Path); i++ {
n := section.Path[i]
mr := multipartReader(header, body)
if mr == nil {
// First part of non-multipart message refers to the message itself.
// See RFC 3501, Page 55.
if len(section.Path) == 1 && section.Path[0] == 1 {
break
}
return nil, errNoSuchPart
}
for j := 1; j <= n; j++ {
p, err := mr.NextPart()
if err == io.EOF {
return nil, errNoSuchPart
} else if err != nil {
return nil, err
}
if j == n {
body = p
header = p.Header
break
}
}
}
// Then, write the requested data to a buffer
b := new(bytes.Buffer)
resHeader := header
if section.Fields != nil {
// Copy header so we will not change value passed to us.
resHeader = header.Copy()
if section.NotFields {
for _, fieldName := range section.Fields {
resHeader.Del(fieldName)
}
} else {
fieldsMap := make(map[string]struct{}, len(section.Fields))
for _, field := range section.Fields {
fieldsMap[nettextproto.CanonicalMIMEHeaderKey(field)] = struct{}{}
}
for field := resHeader.Fields(); field.Next(); {
if _, ok := fieldsMap[field.Key()]; !ok {
field.Del()
}
}
}
}
// Write the header
err := textproto.WriteHeader(b, resHeader)
if err != nil {
return nil, err
}
switch section.Specifier {
case imap.TextSpecifier:
// The header hasn't been requested. Discard it.
b.Reset()
case imap.EntireSpecifier:
if len(section.Path) > 0 {
// When selecting a specific part by index, IMAP servers
// return only the text, not the associated MIME header.
b.Reset()
}
}
// Write the body, if requested
switch section.Specifier {
case imap.EntireSpecifier, imap.TextSpecifier:
if _, err := io.Copy(b, body); err != nil {
return nil, err
}
}
var l imap.Literal = b
if section.Partial != nil {
l = bytes.NewReader(section.ExtractPartial(b.Bytes()))
}
return l, nil
}

View File

@@ -0,0 +1,196 @@
package backendutil
import (
"bufio"
"io/ioutil"
"strings"
"testing"
"github.com/emersion/go-imap"
"github.com/emersion/go-message/textproto"
)
var bodyTests = []struct {
section string
body string
}{
{
section: "BODY[]",
body: testMailString,
},
{
section: "BODY[1.1]",
body: testTextBodyString,
},
{
section: "BODY[1.2]",
body: testHTMLBodyString,
},
{
section: "BODY[2]",
body: testAttachmentBodyString,
},
{
section: "BODY[HEADER]",
body: testHeaderString,
},
{
section: "BODY[HEADER.FIELDS (From To)]",
body: testHeaderFromToString,
},
{
section: "BODY[HEADER.FIELDS (FROM to)]",
body: testHeaderFromToString,
},
{
section: "BODY[HEADER.FIELDS.NOT (From To)]",
body: testHeaderNoFromToString,
},
{
section: "BODY[HEADER.FIELDS (Date)]",
body: testHeaderDateString,
},
{
section: "BODY[1.1.HEADER]",
body: testTextHeaderString,
},
{
section: "BODY[1.1.HEADER.FIELDS (Content-Type)]",
body: testTextContentTypeString,
},
{
section: "BODY[1.1.HEADER.FIELDS.NOT (Content-Type)]",
body: testTextNoContentTypeString,
},
{
section: "BODY[2.HEADER]",
body: testAttachmentHeaderString,
},
{
section: "BODY[2.MIME]",
body: testAttachmentHeaderString,
},
{
section: "BODY[TEXT]",
body: testBodyString,
},
{
section: "BODY[1.1.TEXT]",
body: testTextBodyString,
},
{
section: "BODY[2.TEXT]",
body: testAttachmentBodyString,
},
{
section: "BODY[2.1]",
body: "",
},
{
section: "BODY[3]",
body: "",
},
{
section: "BODY[2.TEXT]<0.9>",
body: testAttachmentBodyString[:9],
},
}
func TestFetchBodySection(t *testing.T) {
for _, test := range bodyTests {
test := test
t.Run(test.section, func(t *testing.T) {
bufferedBody := bufio.NewReader(strings.NewReader(testMailString))
header, err := textproto.ReadHeader(bufferedBody)
if err != nil {
t.Fatal("Expected no error while reading mail, got:", err)
}
section, err := imap.ParseBodySectionName(imap.FetchItem(test.section))
if err != nil {
t.Fatal("Expected no error while parsing body section name, got:", err)
}
r, err := FetchBodySection(header, bufferedBody, section)
if test.body == "" {
if err == nil {
t.Error("Expected an error while extracting non-existing body section")
}
} else {
if err != nil {
t.Fatal("Expected no error while extracting body section, got:", err)
}
b, err := ioutil.ReadAll(r)
if err != nil {
t.Fatal("Expected no error while reading body section, got:", err)
}
if s := string(b); s != test.body {
t.Errorf("Expected body section %q to be \n%s\n but got \n%s", test.section, test.body, s)
}
}
})
}
}
func TestFetchBodySection_NonMultipart(t *testing.T) {
// https://tools.ietf.org/html/rfc3501#page-55:
// Every message has at least one part number. Non-[MIME-IMB]
// messages, and non-multipart [MIME-IMB] messages with no
// encapsulated message, only have a part 1.
testMsgHdr := "From: Mitsuha Miyamizu <mitsuha.miyamizu@example.org>\r\n" +
"To: Taki Tachibana <taki.tachibana@example.org>\r\n" +
"Subject: Your Name.\r\n" +
"Message-Id: 42@example.org\r\n" +
"\r\n"
testMsgBody := "That's not multipart message. Thought it should be possible to get this text using BODY[1]."
testMsg := testMsgHdr + testMsgBody
tests := []struct {
section string
body string
}{
{
section: "BODY[1.MIME]",
body: testMsgHdr,
},
{
section: "BODY[1]",
body: testMsgBody,
},
}
for _, test := range tests {
test := test
t.Run(test.section, func(t *testing.T) {
bufferedBody := bufio.NewReader(strings.NewReader(testMsg))
header, err := textproto.ReadHeader(bufferedBody)
if err != nil {
t.Fatal("Expected no error while reading mail, got:", err)
}
section, err := imap.ParseBodySectionName(imap.FetchItem(test.section))
if err != nil {
t.Fatal("Expected no error while parsing body section name, got:", err)
}
r, err := FetchBodySection(header, bufferedBody, section)
if err != nil {
t.Fatal("Expected no error while extracting body section, got:", err)
}
b, err := ioutil.ReadAll(r)
if err != nil {
t.Fatal("Expected no error while reading body section, got:", err)
}
if s := string(b); s != test.body {
t.Errorf("Expected body section %q to be \n%s\n but got \n%s", test.section, test.body, s)
}
})
}
}

View File

@@ -0,0 +1,117 @@
package backendutil
import (
"bufio"
"bytes"
"io"
"io/ioutil"
"mime"
"strings"
"github.com/emersion/go-imap"
"github.com/emersion/go-message/textproto"
)
type countReader struct {
r io.Reader
bytes uint32
newlines uint32
endsWithLF bool
}
func (r *countReader) Read(b []byte) (int, error) {
n, err := r.r.Read(b)
r.bytes += uint32(n)
if n != 0 {
r.newlines += uint32(bytes.Count(b[:n], []byte{'\n'}))
r.endsWithLF = b[n-1] == '\n'
}
// If the stream does not end with a newline - count missing newline.
if err == io.EOF {
if !r.endsWithLF {
r.newlines++
}
}
return n, err
}
// FetchBodyStructure computes a message's body structure from its content.
func FetchBodyStructure(header textproto.Header, body io.Reader, extended bool) (*imap.BodyStructure, error) {
bs := new(imap.BodyStructure)
mediaType, mediaParams, err := mime.ParseMediaType(header.Get("Content-Type"))
if err == nil {
typeParts := strings.SplitN(mediaType, "/", 2)
bs.MIMEType = typeParts[0]
if len(typeParts) == 2 {
bs.MIMESubType = typeParts[1]
}
bs.Params = mediaParams
} else {
bs.MIMEType = "text"
bs.MIMESubType = "plain"
}
bs.Id = header.Get("Content-Id")
bs.Description = header.Get("Content-Description")
bs.Encoding = header.Get("Content-Transfer-Encoding")
if mr := multipartReader(header, body); mr != nil {
var parts []*imap.BodyStructure
for {
p, err := mr.NextPart()
if err == io.EOF {
break
} else if err != nil {
return nil, err
}
pbs, err := FetchBodyStructure(p.Header, p, extended)
if err != nil {
return nil, err
}
parts = append(parts, pbs)
}
bs.Parts = parts
} else {
countedBody := countReader{r: body}
needLines := false
if bs.MIMEType == "message" && bs.MIMESubType == "rfc822" {
// This will result in double-buffering if body is already a
// bufio.Reader (most likely it is). :\
bufBody := bufio.NewReader(&countedBody)
subMsgHdr, err := textproto.ReadHeader(bufBody)
if err != nil {
return nil, err
}
bs.Envelope, err = FetchEnvelope(subMsgHdr)
if err != nil {
return nil, err
}
bs.BodyStructure, err = FetchBodyStructure(subMsgHdr, bufBody, extended)
if err != nil {
return nil, err
}
needLines = true
} else if bs.MIMEType == "text" {
needLines = true
}
if _, err := io.Copy(ioutil.Discard, &countedBody); err != nil {
return nil, err
}
bs.Size = countedBody.bytes
if needLines {
bs.Lines = countedBody.newlines
}
}
if extended {
bs.Extended = true
bs.Disposition, bs.DispositionParams, _ = mime.ParseMediaType(header.Get("Content-Disposition"))
// TODO: bs.Language, bs.Location
// TODO: bs.MD5
}
return bs, nil
}

View File

@@ -0,0 +1,76 @@
package backendutil
import (
"bufio"
"reflect"
"strings"
"testing"
"github.com/emersion/go-imap"
"github.com/emersion/go-message/textproto"
)
var testBodyStructure = &imap.BodyStructure{
MIMEType: "multipart",
MIMESubType: "mixed",
Params: map[string]string{"boundary": "message-boundary"},
Parts: []*imap.BodyStructure{
{
MIMEType: "multipart",
MIMESubType: "alternative",
Params: map[string]string{"boundary": "b2"},
Extended: true,
Parts: []*imap.BodyStructure{
{
MIMEType: "text",
MIMESubType: "plain",
Params: map[string]string{},
Extended: true,
Disposition: "inline",
DispositionParams: map[string]string{},
Lines: 1,
Size: 17,
},
{
MIMEType: "text",
MIMESubType: "html",
Params: map[string]string{},
Extended: true,
Disposition: "inline",
DispositionParams: map[string]string{},
Lines: 2,
Size: 37,
},
},
},
{
MIMEType: "text",
MIMESubType: "plain",
Params: map[string]string{},
Extended: true,
Disposition: "attachment",
DispositionParams: map[string]string{"filename": "note.txt"},
Lines: 1,
Size: 19,
},
},
Extended: true,
}
func TestFetchBodyStructure(t *testing.T) {
bufferedBody := bufio.NewReader(strings.NewReader(testMailString))
header, err := textproto.ReadHeader(bufferedBody)
if err != nil {
t.Fatal("Expected no error while reading mail, got:", err)
}
bs, err := FetchBodyStructure(header, bufferedBody, true)
if err != nil {
t.Fatal("Expected no error while fetching body structure, got:", err)
}
if !reflect.DeepEqual(testBodyStructure, bs) {
t.Errorf("Expected body structure \n%+v\n but got \n%+v", testBodyStructure, bs)
}
}

View File

@@ -0,0 +1,58 @@
package backendutil
import (
"net/mail"
"strings"
"github.com/emersion/go-imap"
"github.com/emersion/go-message/textproto"
)
func headerAddressList(value string) ([]*imap.Address, error) {
addrs, err := mail.ParseAddressList(value)
if err != nil {
return []*imap.Address{}, err
}
list := make([]*imap.Address, len(addrs))
for i, a := range addrs {
parts := strings.SplitN(a.Address, "@", 2)
mailbox := parts[0]
var hostname string
if len(parts) == 2 {
hostname = parts[1]
}
list[i] = &imap.Address{
PersonalName: a.Name,
MailboxName: mailbox,
HostName: hostname,
}
}
return list, err
}
// FetchEnvelope returns a message's envelope from its header.
func FetchEnvelope(h textproto.Header) (*imap.Envelope, error) {
env := new(imap.Envelope)
env.Date, _ = mail.ParseDate(h.Get("Date"))
env.Subject = h.Get("Subject")
env.From, _ = headerAddressList(h.Get("From"))
env.Sender, _ = headerAddressList(h.Get("Sender"))
if len(env.Sender) == 0 {
env.Sender = env.From
}
env.ReplyTo, _ = headerAddressList(h.Get("Reply-To"))
if len(env.ReplyTo) == 0 {
env.ReplyTo = env.From
}
env.To, _ = headerAddressList(h.Get("To"))
env.Cc, _ = headerAddressList(h.Get("Cc"))
env.Bcc, _ = headerAddressList(h.Get("Bcc"))
env.InReplyTo = h.Get("In-Reply-To")
env.MessageId = h.Get("Message-Id")
return env, nil
}

View File

@@ -0,0 +1,40 @@
package backendutil
import (
"bufio"
"reflect"
"strings"
"testing"
"github.com/emersion/go-imap"
"github.com/emersion/go-message/textproto"
)
var testEnvelope = &imap.Envelope{
Date: testDate,
Subject: "Your Name.",
From: []*imap.Address{{PersonalName: "Mitsuha Miyamizu", MailboxName: "mitsuha.miyamizu", HostName: "example.org"}},
Sender: []*imap.Address{{PersonalName: "Mitsuha Miyamizu", MailboxName: "mitsuha.miyamizu", HostName: "example.org"}},
ReplyTo: []*imap.Address{{PersonalName: "Mitsuha Miyamizu", MailboxName: "mitsuha.miyamizu+replyto", HostName: "example.org"}},
To: []*imap.Address{{PersonalName: "Taki Tachibana", MailboxName: "taki.tachibana", HostName: "example.org"}},
Cc: []*imap.Address{},
Bcc: []*imap.Address{},
InReplyTo: "",
MessageId: "42@example.org",
}
func TestFetchEnvelope(t *testing.T) {
hdr, err := textproto.ReadHeader(bufio.NewReader(strings.NewReader(testMailString)))
if err != nil {
t.Fatal("Expected no error while reading mail, got:", err)
}
env, err := FetchEnvelope(hdr)
if err != nil {
t.Fatal("Expected no error while fetching envelope, got:", err)
}
if !reflect.DeepEqual(env, testEnvelope) {
t.Errorf("Expected envelope \n%+v\n but got \n%+v", testEnvelope, env)
}
}

View File

@@ -0,0 +1,73 @@
package backendutil
import (
"github.com/emersion/go-imap"
)
// UpdateFlags executes a flag operation on the flag set current.
func UpdateFlags(current []string, op imap.FlagsOp, flags []string) []string {
// Don't modify contents of 'flags' slice. Only modify 'current'.
// See https://github.com/golang/go/wiki/SliceTricks
// Re-use current's backing store
newFlags := current[:0]
switch op {
case imap.SetFlags:
hasRecent := false
// keep recent flag
for _, flag := range current {
if flag == imap.RecentFlag {
newFlags = append(newFlags, imap.RecentFlag)
hasRecent = true
break
}
}
// append new flags
for _, flag := range flags {
if flag == imap.RecentFlag {
// Make sure we don't add the recent flag multiple times.
if hasRecent {
// Already have the recent flag, skip.
continue
}
hasRecent = true
}
// append new flag
newFlags = append(newFlags, flag)
}
case imap.AddFlags:
// keep current flags
newFlags = current
// Only add new flag if it isn't already in current list.
for _, addFlag := range flags {
found := false
for _, flag := range current {
if addFlag == flag {
found = true
break
}
}
// new flag not found, add it.
if !found {
newFlags = append(newFlags, addFlag)
}
}
case imap.RemoveFlags:
// Filter current flags
for _, flag := range current {
remove := false
for _, removeFlag := range flags {
if removeFlag == flag {
remove = true
}
}
if !remove {
newFlags = append(newFlags, flag)
}
}
default:
// Unknown operation, return current flags unchanged
newFlags = current
}
return newFlags
}

View File

@@ -0,0 +1,86 @@
package backendutil
import (
"reflect"
"testing"
"github.com/emersion/go-imap"
)
var updateFlagsTests = []struct {
op imap.FlagsOp
flags []string
res []string
}{
{
op: imap.AddFlags,
flags: []string{"d", "e"},
res: []string{"a", "b", "c", "d", "e"},
},
{
op: imap.AddFlags,
flags: []string{"a", "d", "b"},
res: []string{"a", "b", "c", "d"},
},
{
op: imap.RemoveFlags,
flags: []string{"b", "v", "e", "a"},
res: []string{"c"},
},
{
op: imap.SetFlags,
flags: []string{"a", "d", "e"},
res: []string{"a", "d", "e"},
},
// Test unknown op for code coverage.
{
op: imap.FlagsOp("TestUnknownOp"),
flags: []string{"a", "d", "e"},
res: []string{"a", "b", "c"},
},
}
func TestUpdateFlags(t *testing.T) {
flagsList := []string{"a", "b", "c"}
for _, test := range updateFlagsTests {
// Make a backup copy of 'test.flags'
origFlags := append(test.flags[:0:0], test.flags...)
// Copy flags
current := append(flagsList[:0:0], flagsList...)
got := UpdateFlags(current, test.op, test.flags)
if !reflect.DeepEqual(got, test.res) {
t.Errorf("Expected result to be \n%v\n but got \n%v", test.res, got)
}
// Verify that 'test.flags' wasn't modified
if !reflect.DeepEqual(origFlags, test.flags) {
t.Errorf("Unexpected change to operation flags list changed \nbefore %v\n after \n%v",
origFlags, test.flags)
}
}
}
func TestUpdateFlags_Recent(t *testing.T) {
current := []string{}
current = UpdateFlags(current, imap.SetFlags, []string{imap.RecentFlag})
res := []string{imap.RecentFlag}
if !reflect.DeepEqual(current, res) {
t.Errorf("Expected result to be \n%v\n but got \n%v", res, current)
}
current = UpdateFlags(current, imap.SetFlags, []string{"something"})
res = []string{imap.RecentFlag, "something"}
if !reflect.DeepEqual(current, res) {
t.Errorf("Expected result to be \n%v\n but got \n%v", res, current)
}
current = UpdateFlags(current, imap.SetFlags, []string{"another", imap.RecentFlag})
res = []string{imap.RecentFlag, "another"}
if !reflect.DeepEqual(current, res) {
t.Errorf("Expected result to be \n%v\n but got \n%v", res, current)
}
}

View File

@@ -0,0 +1,230 @@
package backendutil
import (
"bytes"
"fmt"
"io"
"strings"
"time"
"github.com/emersion/go-imap"
"github.com/emersion/go-message"
"github.com/emersion/go-message/mail"
"github.com/emersion/go-message/textproto"
)
func matchString(s, substr string) bool {
return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
}
func bufferBody(e *message.Entity) (*bytes.Buffer, error) {
b := new(bytes.Buffer)
if _, err := io.Copy(b, e.Body); err != nil {
return nil, err
}
e.Body = b
return b, nil
}
func matchBody(e *message.Entity, substr string) (bool, error) {
if s, ok := e.Body.(fmt.Stringer); ok {
return matchString(s.String(), substr), nil
}
b, err := bufferBody(e)
if err != nil {
return false, err
}
return matchString(b.String(), substr), nil
}
type lengther interface {
Len() int
}
type countWriter struct {
N int
}
func (w *countWriter) Write(b []byte) (int, error) {
w.N += len(b)
return len(b), nil
}
func bodyLen(e *message.Entity) (int, error) {
headerSize := countWriter{}
textproto.WriteHeader(&headerSize, e.Header.Header)
if l, ok := e.Body.(lengther); ok {
return l.Len() + headerSize.N, nil
}
b, err := bufferBody(e)
if err != nil {
return 0, err
}
return b.Len() + headerSize.N, nil
}
// Match returns true if a message and its metadata matches the provided
// criteria.
func Match(e *message.Entity, seqNum, uid uint32, date time.Time, flags []string, c *imap.SearchCriteria) (bool, error) {
// TODO: support encoded header fields for Bcc, Cc, From, To
// TODO: add header size for Larger and Smaller
h := mail.Header{Header: e.Header}
if !c.SentBefore.IsZero() || !c.SentSince.IsZero() {
t, err := h.Date()
if err != nil {
return false, err
}
t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
if !c.SentBefore.IsZero() && !t.Before(c.SentBefore) {
return false, nil
}
if !c.SentSince.IsZero() && t.Before(c.SentSince) {
return false, nil
}
}
for key, wantValues := range c.Header {
ok := e.Header.Has(key)
for _, wantValue := range wantValues {
if wantValue == "" && !ok {
return false, nil
}
if wantValue != "" {
ok := false
values := e.Header.FieldsByKey(key)
for values.Next() {
decoded, _ := values.Text()
if matchString(decoded, wantValue) {
ok = true
break
}
}
if !ok {
return false, nil
}
}
}
}
for _, body := range c.Body {
if ok, err := matchBody(e, body); err != nil || !ok {
return false, err
}
}
for _, text := range c.Text {
headerMatch := false
for f := e.Header.Fields(); f.Next(); {
decoded, err := f.Text()
if err != nil {
continue
}
if strings.Contains(f.Key()+": "+decoded, text) {
headerMatch = true
}
}
if ok, err := matchBody(e, text); err != nil || !ok && !headerMatch {
return false, err
}
}
if c.Larger > 0 || c.Smaller > 0 {
n, err := bodyLen(e)
if err != nil {
return false, err
}
if c.Larger > 0 && uint32(n) <= c.Larger {
return false, nil
}
if c.Smaller > 0 && uint32(n) >= c.Smaller {
return false, nil
}
}
if !c.Since.IsZero() || !c.Before.IsZero() {
if !matchDate(date, c) {
return false, nil
}
}
if c.WithFlags != nil || c.WithoutFlags != nil {
if !matchFlags(flags, c) {
return false, nil
}
}
if c.SeqNum != nil || c.Uid != nil {
if !matchSeqNumAndUid(seqNum, uid, c) {
return false, nil
}
}
for _, not := range c.Not {
ok, err := Match(e, seqNum, uid, date, flags, not)
if err != nil || ok {
return false, err
}
}
for _, or := range c.Or {
ok1, err := Match(e, seqNum, uid, date, flags, or[0])
if err != nil {
return ok1, err
}
ok2, err := Match(e, seqNum, uid, date, flags, or[1])
if err != nil || (!ok1 && !ok2) {
return false, err
}
}
return true, nil
}
func matchFlags(flags []string, c *imap.SearchCriteria) bool {
flagsMap := make(map[string]bool)
for _, f := range flags {
flagsMap[f] = true
}
for _, f := range c.WithFlags {
if !flagsMap[f] {
return false
}
}
for _, f := range c.WithoutFlags {
if flagsMap[f] {
return false
}
}
return true
}
func matchSeqNumAndUid(seqNum uint32, uid uint32, c *imap.SearchCriteria) bool {
if c.SeqNum != nil && !c.SeqNum.Contains(seqNum) {
return false
}
if c.Uid != nil && !c.Uid.Contains(uid) {
return false
}
return true
}
func matchDate(date time.Time, c *imap.SearchCriteria) bool {
// We discard time zone information by setting it to UTC.
// RFC 3501 explicitly requires zone unaware date comparison.
date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.UTC)
if !c.Since.IsZero() && !date.After(c.Since) {
return false
}
if !c.Before.IsZero() && !date.Before(c.Before) {
return false
}
return true
}

View File

@@ -0,0 +1,432 @@
package backendutil
import (
"net/textproto"
"strings"
"testing"
"time"
"github.com/emersion/go-imap"
"github.com/emersion/go-message"
)
var testInternalDate = time.Unix(1483997966, 0)
var matchTests = []struct {
criteria *imap.SearchCriteria
seqNum uint32
uid uint32
date time.Time
flags []string
res bool
}{
{
criteria: &imap.SearchCriteria{
Header: textproto.MIMEHeader{"From": {"Mitsuha"}},
},
res: true,
},
{
criteria: &imap.SearchCriteria{
Header: textproto.MIMEHeader{"To": {"Mitsuha"}},
},
res: false,
},
{
criteria: &imap.SearchCriteria{SentBefore: testDate.Add(48 * time.Hour)},
res: true,
},
{
criteria: &imap.SearchCriteria{
Not: []*imap.SearchCriteria{{SentSince: testDate.Add(48 * time.Hour)}},
},
res: true,
},
{
criteria: &imap.SearchCriteria{
Not: []*imap.SearchCriteria{{Body: []string{"name"}}},
},
res: false,
},
{
criteria: &imap.SearchCriteria{
Text: []string{"name"},
},
res: true,
},
{
criteria: &imap.SearchCriteria{
Or: [][2]*imap.SearchCriteria{{
{Text: []string{"i'm not in the text"}},
{Body: []string{"i'm not in the body"}},
}},
},
res: false,
},
{
criteria: &imap.SearchCriteria{
Header: textproto.MIMEHeader{"Message-Id": {"42@example.org"}},
},
res: true,
},
{
criteria: &imap.SearchCriteria{
Header: textproto.MIMEHeader{"Message-Id": {"43@example.org"}},
},
res: false,
},
{
criteria: &imap.SearchCriteria{
Header: textproto.MIMEHeader{"Message-Id": {""}},
},
res: true,
},
{
criteria: &imap.SearchCriteria{
Header: textproto.MIMEHeader{"Totally-Not-Reply-To": {""}},
},
res: false,
},
{
criteria: &imap.SearchCriteria{
Larger: 10,
},
res: true,
},
{
criteria: &imap.SearchCriteria{
Smaller: 10,
},
res: false,
},
{
criteria: &imap.SearchCriteria{
Header: textproto.MIMEHeader{"Subject": {"your"}},
},
res: true,
},
{
criteria: &imap.SearchCriteria{
Header: textproto.MIMEHeader{"Subject": {"Taki"}},
},
res: false,
},
{
flags: []string{imap.SeenFlag},
criteria: &imap.SearchCriteria{
WithFlags: []string{imap.SeenFlag},
WithoutFlags: []string{imap.FlaggedFlag},
},
res: true,
},
{
flags: []string{imap.SeenFlag},
criteria: &imap.SearchCriteria{
WithFlags: []string{imap.DraftFlag},
WithoutFlags: []string{imap.FlaggedFlag},
},
res: false,
},
{
flags: []string{imap.SeenFlag, imap.FlaggedFlag},
criteria: &imap.SearchCriteria{
WithFlags: []string{imap.SeenFlag},
WithoutFlags: []string{imap.FlaggedFlag},
},
res: false,
},
{
flags: []string{imap.SeenFlag, imap.FlaggedFlag},
criteria: &imap.SearchCriteria{
Or: [][2]*imap.SearchCriteria{{
{WithFlags: []string{imap.DraftFlag}},
{WithoutFlags: []string{imap.SeenFlag}},
}},
},
res: false,
},
{
flags: []string{imap.SeenFlag, imap.FlaggedFlag},
criteria: &imap.SearchCriteria{
Not: []*imap.SearchCriteria{
{WithFlags: []string{imap.SeenFlag}},
},
},
res: false,
},
{
seqNum: 42,
uid: 69,
criteria: &imap.SearchCriteria{
Or: [][2]*imap.SearchCriteria{{
{
Uid: new(imap.SeqSet),
Not: []*imap.SearchCriteria{{SeqNum: new(imap.SeqSet)}},
},
{
SeqNum: new(imap.SeqSet),
},
}},
},
res: false,
},
{
seqNum: 42,
uid: 69,
criteria: &imap.SearchCriteria{
Or: [][2]*imap.SearchCriteria{{
{
Uid: &imap.SeqSet{Set: []imap.Seq{{69, 69}}},
Not: []*imap.SearchCriteria{{SeqNum: new(imap.SeqSet)}},
},
{
SeqNum: new(imap.SeqSet),
},
}},
},
res: true,
},
{
seqNum: 42,
uid: 69,
criteria: &imap.SearchCriteria{
Or: [][2]*imap.SearchCriteria{{
{
Uid: &imap.SeqSet{Set: []imap.Seq{{69, 69}}},
Not: []*imap.SearchCriteria{{
SeqNum: &imap.SeqSet{Set: []imap.Seq{imap.Seq{42, 42}}},
}},
},
{
SeqNum: new(imap.SeqSet),
},
}},
},
res: false,
},
{
seqNum: 42,
uid: 69,
criteria: &imap.SearchCriteria{
Or: [][2]*imap.SearchCriteria{{
{
Uid: &imap.SeqSet{Set: []imap.Seq{{69, 69}}},
Not: []*imap.SearchCriteria{{
SeqNum: &imap.SeqSet{Set: []imap.Seq{{42, 42}}},
}},
},
{
SeqNum: &imap.SeqSet{Set: []imap.Seq{{42, 42}}},
},
}},
},
res: true,
},
{
date: testInternalDate,
criteria: &imap.SearchCriteria{
Or: [][2]*imap.SearchCriteria{{
{
Since: testInternalDate.Add(48 * time.Hour),
Not: []*imap.SearchCriteria{{
Since: testInternalDate.Add(48 * time.Hour),
}},
},
{
Before: testInternalDate.Add(-48 * time.Hour),
},
}},
},
res: false,
},
{
date: testInternalDate,
criteria: &imap.SearchCriteria{
Or: [][2]*imap.SearchCriteria{{
{
Since: testInternalDate.Add(-48 * time.Hour),
Not: []*imap.SearchCriteria{{
Since: testInternalDate.Add(48 * time.Hour),
}},
},
{
Before: testInternalDate.Add(-48 * time.Hour),
},
}},
},
res: true,
},
{
date: testInternalDate,
criteria: &imap.SearchCriteria{
Or: [][2]*imap.SearchCriteria{{
{
Since: testInternalDate.Add(-48 * time.Hour),
Not: []*imap.SearchCriteria{{
Since: testInternalDate.Add(-48 * time.Hour),
}},
},
{
Before: testInternalDate.Add(-48 * time.Hour),
},
}},
},
res: false,
},
{
date: testInternalDate,
criteria: &imap.SearchCriteria{
Or: [][2]*imap.SearchCriteria{{
{
Since: testInternalDate.Add(-48 * time.Hour),
Not: []*imap.SearchCriteria{{
Since: testInternalDate.Add(-48 * time.Hour),
}},
},
{
Before: testInternalDate.Add(48 * time.Hour),
},
}},
},
res: true,
},
}
func TestMatch(t *testing.T) {
for i, test := range matchTests {
e, err := message.Read(strings.NewReader(testMailString))
if err != nil {
t.Fatal("Expected no error while reading entity, got:", err)
}
ok, err := Match(e, test.seqNum, test.uid, test.date, test.flags, test.criteria)
if err != nil {
t.Fatal("Expected no error while matching entity, got:", err)
}
if test.res && !ok {
t.Errorf("Expected #%v to match search criteria", i+1)
}
if !test.res && ok {
t.Errorf("Expected #%v not to match search criteria", i+1)
}
}
}
func TestMatchEncoded(t *testing.T) {
encodedTestMsg := `From: "fox.cpp" <foxcpp@foxcpp.dev>
To: "fox.cpp" <foxcpp@foxcpp.dev>
Subject: =?utf-8?B?0J/RgNC+0LLQtdGA0LrQsCE=?=
Date: Sun, 09 Jun 2019 00:06:43 +0300
MIME-Version: 1.0
Message-ID: <a2aeb99e-52dd-40d3-b99f-1fdaad77ed98@foxcpp.dev>
Content-Type: text/plain; charset=utf-8; format=flowed
Content-Transfer-Encoding: quoted-printable
=D0=AD=D1=82=D0=BE=D1=82 =D1=82=D0=B5=D0=BA=D1=81=D1=82 =D0=B4=D0=BE=D0=BB=
=D0=B6=D0=B5=D0=BD =D0=B1=D1=8B=D1=82=D1=8C =D0=B7=D0=B0=D0=BA=D0=BE=D0=B4=
=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD =D0=B2 base64 =D0=B8=D0=BB=D0=B8 quote=
d-encoding.`
e, err := message.Read(strings.NewReader(encodedTestMsg))
if err != nil {
t.Fatal("Expected no error while reading entity, got:", err)
}
// Check encoded header.
crit := imap.SearchCriteria{
Header: textproto.MIMEHeader{"Subject": []string{"Проверка!"}},
}
ok, err := Match(e, 0, 0, time.Now(), []string{}, &crit)
if err != nil {
t.Fatal("Expected no error while matching entity, got:", err)
}
if !ok {
t.Error("Expected match for encoded header")
}
// Encoded body.
crit = imap.SearchCriteria{
Body: []string{"или"},
}
ok, err = Match(e, 0, 0, time.Now(), []string{}, &crit)
if err != nil {
t.Fatal("Expected no error while matching entity, got:", err)
}
if !ok {
t.Error("Expected match for encoded body")
}
}
func TestMatchIssue298Regression(t *testing.T) {
raw1 := "Subject: 1\r\n\r\n1"
raw2 := "Subject: 2\r\n\r\n22"
raw3 := "Subject: 3\r\n\r\n333"
e1, err := message.Read(strings.NewReader(raw1))
if err != nil {
t.Fatal("Expected no error while reading entity, got:", err)
}
e2, err := message.Read(strings.NewReader(raw2))
if err != nil {
t.Fatal("Expected no error while reading entity, got:", err)
}
e3, err := message.Read(strings.NewReader(raw3))
if err != nil {
t.Fatal("Expected no error while reading entity, got:", err)
}
// Search for body size > 15 ("LARGER 15"), which should match messages #2 and #3
criteria := &imap.SearchCriteria{
Larger: 15,
}
ok1, err := Match(e1, 1, 101, time.Now(), nil, criteria)
if err != nil {
t.Fatal("Expected no error while matching entity, got:", err)
}
if ok1 {
t.Errorf("Expected message #1 to not match search criteria")
}
ok2, err := Match(e2, 2, 102, time.Now(), nil, criteria)
if err != nil {
t.Fatal("Expected no error while matching entity, got:", err)
}
if !ok2 {
t.Errorf("Expected message #2 to match search criteria")
}
ok3, err := Match(e3, 3, 103, time.Now(), nil, criteria)
if err != nil {
t.Fatal("Expected no error while matching entity, got:", err)
}
if !ok3 {
t.Errorf("Expected message #3 to match search criteria")
}
// Search for body size < 17 ("SMALLER 17"), which should match messages #1 and #2
criteria = &imap.SearchCriteria{
Smaller: 17,
}
ok1, err = Match(e1, 1, 101, time.Now(), nil, criteria)
if err != nil {
t.Fatal("Expected no error while matching entity, got:", err)
}
if !ok1 {
t.Errorf("Expected message #1 to match search criteria")
}
ok2, err = Match(e2, 2, 102, time.Now(), nil, criteria)
if err != nil {
t.Fatal("Expected no error while matching entity, got:", err)
}
if !ok2 {
t.Errorf("Expected message #2 to match search criteria")
}
ok3, err = Match(e3, 3, 103, time.Now(), nil, criteria)
if err != nil {
t.Fatal("Expected no error while matching entity, got:", err)
}
if ok3 {
t.Errorf("Expected message #3 to not match search criteria")
}
}