This is the v1 version, had the v2 before.
This commit is contained in:
29
backend/appendlimit.go
Normal file
29
backend/appendlimit.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
// An error that should be returned by User.CreateMessage when the message size
|
||||
// is too big.
|
||||
var ErrTooBig = errors.New("Message size exceeding limit")
|
||||
|
||||
// A backend that supports retrieving per-user message size limits.
|
||||
type AppendLimitBackend interface {
|
||||
Backend
|
||||
|
||||
// Get the fixed maximum message size in octets that the backend will accept
|
||||
// when creating a new message. If there is no limit, return nil.
|
||||
CreateMessageLimit() *uint32
|
||||
}
|
||||
|
||||
// A user that supports retrieving per-user message size limits.
|
||||
type AppendLimitUser interface {
|
||||
User
|
||||
|
||||
// Get the fixed maximum message size in octets that the backend will accept
|
||||
// when creating a new message. If there is no limit, return nil.
|
||||
//
|
||||
// This overrides the global backend limit.
|
||||
CreateMessageLimit() *uint32
|
||||
}
|
||||
20
backend/backend.go
Normal file
20
backend/backend.go
Normal file
@@ -0,0 +1,20 @@
|
||||
// Package backend defines an IMAP server backend interface.
|
||||
package backend
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/emersion/go-imap"
|
||||
)
|
||||
|
||||
// ErrInvalidCredentials is returned by Backend.Login when a username or a
|
||||
// password is incorrect.
|
||||
var ErrInvalidCredentials = errors.New("Invalid credentials")
|
||||
|
||||
// Backend is an IMAP server backend. A backend operation always deals with
|
||||
// users.
|
||||
type Backend interface {
|
||||
// Login authenticates a user. If the username or the password is incorrect,
|
||||
// it returns ErrInvalidCredentials.
|
||||
Login(connInfo *imap.ConnInfo, username, password string) (User, error)
|
||||
}
|
||||
2
backend/backendutil/backendutil.go
Normal file
2
backend/backendutil/backendutil.go
Normal file
@@ -0,0 +1,2 @@
|
||||
// Package backendutil provides utility functions to implement IMAP backends.
|
||||
package backendutil
|
||||
79
backend/backendutil/backendutil_test.go
Normal file
79
backend/backendutil/backendutil_test.go
Normal 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
121
backend/backendutil/body.go
Normal 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
|
||||
}
|
||||
196
backend/backendutil/body_test.go
Normal file
196
backend/backendutil/body_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
117
backend/backendutil/bodystructure.go
Normal file
117
backend/backendutil/bodystructure.go
Normal 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
|
||||
}
|
||||
76
backend/backendutil/bodystructure_test.go
Normal file
76
backend/backendutil/bodystructure_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
58
backend/backendutil/envelope.go
Normal file
58
backend/backendutil/envelope.go
Normal 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
|
||||
}
|
||||
40
backend/backendutil/envelope_test.go
Normal file
40
backend/backendutil/envelope_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
73
backend/backendutil/flags.go
Normal file
73
backend/backendutil/flags.go
Normal 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
|
||||
}
|
||||
86
backend/backendutil/flags_test.go
Normal file
86
backend/backendutil/flags_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
230
backend/backendutil/search.go
Normal file
230
backend/backendutil/search.go
Normal 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
|
||||
}
|
||||
432
backend/backendutil/search_test.go
Normal file
432
backend/backendutil/search_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
78
backend/mailbox.go
Normal file
78
backend/mailbox.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap"
|
||||
)
|
||||
|
||||
// Mailbox represents a mailbox belonging to a user in the mail storage system.
|
||||
// A mailbox operation always deals with messages.
|
||||
type Mailbox interface {
|
||||
// Name returns this mailbox name.
|
||||
Name() string
|
||||
|
||||
// Info returns this mailbox info.
|
||||
Info() (*imap.MailboxInfo, error)
|
||||
|
||||
// Status returns this mailbox status. The fields Name, Flags, PermanentFlags
|
||||
// and UnseenSeqNum in the returned MailboxStatus must be always populated.
|
||||
// This function does not affect the state of any messages in the mailbox. See
|
||||
// RFC 3501 section 6.3.10 for a list of items that can be requested.
|
||||
Status(items []imap.StatusItem) (*imap.MailboxStatus, error)
|
||||
|
||||
// SetSubscribed adds or removes the mailbox to the server's set of "active"
|
||||
// or "subscribed" mailboxes.
|
||||
SetSubscribed(subscribed bool) error
|
||||
|
||||
// Check requests a checkpoint of the currently selected mailbox. A checkpoint
|
||||
// refers to any implementation-dependent housekeeping associated with the
|
||||
// mailbox (e.g., resolving the server's in-memory state of the mailbox with
|
||||
// the state on its disk). A checkpoint MAY take a non-instantaneous amount of
|
||||
// real time to complete. If a server implementation has no such housekeeping
|
||||
// considerations, CHECK is equivalent to NOOP.
|
||||
Check() error
|
||||
|
||||
// ListMessages returns a list of messages. seqset must be interpreted as UIDs
|
||||
// if uid is set to true and as message sequence numbers otherwise. See RFC
|
||||
// 3501 section 6.4.5 for a list of items that can be requested.
|
||||
//
|
||||
// Messages must be sent to ch. When the function returns, ch must be closed.
|
||||
ListMessages(uid bool, seqset *imap.SeqSet, items []imap.FetchItem, ch chan<- *imap.Message) error
|
||||
|
||||
// SearchMessages searches messages. The returned list must contain UIDs if
|
||||
// uid is set to true, or sequence numbers otherwise.
|
||||
SearchMessages(uid bool, criteria *imap.SearchCriteria) ([]uint32, error)
|
||||
|
||||
// CreateMessage appends a new message to this mailbox. The \Recent flag will
|
||||
// be added no matter flags is empty or not. If date is nil, the current time
|
||||
// will be used.
|
||||
//
|
||||
// If the Backend implements Updater, it must notify the client immediately
|
||||
// via a mailbox update.
|
||||
CreateMessage(flags []string, date time.Time, body imap.Literal) error
|
||||
|
||||
// UpdateMessagesFlags alters flags for the specified message(s).
|
||||
//
|
||||
// If the Backend implements Updater, it must notify the client immediately
|
||||
// via a message update.
|
||||
UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, operation imap.FlagsOp, flags []string) error
|
||||
|
||||
// CopyMessages copies the specified message(s) to the end of the specified
|
||||
// destination mailbox. The flags and internal date of the message(s) SHOULD
|
||||
// be preserved, and the Recent flag SHOULD be set, in the copy.
|
||||
//
|
||||
// If the destination mailbox does not exist, a server SHOULD return an error.
|
||||
// It SHOULD NOT automatically create the mailbox.
|
||||
//
|
||||
// If the Backend implements Updater, it must notify the client immediately
|
||||
// via a mailbox update.
|
||||
CopyMessages(uid bool, seqset *imap.SeqSet, dest string) error
|
||||
|
||||
// Expunge permanently removes all messages that have the \Deleted flag set
|
||||
// from the currently selected mailbox.
|
||||
//
|
||||
// If the Backend implements Updater, it must notify the client immediately
|
||||
// via an expunge update.
|
||||
Expunge() error
|
||||
}
|
||||
78
backend/memory/backend.go
Normal file
78
backend/memory/backend.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// A memory backend.
|
||||
package memory
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap"
|
||||
"github.com/emersion/go-imap/backend"
|
||||
)
|
||||
|
||||
type Backend struct {
|
||||
users map[string]*User
|
||||
}
|
||||
|
||||
func (be *Backend) Login(_ *imap.ConnInfo, username, password string) (backend.User, error) {
|
||||
user, ok := be.users[username]
|
||||
if ok && user.password == password {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("Bad username or password")
|
||||
}
|
||||
|
||||
func New() *Backend {
|
||||
user := &User{username: "username", password: "password"}
|
||||
|
||||
body := "From: contact@example.org\r\n" +
|
||||
"To: contact@example.org\r\n" +
|
||||
"Subject: A little message, just for you\r\n" +
|
||||
"Date: Wed, 11 May 2016 14:31:59 +0000\r\n" +
|
||||
"Message-ID: <0000000@localhost/>\r\n" +
|
||||
"Content-Type: text/plain\r\n" +
|
||||
"\r\n" +
|
||||
"Hi there :)"
|
||||
|
||||
user.mailboxes = map[string]*Mailbox{
|
||||
"INBOX": {
|
||||
name: "INBOX",
|
||||
user: user,
|
||||
Messages: []*Message{
|
||||
{
|
||||
Uid: 6,
|
||||
Date: time.Now(),
|
||||
Flags: []string{"\\Seen"},
|
||||
Size: uint32(len(body)),
|
||||
Body: []byte(body),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return &Backend{
|
||||
users: map[string]*User{user.username: user},
|
||||
}
|
||||
}
|
||||
|
||||
// NewUser adds a user to the backend.
|
||||
func (be *Backend) NewUser(username, password string) (*User, error) {
|
||||
_, ok := be.users[username]
|
||||
if ok {
|
||||
return nil, fmt.Errorf("user %s is already defined.", username)
|
||||
}
|
||||
u := &User{username: username, password: password, mailboxes: make(map[string]*Mailbox)}
|
||||
be.users[username] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// DeleteUser removes a user from the backend.
|
||||
func (be *Backend) DeleteUser(username string) error {
|
||||
_, ok := be.users[username]
|
||||
if !ok {
|
||||
return fmt.Errorf("user %s is not defined.", username)
|
||||
}
|
||||
delete(be.users, username)
|
||||
return nil
|
||||
}
|
||||
243
backend/memory/mailbox.go
Normal file
243
backend/memory/mailbox.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap"
|
||||
"github.com/emersion/go-imap/backend"
|
||||
"github.com/emersion/go-imap/backend/backendutil"
|
||||
)
|
||||
|
||||
var Delimiter = "/"
|
||||
|
||||
type Mailbox struct {
|
||||
Subscribed bool
|
||||
Messages []*Message
|
||||
|
||||
name string
|
||||
user *User
|
||||
}
|
||||
|
||||
func (mbox *Mailbox) Name() string {
|
||||
return mbox.name
|
||||
}
|
||||
|
||||
func (mbox *Mailbox) Info() (*imap.MailboxInfo, error) {
|
||||
info := &imap.MailboxInfo{
|
||||
Delimiter: Delimiter,
|
||||
Name: mbox.name,
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (mbox *Mailbox) uidNext() uint32 {
|
||||
var uid uint32
|
||||
for _, msg := range mbox.Messages {
|
||||
if msg.Uid > uid {
|
||||
uid = msg.Uid
|
||||
}
|
||||
}
|
||||
uid++
|
||||
return uid
|
||||
}
|
||||
|
||||
func (mbox *Mailbox) flags() []string {
|
||||
flagsMap := make(map[string]bool)
|
||||
for _, msg := range mbox.Messages {
|
||||
for _, f := range msg.Flags {
|
||||
if !flagsMap[f] {
|
||||
flagsMap[f] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var flags []string
|
||||
for f := range flagsMap {
|
||||
flags = append(flags, f)
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
func (mbox *Mailbox) unseenSeqNum() uint32 {
|
||||
for i, msg := range mbox.Messages {
|
||||
seqNum := uint32(i + 1)
|
||||
|
||||
seen := false
|
||||
for _, flag := range msg.Flags {
|
||||
if flag == imap.SeenFlag {
|
||||
seen = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !seen {
|
||||
return seqNum
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (mbox *Mailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, error) {
|
||||
status := imap.NewMailboxStatus(mbox.name, items)
|
||||
status.Flags = mbox.flags()
|
||||
status.PermanentFlags = []string{"\\*"}
|
||||
status.UnseenSeqNum = mbox.unseenSeqNum()
|
||||
|
||||
for _, name := range items {
|
||||
switch name {
|
||||
case imap.StatusMessages:
|
||||
status.Messages = uint32(len(mbox.Messages))
|
||||
case imap.StatusUidNext:
|
||||
status.UidNext = mbox.uidNext()
|
||||
case imap.StatusUidValidity:
|
||||
status.UidValidity = 1
|
||||
case imap.StatusRecent:
|
||||
status.Recent = 0 // TODO
|
||||
case imap.StatusUnseen:
|
||||
status.Unseen = 0 // TODO
|
||||
}
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (mbox *Mailbox) SetSubscribed(subscribed bool) error {
|
||||
mbox.Subscribed = subscribed
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mbox *Mailbox) Check() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mbox *Mailbox) ListMessages(uid bool, seqSet *imap.SeqSet, items []imap.FetchItem, ch chan<- *imap.Message) error {
|
||||
defer close(ch)
|
||||
|
||||
for i, msg := range mbox.Messages {
|
||||
seqNum := uint32(i + 1)
|
||||
|
||||
var id uint32
|
||||
if uid {
|
||||
id = msg.Uid
|
||||
} else {
|
||||
id = seqNum
|
||||
}
|
||||
if !seqSet.Contains(id) {
|
||||
continue
|
||||
}
|
||||
|
||||
m, err := msg.Fetch(seqNum, items)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
ch <- m
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mbox *Mailbox) SearchMessages(uid bool, criteria *imap.SearchCriteria) ([]uint32, error) {
|
||||
var ids []uint32
|
||||
for i, msg := range mbox.Messages {
|
||||
seqNum := uint32(i + 1)
|
||||
|
||||
ok, err := msg.Match(seqNum, criteria)
|
||||
if err != nil || !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
var id uint32
|
||||
if uid {
|
||||
id = msg.Uid
|
||||
} else {
|
||||
id = seqNum
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (mbox *Mailbox) CreateMessage(flags []string, date time.Time, body imap.Literal) error {
|
||||
if date.IsZero() {
|
||||
date = time.Now()
|
||||
}
|
||||
|
||||
b, err := ioutil.ReadAll(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mbox.Messages = append(mbox.Messages, &Message{
|
||||
Uid: mbox.uidNext(),
|
||||
Date: date,
|
||||
Size: uint32(len(b)),
|
||||
Flags: flags,
|
||||
Body: b,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mbox *Mailbox) UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, op imap.FlagsOp, flags []string) error {
|
||||
for i, msg := range mbox.Messages {
|
||||
var id uint32
|
||||
if uid {
|
||||
id = msg.Uid
|
||||
} else {
|
||||
id = uint32(i + 1)
|
||||
}
|
||||
if !seqset.Contains(id) {
|
||||
continue
|
||||
}
|
||||
|
||||
msg.Flags = backendutil.UpdateFlags(msg.Flags, op, flags)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mbox *Mailbox) CopyMessages(uid bool, seqset *imap.SeqSet, destName string) error {
|
||||
dest, ok := mbox.user.mailboxes[destName]
|
||||
if !ok {
|
||||
return backend.ErrNoSuchMailbox
|
||||
}
|
||||
|
||||
for i, msg := range mbox.Messages {
|
||||
var id uint32
|
||||
if uid {
|
||||
id = msg.Uid
|
||||
} else {
|
||||
id = uint32(i + 1)
|
||||
}
|
||||
if !seqset.Contains(id) {
|
||||
continue
|
||||
}
|
||||
|
||||
msgCopy := *msg
|
||||
msgCopy.Uid = dest.uidNext()
|
||||
dest.Messages = append(dest.Messages, &msgCopy)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mbox *Mailbox) Expunge() error {
|
||||
for i := len(mbox.Messages) - 1; i >= 0; i-- {
|
||||
msg := mbox.Messages[i]
|
||||
|
||||
deleted := false
|
||||
for _, flag := range msg.Flags {
|
||||
if flag == imap.DeletedFlag {
|
||||
deleted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if deleted {
|
||||
mbox.Messages = append(mbox.Messages[:i], mbox.Messages[i+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
74
backend/memory/message.go
Normal file
74
backend/memory/message.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap"
|
||||
"github.com/emersion/go-imap/backend/backendutil"
|
||||
"github.com/emersion/go-message"
|
||||
"github.com/emersion/go-message/textproto"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
Uid uint32
|
||||
Date time.Time
|
||||
Size uint32
|
||||
Flags []string
|
||||
Body []byte
|
||||
}
|
||||
|
||||
func (m *Message) entity() (*message.Entity, error) {
|
||||
return message.Read(bytes.NewReader(m.Body))
|
||||
}
|
||||
|
||||
func (m *Message) headerAndBody() (textproto.Header, io.Reader, error) {
|
||||
body := bufio.NewReader(bytes.NewReader(m.Body))
|
||||
hdr, err := textproto.ReadHeader(body)
|
||||
return hdr, body, err
|
||||
}
|
||||
|
||||
func (m *Message) Fetch(seqNum uint32, items []imap.FetchItem) (*imap.Message, error) {
|
||||
fetched := imap.NewMessage(seqNum, items)
|
||||
for _, item := range items {
|
||||
switch item {
|
||||
case imap.FetchEnvelope:
|
||||
hdr, _, _ := m.headerAndBody()
|
||||
fetched.Envelope, _ = backendutil.FetchEnvelope(hdr)
|
||||
case imap.FetchBody, imap.FetchBodyStructure:
|
||||
hdr, body, _ := m.headerAndBody()
|
||||
fetched.BodyStructure, _ = backendutil.FetchBodyStructure(hdr, body, item == imap.FetchBodyStructure)
|
||||
case imap.FetchFlags:
|
||||
fetched.Flags = m.Flags
|
||||
case imap.FetchInternalDate:
|
||||
fetched.InternalDate = m.Date
|
||||
case imap.FetchRFC822Size:
|
||||
fetched.Size = m.Size
|
||||
case imap.FetchUid:
|
||||
fetched.Uid = m.Uid
|
||||
default:
|
||||
section, err := imap.ParseBodySectionName(item)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
body := bufio.NewReader(bytes.NewReader(m.Body))
|
||||
hdr, err := textproto.ReadHeader(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
l, _ := backendutil.FetchBodySection(hdr, body, section)
|
||||
fetched.Body[section] = l
|
||||
}
|
||||
}
|
||||
|
||||
return fetched, nil
|
||||
}
|
||||
|
||||
func (m *Message) Match(seqNum uint32, c *imap.SearchCriteria) (bool, error) {
|
||||
e, _ := m.entity()
|
||||
return backendutil.Match(e, seqNum, m.Uid, m.Date, m.Flags, c)
|
||||
}
|
||||
82
backend/memory/user.go
Normal file
82
backend/memory/user.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/emersion/go-imap/backend"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
username string
|
||||
password string
|
||||
mailboxes map[string]*Mailbox
|
||||
}
|
||||
|
||||
func (u *User) Username() string {
|
||||
return u.username
|
||||
}
|
||||
|
||||
func (u *User) ListMailboxes(subscribed bool) (mailboxes []backend.Mailbox, err error) {
|
||||
for _, mailbox := range u.mailboxes {
|
||||
if subscribed && !mailbox.Subscribed {
|
||||
continue
|
||||
}
|
||||
|
||||
mailboxes = append(mailboxes, mailbox)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (u *User) GetMailbox(name string) (mailbox backend.Mailbox, err error) {
|
||||
mailbox, ok := u.mailboxes[name]
|
||||
if !ok {
|
||||
err = errors.New("No such mailbox")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (u *User) CreateMailbox(name string) error {
|
||||
if _, ok := u.mailboxes[name]; ok {
|
||||
return errors.New("Mailbox already exists")
|
||||
}
|
||||
|
||||
u.mailboxes[name] = &Mailbox{name: name, user: u}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *User) DeleteMailbox(name string) error {
|
||||
if name == "INBOX" {
|
||||
return errors.New("Cannot delete INBOX")
|
||||
}
|
||||
if _, ok := u.mailboxes[name]; !ok {
|
||||
return errors.New("No such mailbox")
|
||||
}
|
||||
|
||||
delete(u.mailboxes, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *User) RenameMailbox(existingName, newName string) error {
|
||||
mbox, ok := u.mailboxes[existingName]
|
||||
if !ok {
|
||||
return errors.New("No such mailbox")
|
||||
}
|
||||
|
||||
u.mailboxes[newName] = &Mailbox{
|
||||
name: newName,
|
||||
Messages: mbox.Messages,
|
||||
user: u,
|
||||
}
|
||||
|
||||
mbox.Messages = nil
|
||||
|
||||
if existingName != "INBOX" {
|
||||
delete(u.mailboxes, existingName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *User) Logout() error {
|
||||
return nil
|
||||
}
|
||||
19
backend/move.go
Normal file
19
backend/move.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"github.com/emersion/go-imap"
|
||||
)
|
||||
|
||||
// MoveMailbox is a mailbox that supports moving messages.
|
||||
type MoveMailbox interface {
|
||||
Mailbox
|
||||
|
||||
// Move the specified message(s) to the end of the specified destination
|
||||
// mailbox. This means that a new message is created in the target mailbox
|
||||
// with a new UID, the original message is removed from the source mailbox,
|
||||
// and it appears to the client as a single action.
|
||||
//
|
||||
// If the destination mailbox does not exist, a server SHOULD return an error.
|
||||
// It SHOULD NOT automatically create the mailbox.
|
||||
MoveMessages(uid bool, seqset *imap.SeqSet, dest string) error
|
||||
}
|
||||
98
backend/updates.go
Normal file
98
backend/updates.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"github.com/emersion/go-imap"
|
||||
)
|
||||
|
||||
// Update contains user and mailbox information about an unilateral backend
|
||||
// update.
|
||||
type Update interface {
|
||||
// The user targeted by this update. If empty, all connected users will
|
||||
// be notified.
|
||||
Username() string
|
||||
// The mailbox targeted by this update. If empty, the update targets all
|
||||
// mailboxes.
|
||||
Mailbox() string
|
||||
// Done returns a channel that is closed when the update has been broadcast to
|
||||
// all clients.
|
||||
Done() chan struct{}
|
||||
}
|
||||
|
||||
// NewUpdate creates a new update.
|
||||
func NewUpdate(username, mailbox string) Update {
|
||||
return &update{
|
||||
username: username,
|
||||
mailbox: mailbox,
|
||||
}
|
||||
}
|
||||
|
||||
type update struct {
|
||||
username string
|
||||
mailbox string
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func (u *update) Username() string {
|
||||
return u.username
|
||||
}
|
||||
|
||||
func (u *update) Mailbox() string {
|
||||
return u.mailbox
|
||||
}
|
||||
|
||||
func (u *update) Done() chan struct{} {
|
||||
if u.done == nil {
|
||||
u.done = make(chan struct{})
|
||||
}
|
||||
return u.done
|
||||
}
|
||||
|
||||
// StatusUpdate is a status update. See RFC 3501 section 7.1 for a list of
|
||||
// status responses.
|
||||
type StatusUpdate struct {
|
||||
Update
|
||||
*imap.StatusResp
|
||||
}
|
||||
|
||||
// MailboxUpdate is a mailbox update.
|
||||
type MailboxUpdate struct {
|
||||
Update
|
||||
*imap.MailboxStatus
|
||||
}
|
||||
|
||||
// MailboxInfoUpdate is a maiblox info update.
|
||||
type MailboxInfoUpdate struct {
|
||||
Update
|
||||
*imap.MailboxInfo
|
||||
}
|
||||
|
||||
// MessageUpdate is a message update.
|
||||
type MessageUpdate struct {
|
||||
Update
|
||||
*imap.Message
|
||||
}
|
||||
|
||||
// ExpungeUpdate is an expunge update.
|
||||
type ExpungeUpdate struct {
|
||||
Update
|
||||
SeqNum uint32
|
||||
}
|
||||
|
||||
// BackendUpdater is a Backend that implements Updater is able to send
|
||||
// unilateral backend updates. Backends not implementing this interface don't
|
||||
// correctly send unilateral updates, for instance if a user logs in from two
|
||||
// connections and deletes a message from one of them, the over is not aware
|
||||
// that such a mesage has been deleted. More importantly, backends implementing
|
||||
// Updater can notify the user for external updates such as new message
|
||||
// notifications.
|
||||
type BackendUpdater interface {
|
||||
// Updates returns a set of channels where updates are sent to.
|
||||
Updates() <-chan Update
|
||||
}
|
||||
|
||||
// MailboxPoller is a Mailbox that is able to poll updates for new messages or
|
||||
// message status updates during a period of inactivity.
|
||||
type MailboxPoller interface {
|
||||
// Poll requests mailbox updates.
|
||||
Poll() error
|
||||
}
|
||||
92
backend/user.go
Normal file
92
backend/user.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package backend
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
// ErrNoSuchMailbox is returned by User.GetMailbox, User.DeleteMailbox and
|
||||
// User.RenameMailbox when retrieving, deleting or renaming a mailbox that
|
||||
// doesn't exist.
|
||||
ErrNoSuchMailbox = errors.New("No such mailbox")
|
||||
// ErrMailboxAlreadyExists is returned by User.CreateMailbox and
|
||||
// User.RenameMailbox when creating or renaming mailbox that already exists.
|
||||
ErrMailboxAlreadyExists = errors.New("Mailbox already exists")
|
||||
)
|
||||
|
||||
// User represents a user in the mail storage system. A user operation always
|
||||
// deals with mailboxes.
|
||||
type User interface {
|
||||
// Username returns this user's username.
|
||||
Username() string
|
||||
|
||||
// ListMailboxes returns a list of mailboxes belonging to this user. If
|
||||
// subscribed is set to true, only returns subscribed mailboxes.
|
||||
ListMailboxes(subscribed bool) ([]Mailbox, error)
|
||||
|
||||
// GetMailbox returns a mailbox. If it doesn't exist, it returns
|
||||
// ErrNoSuchMailbox.
|
||||
GetMailbox(name string) (Mailbox, error)
|
||||
|
||||
// CreateMailbox creates a new mailbox.
|
||||
//
|
||||
// If the mailbox already exists, an error must be returned. If the mailbox
|
||||
// name is suffixed with the server's hierarchy separator character, this is a
|
||||
// declaration that the client intends to create mailbox names under this name
|
||||
// in the hierarchy.
|
||||
//
|
||||
// If the server's hierarchy separator character appears elsewhere in the
|
||||
// name, the server SHOULD create any superior hierarchical names that are
|
||||
// needed for the CREATE command to be successfully completed. In other
|
||||
// words, an attempt to create "foo/bar/zap" on a server in which "/" is the
|
||||
// hierarchy separator character SHOULD create foo/ and foo/bar/ if they do
|
||||
// not already exist.
|
||||
//
|
||||
// If a new mailbox is created with the same name as a mailbox which was
|
||||
// deleted, its unique identifiers MUST be greater than any unique identifiers
|
||||
// used in the previous incarnation of the mailbox UNLESS the new incarnation
|
||||
// has a different unique identifier validity value.
|
||||
CreateMailbox(name string) error
|
||||
|
||||
// DeleteMailbox permanently remove the mailbox with the given name. It is an
|
||||
// error to // attempt to delete INBOX or a mailbox name that does not exist.
|
||||
//
|
||||
// The DELETE command MUST NOT remove inferior hierarchical names. For
|
||||
// example, if a mailbox "foo" has an inferior "foo.bar" (assuming "." is the
|
||||
// hierarchy delimiter character), removing "foo" MUST NOT remove "foo.bar".
|
||||
//
|
||||
// The value of the highest-used unique identifier of the deleted mailbox MUST
|
||||
// be preserved so that a new mailbox created with the same name will not
|
||||
// reuse the identifiers of the former incarnation, UNLESS the new incarnation
|
||||
// has a different unique identifier validity value.
|
||||
DeleteMailbox(name string) error
|
||||
|
||||
// RenameMailbox changes the name of a mailbox. It is an error to attempt to
|
||||
// rename from a mailbox name that does not exist or to a mailbox name that
|
||||
// already exists.
|
||||
//
|
||||
// If the name has inferior hierarchical names, then the inferior hierarchical
|
||||
// names MUST also be renamed. For example, a rename of "foo" to "zap" will
|
||||
// rename "foo/bar" (assuming "/" is the hierarchy delimiter character) to
|
||||
// "zap/bar".
|
||||
//
|
||||
// If the server's hierarchy separator character appears in the name, the
|
||||
// server SHOULD create any superior hierarchical names that are needed for
|
||||
// the RENAME command to complete successfully. In other words, an attempt to
|
||||
// rename "foo/bar/zap" to baz/rag/zowie on a server in which "/" is the
|
||||
// hierarchy separator character SHOULD create baz/ and baz/rag/ if they do
|
||||
// not already exist.
|
||||
//
|
||||
// The value of the highest-used unique identifier of the old mailbox name
|
||||
// MUST be preserved so that a new mailbox created with the same name will not
|
||||
// reuse the identifiers of the former incarnation, UNLESS the new incarnation
|
||||
// has a different unique identifier validity value.
|
||||
//
|
||||
// Renaming INBOX is permitted, and has special behavior. It moves all
|
||||
// messages in INBOX to a new mailbox with the given name, leaving INBOX
|
||||
// empty. If the server implementation supports inferior hierarchical names
|
||||
// of INBOX, these are unaffected by a rename of INBOX.
|
||||
RenameMailbox(existingName, newName string) error
|
||||
|
||||
// Logout is called when this User will no longer be used, likely because the
|
||||
// client closed the connection.
|
||||
Logout() error
|
||||
}
|
||||
Reference in New Issue
Block a user