Added files.

This commit is contained in:
2025-12-08 06:42:29 +02:00
commit a65a31fdac
109 changed files with 16539 additions and 0 deletions

123
imapserver/append.go Normal file
View File

@@ -0,0 +1,123 @@
package imapserver
import (
"fmt"
"io"
"strings"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
// defaultAppendLimit is the default maximum size of an APPEND payload.
const defaultAppendLimit = 100 * 1024 * 1024 // 100MiB
func (c *Conn) handleAppend(tag string, dec *imapwire.Decoder) error {
var (
mailbox string
options imap.AppendOptions
)
if !dec.ExpectSP() || !dec.ExpectMailbox(&mailbox) || !dec.ExpectSP() {
return dec.Err()
}
hasFlagList, err := dec.List(func() error {
flag, err := internal.ExpectFlag(dec)
if err != nil {
return err
}
options.Flags = append(options.Flags, flag)
return nil
})
if err != nil {
return err
}
if hasFlagList && !dec.ExpectSP() {
return dec.Err()
}
t, err := internal.DecodeDateTime(dec)
if err != nil {
return err
}
if !t.IsZero() && !dec.ExpectSP() {
return dec.Err()
}
options.Time = t
var dataExt string
if !dec.Special('~') && dec.Atom(&dataExt) { // ignore literal8 prefix if any for BINARY
switch strings.ToUpper(dataExt) {
case "UTF8":
// '~' is the literal8 prefix
if !dec.ExpectSP() || !dec.ExpectSpecial('(') || !dec.ExpectSpecial('~') {
return dec.Err()
}
default:
return newClientBugError("Unknown APPEND data extension")
}
}
lit, nonSync, err := dec.ExpectLiteralReader()
if err != nil {
return err
}
appendLimit := int64(defaultAppendLimit)
if appendLimitSession, ok := c.session.(SessionAppendLimit); ok {
appendLimit = int64(appendLimitSession.AppendLimit())
}
if lit.Size() > appendLimit {
return &imap.Error{
Type: imap.StatusResponseTypeNo,
Code: imap.ResponseCodeTooBig,
Text: fmt.Sprintf("Literals are limited to %v bytes for this command", appendLimit),
}
}
if err := c.acceptLiteral(lit.Size(), nonSync); err != nil {
return err
}
c.setReadTimeout(literalReadTimeout)
defer c.setReadTimeout(cmdReadTimeout)
if err := c.checkState(imap.ConnStateAuthenticated); err != nil {
io.Copy(io.Discard, lit)
dec.CRLF()
return err
}
data, appendErr := c.session.Append(mailbox, lit, &options)
if _, discardErr := io.Copy(io.Discard, lit); discardErr != nil {
return err
}
if dataExt != "" && !dec.ExpectSpecial(')') {
return dec.Err()
}
if !dec.ExpectCRLF() {
return err
}
if appendErr != nil {
return appendErr
}
if err := c.poll("APPEND"); err != nil {
return err
}
return c.writeAppendOK(tag, data)
}
func (c *Conn) writeAppendOK(tag string, data *imap.AppendData) error {
enc := newResponseEncoder(c)
defer enc.end()
enc.Atom(tag).SP().Atom("OK").SP()
if data != nil {
enc.Special('[')
enc.Atom("APPENDUID").SP().Number(data.UIDValidity).SP().UID(data.UID)
enc.Special(']').SP()
}
enc.Text("APPEND completed")
return enc.CRLF()
}

148
imapserver/authenticate.go Normal file
View File

@@ -0,0 +1,148 @@
package imapserver
import (
"fmt"
"strings"
"github.com/emersion/go-sasl"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
func (c *Conn) handleAuthenticate(tag string, dec *imapwire.Decoder) error {
var mech string
if !dec.ExpectSP() || !dec.ExpectAtom(&mech) {
return dec.Err()
}
mech = strings.ToUpper(mech)
var initialResp []byte
if dec.SP() {
var initialRespStr string
if !dec.ExpectText(&initialRespStr) {
return dec.Err()
}
var err error
initialResp, err = internal.DecodeSASL(initialRespStr)
if err != nil {
return err
}
}
if !dec.ExpectCRLF() {
return dec.Err()
}
if err := c.checkState(imap.ConnStateNotAuthenticated); err != nil {
return err
}
if !c.canAuth() {
return &imap.Error{
Type: imap.StatusResponseTypeNo,
Code: imap.ResponseCodePrivacyRequired,
Text: "TLS is required to authenticate",
}
}
var saslServer sasl.Server
if authSess, ok := c.session.(SessionSASL); ok {
var err error
saslServer, err = authSess.Authenticate(mech)
if err != nil {
return err
}
} else {
if mech != "PLAIN" {
return &imap.Error{
Type: imap.StatusResponseTypeNo,
Text: "SASL mechanism not supported",
}
}
saslServer = sasl.NewPlainServer(func(identity, username, password string) error {
if identity != "" && identity != username {
return &imap.Error{
Type: imap.StatusResponseTypeNo,
Code: imap.ResponseCodeAuthorizationFailed,
Text: "SASL identity not supported",
}
}
return c.session.Login(username, password)
})
}
enc := newResponseEncoder(c)
defer enc.end()
resp := initialResp
for {
challenge, done, err := saslServer.Next(resp)
if err != nil {
return err
} else if done {
break
}
var challengeStr string
if challenge != nil {
challengeStr = internal.EncodeSASL(challenge)
}
if err := writeContReq(enc.Encoder, challengeStr); err != nil {
return err
}
encodedResp, isPrefix, err := c.br.ReadLine()
if err != nil {
return err
} else if isPrefix {
return fmt.Errorf("SASL response too long")
} else if string(encodedResp) == "*" {
return &imap.Error{
Type: imap.StatusResponseTypeBad,
Text: "AUTHENTICATE cancelled",
}
}
resp, err = decodeSASL(string(encodedResp))
if err != nil {
return err
}
}
c.state = imap.ConnStateAuthenticated
text := fmt.Sprintf("%v authentication successful", mech)
return writeCapabilityOK(enc.Encoder, tag, c.availableCaps(), text)
}
func decodeSASL(s string) ([]byte, error) {
b, err := internal.DecodeSASL(s)
if err != nil {
return nil, &imap.Error{
Type: imap.StatusResponseTypeBad,
Text: "Malformed SASL response",
}
}
return b, nil
}
func (c *Conn) handleUnauthenticate(dec *imapwire.Decoder) error {
if !dec.ExpectCRLF() {
return dec.Err()
}
if err := c.checkState(imap.ConnStateAuthenticated); err != nil {
return err
}
session, ok := c.session.(SessionUnauthenticate)
if !ok {
return newClientBugError("UNAUTHENTICATE is not supported")
}
if err := session.Unauthenticate(); err != nil {
return err
}
c.state = imap.ConnStateNotAuthenticated
c.mutex.Lock()
c.enabled = make(imap.CapSet)
c.mutex.Unlock()
return nil
}

114
imapserver/capability.go Normal file
View File

@@ -0,0 +1,114 @@
package imapserver
import (
"fmt"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
func (c *Conn) handleCapability(dec *imapwire.Decoder) error {
if !dec.ExpectCRLF() {
return dec.Err()
}
enc := newResponseEncoder(c)
defer enc.end()
enc.Atom("*").SP().Atom("CAPABILITY")
for _, c := range c.availableCaps() {
enc.SP().Atom(string(c))
}
return enc.CRLF()
}
// availableCaps returns the capabilities supported by the server.
//
// They depend on the connection state.
//
// Some extensions (e.g. SASL-IR, ENABLE) don't require backend support and
// thus are always enabled.
func (c *Conn) availableCaps() []imap.Cap {
available := c.server.options.caps()
var caps []imap.Cap
addAvailableCaps(&caps, available, []imap.Cap{
imap.CapIMAP4rev2,
imap.CapIMAP4rev1,
})
if len(caps) == 0 {
panic("imapserver: must support at least IMAP4rev1 or IMAP4rev2")
}
if available.Has(imap.CapIMAP4rev1) {
caps = append(caps, []imap.Cap{
imap.CapSASLIR,
imap.CapLiteralMinus,
}...)
}
if c.canStartTLS() {
caps = append(caps, imap.CapStartTLS)
}
if c.canAuth() {
mechs := []string{"PLAIN"}
if authSess, ok := c.session.(SessionSASL); ok {
mechs = authSess.AuthenticateMechanisms()
}
for _, mech := range mechs {
caps = append(caps, imap.Cap("AUTH="+mech))
}
} else if c.state == imap.ConnStateNotAuthenticated {
caps = append(caps, imap.CapLoginDisabled)
}
if c.state == imap.ConnStateAuthenticated || c.state == imap.ConnStateSelected {
if available.Has(imap.CapIMAP4rev1) {
// IMAP4rev1-specific capabilities that don't require backend
// support and are not applicable to IMAP4rev2
caps = append(caps, []imap.Cap{
imap.CapUnselect,
imap.CapEnable,
imap.CapIdle,
imap.CapUTF8Accept,
}...)
// IMAP4rev1-specific capabilities which require backend support
// and are not applicable to IMAP4rev2
addAvailableCaps(&caps, available, []imap.Cap{
imap.CapNamespace,
imap.CapUIDPlus,
imap.CapESearch,
imap.CapSearchRes,
imap.CapListExtended,
imap.CapListStatus,
imap.CapMove,
imap.CapStatusSize,
imap.CapBinary,
imap.CapChildren,
})
}
// Capabilities which require backend support and apply to both
// IMAP4rev1 and IMAP4rev2
addAvailableCaps(&caps, available, []imap.Cap{
imap.CapSpecialUse,
imap.CapCreateSpecialUse,
imap.CapLiteralPlus,
imap.CapUnauthenticate,
})
if appendLimitSession, ok := c.session.(SessionAppendLimit); ok {
limit := appendLimitSession.AppendLimit()
caps = append(caps, imap.Cap(fmt.Sprintf("APPENDLIMIT=%d", limit)))
} else {
addAvailableCaps(&caps, available, []imap.Cap{imap.CapAppendLimit})
}
}
return caps
}
func addAvailableCaps(caps *[]imap.Cap, available imap.CapSet, l []imap.Cap) {
for _, c := range l {
if available.Has(c) {
*caps = append(*caps, c)
}
}
}

618
imapserver/conn.go Normal file
View File

@@ -0,0 +1,618 @@
package imapserver
import (
"bufio"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"runtime/debug"
"strings"
"sync"
"time"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
const (
cmdReadTimeout = 30 * time.Second
idleReadTimeout = 35 * time.Minute // section 5.4 says 30min minimum
literalReadTimeout = 5 * time.Minute
respWriteTimeout = 30 * time.Second
literalWriteTimeout = 5 * time.Minute
maxCommandSize = 50 * 1024 // RFC 2683 section 3.2.1.5 says 8KiB minimum
)
var internalServerErrorResp = &imap.StatusResponse{
Type: imap.StatusResponseTypeNo,
Code: imap.ResponseCodeServerBug,
Text: "Internal server error",
}
// A Conn represents an IMAP connection to the server.
type Conn struct {
server *Server
br *bufio.Reader
bw *bufio.Writer
encMutex sync.Mutex
mutex sync.Mutex
conn net.Conn
enabled imap.CapSet
state imap.ConnState
session Session
}
func newConn(c net.Conn, server *Server) *Conn {
rw := server.options.wrapReadWriter(c)
br := bufio.NewReader(rw)
bw := bufio.NewWriter(rw)
return &Conn{
conn: c,
server: server,
br: br,
bw: bw,
enabled: make(imap.CapSet),
}
}
// NetConn returns the underlying connection that is wrapped by the IMAP
// connection.
//
// Writing to or reading from this connection directly will corrupt the IMAP
// session.
func (c *Conn) NetConn() net.Conn {
c.mutex.Lock()
defer c.mutex.Unlock()
return c.conn
}
// Bye terminates the IMAP connection.
func (c *Conn) Bye(text string) error {
respErr := c.writeStatusResp("", &imap.StatusResponse{
Type: imap.StatusResponseTypeBye,
Text: text,
})
closeErr := c.conn.Close()
if respErr != nil {
return respErr
}
return closeErr
}
func (c *Conn) EnabledCaps() imap.CapSet {
c.mutex.Lock()
defer c.mutex.Unlock()
return c.enabled.Copy()
}
func (c *Conn) serve() {
defer func() {
if v := recover(); v != nil {
c.server.logger().Printf("panic handling command: %v\n%s", v, debug.Stack())
}
c.conn.Close()
}()
c.server.mutex.Lock()
c.server.conns[c] = struct{}{}
c.server.mutex.Unlock()
defer func() {
c.server.mutex.Lock()
delete(c.server.conns, c)
c.server.mutex.Unlock()
}()
var (
greetingData *GreetingData
err error
)
c.session, greetingData, err = c.server.options.NewSession(c)
if err != nil {
var (
resp *imap.StatusResponse
imapErr *imap.Error
)
if errors.As(err, &imapErr) && imapErr.Type == imap.StatusResponseTypeBye {
resp = (*imap.StatusResponse)(imapErr)
} else {
c.server.logger().Printf("failed to create session: %v", err)
resp = internalServerErrorResp
}
if err := c.writeStatusResp("", resp); err != nil {
c.server.logger().Printf("failed to write greeting: %v", err)
}
return
}
defer func() {
if c.session != nil {
if err := c.session.Close(); err != nil {
c.server.logger().Printf("failed to close session: %v", err)
}
}
}()
caps := c.server.options.caps()
if _, ok := c.session.(SessionIMAP4rev2); !ok && caps.Has(imap.CapIMAP4rev2) {
panic("imapserver: server advertises IMAP4rev2 but session doesn't support it")
}
if _, ok := c.session.(SessionNamespace); !ok && caps.Has(imap.CapNamespace) {
panic("imapserver: server advertises NAMESPACE but session doesn't support it")
}
if _, ok := c.session.(SessionMove); !ok && caps.Has(imap.CapMove) {
panic("imapserver: server advertises MOVE but session doesn't support it")
}
if _, ok := c.session.(SessionUnauthenticate); !ok && caps.Has(imap.CapUnauthenticate) {
panic("imapserver: server advertises UNAUTHENTICATE but session doesn't support it")
}
c.state = imap.ConnStateNotAuthenticated
statusType := imap.StatusResponseTypeOK
if greetingData != nil && greetingData.PreAuth {
c.state = imap.ConnStateAuthenticated
statusType = imap.StatusResponseTypePreAuth
}
if err := c.writeCapabilityStatus("", statusType, "IMAP server ready"); err != nil {
c.server.logger().Printf("failed to write greeting: %v", err)
return
}
for {
var readTimeout time.Duration
switch c.state {
case imap.ConnStateAuthenticated, imap.ConnStateSelected:
readTimeout = idleReadTimeout
default:
readTimeout = cmdReadTimeout
}
c.setReadTimeout(readTimeout)
dec := imapwire.NewDecoder(c.br, imapwire.ConnSideServer)
dec.MaxSize = maxCommandSize
dec.CheckBufferedLiteralFunc = c.checkBufferedLiteral
if c.state == imap.ConnStateLogout || dec.EOF() {
break
}
c.setReadTimeout(cmdReadTimeout)
if err := c.readCommand(dec); err != nil {
if !errors.Is(err, net.ErrClosed) {
c.server.logger().Printf("failed to read command: %v", err)
}
break
}
}
}
func (c *Conn) readCommand(dec *imapwire.Decoder) error {
var tag, name string
if !dec.ExpectAtom(&tag) || !dec.ExpectSP() || !dec.ExpectAtom(&name) {
return fmt.Errorf("in command: %w", dec.Err())
}
name = strings.ToUpper(name)
numKind := NumKindSeq
if name == "UID" {
numKind = NumKindUID
var subName string
if !dec.ExpectSP() || !dec.ExpectAtom(&subName) {
return fmt.Errorf("in command: %w", dec.Err())
}
name = "UID " + strings.ToUpper(subName)
}
// TODO: handle multiple commands concurrently
sendOK := true
var err error
switch name {
case "NOOP", "CHECK":
err = c.handleNoop(dec)
case "LOGOUT":
err = c.handleLogout(dec)
case "CAPABILITY":
err = c.handleCapability(dec)
case "STARTTLS":
err = c.handleStartTLS(tag, dec)
sendOK = false
case "AUTHENTICATE":
err = c.handleAuthenticate(tag, dec)
sendOK = false
case "UNAUTHENTICATE":
err = c.handleUnauthenticate(dec)
case "LOGIN":
err = c.handleLogin(tag, dec)
sendOK = false
case "ENABLE":
err = c.handleEnable(dec)
case "CREATE":
err = c.handleCreate(dec)
case "DELETE":
err = c.handleDelete(dec)
case "RENAME":
err = c.handleRename(dec)
case "SUBSCRIBE":
err = c.handleSubscribe(dec)
case "UNSUBSCRIBE":
err = c.handleUnsubscribe(dec)
case "STATUS":
err = c.handleStatus(dec)
case "LIST":
err = c.handleList(dec)
case "LSUB":
err = c.handleLSub(dec)
case "NAMESPACE":
err = c.handleNamespace(dec)
case "IDLE":
err = c.handleIdle(dec)
case "SELECT", "EXAMINE":
err = c.handleSelect(tag, dec, name == "EXAMINE")
sendOK = false
case "CLOSE", "UNSELECT":
err = c.handleUnselect(dec, name == "CLOSE")
case "APPEND":
err = c.handleAppend(tag, dec)
sendOK = false
case "FETCH", "UID FETCH":
err = c.handleFetch(dec, numKind)
case "EXPUNGE":
err = c.handleExpunge(dec)
case "UID EXPUNGE":
err = c.handleUIDExpunge(dec)
case "STORE", "UID STORE":
err = c.handleStore(dec, numKind)
case "COPY", "UID COPY":
err = c.handleCopy(tag, dec, numKind)
sendOK = false
case "MOVE", "UID MOVE":
err = c.handleMove(dec, numKind)
case "SEARCH", "UID SEARCH":
err = c.handleSearch(tag, dec, numKind)
default:
if c.state == imap.ConnStateNotAuthenticated {
// Don't allow a single unknown command before authentication to
// mitigate cross-protocol attacks:
// https://www-archive.mozilla.org/projects/netlib/portbanning
c.state = imap.ConnStateLogout
defer c.Bye("Unknown command")
}
err = &imap.Error{
Type: imap.StatusResponseTypeBad,
Text: "Unknown command",
}
}
dec.DiscardLine()
var (
resp *imap.StatusResponse
imapErr *imap.Error
decErr *imapwire.DecoderExpectError
)
if errors.As(err, &imapErr) {
resp = (*imap.StatusResponse)(imapErr)
} else if errors.As(err, &decErr) {
resp = &imap.StatusResponse{
Type: imap.StatusResponseTypeBad,
Code: imap.ResponseCodeClientBug,
Text: "Syntax error: " + decErr.Message,
}
} else if err != nil {
c.server.logger().Printf("handling %v command: %v", name, err)
resp = internalServerErrorResp
} else {
if !sendOK {
return nil
}
if err := c.poll(name); err != nil {
return err
}
resp = &imap.StatusResponse{
Type: imap.StatusResponseTypeOK,
Text: fmt.Sprintf("%v completed", name),
}
}
return c.writeStatusResp(tag, resp)
}
func (c *Conn) handleNoop(dec *imapwire.Decoder) error {
if !dec.ExpectCRLF() {
return dec.Err()
}
return nil
}
func (c *Conn) handleLogout(dec *imapwire.Decoder) error {
if !dec.ExpectCRLF() {
return dec.Err()
}
c.state = imap.ConnStateLogout
return c.writeStatusResp("", &imap.StatusResponse{
Type: imap.StatusResponseTypeBye,
Text: "Logging out",
})
}
func (c *Conn) handleDelete(dec *imapwire.Decoder) error {
var name string
if !dec.ExpectSP() || !dec.ExpectMailbox(&name) || !dec.ExpectCRLF() {
return dec.Err()
}
if err := c.checkState(imap.ConnStateAuthenticated); err != nil {
return err
}
return c.session.Delete(name)
}
func (c *Conn) handleRename(dec *imapwire.Decoder) error {
var oldName, newName string
if !dec.ExpectSP() || !dec.ExpectMailbox(&oldName) || !dec.ExpectSP() || !dec.ExpectMailbox(&newName) || !dec.ExpectCRLF() {
return dec.Err()
}
if err := c.checkState(imap.ConnStateAuthenticated); err != nil {
return err
}
var options imap.RenameOptions
return c.session.Rename(oldName, newName, &options)
}
func (c *Conn) handleSubscribe(dec *imapwire.Decoder) error {
var name string
if !dec.ExpectSP() || !dec.ExpectMailbox(&name) || !dec.ExpectCRLF() {
return dec.Err()
}
if err := c.checkState(imap.ConnStateAuthenticated); err != nil {
return err
}
return c.session.Subscribe(name)
}
func (c *Conn) handleUnsubscribe(dec *imapwire.Decoder) error {
var name string
if !dec.ExpectSP() || !dec.ExpectMailbox(&name) || !dec.ExpectCRLF() {
return dec.Err()
}
if err := c.checkState(imap.ConnStateAuthenticated); err != nil {
return err
}
return c.session.Unsubscribe(name)
}
func (c *Conn) checkBufferedLiteral(size int64, nonSync bool) error {
if size > 4096 {
return &imap.Error{
Type: imap.StatusResponseTypeNo,
Code: imap.ResponseCodeTooBig,
Text: "Literals are limited to 4096 bytes for this command",
}
}
return c.acceptLiteral(size, nonSync)
}
func (c *Conn) acceptLiteral(size int64, nonSync bool) error {
if nonSync && size > 4096 && !c.server.options.caps().Has(imap.CapLiteralPlus) {
return &imap.Error{
Type: imap.StatusResponseTypeBad,
Text: "Non-synchronizing literals are limited to 4096 bytes",
}
}
if nonSync {
return nil
}
return c.writeContReq("Ready for literal data")
}
func (c *Conn) canAuth() bool {
if c.state != imap.ConnStateNotAuthenticated {
return false
}
_, isTLS := c.conn.(*tls.Conn)
return isTLS || c.server.options.InsecureAuth
}
func (c *Conn) writeStatusResp(tag string, statusResp *imap.StatusResponse) error {
enc := newResponseEncoder(c)
defer enc.end()
return writeStatusResp(enc.Encoder, tag, statusResp)
}
func (c *Conn) writeContReq(text string) error {
enc := newResponseEncoder(c)
defer enc.end()
return writeContReq(enc.Encoder, text)
}
func (c *Conn) writeCapabilityStatus(tag string, typ imap.StatusResponseType, text string) error {
enc := newResponseEncoder(c)
defer enc.end()
return writeCapabilityStatus(enc.Encoder, tag, typ, c.availableCaps(), text)
}
func (c *Conn) checkState(state imap.ConnState) error {
if state == imap.ConnStateAuthenticated && c.state == imap.ConnStateSelected {
return nil
}
if c.state != state {
return newClientBugError(fmt.Sprintf("This command is only valid in the %s state", state))
}
return nil
}
func (c *Conn) setReadTimeout(dur time.Duration) {
if dur > 0 {
c.conn.SetReadDeadline(time.Now().Add(dur))
} else {
c.conn.SetReadDeadline(time.Time{})
}
}
func (c *Conn) setWriteTimeout(dur time.Duration) {
if dur > 0 {
c.conn.SetWriteDeadline(time.Now().Add(dur))
} else {
c.conn.SetWriteDeadline(time.Time{})
}
}
func (c *Conn) poll(cmd string) error {
switch c.state {
case imap.ConnStateAuthenticated, imap.ConnStateSelected:
// nothing to do
default:
return nil
}
allowExpunge := true
switch cmd {
case "FETCH", "STORE", "SEARCH":
allowExpunge = false
}
w := &UpdateWriter{conn: c, allowExpunge: allowExpunge}
return c.session.Poll(w, allowExpunge)
}
type responseEncoder struct {
*imapwire.Encoder
conn *Conn
}
func newResponseEncoder(conn *Conn) *responseEncoder {
conn.mutex.Lock()
quotedUTF8 := conn.enabled.Has(imap.CapIMAP4rev2) || conn.enabled.Has(imap.CapUTF8Accept)
conn.mutex.Unlock()
wireEnc := imapwire.NewEncoder(conn.bw, imapwire.ConnSideServer)
wireEnc.QuotedUTF8 = quotedUTF8
conn.encMutex.Lock() // released by responseEncoder.end
conn.setWriteTimeout(respWriteTimeout)
return &responseEncoder{
Encoder: wireEnc,
conn: conn,
}
}
func (enc *responseEncoder) end() {
if enc.Encoder == nil {
panic("imapserver: responseEncoder.end called twice")
}
enc.Encoder = nil
enc.conn.setWriteTimeout(0)
enc.conn.encMutex.Unlock()
}
func (enc *responseEncoder) Literal(size int64) io.WriteCloser {
enc.conn.setWriteTimeout(literalWriteTimeout)
return literalWriter{
WriteCloser: enc.Encoder.Literal(size, nil),
conn: enc.conn,
}
}
type literalWriter struct {
io.WriteCloser
conn *Conn
}
func (lw literalWriter) Close() error {
lw.conn.setWriteTimeout(respWriteTimeout)
return lw.WriteCloser.Close()
}
func writeStatusResp(enc *imapwire.Encoder, tag string, statusResp *imap.StatusResponse) error {
if tag == "" {
tag = "*"
}
enc.Atom(tag).SP().Atom(string(statusResp.Type)).SP()
if statusResp.Code != "" {
enc.Atom(fmt.Sprintf("[%v]", statusResp.Code)).SP()
}
enc.Text(statusResp.Text)
return enc.CRLF()
}
func writeCapabilityOK(enc *imapwire.Encoder, tag string, caps []imap.Cap, text string) error {
return writeCapabilityStatus(enc, tag, imap.StatusResponseTypeOK, caps, text)
}
func writeCapabilityStatus(enc *imapwire.Encoder, tag string, typ imap.StatusResponseType, caps []imap.Cap, text string) error {
if tag == "" {
tag = "*"
}
enc.Atom(tag).SP().Atom(string(typ)).SP().Special('[').Atom("CAPABILITY")
for _, c := range caps {
enc.SP().Atom(string(c))
}
enc.Special(']').SP().Text(text)
return enc.CRLF()
}
func writeContReq(enc *imapwire.Encoder, text string) error {
return enc.Atom("+").SP().Text(text).CRLF()
}
func newClientBugError(text string) error {
return &imap.Error{
Type: imap.StatusResponseTypeBad,
Code: imap.ResponseCodeClientBug,
Text: text,
}
}
// UpdateWriter writes status updates.
type UpdateWriter struct {
conn *Conn
allowExpunge bool
}
// WriteExpunge writes an EXPUNGE response.
func (w *UpdateWriter) WriteExpunge(seqNum uint32) error {
if !w.allowExpunge {
return fmt.Errorf("imapserver: EXPUNGE updates are not allowed in this context")
}
return w.conn.writeExpunge(seqNum)
}
// WriteNumMessages writes an EXISTS response.
func (w *UpdateWriter) WriteNumMessages(n uint32) error {
return w.conn.writeExists(n)
}
// WriteNumRecent writes an RECENT response (not used in IMAP4rev2, will be ignored).
func (w *UpdateWriter) WriteNumRecent(n uint32) error {
if w.conn.enabled.Has(imap.CapIMAP4rev2) || !w.conn.server.options.caps().Has(imap.CapIMAP4rev1) {
return nil
}
return w.conn.writeObsoleteRecent(n)
}
// WriteMailboxFlags writes a FLAGS response.
func (w *UpdateWriter) WriteMailboxFlags(flags []imap.Flag) error {
return w.conn.writeFlags(flags)
}
// WriteMessageFlags writes a FETCH response with FLAGS.
func (w *UpdateWriter) WriteMessageFlags(seqNum uint32, uid imap.UID, flags []imap.Flag) error {
fetchWriter := &FetchWriter{conn: w.conn}
respWriter := fetchWriter.CreateMessage(seqNum)
if uid != 0 {
respWriter.WriteUID(uid)
}
respWriter.WriteFlags(flags)
return respWriter.Close()
}

55
imapserver/copy.go Normal file
View File

@@ -0,0 +1,55 @@
package imapserver
import (
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
func (c *Conn) handleCopy(tag string, dec *imapwire.Decoder, numKind NumKind) error {
numSet, dest, err := readCopy(numKind, dec)
if err != nil {
return err
}
if err := c.checkState(imap.ConnStateSelected); err != nil {
return err
}
data, err := c.session.Copy(numSet, dest)
if err != nil {
return err
}
cmdName := "COPY"
if numKind == NumKindUID {
cmdName = "UID COPY"
}
if err := c.poll(cmdName); err != nil {
return err
}
return c.writeCopyOK(tag, data)
}
func (c *Conn) writeCopyOK(tag string, data *imap.CopyData) error {
enc := newResponseEncoder(c)
defer enc.end()
if tag == "" {
tag = "*"
}
enc.Atom(tag).SP().Atom("OK").SP()
if data != nil {
enc.Special('[')
enc.Atom("COPYUID").SP().Number(data.UIDValidity).SP().NumSet(data.SourceUIDs).SP().NumSet(data.DestUIDs)
enc.Special(']').SP()
}
enc.Text("COPY completed")
return enc.CRLF()
}
func readCopy(numKind NumKind, dec *imapwire.Decoder) (numSet imap.NumSet, dest string, err error) {
if !dec.ExpectSP() || !dec.ExpectNumSet(numKind.wire(), &numSet) || !dec.ExpectSP() || !dec.ExpectMailbox(&dest) || !dec.ExpectCRLF() {
return nil, "", dec.Err()
}
return numSet, dest, nil
}

45
imapserver/create.go Normal file
View File

@@ -0,0 +1,45 @@
package imapserver
import (
"strings"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
func (c *Conn) handleCreate(dec *imapwire.Decoder) error {
var (
name string
options imap.CreateOptions
)
if !dec.ExpectSP() || !dec.ExpectMailbox(&name) {
return dec.Err()
}
if dec.SP() {
var name string
if !dec.ExpectSpecial('(') || !dec.ExpectAtom(&name) || !dec.ExpectSP() {
return dec.Err()
}
switch strings.ToUpper(name) {
case "USE":
var err error
options.SpecialUse, err = internal.ExpectMailboxAttrList(dec)
if err != nil {
return err
}
default:
return newClientBugError("unknown CREATE parameter")
}
if !dec.ExpectSpecial(')') {
return dec.Err()
}
}
if !dec.ExpectCRLF() {
return dec.Err()
}
if err := c.checkState(imap.ConnStateAuthenticated); err != nil {
return err
}
return c.session.Create(name, &options)
}

47
imapserver/enable.go Normal file
View File

@@ -0,0 +1,47 @@
package imapserver
import (
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
func (c *Conn) handleEnable(dec *imapwire.Decoder) error {
var requested []imap.Cap
for dec.SP() {
cap, err := internal.ExpectCap(dec)
if err != nil {
return err
}
requested = append(requested, cap)
}
if !dec.ExpectCRLF() {
return dec.Err()
}
if err := c.checkState(imap.ConnStateAuthenticated); err != nil {
return err
}
var enabled []imap.Cap
for _, req := range requested {
switch req {
case imap.CapIMAP4rev2, imap.CapUTF8Accept:
enabled = append(enabled, req)
}
}
c.mutex.Lock()
for _, e := range enabled {
c.enabled[e] = struct{}{}
}
c.mutex.Unlock()
enc := newResponseEncoder(c)
defer enc.end()
enc.Atom("*").SP().Atom("ENABLED")
for _, c := range enabled {
enc.SP().Atom(string(c))
}
return enc.CRLF()
}

50
imapserver/expunge.go Normal file
View File

@@ -0,0 +1,50 @@
package imapserver
import (
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
func (c *Conn) handleExpunge(dec *imapwire.Decoder) error {
if !dec.ExpectCRLF() {
return dec.Err()
}
return c.expunge(nil)
}
func (c *Conn) handleUIDExpunge(dec *imapwire.Decoder) error {
var uidSet imap.UIDSet
if !dec.ExpectSP() || !dec.ExpectUIDSet(&uidSet) || !dec.ExpectCRLF() {
return dec.Err()
}
return c.expunge(&uidSet)
}
func (c *Conn) expunge(uids *imap.UIDSet) error {
if err := c.checkState(imap.ConnStateSelected); err != nil {
return err
}
w := &ExpungeWriter{conn: c}
return c.session.Expunge(w, uids)
}
func (c *Conn) writeExpunge(seqNum uint32) error {
enc := newResponseEncoder(c)
defer enc.end()
enc.Atom("*").SP().Number(seqNum).SP().Atom("EXPUNGE")
return enc.CRLF()
}
// ExpungeWriter writes EXPUNGE updates.
type ExpungeWriter struct {
conn *Conn
}
// WriteExpunge notifies the client that the message with the provided sequence
// number has been deleted.
func (w *ExpungeWriter) WriteExpunge(seqNum uint32) error {
if w.conn == nil {
return nil
}
return w.conn.writeExpunge(seqNum)
}

715
imapserver/fetch.go Normal file
View File

@@ -0,0 +1,715 @@
package imapserver
import (
"fmt"
"io"
"mime"
"sort"
"strings"
"time"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
const envelopeDateLayout = "Mon, 02 Jan 2006 15:04:05 -0700"
type fetchWriterOptions struct {
bodyStructure struct {
extended bool // BODYSTRUCTURE
nonExtended bool // BODY
}
obsolete map[*imap.FetchItemBodySection]string
}
func (c *Conn) handleFetch(dec *imapwire.Decoder, numKind NumKind) error {
var numSet imap.NumSet
if !dec.ExpectSP() || !dec.ExpectNumSet(numKind.wire(), &numSet) || !dec.ExpectSP() {
return dec.Err()
}
var options imap.FetchOptions
writerOptions := fetchWriterOptions{obsolete: make(map[*imap.FetchItemBodySection]string)}
isList, err := dec.List(func() error {
name, err := readFetchAttName(dec)
if err != nil {
return err
}
switch name {
case "ALL", "FAST", "FULL":
return newClientBugError("FETCH macros are not allowed in a list")
}
return handleFetchAtt(dec, name, &options, &writerOptions)
})
if err != nil {
return err
}
if !isList {
name, err := readFetchAttName(dec)
if err != nil {
return err
}
// Handle macros
switch name {
case "ALL":
options.Flags = true
options.InternalDate = true
options.RFC822Size = true
options.Envelope = true
case "FAST":
options.Flags = true
options.InternalDate = true
options.RFC822Size = true
case "FULL":
options.Flags = true
options.InternalDate = true
options.RFC822Size = true
options.Envelope = true
handleFetchBodyStructure(&options, &writerOptions, false)
default:
if err := handleFetchAtt(dec, name, &options, &writerOptions); err != nil {
return err
}
}
}
if !dec.ExpectCRLF() {
return dec.Err()
}
if err := c.checkState(imap.ConnStateSelected); err != nil {
return err
}
if numKind == NumKindUID {
options.UID = true
}
w := &FetchWriter{conn: c, options: writerOptions}
if err := c.session.Fetch(w, numSet, &options); err != nil {
return err
}
return nil
}
func handleFetchAtt(dec *imapwire.Decoder, attName string, options *imap.FetchOptions, writerOptions *fetchWriterOptions) error {
switch attName {
case "BODYSTRUCTURE":
handleFetchBodyStructure(options, writerOptions, true)
case "ENVELOPE":
options.Envelope = true
case "FLAGS":
options.Flags = true
case "INTERNALDATE":
options.InternalDate = true
case "RFC822.SIZE":
options.RFC822Size = true
case "UID":
options.UID = true
case "RFC822": // equivalent to BODY[]
bs := &imap.FetchItemBodySection{}
writerOptions.obsolete[bs] = attName
options.BodySection = append(options.BodySection, bs)
case "RFC822.PEEK": // obsolete, equivalent to BODY.PEEK[], used by Outlook
bs := &imap.FetchItemBodySection{Peek: true}
writerOptions.obsolete[bs] = attName
options.BodySection = append(options.BodySection, bs)
case "RFC822.HEADER": // equivalent to BODY.PEEK[HEADER]
bs := &imap.FetchItemBodySection{
Specifier: imap.PartSpecifierHeader,
Peek: true,
}
writerOptions.obsolete[bs] = attName
options.BodySection = append(options.BodySection, bs)
case "RFC822.TEXT": // equivalent to BODY[TEXT]
bs := &imap.FetchItemBodySection{
Specifier: imap.PartSpecifierText,
}
writerOptions.obsolete[bs] = attName
options.BodySection = append(options.BodySection, bs)
case "BINARY", "BINARY.PEEK":
part, err := readSectionBinary(dec)
if err != nil {
return err
}
partial, err := maybeReadPartial(dec)
if err != nil {
return err
}
bs := &imap.FetchItemBinarySection{
Part: part,
Partial: partial,
Peek: attName == "BINARY.PEEK",
}
options.BinarySection = append(options.BinarySection, bs)
case "BINARY.SIZE":
part, err := readSectionBinary(dec)
if err != nil {
return err
}
bss := &imap.FetchItemBinarySectionSize{Part: part}
options.BinarySectionSize = append(options.BinarySectionSize, bss)
case "BODY":
if !dec.Special('[') {
handleFetchBodyStructure(options, writerOptions, false)
return nil
}
section := imap.FetchItemBodySection{}
err := readSection(dec, &section)
if err != nil {
return err
}
section.Partial, err = maybeReadPartial(dec)
if err != nil {
return err
}
options.BodySection = append(options.BodySection, &section)
case "BODY.PEEK":
if !dec.ExpectSpecial('[') {
return dec.Err()
}
section := imap.FetchItemBodySection{Peek: true}
err := readSection(dec, &section)
if err != nil {
return err
}
section.Partial, err = maybeReadPartial(dec)
if err != nil {
return err
}
options.BodySection = append(options.BodySection, &section)
default:
return newClientBugError("Unknown FETCH data item")
}
return nil
}
func handleFetchBodyStructure(options *imap.FetchOptions, writerOptions *fetchWriterOptions, extended bool) {
if options.BodyStructure == nil || extended {
options.BodyStructure = &imap.FetchItemBodyStructure{Extended: extended}
}
if extended {
writerOptions.bodyStructure.extended = true
} else {
writerOptions.bodyStructure.nonExtended = true
}
}
func readFetchAttName(dec *imapwire.Decoder) (string, error) {
var attName string
if !dec.Expect(dec.Func(&attName, isMsgAttNameChar), "msg-att name") {
return "", dec.Err()
}
return strings.ToUpper(attName), nil
}
func isMsgAttNameChar(ch byte) bool {
return ch != '[' && imapwire.IsAtomChar(ch)
}
func readSection(dec *imapwire.Decoder, section *imap.FetchItemBodySection) error {
if dec.Special(']') {
return nil
}
var dot bool
section.Part, dot = readSectionPart(dec)
if dot || len(section.Part) == 0 {
var specifier string
if dot {
if !dec.ExpectAtom(&specifier) {
return dec.Err()
}
} else {
dec.Atom(&specifier)
}
switch specifier := imap.PartSpecifier(strings.ToUpper(specifier)); specifier {
case imap.PartSpecifierNone, imap.PartSpecifierHeader, imap.PartSpecifierMIME, imap.PartSpecifierText:
section.Specifier = specifier
case "HEADER.FIELDS", "HEADER.FIELDS.NOT":
if !dec.ExpectSP() {
return dec.Err()
}
var err error
headerList, err := readHeaderList(dec)
if err != nil {
return err
}
section.Specifier = imap.PartSpecifierHeader
if specifier == "HEADER.FIELDS" {
section.HeaderFields = headerList
} else {
section.HeaderFieldsNot = headerList
}
default:
return newClientBugError("unknown body section specifier")
}
}
if !dec.ExpectSpecial(']') {
return dec.Err()
}
return nil
}
func readSectionPart(dec *imapwire.Decoder) (part []int, dot bool) {
for {
dot = len(part) > 0
if dot && !dec.Special('.') {
return part, false
}
var num uint32
if !dec.Number(&num) {
return part, dot
}
part = append(part, int(num))
}
}
func readHeaderList(dec *imapwire.Decoder) ([]string, error) {
var l []string
err := dec.ExpectList(func() error {
var s string
if !dec.ExpectAString(&s) {
return dec.Err()
}
l = append(l, s)
return nil
})
return l, err
}
func readSectionBinary(dec *imapwire.Decoder) ([]int, error) {
if !dec.ExpectSpecial('[') {
return nil, dec.Err()
}
if dec.Special(']') {
return nil, nil
}
var l []int
for {
var num uint32
if !dec.ExpectNumber(&num) {
return l, dec.Err()
}
l = append(l, int(num))
if !dec.Special('.') {
break
}
}
if !dec.ExpectSpecial(']') {
return l, dec.Err()
}
return l, nil
}
func maybeReadPartial(dec *imapwire.Decoder) (*imap.SectionPartial, error) {
if !dec.Special('<') {
return nil, nil
}
var partial imap.SectionPartial
if !dec.ExpectNumber64(&partial.Offset) || !dec.ExpectSpecial('.') || !dec.ExpectNumber64(&partial.Size) || !dec.ExpectSpecial('>') {
return nil, dec.Err()
}
return &partial, nil
}
// FetchWriter writes FETCH responses.
type FetchWriter struct {
conn *Conn
options fetchWriterOptions
}
// CreateMessage writes a FETCH response for a message.
//
// FetchResponseWriter.Close must be called.
func (cmd *FetchWriter) CreateMessage(seqNum uint32) *FetchResponseWriter {
enc := newResponseEncoder(cmd.conn)
enc.Atom("*").SP().Number(seqNum).SP().Atom("FETCH").SP().Special('(')
return &FetchResponseWriter{enc: enc, options: cmd.options}
}
// FetchResponseWriter writes a single FETCH response for a message.
type FetchResponseWriter struct {
enc *responseEncoder
options fetchWriterOptions
hasItem bool
}
func (w *FetchResponseWriter) writeItemSep() {
if w.hasItem {
w.enc.SP()
}
w.hasItem = true
}
// WriteUID writes the message's UID.
func (w *FetchResponseWriter) WriteUID(uid imap.UID) {
w.writeItemSep()
w.enc.Atom("UID").SP().UID(uid)
}
// WriteFlags writes the message's flags.
func (w *FetchResponseWriter) WriteFlags(flags []imap.Flag) {
w.writeItemSep()
w.enc.Atom("FLAGS").SP().List(len(flags), func(i int) {
w.enc.Flag(flags[i])
})
}
// WriteRFC822Size writes the message's full size.
func (w *FetchResponseWriter) WriteRFC822Size(size int64) {
w.writeItemSep()
w.enc.Atom("RFC822.SIZE").SP().Number64(size)
}
// WriteInternalDate writes the message's internal date.
func (w *FetchResponseWriter) WriteInternalDate(t time.Time) {
w.writeItemSep()
w.enc.Atom("INTERNALDATE").SP().String(t.Format(internal.DateTimeLayout))
}
// WriteBodySection writes a body section.
//
// The returned io.WriteCloser must be closed before writing any more message
// data items.
func (w *FetchResponseWriter) WriteBodySection(section *imap.FetchItemBodySection, size int64) io.WriteCloser {
w.writeItemSep()
enc := w.enc.Encoder
if obs, ok := w.options.obsolete[section]; ok {
enc.Atom(obs)
} else {
writeItemBodySection(enc, section)
}
enc.SP()
return w.enc.Literal(size)
}
func writeItemBodySection(enc *imapwire.Encoder, section *imap.FetchItemBodySection) {
enc.Atom("BODY")
enc.Special('[')
writeSectionPart(enc, section.Part)
if len(section.Part) > 0 && section.Specifier != imap.PartSpecifierNone {
enc.Special('.')
}
if section.Specifier != imap.PartSpecifierNone {
enc.Atom(string(section.Specifier))
var headerList []string
if len(section.HeaderFields) > 0 {
headerList = section.HeaderFields
enc.Atom(".FIELDS")
} else if len(section.HeaderFieldsNot) > 0 {
headerList = section.HeaderFieldsNot
enc.Atom(".FIELDS.NOT")
}
if len(headerList) > 0 {
enc.SP().List(len(headerList), func(i int) {
enc.String(headerList[i])
})
}
}
enc.Special(']')
if partial := section.Partial; partial != nil {
enc.Special('<').Number(uint32(partial.Offset)).Special('>')
}
}
// WriteBinarySection writes a binary section.
//
// The returned io.WriteCloser must be closed before writing any more message
// data items.
func (w *FetchResponseWriter) WriteBinarySection(section *imap.FetchItemBinarySection, size int64) io.WriteCloser {
w.writeItemSep()
enc := w.enc.Encoder
enc.Atom("BINARY").Special('[')
writeSectionPart(enc, section.Part)
enc.Special(']').SP()
enc.Special('~') // indicates literal8
return w.enc.Literal(size)
}
// WriteBinarySectionSize writes a binary section size.
func (w *FetchResponseWriter) WriteBinarySectionSize(section *imap.FetchItemBinarySectionSize, size uint32) {
w.writeItemSep()
enc := w.enc.Encoder
enc.Atom("BINARY.SIZE").Special('[')
writeSectionPart(enc, section.Part)
enc.Special(']').SP().Number(size)
}
// WriteEnvelope writes the message's envelope.
func (w *FetchResponseWriter) WriteEnvelope(envelope *imap.Envelope) {
w.writeItemSep()
enc := w.enc.Encoder
enc.Atom("ENVELOPE").SP()
writeEnvelope(enc, envelope)
}
// WriteBodyStructure writes the message's body structure (either BODYSTRUCTURE
// or BODY).
func (w *FetchResponseWriter) WriteBodyStructure(bs imap.BodyStructure) {
if w.options.bodyStructure.nonExtended {
w.writeBodyStructure(bs, false)
}
if w.options.bodyStructure.extended {
var isExtended bool
switch bs := bs.(type) {
case *imap.BodyStructureSinglePart:
isExtended = bs.Extended != nil
case *imap.BodyStructureMultiPart:
isExtended = bs.Extended != nil
}
if !isExtended {
panic("imapserver: client requested extended body structure but a non-extended one is written back")
}
w.writeBodyStructure(bs, true)
}
}
func (w *FetchResponseWriter) writeBodyStructure(bs imap.BodyStructure, extended bool) {
item := "BODY"
if extended {
item = "BODYSTRUCTURE"
}
w.writeItemSep()
enc := w.enc.Encoder
enc.Atom(item).SP()
writeBodyStructure(enc, bs, extended)
}
// Close closes the FETCH message writer.
func (w *FetchResponseWriter) Close() error {
if w.enc == nil {
return fmt.Errorf("imapserver: FetchResponseWriter already closed")
}
err := w.enc.Special(')').CRLF()
w.enc.end()
w.enc = nil
return err
}
func writeEnvelope(enc *imapwire.Encoder, envelope *imap.Envelope) {
if envelope == nil {
envelope = new(imap.Envelope)
}
sender := envelope.Sender
if sender == nil {
sender = envelope.From
}
replyTo := envelope.ReplyTo
if replyTo == nil {
replyTo = envelope.From
}
enc.Special('(')
if envelope.Date.IsZero() {
enc.NIL()
} else {
enc.String(envelope.Date.Format(envelopeDateLayout))
}
enc.SP()
writeNString(enc, mime.QEncoding.Encode("utf-8", envelope.Subject))
addrs := [][]imap.Address{
envelope.From,
sender,
replyTo,
envelope.To,
envelope.Cc,
envelope.Bcc,
}
for _, l := range addrs {
enc.SP()
writeAddressList(enc, l)
}
enc.SP()
if len(envelope.InReplyTo) > 0 {
enc.String("<" + strings.Join(envelope.InReplyTo, "> <") + ">")
} else {
enc.NIL()
}
enc.SP()
if envelope.MessageID != "" {
enc.String("<" + envelope.MessageID + ">")
} else {
enc.NIL()
}
enc.Special(')')
}
func writeAddressList(enc *imapwire.Encoder, l []imap.Address) {
if len(l) == 0 {
enc.NIL()
return
}
enc.List(len(l), func(i int) {
addr := l[i]
enc.Special('(')
writeNString(enc, mime.QEncoding.Encode("utf-8", addr.Name))
enc.SP().NIL().SP()
writeNString(enc, addr.Mailbox)
enc.SP()
writeNString(enc, addr.Host)
enc.Special(')')
})
}
func writeNString(enc *imapwire.Encoder, s string) {
if s == "" {
enc.NIL()
} else {
enc.String(s)
}
}
func writeSectionPart(enc *imapwire.Encoder, part []int) {
if len(part) == 0 {
return
}
var l []string
for _, num := range part {
l = append(l, fmt.Sprintf("%v", num))
}
enc.Atom(strings.Join(l, "."))
}
func writeBodyStructure(enc *imapwire.Encoder, bs imap.BodyStructure, extended bool) {
enc.Special('(')
switch bs := bs.(type) {
case *imap.BodyStructureSinglePart:
writeBodyType1part(enc, bs, extended)
case *imap.BodyStructureMultiPart:
writeBodyTypeMpart(enc, bs, extended)
default:
panic(fmt.Errorf("unknown body structure type %T", bs))
}
enc.Special(')')
}
func writeBodyType1part(enc *imapwire.Encoder, bs *imap.BodyStructureSinglePart, extended bool) {
enc.String(bs.Type).SP().String(bs.Subtype).SP()
writeBodyFldParam(enc, bs.Params)
enc.SP()
writeNString(enc, bs.ID)
enc.SP()
writeNString(enc, bs.Description)
enc.SP()
if bs.Encoding == "" {
enc.String("7bit")
} else {
// Outlook for iOS chokes on upper-case encodings
enc.String(strings.ToLower(bs.Encoding))
}
enc.SP().Number(bs.Size)
if msg := bs.MessageRFC822; msg != nil {
enc.SP()
writeEnvelope(enc, msg.Envelope)
enc.SP()
writeBodyStructure(enc, msg.BodyStructure, extended)
enc.SP().Number64(msg.NumLines)
} else if text := bs.Text; text != nil {
enc.SP().Number64(text.NumLines)
}
if !extended {
return
}
ext := bs.Extended
enc.SP()
enc.NIL() // MD5
enc.SP()
writeBodyFldDsp(enc, ext.Disposition)
enc.SP()
writeBodyFldLang(enc, ext.Language)
enc.SP()
writeNString(enc, ext.Location)
}
func writeBodyTypeMpart(enc *imapwire.Encoder, bs *imap.BodyStructureMultiPart, extended bool) {
if len(bs.Children) == 0 {
panic("imapserver: imap.BodyStructureMultiPart must have at least one child")
}
for _, child := range bs.Children {
// ABNF for body-type-mpart doesn't have SP between body entries, and
// Outlook for iOS chokes on SP
writeBodyStructure(enc, child, extended)
}
enc.SP().String(bs.Subtype)
if !extended {
return
}
ext := bs.Extended
enc.SP()
writeBodyFldParam(enc, ext.Params)
enc.SP()
writeBodyFldDsp(enc, ext.Disposition)
enc.SP()
writeBodyFldLang(enc, ext.Language)
enc.SP()
writeNString(enc, ext.Location)
}
func writeBodyFldParam(enc *imapwire.Encoder, params map[string]string) {
if len(params) == 0 {
enc.NIL()
return
}
var l []string
for k := range params {
l = append(l, k)
}
sort.Strings(l)
enc.List(len(l), func(i int) {
k := l[i]
v := params[k]
enc.String(k).SP().String(v)
})
}
func writeBodyFldDsp(enc *imapwire.Encoder, disp *imap.BodyStructureDisposition) {
if disp == nil {
enc.NIL()
return
}
enc.Special('(').String(disp.Value).SP()
writeBodyFldParam(enc, disp.Params)
enc.Special(')')
}
func writeBodyFldLang(enc *imapwire.Encoder, l []string) {
if len(l) == 0 {
enc.NIL()
} else {
enc.List(len(l), func(i int) {
enc.String(l[i])
})
}
}

50
imapserver/idle.go Normal file
View File

@@ -0,0 +1,50 @@
package imapserver
import (
"fmt"
"io"
"runtime/debug"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
func (c *Conn) handleIdle(dec *imapwire.Decoder) error {
if !dec.ExpectCRLF() {
return dec.Err()
}
if err := c.checkState(imap.ConnStateAuthenticated); err != nil {
return err
}
if err := c.writeContReq("idling"); err != nil {
return err
}
stop := make(chan struct{})
done := make(chan error, 1)
go func() {
defer func() {
if v := recover(); v != nil {
c.server.logger().Printf("panic idling: %v\n%s", v, debug.Stack())
done <- fmt.Errorf("imapserver: panic idling")
}
}()
w := &UpdateWriter{conn: c, allowExpunge: true}
done <- c.session.Idle(w, stop)
}()
c.setReadTimeout(idleReadTimeout)
line, isPrefix, err := c.br.ReadLine()
close(stop)
if err == io.EOF {
return nil
} else if err != nil {
return err
} else if isPrefix || string(line) != "DONE" {
return newClientBugError("Syntax error: expected DONE to end IDLE command")
}
return <-done
}

View File

@@ -0,0 +1,511 @@
package imapmemserver
import (
"bytes"
"sort"
"sync"
"time"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapserver"
)
// Mailbox is an in-memory mailbox.
//
// The same mailbox can be shared between multiple connections and multiple
// users.
type Mailbox struct {
tracker *imapserver.MailboxTracker
uidValidity uint32
mutex sync.Mutex
name string
subscribed bool
specialUse []imap.MailboxAttr
l []*message
uidNext imap.UID
}
// NewMailbox creates a new mailbox.
func NewMailbox(name string, uidValidity uint32) *Mailbox {
return &Mailbox{
tracker: imapserver.NewMailboxTracker(0),
uidValidity: uidValidity,
name: name,
uidNext: 1,
}
}
func (mbox *Mailbox) list(options *imap.ListOptions) *imap.ListData {
mbox.mutex.Lock()
defer mbox.mutex.Unlock()
if options.SelectSubscribed && !mbox.subscribed {
return nil
}
if options.SelectSpecialUse && len(mbox.specialUse) == 0 {
return nil
}
data := imap.ListData{
Mailbox: mbox.name,
Delim: mailboxDelim,
}
if mbox.subscribed {
data.Attrs = append(data.Attrs, imap.MailboxAttrSubscribed)
}
if (options.ReturnSpecialUse || options.SelectSpecialUse) && len(mbox.specialUse) > 0 {
data.Attrs = append(data.Attrs, mbox.specialUse...)
}
if options.ReturnStatus != nil {
data.Status = mbox.statusDataLocked(options.ReturnStatus)
}
return &data
}
// StatusData returns data for the STATUS command.
func (mbox *Mailbox) StatusData(options *imap.StatusOptions) *imap.StatusData {
mbox.mutex.Lock()
defer mbox.mutex.Unlock()
return mbox.statusDataLocked(options)
}
func (mbox *Mailbox) statusDataLocked(options *imap.StatusOptions) *imap.StatusData {
data := imap.StatusData{Mailbox: mbox.name}
if options.NumMessages {
num := uint32(len(mbox.l))
data.NumMessages = &num
}
if options.UIDNext {
data.UIDNext = mbox.uidNext
}
if options.UIDValidity {
data.UIDValidity = mbox.uidValidity
}
if options.NumUnseen {
num := uint32(len(mbox.l)) - mbox.countByFlagLocked(imap.FlagSeen)
data.NumUnseen = &num
}
if options.NumDeleted {
num := mbox.countByFlagLocked(imap.FlagDeleted)
data.NumDeleted = &num
}
if options.Size {
size := mbox.sizeLocked()
data.Size = &size
}
if options.NumRecent {
num := uint32(0)
data.NumRecent = &num
}
return &data
}
func (mbox *Mailbox) countByFlagLocked(flag imap.Flag) uint32 {
var n uint32
for _, msg := range mbox.l {
if _, ok := msg.flags[canonicalFlag(flag)]; ok {
n++
}
}
return n
}
func (mbox *Mailbox) sizeLocked() int64 {
var size int64
for _, msg := range mbox.l {
size += int64(len(msg.buf))
}
return size
}
func (mbox *Mailbox) appendLiteral(r imap.LiteralReader, options *imap.AppendOptions) (*imap.AppendData, error) {
var buf bytes.Buffer
if _, err := buf.ReadFrom(r); err != nil {
return nil, err
}
return mbox.appendBytes(buf.Bytes(), options), nil
}
func (mbox *Mailbox) copyMsg(msg *message) *imap.AppendData {
return mbox.appendBytes(msg.buf, &imap.AppendOptions{
Time: msg.t,
Flags: msg.flagList(),
})
}
func (mbox *Mailbox) appendBytes(buf []byte, options *imap.AppendOptions) *imap.AppendData {
msg := &message{
flags: make(map[imap.Flag]struct{}),
buf: buf,
}
if options.Time.IsZero() {
msg.t = time.Now()
} else {
msg.t = options.Time
}
for _, flag := range options.Flags {
msg.flags[canonicalFlag(flag)] = struct{}{}
}
mbox.mutex.Lock()
defer mbox.mutex.Unlock()
msg.uid = mbox.uidNext
mbox.uidNext++
mbox.l = append(mbox.l, msg)
mbox.tracker.QueueNumMessages(uint32(len(mbox.l)))
return &imap.AppendData{
UIDValidity: mbox.uidValidity,
UID: msg.uid,
}
}
func (mbox *Mailbox) rename(newName string) {
mbox.mutex.Lock()
mbox.name = newName
mbox.mutex.Unlock()
}
// SetSubscribed changes the subscription state of this mailbox.
func (mbox *Mailbox) SetSubscribed(subscribed bool) {
mbox.mutex.Lock()
mbox.subscribed = subscribed
mbox.mutex.Unlock()
}
func (mbox *Mailbox) selectDataLocked() *imap.SelectData {
flags := mbox.flagsLocked()
permanentFlags := make([]imap.Flag, len(flags))
copy(permanentFlags, flags)
permanentFlags = append(permanentFlags, imap.FlagWildcard)
// TODO: skip if IMAP4rev1 is disabled by the server, or IMAP4rev2 is
// enabled by the client
firstUnseenSeqNum := mbox.firstUnseenSeqNumLocked()
return &imap.SelectData{
Flags: flags,
PermanentFlags: permanentFlags,
NumMessages: uint32(len(mbox.l)),
FirstUnseenSeqNum: firstUnseenSeqNum,
UIDNext: mbox.uidNext,
UIDValidity: mbox.uidValidity,
}
}
func (mbox *Mailbox) firstUnseenSeqNumLocked() uint32 {
for i, msg := range mbox.l {
seqNum := uint32(i) + 1
if _, ok := msg.flags[canonicalFlag(imap.FlagSeen)]; !ok {
return seqNum
}
}
return 0
}
func (mbox *Mailbox) flagsLocked() []imap.Flag {
m := make(map[imap.Flag]struct{})
for _, msg := range mbox.l {
for flag := range msg.flags {
m[flag] = struct{}{}
}
}
var l []imap.Flag
for flag := range m {
l = append(l, flag)
}
sort.Slice(l, func(i, j int) bool {
return l[i] < l[j]
})
return l
}
func (mbox *Mailbox) Expunge(w *imapserver.ExpungeWriter, uids *imap.UIDSet) error {
expunged := make(map[*message]struct{})
mbox.mutex.Lock()
for _, msg := range mbox.l {
if uids != nil && !uids.Contains(msg.uid) {
continue
}
if _, ok := msg.flags[canonicalFlag(imap.FlagDeleted)]; ok {
expunged[msg] = struct{}{}
}
}
mbox.mutex.Unlock()
if len(expunged) == 0 {
return nil
}
mbox.mutex.Lock()
mbox.expungeLocked(expunged)
mbox.mutex.Unlock()
return nil
}
func (mbox *Mailbox) expungeLocked(expunged map[*message]struct{}) (seqNums []uint32) {
// TODO: optimize
// Iterate in reverse order, to keep sequence numbers consistent
var filtered []*message
for i := len(mbox.l) - 1; i >= 0; i-- {
msg := mbox.l[i]
if _, ok := expunged[msg]; ok {
seqNum := uint32(i) + 1
seqNums = append(seqNums, seqNum)
mbox.tracker.QueueExpunge(seqNum)
} else {
filtered = append(filtered, msg)
}
}
// Reverse filtered
for i := 0; i < len(filtered)/2; i++ {
j := len(filtered) - i - 1
filtered[i], filtered[j] = filtered[j], filtered[i]
}
mbox.l = filtered
return seqNums
}
// NewView creates a new view into this mailbox.
//
// Callers must call MailboxView.Close once they are done with the mailbox view.
func (mbox *Mailbox) NewView() *MailboxView {
return &MailboxView{
Mailbox: mbox,
tracker: mbox.tracker.NewSession(),
}
}
// A MailboxView is a view into a mailbox.
//
// Each view has its own queue of pending unilateral updates.
//
// Once the mailbox view is no longer used, Close must be called.
//
// Typically, a new MailboxView is created for each IMAP connection in the
// selected state.
type MailboxView struct {
*Mailbox
tracker *imapserver.SessionTracker
searchRes imap.UIDSet
}
// Close releases the resources allocated for the mailbox view.
func (mbox *MailboxView) Close() {
mbox.tracker.Close()
}
func (mbox *MailboxView) Fetch(w *imapserver.FetchWriter, numSet imap.NumSet, options *imap.FetchOptions) error {
markSeen := false
for _, bs := range options.BodySection {
if !bs.Peek {
markSeen = true
break
}
}
var err error
mbox.forEach(numSet, func(seqNum uint32, msg *message) {
if err != nil {
return
}
if markSeen {
msg.flags[canonicalFlag(imap.FlagSeen)] = struct{}{}
mbox.Mailbox.tracker.QueueMessageFlags(seqNum, msg.uid, msg.flagList(), nil)
}
respWriter := w.CreateMessage(mbox.tracker.EncodeSeqNum(seqNum))
err = msg.fetch(respWriter, options)
})
return err
}
func (mbox *MailboxView) Search(numKind imapserver.NumKind, criteria *imap.SearchCriteria, options *imap.SearchOptions) (*imap.SearchData, error) {
mbox.mutex.Lock()
defer mbox.mutex.Unlock()
mbox.staticSearchCriteria(criteria)
var (
data imap.SearchData
seqSet imap.SeqSet
uidSet imap.UIDSet
)
for i, msg := range mbox.l {
seqNum := mbox.tracker.EncodeSeqNum(uint32(i) + 1)
if !msg.search(seqNum, criteria) {
continue
}
// Always populate the UID set, since it may be saved later for SEARCHRES
uidSet.AddNum(msg.uid)
var num uint32
switch numKind {
case imapserver.NumKindSeq:
if seqNum == 0 {
continue
}
seqSet.AddNum(seqNum)
num = seqNum
case imapserver.NumKindUID:
num = uint32(msg.uid)
}
if data.Min == 0 || num < data.Min {
data.Min = num
}
if data.Max == 0 || num > data.Max {
data.Max = num
}
data.Count++
}
switch numKind {
case imapserver.NumKindSeq:
data.All = seqSet
case imapserver.NumKindUID:
data.All = uidSet
}
if options.ReturnSave {
mbox.searchRes = uidSet
}
return &data, nil
}
func (mbox *MailboxView) staticSearchCriteria(criteria *imap.SearchCriteria) {
seqNums := make([]imap.SeqSet, 0, len(criteria.SeqNum))
for _, seqSet := range criteria.SeqNum {
numSet := mbox.staticNumSet(seqSet)
switch numSet := numSet.(type) {
case imap.SeqSet:
seqNums = append(seqNums, numSet)
case imap.UIDSet: // can happen with SEARCHRES
criteria.UID = append(criteria.UID, numSet)
}
}
criteria.SeqNum = seqNums
for i, uidSet := range criteria.UID {
criteria.UID[i] = mbox.staticNumSet(uidSet).(imap.UIDSet)
}
for i := range criteria.Not {
mbox.staticSearchCriteria(&criteria.Not[i])
}
for i := range criteria.Or {
for j := range criteria.Or[i] {
mbox.staticSearchCriteria(&criteria.Or[i][j])
}
}
}
func (mbox *MailboxView) Store(w *imapserver.FetchWriter, numSet imap.NumSet, flags *imap.StoreFlags, options *imap.StoreOptions) error {
mbox.forEach(numSet, func(seqNum uint32, msg *message) {
msg.store(flags)
mbox.Mailbox.tracker.QueueMessageFlags(seqNum, msg.uid, msg.flagList(), mbox.tracker)
})
if !flags.Silent {
return mbox.Fetch(w, numSet, &imap.FetchOptions{Flags: true})
}
return nil
}
func (mbox *MailboxView) Poll(w *imapserver.UpdateWriter, allowExpunge bool) error {
return mbox.tracker.Poll(w, allowExpunge)
}
func (mbox *MailboxView) Idle(w *imapserver.UpdateWriter, stop <-chan struct{}) error {
return mbox.tracker.Idle(w, stop)
}
func (mbox *MailboxView) forEach(numSet imap.NumSet, f func(seqNum uint32, msg *message)) {
mbox.mutex.Lock()
defer mbox.mutex.Unlock()
mbox.forEachLocked(numSet, f)
}
func (mbox *MailboxView) forEachLocked(numSet imap.NumSet, f func(seqNum uint32, msg *message)) {
// TODO: optimize
numSet = mbox.staticNumSet(numSet)
for i, msg := range mbox.l {
seqNum := uint32(i) + 1
var contains bool
switch numSet := numSet.(type) {
case imap.SeqSet:
seqNum := mbox.tracker.EncodeSeqNum(seqNum)
contains = seqNum != 0 && numSet.Contains(seqNum)
case imap.UIDSet:
contains = numSet.Contains(msg.uid)
}
if !contains {
continue
}
f(seqNum, msg)
}
}
// staticNumSet converts a dynamic sequence set into a static one.
//
// This is necessary to properly handle the special symbol "*", which
// represents the maximum sequence number or UID in the mailbox.
//
// This function also handles the special SEARCHRES marker "$".
func (mbox *MailboxView) staticNumSet(numSet imap.NumSet) imap.NumSet {
if imap.IsSearchRes(numSet) {
return mbox.searchRes
}
switch numSet := numSet.(type) {
case imap.SeqSet:
max := uint32(len(mbox.l))
for i := range numSet {
r := &numSet[i]
staticNumRange(&r.Start, &r.Stop, max)
}
case imap.UIDSet:
max := uint32(mbox.uidNext) - 1
for i := range numSet {
r := &numSet[i]
staticNumRange((*uint32)(&r.Start), (*uint32)(&r.Stop), max)
}
}
return numSet
}
func staticNumRange(start, stop *uint32, max uint32) {
dyn := false
if *start == 0 {
*start = max
dyn = true
}
if *stop == 0 {
*stop = max
dyn = true
}
if dyn && *start > *stop {
*start, *stop = *stop, *start
}
}

View File

@@ -0,0 +1,273 @@
package imapmemserver
import (
"bufio"
"bytes"
"fmt"
"io"
"strings"
"time"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapserver"
gomessage "github.com/emersion/go-message"
"github.com/emersion/go-message/mail"
"github.com/emersion/go-message/textproto"
)
type message struct {
// immutable
uid imap.UID
buf []byte
t time.Time
// mutable, protected by Mailbox.mutex
flags map[imap.Flag]struct{}
}
func (msg *message) fetch(w *imapserver.FetchResponseWriter, options *imap.FetchOptions) error {
w.WriteUID(msg.uid)
if options.Flags {
w.WriteFlags(msg.flagList())
}
if options.InternalDate {
w.WriteInternalDate(msg.t)
}
if options.RFC822Size {
w.WriteRFC822Size(int64(len(msg.buf)))
}
if options.Envelope {
w.WriteEnvelope(msg.envelope())
}
if options.BodyStructure != nil {
w.WriteBodyStructure(imapserver.ExtractBodyStructure(bytes.NewReader(msg.buf)))
}
for _, bs := range options.BodySection {
buf := imapserver.ExtractBodySection(bytes.NewReader(msg.buf), bs)
wc := w.WriteBodySection(bs, int64(len(buf)))
_, writeErr := wc.Write(buf)
closeErr := wc.Close()
if writeErr != nil {
return writeErr
}
if closeErr != nil {
return closeErr
}
}
for _, bs := range options.BinarySection {
buf := imapserver.ExtractBinarySection(bytes.NewReader(msg.buf), bs)
wc := w.WriteBinarySection(bs, int64(len(buf)))
_, writeErr := wc.Write(buf)
closeErr := wc.Close()
if writeErr != nil {
return writeErr
}
if closeErr != nil {
return closeErr
}
}
for _, bss := range options.BinarySectionSize {
n := imapserver.ExtractBinarySectionSize(bytes.NewReader(msg.buf), bss)
w.WriteBinarySectionSize(bss, n)
}
return w.Close()
}
func (msg *message) envelope() *imap.Envelope {
br := bufio.NewReader(bytes.NewReader(msg.buf))
header, err := textproto.ReadHeader(br)
if err != nil {
return nil
}
return imapserver.ExtractEnvelope(header)
}
func (msg *message) flagList() []imap.Flag {
var flags []imap.Flag
for flag := range msg.flags {
flags = append(flags, flag)
}
return flags
}
func (msg *message) store(store *imap.StoreFlags) {
switch store.Op {
case imap.StoreFlagsSet:
msg.flags = make(map[imap.Flag]struct{})
fallthrough
case imap.StoreFlagsAdd:
for _, flag := range store.Flags {
msg.flags[canonicalFlag(flag)] = struct{}{}
}
case imap.StoreFlagsDel:
for _, flag := range store.Flags {
delete(msg.flags, canonicalFlag(flag))
}
default:
panic(fmt.Errorf("unknown STORE flag operation: %v", store.Op))
}
}
func (msg *message) reader() *gomessage.Entity {
r, _ := gomessage.Read(bytes.NewReader(msg.buf))
if r == nil {
r, _ = gomessage.New(gomessage.Header{}, bytes.NewReader(nil))
}
return r
}
func (msg *message) search(seqNum uint32, criteria *imap.SearchCriteria) bool {
for _, seqSet := range criteria.SeqNum {
if seqNum == 0 || !seqSet.Contains(seqNum) {
return false
}
}
for _, uidSet := range criteria.UID {
if !uidSet.Contains(msg.uid) {
return false
}
}
if !matchDate(msg.t, criteria.Since, criteria.Before) {
return false
}
for _, flag := range criteria.Flag {
if _, ok := msg.flags[canonicalFlag(flag)]; !ok {
return false
}
}
for _, flag := range criteria.NotFlag {
if _, ok := msg.flags[canonicalFlag(flag)]; ok {
return false
}
}
if criteria.Larger != 0 && int64(len(msg.buf)) <= criteria.Larger {
return false
}
if criteria.Smaller != 0 && int64(len(msg.buf)) >= criteria.Smaller {
return false
}
header := mail.Header{msg.reader().Header}
for _, fieldCriteria := range criteria.Header {
if !matchHeaderFields(header.FieldsByKey(fieldCriteria.Key), fieldCriteria.Value) {
return false
}
}
if !criteria.SentSince.IsZero() || !criteria.SentBefore.IsZero() {
t, err := header.Date()
if err != nil {
return false
} else if !matchDate(t, criteria.SentSince, criteria.SentBefore) {
return false
}
}
for _, text := range criteria.Text {
if !matchEntity(msg.reader(), text, true) {
return false
}
}
for _, body := range criteria.Body {
if !matchEntity(msg.reader(), body, false) {
return false
}
}
for _, not := range criteria.Not {
if msg.search(seqNum, &not) {
return false
}
}
for _, or := range criteria.Or {
if !msg.search(seqNum, &or[0]) && !msg.search(seqNum, &or[1]) {
return false
}
}
return true
}
func matchDate(t, since, before time.Time) bool {
// We discard time zone information by setting it to UTC.
// RFC 3501 explicitly requires zone unaware date comparison.
t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
if !since.IsZero() && t.Before(since) {
return false
}
if !before.IsZero() && !t.Before(before) {
return false
}
return true
}
func matchHeaderFields(fields gomessage.HeaderFields, pattern string) bool {
if pattern == "" {
return fields.Len() > 0
}
pattern = strings.ToLower(pattern)
for fields.Next() {
v, _ := fields.Text()
if strings.Contains(strings.ToLower(v), pattern) {
return true
}
}
return false
}
func matchEntity(e *gomessage.Entity, pattern string, includeHeader bool) bool {
if pattern == "" {
return true
}
if includeHeader && matchHeaderFields(e.Header.Fields(), pattern) {
return true
}
if mr := e.MultipartReader(); mr != nil {
for {
part, err := mr.NextPart()
if err == io.EOF {
break
} else if err != nil {
return false
}
if matchEntity(part, pattern, includeHeader) {
return true
}
}
return false
} else {
t, _, err := e.Header.ContentType()
if err != nil {
return false
}
if !strings.HasPrefix(t, "text/") && !strings.HasPrefix(t, "message/") {
return false
}
buf, err := io.ReadAll(e.Body)
if err != nil {
return false
}
return bytes.Contains(bytes.ToLower(buf), bytes.ToLower([]byte(pattern)))
}
}
func canonicalFlag(flag imap.Flag) imap.Flag {
return imap.Flag(strings.ToLower(string(flag)))
}

View File

@@ -0,0 +1,61 @@
// Package imapmemserver implements an in-memory IMAP server.
package imapmemserver
import (
"sync"
"github.com/emersion/go-imap/v2/imapserver"
)
// Server is a server instance.
//
// A server contains a list of users.
type Server struct {
mutex sync.Mutex
users map[string]*User
}
// New creates a new server.
func New() *Server {
return &Server{
users: make(map[string]*User),
}
}
// NewSession creates a new IMAP session.
func (s *Server) NewSession() imapserver.Session {
return &serverSession{server: s}
}
func (s *Server) user(username string) *User {
s.mutex.Lock()
defer s.mutex.Unlock()
return s.users[username]
}
// AddUser adds a user to the server.
func (s *Server) AddUser(user *User) {
s.mutex.Lock()
s.users[user.username] = user
s.mutex.Unlock()
}
type serverSession struct {
*UserSession // may be nil
server *Server // immutable
}
var _ imapserver.Session = (*serverSession)(nil)
func (sess *serverSession) Login(username, password string) error {
u := sess.server.user(username)
if u == nil {
return imapserver.ErrAuthFailed
}
if err := u.Login(username, password); err != nil {
return err
}
sess.UserSession = NewUserSession(u)
return nil
}

View File

@@ -0,0 +1,140 @@
package imapmemserver
import (
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapserver"
)
type (
user = User
mailbox = MailboxView
)
// UserSession represents a session tied to a specific user.
//
// UserSession implements imapserver.Session. Typically, a UserSession pointer
// is embedded into a larger struct which overrides Login.
type UserSession struct {
*user // immutable
*mailbox // may be nil
}
var _ imapserver.SessionIMAP4rev2 = (*UserSession)(nil)
// NewUserSession creates a new user session.
func NewUserSession(user *User) *UserSession {
return &UserSession{user: user}
}
func (sess *UserSession) Close() error {
if sess != nil && sess.mailbox != nil {
sess.mailbox.Close()
}
return nil
}
func (sess *UserSession) Select(name string, options *imap.SelectOptions) (*imap.SelectData, error) {
mbox, err := sess.user.mailbox(name)
if err != nil {
return nil, err
}
mbox.mutex.Lock()
defer mbox.mutex.Unlock()
sess.mailbox = mbox.NewView()
return mbox.selectDataLocked(), nil
}
func (sess *UserSession) Unselect() error {
sess.mailbox.Close()
sess.mailbox = nil
return nil
}
func (sess *UserSession) Copy(numSet imap.NumSet, destName string) (*imap.CopyData, error) {
dest, err := sess.user.mailbox(destName)
if err != nil {
return nil, &imap.Error{
Type: imap.StatusResponseTypeNo,
Code: imap.ResponseCodeTryCreate,
Text: "No such mailbox",
}
} else if sess.mailbox != nil && dest == sess.mailbox.Mailbox {
return nil, &imap.Error{
Type: imap.StatusResponseTypeNo,
Text: "Source and destination mailboxes are identical",
}
}
var sourceUIDs, destUIDs imap.UIDSet
sess.mailbox.forEach(numSet, func(seqNum uint32, msg *message) {
appendData := dest.copyMsg(msg)
sourceUIDs.AddNum(msg.uid)
destUIDs.AddNum(appendData.UID)
})
return &imap.CopyData{
UIDValidity: dest.uidValidity,
SourceUIDs: sourceUIDs,
DestUIDs: destUIDs,
}, nil
}
func (sess *UserSession) Move(w *imapserver.MoveWriter, numSet imap.NumSet, destName string) error {
dest, err := sess.user.mailbox(destName)
if err != nil {
return &imap.Error{
Type: imap.StatusResponseTypeNo,
Code: imap.ResponseCodeTryCreate,
Text: "No such mailbox",
}
} else if sess.mailbox != nil && dest == sess.mailbox.Mailbox {
return &imap.Error{
Type: imap.StatusResponseTypeNo,
Text: "Source and destination mailboxes are identical",
}
}
sess.mailbox.mutex.Lock()
defer sess.mailbox.mutex.Unlock()
var sourceUIDs, destUIDs imap.UIDSet
expunged := make(map[*message]struct{})
sess.mailbox.forEachLocked(numSet, func(seqNum uint32, msg *message) {
appendData := dest.copyMsg(msg)
sourceUIDs.AddNum(msg.uid)
destUIDs.AddNum(appendData.UID)
expunged[msg] = struct{}{}
})
seqNums := sess.mailbox.expungeLocked(expunged)
err = w.WriteCopyData(&imap.CopyData{
UIDValidity: dest.uidValidity,
SourceUIDs: sourceUIDs,
DestUIDs: destUIDs,
})
if err != nil {
return err
}
for _, seqNum := range seqNums {
if err := w.WriteExpunge(sess.mailbox.tracker.EncodeSeqNum(seqNum)); err != nil {
return err
}
}
return nil
}
func (sess *UserSession) Poll(w *imapserver.UpdateWriter, allowExpunge bool) error {
if sess.mailbox == nil {
return nil
}
return sess.mailbox.Poll(w, allowExpunge)
}
func (sess *UserSession) Idle(w *imapserver.UpdateWriter, stop <-chan struct{}) error {
if sess.mailbox == nil {
return nil // TODO
}
return sess.mailbox.Idle(w, stop)
}

View File

@@ -0,0 +1,204 @@
package imapmemserver
import (
"crypto/subtle"
"sort"
"strings"
"sync"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapserver"
)
const mailboxDelim rune = '/'
type User struct {
username, password string
mutex sync.Mutex
mailboxes map[string]*Mailbox
prevUidValidity uint32
}
func NewUser(username, password string) *User {
return &User{
username: username,
password: password,
mailboxes: make(map[string]*Mailbox),
}
}
func (u *User) Login(username, password string) error {
if username != u.username {
return imapserver.ErrAuthFailed
}
if subtle.ConstantTimeCompare([]byte(password), []byte(u.password)) != 1 {
return imapserver.ErrAuthFailed
}
return nil
}
func (u *User) mailboxLocked(name string) (*Mailbox, error) {
mbox := u.mailboxes[name]
if mbox == nil {
return nil, &imap.Error{
Type: imap.StatusResponseTypeNo,
Code: imap.ResponseCodeNonExistent,
Text: "No such mailbox",
}
}
return mbox, nil
}
func (u *User) mailbox(name string) (*Mailbox, error) {
u.mutex.Lock()
defer u.mutex.Unlock()
return u.mailboxLocked(name)
}
func (u *User) Status(name string, options *imap.StatusOptions) (*imap.StatusData, error) {
mbox, err := u.mailbox(name)
if err != nil {
return nil, err
}
return mbox.StatusData(options), nil
}
func (u *User) List(w *imapserver.ListWriter, ref string, patterns []string, options *imap.ListOptions) error {
u.mutex.Lock()
defer u.mutex.Unlock()
// TODO: fail if ref doesn't exist
if len(patterns) == 0 {
return w.WriteList(&imap.ListData{
Attrs: []imap.MailboxAttr{imap.MailboxAttrNoSelect},
Delim: mailboxDelim,
})
}
var l []imap.ListData
for name, mbox := range u.mailboxes {
match := false
for _, pattern := range patterns {
match = imapserver.MatchList(name, mailboxDelim, ref, pattern)
if match {
break
}
}
if !match {
continue
}
data := mbox.list(options)
if data != nil {
l = append(l, *data)
}
}
sort.Slice(l, func(i, j int) bool {
return l[i].Mailbox < l[j].Mailbox
})
for _, data := range l {
if err := w.WriteList(&data); err != nil {
return err
}
}
return nil
}
func (u *User) Append(mailbox string, r imap.LiteralReader, options *imap.AppendOptions) (*imap.AppendData, error) {
mbox, err := u.mailbox(mailbox)
if err != nil {
return nil, &imap.Error{
Type: imap.StatusResponseTypeNo,
Code: imap.ResponseCodeTryCreate,
Text: "No such mailbox",
}
}
return mbox.appendLiteral(r, options)
}
func (u *User) Create(name string, options *imap.CreateOptions) error {
u.mutex.Lock()
defer u.mutex.Unlock()
name = strings.TrimRight(name, string(mailboxDelim))
if u.mailboxes[name] != nil {
return &imap.Error{
Type: imap.StatusResponseTypeNo,
Code: imap.ResponseCodeAlreadyExists,
Text: "Mailbox already exists",
}
}
// UIDVALIDITY must change if a mailbox is deleted and re-created with the
// same name.
u.prevUidValidity++
u.mailboxes[name] = NewMailbox(name, u.prevUidValidity)
return nil
}
func (u *User) Delete(name string) error {
u.mutex.Lock()
defer u.mutex.Unlock()
if _, err := u.mailboxLocked(name); err != nil {
return err
}
delete(u.mailboxes, name)
return nil
}
func (u *User) Rename(oldName, newName string, options *imap.RenameOptions) error {
u.mutex.Lock()
defer u.mutex.Unlock()
newName = strings.TrimRight(newName, string(mailboxDelim))
mbox, err := u.mailboxLocked(oldName)
if err != nil {
return err
}
if u.mailboxes[newName] != nil {
return &imap.Error{
Type: imap.StatusResponseTypeNo,
Code: imap.ResponseCodeAlreadyExists,
Text: "Mailbox already exists",
}
}
mbox.rename(newName)
u.mailboxes[newName] = mbox
delete(u.mailboxes, oldName)
return nil
}
func (u *User) Subscribe(name string) error {
mbox, err := u.mailbox(name)
if err != nil {
return err
}
mbox.SetSubscribed(true)
return nil
}
func (u *User) Unsubscribe(name string) error {
mbox, err := u.mailbox(name)
if err != nil {
return err
}
mbox.SetSubscribed(false)
return nil
}
func (u *User) Namespace() (*imap.NamespaceData, error) {
return &imap.NamespaceData{
Personal: []imap.NamespaceDescriptor{{Delim: mailboxDelim}},
}, nil
}

329
imapserver/list.go Normal file
View File

@@ -0,0 +1,329 @@
package imapserver
import (
"fmt"
"strings"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal/imapwire"
"github.com/emersion/go-imap/v2/internal/utf7"
)
func (c *Conn) handleList(dec *imapwire.Decoder) error {
ref, pattern, options, err := readListCmd(dec)
if err != nil {
return err
}
if err := c.checkState(imap.ConnStateAuthenticated); err != nil {
return err
}
w := &ListWriter{
conn: c,
options: options,
}
return c.session.List(w, ref, pattern, options)
}
func (c *Conn) handleLSub(dec *imapwire.Decoder) error {
var ref string
if !dec.ExpectSP() || !dec.ExpectMailbox(&ref) || !dec.ExpectSP() {
return dec.Err()
}
pattern, err := readListMailbox(dec)
if err != nil {
return err
}
if !dec.ExpectCRLF() {
return dec.Err()
}
if err := c.checkState(imap.ConnStateAuthenticated); err != nil {
return err
}
options := &imap.ListOptions{SelectSubscribed: true}
w := &ListWriter{
conn: c,
lsub: true,
}
return c.session.List(w, ref, []string{pattern}, options)
}
func (c *Conn) writeList(data *imap.ListData) error {
enc := newResponseEncoder(c)
defer enc.end()
enc.Atom("*").SP().Atom("LIST").SP()
enc.List(len(data.Attrs), func(i int) {
enc.MailboxAttr(data.Attrs[i])
})
enc.SP()
if data.Delim == 0 {
enc.NIL()
} else {
enc.Quoted(string(data.Delim))
}
enc.SP().Mailbox(data.Mailbox)
var ext []string
if data.ChildInfo != nil {
ext = append(ext, "CHILDINFO")
}
if data.OldName != "" {
ext = append(ext, "OLDNAME")
}
// TODO: omit extended data if the client didn't ask for it
if len(ext) > 0 {
enc.SP().List(len(ext), func(i int) {
name := ext[i]
enc.Atom(name).SP()
switch name {
case "CHILDINFO":
enc.Special('(')
if data.ChildInfo.Subscribed {
enc.Quoted("SUBSCRIBED")
}
enc.Special(')')
case "OLDNAME":
enc.Special('(').Mailbox(data.OldName).Special(')')
default:
panic(fmt.Errorf("imapserver: unknown LIST extended-item %v", name))
}
})
}
return enc.CRLF()
}
func (c *Conn) writeLSub(data *imap.ListData) error {
enc := newResponseEncoder(c)
defer enc.end()
enc.Atom("*").SP().Atom("LSUB").SP()
enc.List(len(data.Attrs), func(i int) {
enc.MailboxAttr(data.Attrs[i])
})
enc.SP()
if data.Delim == 0 {
enc.NIL()
} else {
enc.Quoted(string(data.Delim))
}
enc.SP().Mailbox(data.Mailbox)
return enc.CRLF()
}
func readListCmd(dec *imapwire.Decoder) (ref string, patterns []string, options *imap.ListOptions, err error) {
options = &imap.ListOptions{}
if !dec.ExpectSP() {
return "", nil, nil, dec.Err()
}
hasSelectOpts, err := dec.List(func() error {
var selectOpt string
if !dec.ExpectAString(&selectOpt) {
return dec.Err()
}
switch strings.ToUpper(selectOpt) {
case "SUBSCRIBED":
options.SelectSubscribed = true
case "REMOTE":
options.SelectRemote = true
case "RECURSIVEMATCH":
options.SelectRecursiveMatch = true
case "SPECIAL-USE":
options.SelectSpecialUse = true
default:
return newClientBugError("Unknown LIST select option")
}
return nil
})
if err != nil {
return "", nil, nil, fmt.Errorf("in list-select-opts: %w", err)
}
if hasSelectOpts && !dec.ExpectSP() {
return "", nil, nil, dec.Err()
}
if !dec.ExpectMailbox(&ref) || !dec.ExpectSP() {
return "", nil, nil, dec.Err()
}
hasPatterns, err := dec.List(func() error {
pattern, err := readListMailbox(dec)
if err == nil && pattern != "" {
patterns = append(patterns, pattern)
}
return err
})
if err != nil {
return "", nil, nil, err
} else if hasPatterns && len(patterns) == 0 {
return "", nil, nil, newClientBugError("LIST-EXTENDED requires a non-empty parenthesized pattern list")
} else if !hasPatterns {
pattern, err := readListMailbox(dec)
if err != nil {
return "", nil, nil, err
}
if pattern != "" {
patterns = append(patterns, pattern)
}
}
if dec.SP() { // list-return-opts
var atom string
if !dec.ExpectAtom(&atom) || !dec.Expect(strings.EqualFold(atom, "RETURN"), "RETURN") || !dec.ExpectSP() {
return "", nil, nil, dec.Err()
}
err := dec.ExpectList(func() error {
return readReturnOption(dec, options)
})
if err != nil {
return "", nil, nil, fmt.Errorf("in list-return-opts: %w", err)
}
}
if !dec.ExpectCRLF() {
return "", nil, nil, dec.Err()
}
if options.SelectRecursiveMatch && !options.SelectSubscribed {
return "", nil, nil, newClientBugError("The LIST RECURSIVEMATCH select option requires SUBSCRIBED")
}
return ref, patterns, options, nil
}
func readListMailbox(dec *imapwire.Decoder) (string, error) {
var mailbox string
if !dec.String(&mailbox) {
if !dec.Expect(dec.Func(&mailbox, isListChar), "list-char") {
return "", dec.Err()
}
}
return utf7.Decode(mailbox)
}
func isListChar(ch byte) bool {
switch ch {
case '%', '*': // list-wildcards
return true
case ']': // resp-specials
return true
default:
return imapwire.IsAtomChar(ch)
}
}
func readReturnOption(dec *imapwire.Decoder, options *imap.ListOptions) error {
var name string
if !dec.ExpectAtom(&name) {
return dec.Err()
}
switch strings.ToUpper(name) {
case "SUBSCRIBED":
options.ReturnSubscribed = true
case "CHILDREN":
options.ReturnChildren = true
case "SPECIAL-USE":
options.ReturnSpecialUse = true
case "STATUS":
if !dec.ExpectSP() {
return dec.Err()
}
options.ReturnStatus = new(imap.StatusOptions)
return dec.ExpectList(func() error {
return readStatusItem(dec, options.ReturnStatus)
})
default:
return newClientBugError("Unknown LIST RETURN options")
}
return nil
}
// ListWriter writes LIST responses.
type ListWriter struct {
conn *Conn
options *imap.ListOptions
lsub bool
}
// WriteList writes a single LIST response for a mailbox.
func (w *ListWriter) WriteList(data *imap.ListData) error {
if w.lsub {
return w.conn.writeLSub(data)
}
if err := w.conn.writeList(data); err != nil {
return err
}
if w.options.ReturnStatus != nil && data.Status != nil {
if err := w.conn.writeStatus(data.Status, w.options.ReturnStatus); err != nil {
return err
}
}
return nil
}
// MatchList checks whether a reference and a pattern matches a mailbox.
func MatchList(name string, delim rune, reference, pattern string) bool {
var delimStr string
if delim != 0 {
delimStr = string(delim)
}
if delimStr != "" && strings.HasPrefix(pattern, delimStr) {
reference = ""
pattern = strings.TrimPrefix(pattern, delimStr)
}
if reference != "" {
if delimStr != "" && !strings.HasSuffix(reference, delimStr) {
reference += delimStr
}
if !strings.HasPrefix(name, reference) {
return false
}
name = strings.TrimPrefix(name, reference)
}
return matchList(name, delimStr, pattern)
}
func matchList(name, delim, pattern string) bool {
// TODO: optimize
i := strings.IndexAny(pattern, "*%")
if i == -1 {
// No more wildcards
return name == pattern
}
// Get parts before and after wildcard
chunk, wildcard, rest := pattern[0:i], pattern[i], pattern[i+1:]
// Check that name begins with chunk
if len(chunk) > 0 && !strings.HasPrefix(name, chunk) {
return false
}
name = strings.TrimPrefix(name, chunk)
// Expand wildcard
var j int
for j = 0; j < len(name); j++ {
if wildcard == '%' && string(name[j]) == delim {
break // Stop on delimiter if wildcard is %
}
// Try to match the rest from here
if matchList(name[j:], delim, rest) {
return true
}
}
return matchList(name[j:], delim, rest)
}

51
imapserver/list_test.go Normal file
View File

@@ -0,0 +1,51 @@
package imapserver_test
import (
"testing"
"github.com/emersion/go-imap/v2/imapserver"
)
var matchListTests = []struct {
name, ref, pattern string
result bool
}{
{name: "INBOX", pattern: "INBOX", result: true},
{name: "INBOX", pattern: "Asuka", result: false},
{name: "INBOX", pattern: "*", result: true},
{name: "INBOX", pattern: "%", result: true},
{name: "Neon Genesis Evangelion/Misato", pattern: "*", result: true},
{name: "Neon Genesis Evangelion/Misato", pattern: "%", result: false},
{name: "Neon Genesis Evangelion/Misato", pattern: "Neon Genesis Evangelion/*", result: true},
{name: "Neon Genesis Evangelion/Misato", pattern: "Neon Genesis Evangelion/%", result: true},
{name: "Neon Genesis Evangelion/Misato", pattern: "Neo* Evangelion/Misato", result: true},
{name: "Neon Genesis Evangelion/Misato", pattern: "Neo% Evangelion/Misato", result: true},
{name: "Neon Genesis Evangelion/Misato", pattern: "*Eva*/Misato", result: true},
{name: "Neon Genesis Evangelion/Misato", pattern: "%Eva%/Misato", result: true},
{name: "Neon Genesis Evangelion/Misato", pattern: "*X*/Misato", result: false},
{name: "Neon Genesis Evangelion/Misato", pattern: "%X%/Misato", result: false},
{name: "Neon Genesis Evangelion/Misato", pattern: "Neon Genesis Evangelion/Mi%o", result: true},
{name: "Neon Genesis Evangelion/Misato", pattern: "Neon Genesis Evangelion/Mi%too", result: false},
{name: "Misato/Misato", pattern: "Mis*to/Misato", result: true},
{name: "Misato/Misato", pattern: "Mis*to", result: true},
{name: "Misato/Misato/Misato", pattern: "Mis*to/Mis%to", result: true},
{name: "Misato/Misato", pattern: "Mis**to/Misato", result: true},
{name: "Misato/Misato", pattern: "Misat%/Misato", result: true},
{name: "Misato/Misato", pattern: "Misat%Misato", result: false},
{name: "Misato/Misato", ref: "Misato", pattern: "Misato", result: true},
{name: "Misato/Misato", ref: "Misato/", pattern: "Misato", result: true},
{name: "Misato/Misato", ref: "Shinji", pattern: "/Misato/*", result: true},
{name: "Misato/Misato", ref: "Misato", pattern: "/Misato", result: false},
{name: "Misato/Misato", ref: "Misato", pattern: "Shinji", result: false},
{name: "Misato/Misato", ref: "Shinji", pattern: "Misato", result: false},
}
func TestMatchList(t *testing.T) {
delim := '/'
for _, test := range matchListTests {
result := imapserver.MatchList(test.name, delim, test.ref, test.pattern)
if result != test.result {
t.Errorf("matching name %q with pattern %q and reference %q returns %v, but expected %v", test.name, test.pattern, test.ref, result, test.result)
}
}
}

28
imapserver/login.go Normal file
View File

@@ -0,0 +1,28 @@
package imapserver
import (
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
func (c *Conn) handleLogin(tag string, dec *imapwire.Decoder) error {
var username, password string
if !dec.ExpectSP() || !dec.ExpectAString(&username) || !dec.ExpectSP() || !dec.ExpectAString(&password) || !dec.ExpectCRLF() {
return dec.Err()
}
if err := c.checkState(imap.ConnStateNotAuthenticated); err != nil {
return err
}
if !c.canAuth() {
return &imap.Error{
Type: imap.StatusResponseTypeNo,
Code: imap.ResponseCodePrivacyRequired,
Text: "TLS is required to authenticate",
}
}
if err := c.session.Login(username, password); err != nil {
return err
}
c.state = imap.ConnStateAuthenticated
return c.writeCapabilityStatus(tag, imap.StatusResponseTypeOK, "Logged in")
}

336
imapserver/message.go Normal file
View File

@@ -0,0 +1,336 @@
package imapserver
import (
"bufio"
"bytes"
"io"
"strings"
gomessage "github.com/emersion/go-message"
"github.com/emersion/go-message/mail"
"github.com/emersion/go-message/textproto"
"github.com/emersion/go-imap/v2"
)
// ExtractBodySection extracts a section of a message body.
//
// It can be used by server backends to implement Session.Fetch.
func ExtractBodySection(r io.Reader, item *imap.FetchItemBodySection) []byte {
var (
header textproto.Header
body io.Reader
)
br := bufio.NewReader(r)
header, err := textproto.ReadHeader(br)
if err != nil {
return nil
}
body = br
parentMediaType, header, body := findMessagePart(header, body, item.Part)
if body == nil {
return nil
}
if len(item.Part) > 0 {
switch item.Specifier {
case imap.PartSpecifierHeader, imap.PartSpecifierText:
header, body = openMessagePart(header, body, parentMediaType)
}
}
// Filter header fields
if len(item.HeaderFields) > 0 {
keep := make(map[string]struct{})
for _, k := range item.HeaderFields {
keep[strings.ToLower(k)] = struct{}{}
}
for field := header.Fields(); field.Next(); {
if _, ok := keep[strings.ToLower(field.Key())]; !ok {
field.Del()
}
}
}
for _, k := range item.HeaderFieldsNot {
header.Del(k)
}
// Write the requested data to a buffer
var buf bytes.Buffer
writeHeader := true
switch item.Specifier {
case imap.PartSpecifierNone:
writeHeader = len(item.Part) == 0
case imap.PartSpecifierText:
writeHeader = false
}
if writeHeader {
if err := textproto.WriteHeader(&buf, header); err != nil {
return nil
}
}
switch item.Specifier {
case imap.PartSpecifierNone, imap.PartSpecifierText:
if _, err := io.Copy(&buf, body); err != nil {
return nil
}
}
return extractPartial(buf.Bytes(), item.Partial)
}
func findMessagePart(header textproto.Header, body io.Reader, partPath []int) (string, textproto.Header, io.Reader) {
// First part of non-multipart message refers to the message itself
msgHeader := gomessage.Header{header}
mediaType, _, _ := msgHeader.ContentType()
if !strings.HasPrefix(mediaType, "multipart/") && len(partPath) > 0 && partPath[0] == 1 {
partPath = partPath[1:]
}
var parentMediaType string
for i := 0; i < len(partPath); i++ {
partNum := partPath[i]
header, body = openMessagePart(header, body, parentMediaType)
msgHeader := gomessage.Header{header}
mediaType, typeParams, _ := msgHeader.ContentType()
if !strings.HasPrefix(mediaType, "multipart/") {
if partNum != 1 {
return "", textproto.Header{}, nil
}
continue
}
mr := textproto.NewMultipartReader(body, typeParams["boundary"])
found := false
for j := 1; j <= partNum; j++ {
p, err := mr.NextPart()
if err != nil {
return "", textproto.Header{}, nil
}
if j == partNum {
parentMediaType = mediaType
header = p.Header
body = p
found = true
break
}
}
if !found {
return "", textproto.Header{}, nil
}
}
return parentMediaType, header, body
}
func openMessagePart(header textproto.Header, body io.Reader, parentMediaType string) (textproto.Header, io.Reader) {
msgHeader := gomessage.Header{header}
mediaType, _, _ := msgHeader.ContentType()
if !msgHeader.Has("Content-Type") && parentMediaType == "multipart/digest" {
mediaType = "message/rfc822"
}
if mediaType == "message/rfc822" || mediaType == "message/global" {
br := bufio.NewReader(body)
header, _ = textproto.ReadHeader(br)
return header, br
}
return header, body
}
func extractPartial(b []byte, partial *imap.SectionPartial) []byte {
if partial == nil {
return b
}
end := partial.Offset + partial.Size
if partial.Offset > int64(len(b)) {
return nil
}
if end > int64(len(b)) {
end = int64(len(b))
}
return b[partial.Offset:end]
}
func ExtractBinarySection(r io.Reader, item *imap.FetchItemBinarySection) []byte {
var (
header textproto.Header
body io.Reader
)
br := bufio.NewReader(r)
header, err := textproto.ReadHeader(br)
if err != nil {
return nil
}
body = br
_, header, body = findMessagePart(header, body, item.Part)
if body == nil {
return nil
}
part, err := gomessage.New(gomessage.Header{header}, body)
if err != nil {
return nil
}
// Write the requested data to a buffer
var buf bytes.Buffer
if len(item.Part) == 0 {
if err := textproto.WriteHeader(&buf, part.Header.Header); err != nil {
return nil
}
}
if _, err := io.Copy(&buf, part.Body); err != nil {
return nil
}
return extractPartial(buf.Bytes(), item.Partial)
}
func ExtractBinarySectionSize(r io.Reader, item *imap.FetchItemBinarySectionSize) uint32 {
// TODO: optimize
b := ExtractBinarySection(r, &imap.FetchItemBinarySection{Part: item.Part})
return uint32(len(b))
}
// ExtractEnvelope returns a message envelope from its header.
//
// It can be used by server backends to implement Session.Fetch.
func ExtractEnvelope(h textproto.Header) *imap.Envelope {
mh := mail.Header{gomessage.Header{h}}
date, _ := mh.Date()
subject, _ := mh.Subject()
inReplyTo, _ := mh.MsgIDList("In-Reply-To")
messageID, _ := mh.MessageID()
return &imap.Envelope{
Date: date,
Subject: subject,
From: parseAddressList(mh, "From"),
Sender: parseAddressList(mh, "Sender"),
ReplyTo: parseAddressList(mh, "Reply-To"),
To: parseAddressList(mh, "To"),
Cc: parseAddressList(mh, "Cc"),
Bcc: parseAddressList(mh, "Bcc"),
InReplyTo: inReplyTo,
MessageID: messageID,
}
}
func parseAddressList(mh mail.Header, k string) []imap.Address {
// TODO: handle groups
addrs, _ := mh.AddressList(k)
var l []imap.Address
for _, addr := range addrs {
mailbox, host, ok := strings.Cut(addr.Address, "@")
if !ok {
continue
}
l = append(l, imap.Address{
Name: addr.Name,
Mailbox: mailbox,
Host: host,
})
}
return l
}
// ExtractBodyStructure extracts the structure of a message body.
//
// It can be used by server backends to implement Session.Fetch.
func ExtractBodyStructure(r io.Reader) imap.BodyStructure {
br := bufio.NewReader(r)
header, _ := textproto.ReadHeader(br)
return extractBodyStructure(header, br)
}
func extractBodyStructure(rawHeader textproto.Header, r io.Reader) imap.BodyStructure {
header := gomessage.Header{rawHeader}
mediaType, typeParams, _ := header.ContentType()
primaryType, subType, _ := strings.Cut(mediaType, "/")
if primaryType == "multipart" {
bs := &imap.BodyStructureMultiPart{Subtype: subType}
mr := textproto.NewMultipartReader(r, typeParams["boundary"])
for {
part, _ := mr.NextPart()
if part == nil {
break
}
bs.Children = append(bs.Children, extractBodyStructure(part.Header, part))
}
bs.Extended = &imap.BodyStructureMultiPartExt{
Params: typeParams,
Disposition: getContentDisposition(header),
Language: getContentLanguage(header),
Location: header.Get("Content-Location"),
}
return bs
} else {
body, _ := io.ReadAll(r) // TODO: optimize
bs := &imap.BodyStructureSinglePart{
Type: primaryType,
Subtype: subType,
Params: typeParams,
ID: header.Get("Content-Id"),
Description: header.Get("Content-Description"),
Encoding: header.Get("Content-Transfer-Encoding"),
Size: uint32(len(body)),
}
if mediaType == "message/rfc822" || mediaType == "message/global" {
br := bufio.NewReader(bytes.NewReader(body))
childHeader, _ := textproto.ReadHeader(br)
bs.MessageRFC822 = &imap.BodyStructureMessageRFC822{
Envelope: ExtractEnvelope(childHeader),
BodyStructure: extractBodyStructure(childHeader, br),
NumLines: int64(bytes.Count(body, []byte("\n"))),
}
}
if primaryType == "text" {
bs.Text = &imap.BodyStructureText{
NumLines: int64(bytes.Count(body, []byte("\n"))),
}
}
bs.Extended = &imap.BodyStructureSinglePartExt{
Disposition: getContentDisposition(header),
Language: getContentLanguage(header),
Location: header.Get("Content-Location"),
}
return bs
}
}
func getContentDisposition(header gomessage.Header) *imap.BodyStructureDisposition {
disp, dispParams, _ := header.ContentDisposition()
if disp == "" {
return nil
}
return &imap.BodyStructureDisposition{
Value: disp,
Params: dispParams,
}
}
func getContentLanguage(header gomessage.Header) []string {
v := header.Get("Content-Language")
if v == "" {
return nil
}
// TODO: handle CFWS
l := strings.Split(v, ",")
for i, lang := range l {
l[i] = strings.TrimSpace(lang)
}
return l
}

40
imapserver/move.go Normal file
View File

@@ -0,0 +1,40 @@
package imapserver
import (
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
func (c *Conn) handleMove(dec *imapwire.Decoder, numKind NumKind) error {
numSet, dest, err := readCopy(numKind, dec)
if err != nil {
return err
}
if err := c.checkState(imap.ConnStateSelected); err != nil {
return err
}
session, ok := c.session.(SessionMove)
if !ok {
return newClientBugError("MOVE is not supported")
}
w := &MoveWriter{conn: c}
return session.Move(w, numSet, dest)
}
// MoveWriter writes responses for the MOVE command.
//
// Servers must first call WriteCopyData once, then call WriteExpunge any
// number of times.
type MoveWriter struct {
conn *Conn
}
// WriteCopyData writes the untagged COPYUID response for a MOVE command.
func (w *MoveWriter) WriteCopyData(data *imap.CopyData) error {
return w.conn.writeCopyOK("", data)
}
// WriteExpunge writes an EXPUNGE response for a MOVE command.
func (w *MoveWriter) WriteExpunge(seqNum uint32) error {
return w.conn.writeExpunge(seqNum)
}

54
imapserver/namespace.go Normal file
View File

@@ -0,0 +1,54 @@
package imapserver
import (
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
func (c *Conn) handleNamespace(dec *imapwire.Decoder) error {
if !dec.ExpectCRLF() {
return dec.Err()
}
if err := c.checkState(imap.ConnStateAuthenticated); err != nil {
return err
}
session, ok := c.session.(SessionNamespace)
if !ok {
return newClientBugError("NAMESPACE is not supported")
}
data, err := session.Namespace()
if err != nil {
return err
}
enc := newResponseEncoder(c)
defer enc.end()
enc.Atom("*").SP().Atom("NAMESPACE").SP()
writeNamespace(enc.Encoder, data.Personal)
enc.SP()
writeNamespace(enc.Encoder, data.Other)
enc.SP()
writeNamespace(enc.Encoder, data.Shared)
return enc.CRLF()
}
func writeNamespace(enc *imapwire.Encoder, l []imap.NamespaceDescriptor) {
if l == nil {
enc.NIL()
return
}
enc.List(len(l), func(i int) {
descr := l[i]
enc.Special('(').String(descr.Prefix).SP()
if descr.Delim == 0 {
enc.NIL()
} else {
enc.Quoted(string(descr.Delim))
}
enc.Special(')')
})
}

343
imapserver/search.go Normal file
View File

@@ -0,0 +1,343 @@
package imapserver
import (
"fmt"
"strings"
"time"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
func (c *Conn) handleSearch(tag string, dec *imapwire.Decoder, numKind NumKind) error {
if !dec.ExpectSP() {
return dec.Err()
}
var (
atom string
options imap.SearchOptions
extended bool
)
if maybeReadSearchKeyAtom(dec, &atom) && strings.EqualFold(atom, "RETURN") {
if err := readSearchReturnOpts(dec, &options); err != nil {
return fmt.Errorf("in search-return-opts: %w", err)
}
if !dec.ExpectSP() {
return dec.Err()
}
extended = true
atom = ""
maybeReadSearchKeyAtom(dec, &atom)
}
if strings.EqualFold(atom, "CHARSET") {
var charset string
if !dec.ExpectSP() || !dec.ExpectAString(&charset) || !dec.ExpectSP() {
return dec.Err()
}
switch strings.ToUpper(charset) {
case "US-ASCII", "UTF-8":
// nothing to do
default:
return &imap.Error{
Type: imap.StatusResponseTypeNo,
Code: imap.ResponseCodeBadCharset, // TODO: return list of supported charsets
Text: "Only US-ASCII and UTF-8 are supported SEARCH charsets",
}
}
atom = ""
maybeReadSearchKeyAtom(dec, &atom)
}
var criteria imap.SearchCriteria
for {
var err error
if atom != "" {
err = readSearchKeyWithAtom(&criteria, dec, atom)
atom = ""
} else {
err = readSearchKey(&criteria, dec)
}
if err != nil {
return fmt.Errorf("in search-key: %w", err)
}
if !dec.SP() {
break
}
}
if !dec.ExpectCRLF() {
return dec.Err()
}
if err := c.checkState(imap.ConnStateSelected); err != nil {
return err
}
// If no return option is specified, ALL is assumed
if !options.ReturnMin && !options.ReturnMax && !options.ReturnAll && !options.ReturnCount {
options.ReturnAll = true
}
data, err := c.session.Search(numKind, &criteria, &options)
if err != nil {
return err
}
if c.enabled.Has(imap.CapIMAP4rev2) || extended {
return c.writeESearch(tag, data, &options, numKind)
} else {
return c.writeSearch(data.All)
}
}
func (c *Conn) writeESearch(tag string, data *imap.SearchData, options *imap.SearchOptions, numKind NumKind) error {
enc := newResponseEncoder(c)
defer enc.end()
enc.Atom("*").SP().Atom("ESEARCH")
if tag != "" {
enc.SP().Special('(').Atom("TAG").SP().String(tag).Special(')')
}
if numKind == NumKindUID {
enc.SP().Atom("UID")
}
// When there is no result, we need to send an ESEARCH response with no ALL
// keyword
if options.ReturnAll && !isNumSetEmpty(data.All) {
enc.SP().Atom("ALL").SP().NumSet(data.All)
}
if options.ReturnMin && data.Min > 0 {
enc.SP().Atom("MIN").SP().Number(data.Min)
}
if options.ReturnMax && data.Max > 0 {
enc.SP().Atom("MAX").SP().Number(data.Max)
}
if options.ReturnCount {
enc.SP().Atom("COUNT").SP().Number(data.Count)
}
return enc.CRLF()
}
func isNumSetEmpty(numSet imap.NumSet) bool {
switch numSet := numSet.(type) {
case imap.SeqSet:
return len(numSet) == 0
case imap.UIDSet:
return len(numSet) == 0
default:
panic("unknown imap.NumSet type")
}
}
func (c *Conn) writeSearch(numSet imap.NumSet) error {
enc := newResponseEncoder(c)
defer enc.end()
enc.Atom("*").SP().Atom("SEARCH")
var ok bool
switch numSet := numSet.(type) {
case imap.SeqSet:
var nums []uint32
nums, ok = numSet.Nums()
for _, num := range nums {
enc.SP().Number(num)
}
case imap.UIDSet:
var uids []imap.UID
uids, ok = numSet.Nums()
for _, uid := range uids {
enc.SP().UID(uid)
}
}
if !ok {
return fmt.Errorf("imapserver: failed to enumerate message numbers in SEARCH response")
}
return enc.CRLF()
}
func readSearchReturnOpts(dec *imapwire.Decoder, options *imap.SearchOptions) error {
if !dec.ExpectSP() {
return dec.Err()
}
return dec.ExpectList(func() error {
var name string
if !dec.ExpectAtom(&name) {
return dec.Err()
}
switch strings.ToUpper(name) {
case "MIN":
options.ReturnMin = true
case "MAX":
options.ReturnMax = true
case "ALL":
options.ReturnAll = true
case "COUNT":
options.ReturnCount = true
case "SAVE":
options.ReturnSave = true
default:
return newClientBugError("unknown SEARCH RETURN option")
}
return nil
})
}
func maybeReadSearchKeyAtom(dec *imapwire.Decoder, ptr *string) bool {
return dec.Func(ptr, func(ch byte) bool {
return ch == '*' || imapwire.IsAtomChar(ch)
})
}
func readSearchKey(criteria *imap.SearchCriteria, dec *imapwire.Decoder) error {
var key string
if maybeReadSearchKeyAtom(dec, &key) {
return readSearchKeyWithAtom(criteria, dec, key)
}
return dec.ExpectList(func() error {
return readSearchKey(criteria, dec)
})
}
func readSearchKeyWithAtom(criteria *imap.SearchCriteria, dec *imapwire.Decoder, key string) error {
key = strings.ToUpper(key)
switch key {
case "ALL":
// nothing to do
case "UID":
var uidSet imap.UIDSet
if !dec.ExpectSP() || !dec.ExpectUIDSet(&uidSet) {
return dec.Err()
}
criteria.UID = append(criteria.UID, uidSet)
case "ANSWERED", "DELETED", "DRAFT", "FLAGGED", "RECENT", "SEEN":
criteria.Flag = append(criteria.Flag, searchKeyFlag(key))
case "UNANSWERED", "UNDELETED", "UNDRAFT", "UNFLAGGED", "UNSEEN":
notKey := strings.TrimPrefix(key, "UN")
criteria.NotFlag = append(criteria.NotFlag, searchKeyFlag(notKey))
case "NEW":
criteria.Flag = append(criteria.Flag, internal.FlagRecent)
criteria.NotFlag = append(criteria.NotFlag, imap.FlagSeen)
case "OLD":
criteria.NotFlag = append(criteria.NotFlag, internal.FlagRecent)
case "KEYWORD", "UNKEYWORD":
if !dec.ExpectSP() {
return dec.Err()
}
flag, err := internal.ExpectFlag(dec)
if err != nil {
return err
}
switch key {
case "KEYWORD":
criteria.Flag = append(criteria.Flag, flag)
case "UNKEYWORD":
criteria.NotFlag = append(criteria.NotFlag, flag)
}
case "BCC", "CC", "FROM", "SUBJECT", "TO":
var value string
if !dec.ExpectSP() || !dec.ExpectAString(&value) {
return dec.Err()
}
criteria.Header = append(criteria.Header, imap.SearchCriteriaHeaderField{
Key: strings.Title(strings.ToLower(key)),
Value: value,
})
case "HEADER":
var key, value string
if !dec.ExpectSP() || !dec.ExpectAString(&key) || !dec.ExpectSP() || !dec.ExpectAString(&value) {
return dec.Err()
}
criteria.Header = append(criteria.Header, imap.SearchCriteriaHeaderField{
Key: key,
Value: value,
})
case "SINCE", "BEFORE", "ON", "SENTSINCE", "SENTBEFORE", "SENTON":
if !dec.ExpectSP() {
return dec.Err()
}
t, err := internal.ExpectDate(dec)
if err != nil {
return err
}
var dateCriteria imap.SearchCriteria
switch key {
case "SINCE":
dateCriteria.Since = t
case "BEFORE":
dateCriteria.Before = t
case "ON":
dateCriteria.Since = t
dateCriteria.Before = t.Add(24 * time.Hour)
case "SENTSINCE":
dateCriteria.SentSince = t
case "SENTBEFORE":
dateCriteria.SentBefore = t
case "SENTON":
dateCriteria.SentSince = t
dateCriteria.SentBefore = t.Add(24 * time.Hour)
}
criteria.And(&dateCriteria)
case "BODY":
var body string
if !dec.ExpectSP() || !dec.ExpectAString(&body) {
return dec.Err()
}
criteria.Body = append(criteria.Body, body)
case "TEXT":
var text string
if !dec.ExpectSP() || !dec.ExpectAString(&text) {
return dec.Err()
}
criteria.Text = append(criteria.Text, text)
case "LARGER", "SMALLER":
var n int64
if !dec.ExpectSP() || !dec.ExpectNumber64(&n) {
return dec.Err()
}
switch key {
case "LARGER":
criteria.And(&imap.SearchCriteria{Larger: n})
case "SMALLER":
criteria.And(&imap.SearchCriteria{Smaller: n})
}
case "NOT":
if !dec.ExpectSP() {
return dec.Err()
}
var not imap.SearchCriteria
if err := readSearchKey(&not, dec); err != nil {
return err
}
criteria.Not = append(criteria.Not, not)
case "OR":
if !dec.ExpectSP() {
return dec.Err()
}
var or [2]imap.SearchCriteria
if err := readSearchKey(&or[0], dec); err != nil {
return err
}
if !dec.ExpectSP() {
return dec.Err()
}
if err := readSearchKey(&or[1], dec); err != nil {
return err
}
criteria.Or = append(criteria.Or, or)
case "$":
criteria.UID = append(criteria.UID, imap.SearchRes())
default:
seqSet, err := imapwire.ParseSeqSet(key)
if err != nil {
return err
}
criteria.SeqNum = append(criteria.SeqNum, seqSet)
}
return nil
}
func searchKeyFlag(key string) imap.Flag {
return imap.Flag("\\" + strings.Title(strings.ToLower(key)))
}

174
imapserver/select.go Normal file
View File

@@ -0,0 +1,174 @@
package imapserver
import (
"fmt"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
func (c *Conn) handleSelect(tag string, dec *imapwire.Decoder, readOnly bool) error {
var mailbox string
if !dec.ExpectSP() || !dec.ExpectMailbox(&mailbox) || !dec.ExpectCRLF() {
return dec.Err()
}
if err := c.checkState(imap.ConnStateAuthenticated); err != nil {
return err
}
if c.state == imap.ConnStateSelected {
if err := c.session.Unselect(); err != nil {
return err
}
c.state = imap.ConnStateAuthenticated
err := c.writeStatusResp("", &imap.StatusResponse{
Type: imap.StatusResponseTypeOK,
Code: "CLOSED",
Text: "Previous mailbox is now closed",
})
if err != nil {
return err
}
}
options := imap.SelectOptions{ReadOnly: readOnly}
data, err := c.session.Select(mailbox, &options)
if err != nil {
return err
}
if err := c.writeExists(data.NumMessages); err != nil {
return err
}
if !c.enabled.Has(imap.CapIMAP4rev2) && c.server.options.caps().Has(imap.CapIMAP4rev1) {
if err := c.writeObsoleteRecent(data.NumRecent); err != nil {
return err
}
if data.FirstUnseenSeqNum != 0 {
if err := c.writeObsoleteUnseen(data.FirstUnseenSeqNum); err != nil {
return err
}
}
}
if err := c.writeUIDValidity(data.UIDValidity); err != nil {
return err
}
if err := c.writeUIDNext(data.UIDNext); err != nil {
return err
}
if err := c.writeFlags(data.Flags); err != nil {
return err
}
if err := c.writePermanentFlags(data.PermanentFlags); err != nil {
return err
}
if data.List != nil {
if err := c.writeList(data.List); err != nil {
return err
}
}
c.state = imap.ConnStateSelected
// TODO: forbid write commands in read-only mode
var (
cmdName string
code imap.ResponseCode
)
if readOnly {
cmdName = "EXAMINE"
code = "READ-ONLY"
} else {
cmdName = "SELECT"
code = "READ-WRITE"
}
return c.writeStatusResp(tag, &imap.StatusResponse{
Type: imap.StatusResponseTypeOK,
Code: code,
Text: fmt.Sprintf("%v completed", cmdName),
})
}
func (c *Conn) handleUnselect(dec *imapwire.Decoder, expunge bool) error {
if !dec.ExpectCRLF() {
return dec.Err()
}
if err := c.checkState(imap.ConnStateSelected); err != nil {
return err
}
if expunge {
w := &ExpungeWriter{}
if err := c.session.Expunge(w, nil); err != nil {
return err
}
}
if err := c.session.Unselect(); err != nil {
return err
}
c.state = imap.ConnStateAuthenticated
return nil
}
func (c *Conn) writeExists(numMessages uint32) error {
enc := newResponseEncoder(c)
defer enc.end()
return enc.Atom("*").SP().Number(numMessages).SP().Atom("EXISTS").CRLF()
}
func (c *Conn) writeObsoleteRecent(n uint32) error {
enc := newResponseEncoder(c)
defer enc.end()
return enc.Atom("*").SP().Number(n).SP().Atom("RECENT").CRLF()
}
func (c *Conn) writeObsoleteUnseen(n uint32) error {
enc := newResponseEncoder(c)
defer enc.end()
enc.Atom("*").SP().Atom("OK").SP()
enc.Special('[').Atom("UNSEEN").SP().Number(n).Special(']')
enc.SP().Text("First unseen message")
return enc.CRLF()
}
func (c *Conn) writeUIDValidity(uidValidity uint32) error {
enc := newResponseEncoder(c)
defer enc.end()
enc.Atom("*").SP().Atom("OK").SP()
enc.Special('[').Atom("UIDVALIDITY").SP().Number(uidValidity).Special(']')
enc.SP().Text("UIDs valid")
return enc.CRLF()
}
func (c *Conn) writeUIDNext(uidNext imap.UID) error {
enc := newResponseEncoder(c)
defer enc.end()
enc.Atom("*").SP().Atom("OK").SP()
enc.Special('[').Atom("UIDNEXT").SP().UID(uidNext).Special(']')
enc.SP().Text("Predicted next UID")
return enc.CRLF()
}
func (c *Conn) writeFlags(flags []imap.Flag) error {
enc := newResponseEncoder(c)
defer enc.end()
enc.Atom("*").SP().Atom("FLAGS").SP().List(len(flags), func(i int) {
enc.Flag(flags[i])
})
return enc.CRLF()
}
func (c *Conn) writePermanentFlags(flags []imap.Flag) error {
enc := newResponseEncoder(c)
defer enc.end()
enc.Atom("*").SP().Atom("OK").SP()
enc.Special('[').Atom("PERMANENTFLAGS").SP().List(len(flags), func(i int) {
enc.Flag(flags[i])
}).Special(']')
enc.SP().Text("Permanent flags")
return enc.CRLF()
}

222
imapserver/server.go Normal file
View File

@@ -0,0 +1,222 @@
// Package imapserver implements an IMAP server.
package imapserver
import (
"crypto/tls"
"errors"
"fmt"
"io"
"log"
"net"
"sync"
"time"
"github.com/emersion/go-imap/v2"
)
var errClosed = errors.New("imapserver: server closed")
// Logger is a facility to log error messages.
type Logger interface {
Printf(format string, args ...interface{})
}
// Options contains server options.
//
// The only required field is NewSession.
type Options struct {
// NewSession is called when a client connects.
NewSession func(*Conn) (Session, *GreetingData, error)
// Supported capabilities. If nil, only IMAP4rev1 is advertised. This set
// must contain at least IMAP4rev1 or IMAP4rev2.
//
// The following capabilities are part of IMAP4rev2 and need to be
// explicitly enabled by IMAP4rev1-only servers:
//
// - NAMESPACE
// - UIDPLUS
// - ESEARCH
// - LIST-EXTENDED
// - LIST-STATUS
// - MOVE
// - STATUS=SIZE
Caps imap.CapSet
// Logger is a logger to print error messages. If nil, log.Default is used.
Logger Logger
// TLSConfig is a TLS configuration for STARTTLS. If nil, STARTTLS is
// disabled.
TLSConfig *tls.Config
// InsecureAuth allows clients to authenticate without TLS. In this mode,
// the server is susceptible to man-in-the-middle attacks.
InsecureAuth bool
// Raw ingress and egress data will be written to this writer, if any.
// Note, this may include sensitive information such as credentials used
// during authentication.
DebugWriter io.Writer
}
func (options *Options) wrapReadWriter(rw io.ReadWriter) io.ReadWriter {
if options.DebugWriter == nil {
return rw
}
return struct {
io.Reader
io.Writer
}{
Reader: io.TeeReader(rw, options.DebugWriter),
Writer: io.MultiWriter(rw, options.DebugWriter),
}
}
func (options *Options) caps() imap.CapSet {
if options.Caps != nil {
return options.Caps
}
return imap.CapSet{imap.CapIMAP4rev1: {}}
}
// Server is an IMAP server.
type Server struct {
options Options
listenerWaitGroup sync.WaitGroup
mutex sync.Mutex
listeners map[net.Listener]struct{}
conns map[*Conn]struct{}
closed bool
}
// New creates a new server.
func New(options *Options) *Server {
if caps := options.caps(); !caps.Has(imap.CapIMAP4rev2) && !caps.Has(imap.CapIMAP4rev1) {
panic("imapserver: at least IMAP4rev1 must be supported")
}
return &Server{
options: *options,
listeners: make(map[net.Listener]struct{}),
conns: make(map[*Conn]struct{}),
}
}
func (s *Server) logger() Logger {
if s.options.Logger == nil {
return log.Default()
}
return s.options.Logger
}
// Serve accepts incoming connections on the listener ln.
func (s *Server) Serve(ln net.Listener) error {
s.mutex.Lock()
ok := !s.closed
if ok {
s.listeners[ln] = struct{}{}
}
s.mutex.Unlock()
if !ok {
return errClosed
}
defer func() {
s.mutex.Lock()
delete(s.listeners, ln)
s.mutex.Unlock()
}()
s.listenerWaitGroup.Add(1)
defer s.listenerWaitGroup.Done()
var delay time.Duration
for {
conn, err := ln.Accept()
if ne, ok := err.(net.Error); ok && ne.Temporary() {
if delay == 0 {
delay = 5 * time.Millisecond
} else {
delay *= 2
}
if max := 1 * time.Second; delay > max {
delay = max
}
s.logger().Printf("accept error (retrying in %v): %v", delay, err)
time.Sleep(delay)
continue
} else if errors.Is(err, net.ErrClosed) {
return nil
} else if err != nil {
return fmt.Errorf("accept error: %w", err)
}
delay = 0
go newConn(conn, s).serve()
}
}
// ListenAndServe listens on the TCP network address addr and then calls Serve.
//
// If addr is empty, ":143" is used.
func (s *Server) ListenAndServe(addr string) error {
if addr == "" {
addr = ":143"
}
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
return s.Serve(ln)
}
// ListenAndServeTLS listens on the TCP network address addr and then calls
// Serve to handle incoming TLS connections.
//
// The TLS configuration set in Options.TLSConfig is used. If addr is empty,
// ":993" is used.
func (s *Server) ListenAndServeTLS(addr string) error {
if addr == "" {
addr = ":993"
}
ln, err := tls.Listen("tcp", addr, s.options.TLSConfig)
if err != nil {
return err
}
return s.Serve(ln)
}
// Close immediately closes all active listeners and connections.
//
// Close returns any error returned from closing the server's underlying
// listeners.
//
// Once Close has been called on a server, it may not be reused; future calls
// to methods such as Serve will return an error.
func (s *Server) Close() error {
var err error
s.mutex.Lock()
ok := !s.closed
if ok {
s.closed = true
for l := range s.listeners {
if closeErr := l.Close(); closeErr != nil && err == nil {
err = closeErr
}
}
}
s.mutex.Unlock()
if !ok {
return errClosed
}
s.listenerWaitGroup.Wait()
s.mutex.Lock()
for c := range s.conns {
c.mutex.Lock()
c.conn.Close()
c.mutex.Unlock()
}
s.mutex.Unlock()
return err
}

126
imapserver/session.go Normal file
View File

@@ -0,0 +1,126 @@
package imapserver
import (
"fmt"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal/imapwire"
"github.com/emersion/go-sasl"
)
var errAuthFailed = &imap.Error{
Type: imap.StatusResponseTypeNo,
Code: imap.ResponseCodeAuthenticationFailed,
Text: "Authentication failed",
}
// ErrAuthFailed is returned by Session.Login on authentication failure.
var ErrAuthFailed = errAuthFailed
// GreetingData is the data associated with an IMAP greeting.
type GreetingData struct {
PreAuth bool
}
// NumKind describes how a number should be interpreted: either as a sequence
// number, either as a UID.
type NumKind int
const (
NumKindSeq = NumKind(imapwire.NumKindSeq)
NumKindUID = NumKind(imapwire.NumKindUID)
)
// String implements fmt.Stringer.
func (kind NumKind) String() string {
switch kind {
case NumKindSeq:
return "seq"
case NumKindUID:
return "uid"
default:
panic(fmt.Errorf("imapserver: unknown NumKind %d", kind))
}
}
func (kind NumKind) wire() imapwire.NumKind {
return imapwire.NumKind(kind)
}
// Session is an IMAP session.
type Session interface {
Close() error
// Not authenticated state
Login(username, password string) error
// Authenticated state
Select(mailbox string, options *imap.SelectOptions) (*imap.SelectData, error)
Create(mailbox string, options *imap.CreateOptions) error
Delete(mailbox string) error
Rename(mailbox, newName string, options *imap.RenameOptions) error
Subscribe(mailbox string) error
Unsubscribe(mailbox string) error
List(w *ListWriter, ref string, patterns []string, options *imap.ListOptions) error
Status(mailbox string, options *imap.StatusOptions) (*imap.StatusData, error)
Append(mailbox string, r imap.LiteralReader, options *imap.AppendOptions) (*imap.AppendData, error)
Poll(w *UpdateWriter, allowExpunge bool) error
Idle(w *UpdateWriter, stop <-chan struct{}) error
// Selected state
Unselect() error
Expunge(w *ExpungeWriter, uids *imap.UIDSet) error
Search(kind NumKind, criteria *imap.SearchCriteria, options *imap.SearchOptions) (*imap.SearchData, error)
Fetch(w *FetchWriter, numSet imap.NumSet, options *imap.FetchOptions) error
Store(w *FetchWriter, numSet imap.NumSet, flags *imap.StoreFlags, options *imap.StoreOptions) error
Copy(numSet imap.NumSet, dest string) (*imap.CopyData, error)
}
// SessionNamespace is an IMAP session which supports NAMESPACE.
type SessionNamespace interface {
Session
// Authenticated state
Namespace() (*imap.NamespaceData, error)
}
// SessionMove is an IMAP session which supports MOVE.
type SessionMove interface {
Session
// Selected state
Move(w *MoveWriter, numSet imap.NumSet, dest string) error
}
// SessionIMAP4rev2 is an IMAP session which supports IMAP4rev2.
type SessionIMAP4rev2 interface {
Session
SessionNamespace
SessionMove
}
// SessionSASL is an IMAP session which supports its own set of SASL
// authentication mechanisms.
type SessionSASL interface {
Session
AuthenticateMechanisms() []string
Authenticate(mech string) (sasl.Server, error)
}
// SessionUnauthenticate is an IMAP session which supports UNAUTHENTICATE.
type SessionUnauthenticate interface {
Session
// Authenticated state
Unauthenticate() error
}
// SessionAppendLimit is an IMAP session which has the same APPEND limit for
// all mailboxes.
type SessionAppendLimit interface {
Session
// AppendLimit returns the maximum size in bytes that can be uploaded to
// this server in an APPEND command.
AppendLimit() uint32
}

83
imapserver/starttls.go Normal file
View File

@@ -0,0 +1,83 @@
package imapserver
import (
"bytes"
"crypto/tls"
"io"
"net"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
func (c *Conn) canStartTLS() bool {
_, isTLS := c.conn.(*tls.Conn)
return c.server.options.TLSConfig != nil && c.state == imap.ConnStateNotAuthenticated && !isTLS
}
func (c *Conn) handleStartTLS(tag string, dec *imapwire.Decoder) error {
if !dec.ExpectCRLF() {
return dec.Err()
}
if c.server.options.TLSConfig == nil {
return &imap.Error{
Type: imap.StatusResponseTypeNo,
Text: "STARTTLS not supported",
}
}
if !c.canStartTLS() {
return &imap.Error{
Type: imap.StatusResponseTypeBad,
Text: "STARTTLS not available",
}
}
// Do not allow to write cleartext data past this point: keep c.encMutex
// locked until the end
enc := newResponseEncoder(c)
defer enc.end()
err := writeStatusResp(enc.Encoder, tag, &imap.StatusResponse{
Type: imap.StatusResponseTypeOK,
Text: "Begin TLS negotiation now",
})
if err != nil {
return err
}
// Drain buffered data from our bufio.Reader
var buf bytes.Buffer
if _, err := io.CopyN(&buf, c.br, int64(c.br.Buffered())); err != nil {
panic(err) // unreachable
}
var cleartextConn net.Conn
if buf.Len() > 0 {
r := io.MultiReader(&buf, c.conn)
cleartextConn = startTLSConn{c.conn, r}
} else {
cleartextConn = c.conn
}
tlsConn := tls.Server(cleartextConn, c.server.options.TLSConfig)
c.mutex.Lock()
c.conn = tlsConn
c.mutex.Unlock()
rw := c.server.options.wrapReadWriter(tlsConn)
c.br.Reset(rw)
c.bw.Reset(rw)
return nil
}
type startTLSConn struct {
net.Conn
r io.Reader
}
func (conn startTLSConn) Read(b []byte) (int, error) {
return conn.r.Read(b)
}

125
imapserver/status.go Normal file
View File

@@ -0,0 +1,125 @@
package imapserver
import (
"strings"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
func (c *Conn) handleStatus(dec *imapwire.Decoder) error {
var mailbox string
if !dec.ExpectSP() || !dec.ExpectMailbox(&mailbox) || !dec.ExpectSP() {
return dec.Err()
}
var options imap.StatusOptions
err := dec.ExpectList(func() error {
err := readStatusItem(dec, &options)
if err != nil {
return err
}
return nil
})
if err != nil {
return err
}
if !dec.ExpectCRLF() {
return dec.Err()
}
if options.NumRecent && !c.server.options.caps().Has(imap.CapIMAP4rev1) {
return &imap.Error{
Type: imap.StatusResponseTypeBad,
Text: "Unknown STATUS data item",
}
}
if err := c.checkState(imap.ConnStateAuthenticated); err != nil {
return err
}
data, err := c.session.Status(mailbox, &options)
if err != nil {
return err
}
return c.writeStatus(data, &options)
}
func (c *Conn) writeStatus(data *imap.StatusData, options *imap.StatusOptions) error {
enc := newResponseEncoder(c)
defer enc.end()
enc.Atom("*").SP().Atom("STATUS").SP().Mailbox(data.Mailbox).SP()
listEnc := enc.BeginList()
if options.NumMessages {
listEnc.Item().Atom("MESSAGES").SP().Number(*data.NumMessages)
}
if options.UIDNext {
listEnc.Item().Atom("UIDNEXT").SP().UID(data.UIDNext)
}
if options.UIDValidity {
listEnc.Item().Atom("UIDVALIDITY").SP().Number(data.UIDValidity)
}
if options.NumUnseen {
listEnc.Item().Atom("UNSEEN").SP().Number(*data.NumUnseen)
}
if options.NumDeleted {
listEnc.Item().Atom("DELETED").SP().Number(*data.NumDeleted)
}
if options.Size {
listEnc.Item().Atom("SIZE").SP().Number64(*data.Size)
}
if options.AppendLimit {
listEnc.Item().Atom("APPENDLIMIT").SP()
if data.AppendLimit != nil {
enc.Number(*data.AppendLimit)
} else {
enc.NIL()
}
}
if options.DeletedStorage {
listEnc.Item().Atom("DELETED-STORAGE").SP().Number64(*data.DeletedStorage)
}
if options.NumRecent {
listEnc.Item().Atom("RECENT").SP().Number(*data.NumRecent)
}
listEnc.End()
return enc.CRLF()
}
func readStatusItem(dec *imapwire.Decoder, options *imap.StatusOptions) error {
var name string
if !dec.ExpectAtom(&name) {
return dec.Err()
}
switch strings.ToUpper(name) {
case "MESSAGES":
options.NumMessages = true
case "UIDNEXT":
options.UIDNext = true
case "UIDVALIDITY":
options.UIDValidity = true
case "UNSEEN":
options.NumUnseen = true
case "DELETED":
options.NumDeleted = true
case "SIZE":
options.Size = true
case "APPENDLIMIT":
options.AppendLimit = true
case "DELETED-STORAGE":
options.DeletedStorage = true
case "RECENT":
options.NumRecent = true
default:
return &imap.Error{
Type: imap.StatusResponseTypeBad,
Text: "Unknown STATUS data item",
}
}
return nil
}

78
imapserver/store.go Normal file
View File

@@ -0,0 +1,78 @@
package imapserver
import (
"strings"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
func (c *Conn) handleStore(dec *imapwire.Decoder, numKind NumKind) error {
var (
numSet imap.NumSet
item string
)
if !dec.ExpectSP() || !dec.ExpectNumSet(numKind.wire(), &numSet) || !dec.ExpectSP() || !dec.ExpectAtom(&item) || !dec.ExpectSP() {
return dec.Err()
}
var flags []imap.Flag
isList, err := dec.List(func() error {
flag, err := internal.ExpectFlag(dec)
if err != nil {
return err
}
flags = append(flags, flag)
return nil
})
if err != nil {
return err
} else if !isList {
for {
flag, err := internal.ExpectFlag(dec)
if err != nil {
return err
}
flags = append(flags, flag)
if !dec.SP() {
break
}
}
}
if !dec.ExpectCRLF() {
return dec.Err()
}
item = strings.ToUpper(item)
silent := strings.HasSuffix(item, ".SILENT")
item = strings.TrimSuffix(item, ".SILENT")
var op imap.StoreFlagsOp
switch {
case strings.HasPrefix(item, "+"):
op = imap.StoreFlagsAdd
item = strings.TrimPrefix(item, "+")
case strings.HasPrefix(item, "-"):
op = imap.StoreFlagsDel
item = strings.TrimPrefix(item, "-")
default:
op = imap.StoreFlagsSet
}
if item != "FLAGS" {
return newClientBugError("STORE can only change FLAGS")
}
if err := c.checkState(imap.ConnStateSelected); err != nil {
return err
}
w := &FetchWriter{conn: c}
options := imap.StoreOptions{}
return c.session.Store(w, numSet, &imap.StoreFlags{
Op: op,
Silent: silent,
Flags: flags,
}, &options)
}

284
imapserver/tracker.go Normal file
View File

@@ -0,0 +1,284 @@
package imapserver
import (
"fmt"
"sync"
"github.com/emersion/go-imap/v2"
)
// MailboxTracker tracks the state of a mailbox.
//
// A mailbox can have multiple sessions listening for updates. Each session has
// its own view of the mailbox, because IMAP clients asynchronously receive
// mailbox updates.
type MailboxTracker struct {
mutex sync.Mutex
numMessages uint32
sessions map[*SessionTracker]struct{}
}
// NewMailboxTracker creates a new mailbox tracker.
func NewMailboxTracker(numMessages uint32) *MailboxTracker {
return &MailboxTracker{
numMessages: numMessages,
sessions: make(map[*SessionTracker]struct{}),
}
}
// NewSession creates a new session tracker for the mailbox.
//
// The caller must call SessionTracker.Close once they are done with the
// session.
func (t *MailboxTracker) NewSession() *SessionTracker {
st := &SessionTracker{mailbox: t}
t.mutex.Lock()
t.sessions[st] = struct{}{}
t.mutex.Unlock()
return st
}
func (t *MailboxTracker) queueUpdate(update *trackerUpdate, source *SessionTracker) {
t.mutex.Lock()
defer t.mutex.Unlock()
if update.expunge != 0 && update.expunge > t.numMessages {
panic(fmt.Errorf("imapserver: expunge sequence number (%v) out of range (%v messages in mailbox)", update.expunge, t.numMessages))
}
if update.numMessages != 0 && update.numMessages < t.numMessages {
panic(fmt.Errorf("imapserver: cannot decrease mailbox number of messages from %v to %v", t.numMessages, update.numMessages))
}
for st := range t.sessions {
if source != nil && st == source {
continue
}
st.queueUpdate(update)
}
switch {
case update.expunge != 0:
t.numMessages--
case update.numMessages != 0:
t.numMessages = update.numMessages
}
}
// QueueExpunge queues a new EXPUNGE update.
func (t *MailboxTracker) QueueExpunge(seqNum uint32) {
if seqNum == 0 {
panic("imapserver: invalid expunge message sequence number")
}
t.queueUpdate(&trackerUpdate{expunge: seqNum}, nil)
}
// QueueNumMessages queues a new EXISTS update.
func (t *MailboxTracker) QueueNumMessages(n uint32) {
// TODO: merge consecutive NumMessages updates
t.queueUpdate(&trackerUpdate{numMessages: n}, nil)
}
// QueueMailboxFlags queues a new FLAGS update.
func (t *MailboxTracker) QueueMailboxFlags(flags []imap.Flag) {
if flags == nil {
flags = []imap.Flag{}
}
t.queueUpdate(&trackerUpdate{mailboxFlags: flags}, nil)
}
// QueueMessageFlags queues a new FETCH FLAGS update.
//
// If source is not nil, the update won't be dispatched to it.
func (t *MailboxTracker) QueueMessageFlags(seqNum uint32, uid imap.UID, flags []imap.Flag, source *SessionTracker) {
t.queueUpdate(&trackerUpdate{fetch: &trackerUpdateFetch{
seqNum: seqNum,
uid: uid,
flags: flags,
}}, source)
}
type trackerUpdate struct {
expunge uint32
numMessages uint32
mailboxFlags []imap.Flag
fetch *trackerUpdateFetch
}
type trackerUpdateFetch struct {
seqNum uint32
uid imap.UID
flags []imap.Flag
}
// SessionTracker tracks the state of a mailbox for an IMAP client.
type SessionTracker struct {
mailbox *MailboxTracker
mutex sync.Mutex
queue []trackerUpdate
updates chan<- struct{}
}
// Close unregisters the session.
func (t *SessionTracker) Close() {
t.mailbox.mutex.Lock()
delete(t.mailbox.sessions, t)
t.mailbox.mutex.Unlock()
t.mailbox = nil
}
func (t *SessionTracker) queueUpdate(update *trackerUpdate) {
var updates chan<- struct{}
t.mutex.Lock()
t.queue = append(t.queue, *update)
updates = t.updates
t.mutex.Unlock()
if updates != nil {
select {
case updates <- struct{}{}:
// we notified SessionTracker.Idle about the update
default:
// skip the update
}
}
}
// Poll dequeues pending mailbox updates for this session.
func (t *SessionTracker) Poll(w *UpdateWriter, allowExpunge bool) error {
var updates []trackerUpdate
t.mutex.Lock()
if allowExpunge {
updates = t.queue
t.queue = nil
} else {
stopIndex := -1
for i, update := range t.queue {
if update.expunge != 0 {
stopIndex = i
break
}
updates = append(updates, update)
}
if stopIndex >= 0 {
t.queue = t.queue[stopIndex:]
} else {
t.queue = nil
}
}
t.mutex.Unlock()
for _, update := range updates {
var err error
switch {
case update.expunge != 0:
err = w.WriteExpunge(update.expunge)
case update.numMessages != 0:
err = w.WriteNumMessages(update.numMessages)
case update.mailboxFlags != nil:
err = w.WriteMailboxFlags(update.mailboxFlags)
case update.fetch != nil:
err = w.WriteMessageFlags(update.fetch.seqNum, update.fetch.uid, update.fetch.flags)
default:
panic(fmt.Errorf("imapserver: unknown tracker update %#v", update))
}
if err != nil {
return err
}
}
return nil
}
// Idle continuously writes mailbox updates.
//
// When the stop channel is closed, it returns.
//
// Idle cannot be invoked concurrently from two separate goroutines.
func (t *SessionTracker) Idle(w *UpdateWriter, stop <-chan struct{}) error {
updates := make(chan struct{}, 64)
t.mutex.Lock()
ok := t.updates == nil
if ok {
t.updates = updates
}
t.mutex.Unlock()
if !ok {
return fmt.Errorf("imapserver: only a single SessionTracker.Idle call is allowed at a time")
}
defer func() {
t.mutex.Lock()
t.updates = nil
t.mutex.Unlock()
}()
for {
select {
case <-updates:
if err := t.Poll(w, true); err != nil {
return err
}
case <-stop:
return nil
}
}
}
// DecodeSeqNum converts a message sequence number from the client view to the
// server view.
//
// Zero is returned if the message doesn't exist from the server point-of-view.
func (t *SessionTracker) DecodeSeqNum(seqNum uint32) uint32 {
if seqNum == 0 {
return 0
}
t.mutex.Lock()
defer t.mutex.Unlock()
for _, update := range t.queue {
if update.expunge == 0 {
continue
}
if seqNum == update.expunge {
return 0
} else if seqNum > update.expunge {
seqNum--
}
}
if seqNum > t.mailbox.numMessages {
return 0
}
return seqNum
}
// EncodeSeqNum converts a message sequence number from the server view to the
// client view.
//
// Zero is returned if the message doesn't exist from the client point-of-view.
func (t *SessionTracker) EncodeSeqNum(seqNum uint32) uint32 {
if seqNum == 0 {
return 0
}
t.mutex.Lock()
defer t.mutex.Unlock()
if seqNum > t.mailbox.numMessages {
return 0
}
for i := len(t.queue) - 1; i >= 0; i-- {
update := t.queue[i]
// TODO: this doesn't handle increments > 1
if update.numMessages != 0 && seqNum == update.numMessages {
return 0
}
if update.expunge != 0 && seqNum >= update.expunge {
seqNum++
}
}
return seqNum
}

155
imapserver/tracker_test.go Normal file
View File

@@ -0,0 +1,155 @@
package imapserver_test
import (
"testing"
"github.com/emersion/go-imap/v2/imapserver"
)
type trackerUpdate struct {
expunge uint32
numMessages uint32
}
var sessionTrackerSeqNumTests = []struct {
name string
pending []trackerUpdate
clientSeqNum, serverSeqNum uint32
}{
{
name: "noop",
pending: nil,
clientSeqNum: 20,
serverSeqNum: 20,
},
{
name: "noop_last",
pending: nil,
clientSeqNum: 42,
serverSeqNum: 42,
},
{
name: "noop_client_oob",
pending: nil,
clientSeqNum: 43,
serverSeqNum: 0,
},
{
name: "noop_server_oob",
pending: nil,
clientSeqNum: 0,
serverSeqNum: 43,
},
{
name: "expunge_eq",
pending: []trackerUpdate{{expunge: 20}},
clientSeqNum: 20,
serverSeqNum: 0,
},
{
name: "expunge_lt",
pending: []trackerUpdate{{expunge: 20}},
clientSeqNum: 10,
serverSeqNum: 10,
},
{
name: "expunge_gt",
pending: []trackerUpdate{{expunge: 10}},
clientSeqNum: 20,
serverSeqNum: 19,
},
{
name: "append_eq",
pending: []trackerUpdate{{numMessages: 43}},
clientSeqNum: 0,
serverSeqNum: 43,
},
{
name: "append_lt",
pending: []trackerUpdate{{numMessages: 43}},
clientSeqNum: 42,
serverSeqNum: 42,
},
{
name: "expunge_append",
pending: []trackerUpdate{
{expunge: 42},
{numMessages: 42},
},
clientSeqNum: 42,
serverSeqNum: 0,
},
{
name: "expunge_append",
pending: []trackerUpdate{
{expunge: 42},
{numMessages: 42},
},
clientSeqNum: 0,
serverSeqNum: 42,
},
{
name: "append_expunge",
pending: []trackerUpdate{
{numMessages: 43},
{expunge: 42},
},
clientSeqNum: 42,
serverSeqNum: 0,
},
{
name: "append_expunge",
pending: []trackerUpdate{
{numMessages: 43},
{expunge: 42},
},
clientSeqNum: 0,
serverSeqNum: 42,
},
{
name: "multi_expunge_middle",
pending: []trackerUpdate{
{expunge: 3},
{expunge: 1},
},
clientSeqNum: 2,
serverSeqNum: 1,
},
{
name: "multi_expunge_after",
pending: []trackerUpdate{
{expunge: 3},
{expunge: 1},
},
clientSeqNum: 4,
serverSeqNum: 2,
},
}
func TestSessionTracker(t *testing.T) {
for _, tc := range sessionTrackerSeqNumTests {
tc := tc // capture range variable
t.Run(tc.name, func(t *testing.T) {
mboxTracker := imapserver.NewMailboxTracker(42)
sessTracker := mboxTracker.NewSession()
for _, update := range tc.pending {
switch {
case update.expunge != 0:
mboxTracker.QueueExpunge(update.expunge)
case update.numMessages != 0:
mboxTracker.QueueNumMessages(update.numMessages)
}
}
serverSeqNum := sessTracker.DecodeSeqNum(tc.clientSeqNum)
if tc.clientSeqNum != 0 && serverSeqNum != tc.serverSeqNum {
t.Errorf("DecodeSeqNum(%v): got %v, want %v", tc.clientSeqNum, serverSeqNum, tc.serverSeqNum)
}
clientSeqNum := sessTracker.EncodeSeqNum(tc.serverSeqNum)
if tc.serverSeqNum != 0 && clientSeqNum != tc.clientSeqNum {
t.Errorf("EncodeSeqNum(%v): got %v, want %v", tc.serverSeqNum, clientSeqNum, tc.clientSeqNum)
}
})
}
}