Forked the emersion/go-imap v1 project.

This commit is contained in:
2025-05-01 11:58:18 +03:00
commit bcc3f95e8e
107 changed files with 16268 additions and 0 deletions

138
imapclient/acl.go Normal file
View File

@@ -0,0 +1,138 @@
package imapclient
import (
"fmt"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
// MyRights sends a MYRIGHTS command.
//
// This command requires support for the ACL extension.
func (c *Client) MyRights(mailbox string) *MyRightsCommand {
cmd := &MyRightsCommand{}
enc := c.beginCommand("MYRIGHTS", cmd)
enc.SP().Mailbox(mailbox)
enc.end()
return cmd
}
// SetACL sends a SETACL command.
//
// This command requires support for the ACL extension.
func (c *Client) SetACL(mailbox string, ri imap.RightsIdentifier, rm imap.RightModification, rs imap.RightSet) *SetACLCommand {
cmd := &SetACLCommand{}
enc := c.beginCommand("SETACL", cmd)
enc.SP().Mailbox(mailbox).SP().String(string(ri)).SP()
enc.String(internal.FormatRights(rm, rs))
enc.end()
return cmd
}
// SetACLCommand is a SETACL command.
type SetACLCommand struct {
commandBase
}
func (cmd *SetACLCommand) Wait() error {
return cmd.wait()
}
// GetACL sends a GETACL command.
//
// This command requires support for the ACL extension.
func (c *Client) GetACL(mailbox string) *GetACLCommand {
cmd := &GetACLCommand{}
enc := c.beginCommand("GETACL", cmd)
enc.SP().Mailbox(mailbox)
enc.end()
return cmd
}
// GetACLCommand is a GETACL command.
type GetACLCommand struct {
commandBase
data GetACLData
}
func (cmd *GetACLCommand) Wait() (*GetACLData, error) {
return &cmd.data, cmd.wait()
}
func (c *Client) handleMyRights() error {
data, err := readMyRights(c.dec)
if err != nil {
return fmt.Errorf("in myrights-response: %v", err)
}
if cmd := findPendingCmdByType[*MyRightsCommand](c); cmd != nil {
cmd.data = *data
}
return nil
}
func (c *Client) handleGetACL() error {
data, err := readGetACL(c.dec)
if err != nil {
return fmt.Errorf("in getacl-response: %v", err)
}
if cmd := findPendingCmdByType[*GetACLCommand](c); cmd != nil {
cmd.data = *data
}
return nil
}
// MyRightsCommand is a MYRIGHTS command.
type MyRightsCommand struct {
commandBase
data MyRightsData
}
func (cmd *MyRightsCommand) Wait() (*MyRightsData, error) {
return &cmd.data, cmd.wait()
}
// MyRightsData is the data returned by the MYRIGHTS command.
type MyRightsData struct {
Mailbox string
Rights imap.RightSet
}
func readMyRights(dec *imapwire.Decoder) (*MyRightsData, error) {
var (
rights string
data MyRightsData
)
if !dec.ExpectMailbox(&data.Mailbox) || !dec.ExpectSP() || !dec.ExpectAString(&rights) {
return nil, dec.Err()
}
data.Rights = imap.RightSet(rights)
return &data, nil
}
// GetACLData is the data returned by the GETACL command.
type GetACLData struct {
Mailbox string
Rights map[imap.RightsIdentifier]imap.RightSet
}
func readGetACL(dec *imapwire.Decoder) (*GetACLData, error) {
data := &GetACLData{Rights: make(map[imap.RightsIdentifier]imap.RightSet)}
if !dec.ExpectMailbox(&data.Mailbox) {
return nil, dec.Err()
}
for dec.SP() {
var rsStr, riStr string
if !dec.ExpectAString(&riStr) || !dec.ExpectSP() || !dec.ExpectAString(&rsStr) {
return nil, dec.Err()
}
data.Rights[imap.RightsIdentifier(riStr)] = imap.RightSet(rsStr)
}
return data, nil
}

115
imapclient/acl_test.go Normal file
View File

@@ -0,0 +1,115 @@
package imapclient_test
import (
"testing"
"github.com/emersion/go-imap/v2"
)
// order matters
var testCases = []struct {
name string
mailbox string
setRightsModification imap.RightModification
setRights imap.RightSet
expectedRights imap.RightSet
execStatusCmd bool
}{
{
name: "inbox",
mailbox: "INBOX",
setRightsModification: imap.RightModificationReplace,
setRights: imap.RightSet("akxeilprwtscd"),
expectedRights: imap.RightSet("akxeilprwtscd"),
},
{
name: "custom_folder",
mailbox: "MyFolder",
setRightsModification: imap.RightModificationReplace,
setRights: imap.RightSet("ailw"),
expectedRights: imap.RightSet("ailw"),
},
{
name: "custom_child_folder",
mailbox: "MyFolder.Child",
setRightsModification: imap.RightModificationReplace,
setRights: imap.RightSet("aelrwtd"),
expectedRights: imap.RightSet("aelrwtd"),
},
{
name: "add_rights",
mailbox: "MyFolder",
setRightsModification: imap.RightModificationAdd,
setRights: imap.RightSet("rwi"),
expectedRights: imap.RightSet("ailwr"),
},
{
name: "remove_rights",
mailbox: "MyFolder",
setRightsModification: imap.RightModificationRemove,
setRights: imap.RightSet("iwc"),
expectedRights: imap.RightSet("alr"),
},
{
name: "empty_rights",
mailbox: "MyFolder.Child",
setRightsModification: imap.RightModificationReplace,
setRights: imap.RightSet("a"),
expectedRights: imap.RightSet("a"),
},
}
// TestACL runs tests on SetACL, GetACL and MyRights commands.
func TestACL(t *testing.T) {
client, server := newClientServerPair(t, imap.ConnStateAuthenticated)
defer client.Close()
defer server.Close()
if !client.Caps().Has(imap.CapACL) {
t.Skipf("server doesn't support ACL")
}
if err := client.Create("MyFolder", nil).Wait(); err != nil {
t.Fatalf("create MyFolder error: %v", err)
}
if err := client.Create("MyFolder/Child", nil).Wait(); err != nil {
t.Fatalf("create MyFolder/Child error: %v", err)
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// execute SETACL command
err := client.SetACL(tc.mailbox, testUsername, tc.setRightsModification, tc.setRights).Wait()
if err != nil {
t.Errorf("SetACL().Wait() error: %v", err)
}
// execute GETACL command to reset cache on server
getACLData, err := client.GetACL(tc.mailbox).Wait()
if err != nil {
t.Errorf("GetACL().Wait() error: %v", err)
}
if !tc.expectedRights.Equal(getACLData.Rights[testUsername]) {
t.Errorf("GETACL returned wrong rights; expected: %s, got: %s", tc.expectedRights, getACLData.Rights[testUsername])
}
// execute MYRIGHTS command
myRightsData, err := client.MyRights(tc.mailbox).Wait()
if err != nil {
t.Errorf("MyRights().Wait() error: %v", err)
}
if !tc.expectedRights.Equal(myRightsData.Rights) {
t.Errorf("MYRIGHTS returned wrong rights; expected: %s, got: %s", tc.expectedRights, myRightsData.Rights)
}
})
}
t.Run("nonexistent_mailbox", func(t *testing.T) {
if client.SetACL("BibiMailbox", testUsername, imap.RightModificationReplace, nil).Wait() == nil {
t.Errorf("expected error")
}
})
}

58
imapclient/append.go Normal file
View File

@@ -0,0 +1,58 @@
package imapclient
import (
"io"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal"
)
// Append sends an APPEND command.
//
// The caller must call AppendCommand.Close.
//
// The options are optional.
func (c *Client) Append(mailbox string, size int64, options *imap.AppendOptions) *AppendCommand {
cmd := &AppendCommand{}
cmd.enc = c.beginCommand("APPEND", cmd)
cmd.enc.SP().Mailbox(mailbox).SP()
if options != nil && len(options.Flags) > 0 {
cmd.enc.List(len(options.Flags), func(i int) {
cmd.enc.Flag(options.Flags[i])
}).SP()
}
if options != nil && !options.Time.IsZero() {
cmd.enc.String(options.Time.Format(internal.DateTimeLayout)).SP()
}
// TODO: literal8 for BINARY
// TODO: UTF8 data ext for UTF8=ACCEPT, with literal8
cmd.wc = cmd.enc.Literal(size)
return cmd
}
// AppendCommand is an APPEND command.
//
// Callers must write the message contents, then call Close.
type AppendCommand struct {
commandBase
enc *commandEncoder
wc io.WriteCloser
data imap.AppendData
}
func (cmd *AppendCommand) Write(b []byte) (int, error) {
return cmd.wc.Write(b)
}
func (cmd *AppendCommand) Close() error {
err := cmd.wc.Close()
if cmd.enc != nil {
cmd.enc.end()
cmd.enc = nil
}
return err
}
func (cmd *AppendCommand) Wait() (*imap.AppendData, error) {
return &cmd.data, cmd.wait()
}

28
imapclient/append_test.go Normal file
View File

@@ -0,0 +1,28 @@
package imapclient_test
import (
"testing"
"github.com/emersion/go-imap/v2"
)
func TestAppend(t *testing.T) {
client, server := newClientServerPair(t, imap.ConnStateSelected)
defer client.Close()
defer server.Close()
body := "This is a test message."
appendCmd := client.Append("INBOX", int64(len(body)), nil)
if _, err := appendCmd.Write([]byte(body)); err != nil {
t.Fatalf("AppendCommand.Write() = %v", err)
}
if err := appendCmd.Close(); err != nil {
t.Fatalf("AppendCommand.Close() = %v", err)
}
if _, err := appendCmd.Wait(); err != nil {
t.Fatalf("AppendCommand.Wait() = %v", err)
}
// TODO: fetch back message and check body
}

100
imapclient/authenticate.go Normal file
View File

@@ -0,0 +1,100 @@
package imapclient
import (
"fmt"
"github.com/emersion/go-sasl"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal"
)
// Authenticate sends an AUTHENTICATE command.
//
// Unlike other commands, this method blocks until the SASL exchange completes.
func (c *Client) Authenticate(saslClient sasl.Client) error {
mech, initialResp, err := saslClient.Start()
if err != nil {
return err
}
// c.Caps may send a CAPABILITY command, so check it before c.beginCommand
var hasSASLIR bool
if initialResp != nil {
hasSASLIR = c.Caps().Has(imap.CapSASLIR)
}
cmd := &authenticateCommand{}
contReq := c.registerContReq(cmd)
enc := c.beginCommand("AUTHENTICATE", cmd)
enc.SP().Atom(mech)
if initialResp != nil && hasSASLIR {
enc.SP().Atom(internal.EncodeSASL(initialResp))
initialResp = nil
}
enc.flush()
defer enc.end()
for {
challengeStr, err := contReq.Wait()
if err != nil {
return cmd.wait()
}
if challengeStr == "" {
if initialResp == nil {
return fmt.Errorf("imapclient: server requested SASL initial response, but we don't have one")
}
contReq = c.registerContReq(cmd)
if err := c.writeSASLResp(initialResp); err != nil {
return err
}
initialResp = nil
continue
}
challenge, err := internal.DecodeSASL(challengeStr)
if err != nil {
return err
}
resp, err := saslClient.Next(challenge)
if err != nil {
return err
}
contReq = c.registerContReq(cmd)
if err := c.writeSASLResp(resp); err != nil {
return err
}
}
}
type authenticateCommand struct {
commandBase
}
func (c *Client) writeSASLResp(resp []byte) error {
respStr := internal.EncodeSASL(resp)
if _, err := c.bw.WriteString(respStr + "\r\n"); err != nil {
return err
}
if err := c.bw.Flush(); err != nil {
return err
}
return nil
}
// Unauthenticate sends an UNAUTHENTICATE command.
//
// This command requires support for the UNAUTHENTICATE extension.
func (c *Client) Unauthenticate() *Command {
cmd := &unauthenticateCommand{}
c.beginCommand("UNAUTHENTICATE", cmd).end()
return &cmd.Command
}
type unauthenticateCommand struct {
Command
}

View File

@@ -0,0 +1,24 @@
package imapclient_test
import (
"testing"
"github.com/emersion/go-sasl"
"github.com/emersion/go-imap/v2"
)
func TestClient_Authenticate(t *testing.T) {
client, server := newClientServerPair(t, imap.ConnStateNotAuthenticated)
defer client.Close()
defer server.Close()
saslClient := sasl.NewPlainClient("", testUsername, testPassword)
if err := client.Authenticate(saslClient); err != nil {
t.Fatalf("Authenticate() = %v", err)
}
if state := client.State(); state != imap.ConnStateAuthenticated {
t.Errorf("State() = %v, want %v", state, imap.ConnStateAuthenticated)
}
}

55
imapclient/capability.go Normal file
View File

@@ -0,0 +1,55 @@
package imapclient
import (
"fmt"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
// Capability sends a CAPABILITY command.
func (c *Client) Capability() *CapabilityCommand {
cmd := &CapabilityCommand{}
c.beginCommand("CAPABILITY", cmd).end()
return cmd
}
func (c *Client) handleCapability() error {
caps, err := readCapabilities(c.dec)
if err != nil {
return err
}
c.setCaps(caps)
if cmd := findPendingCmdByType[*CapabilityCommand](c); cmd != nil {
cmd.caps = caps
}
return nil
}
// CapabilityCommand is a CAPABILITY command.
type CapabilityCommand struct {
commandBase
caps imap.CapSet
}
func (cmd *CapabilityCommand) Wait() (imap.CapSet, error) {
err := cmd.wait()
return cmd.caps, err
}
func readCapabilities(dec *imapwire.Decoder) (imap.CapSet, error) {
caps := make(imap.CapSet)
for dec.SP() {
// Some IMAP servers send multiple SP between caps:
// https://github.com/emersion/go-imap/pull/652
for dec.SP() {
}
var name string
if !dec.ExpectAtom(&name) {
return caps, fmt.Errorf("in capability-data: %v", dec.Err())
}
caps[imap.Cap(name)] = struct{}{}
}
return caps, nil
}

1215
imapclient/client.go Normal file

File diff suppressed because it is too large Load Diff

277
imapclient/client_test.go Normal file
View File

@@ -0,0 +1,277 @@
package imapclient_test
import (
"crypto/tls"
"io"
"net"
"os"
"sync"
"testing"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
"github.com/emersion/go-imap/v2/imapserver"
"github.com/emersion/go-imap/v2/imapserver/imapmemserver"
)
const (
testUsername = "test-user"
testPassword = "test-password"
)
const simpleRawMessage = `MIME-Version: 1.0
Message-Id: <191101702316132@example.com>
Content-Transfer-Encoding: 8bit
Content-Type: text/plain; charset=utf-8
This is my letter!`
var rsaCertPEM = `-----BEGIN CERTIFICATE-----
MIIDOTCCAiGgAwIBAgIQSRJrEpBGFc7tNb1fb5pKFzANBgkqhkiG9w0BAQsFADAS
MRAwDgYDVQQKEwdBY21lIENvMCAXDTcwMDEwMTAwMDAwMFoYDzIwODQwMTI5MTYw
MDAwWjASMRAwDgYDVQQKEwdBY21lIENvMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
MIIBCgKCAQEA6Gba5tHV1dAKouAaXO3/ebDUU4rvwCUg/CNaJ2PT5xLD4N1Vcb8r
bFSW2HXKq+MPfVdwIKR/1DczEoAGf/JWQTW7EgzlXrCd3rlajEX2D73faWJekD0U
aUgz5vtrTXZ90BQL7WvRICd7FlEZ6FPOcPlumiyNmzUqtwGhO+9ad1W5BqJaRI6P
YfouNkwR6Na4TzSj5BrqUfP0FwDizKSJ0XXmh8g8G9mtwxOSN3Ru1QFc61Xyeluk
POGKBV/q6RBNklTNe0gI8usUMlYyoC7ytppNMW7X2vodAelSu25jgx2anj9fDVZu
h7AXF5+4nJS4AAt0n1lNY7nGSsdZas8PbQIDAQABo4GIMIGFMA4GA1UdDwEB/wQE
AwICpDATBgNVHSUEDDAKBggrBgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud
DgQWBBStsdjh3/JCXXYlQryOrL4Sh7BW5TAuBgNVHREEJzAlggtleGFtcGxlLmNv
bYcEfwAAAYcQAAAAAAAAAAAAAAAAAAAAATANBgkqhkiG9w0BAQsFAAOCAQEAxWGI
5NhpF3nwwy/4yB4i/CwwSpLrWUa70NyhvprUBC50PxiXav1TeDzwzLx/o5HyNwsv
cxv3HdkLW59i/0SlJSrNnWdfZ19oTcS+6PtLoVyISgtyN6DpkKpdG1cOkW3Cy2P2
+tK/tKHRP1Y/Ra0RiDpOAmqn0gCOFGz8+lqDIor/T7MTpibL3IxqWfPrvfVRHL3B
grw/ZQTTIVjjh4JBSW3WyWgNo/ikC1lrVxzl4iPUGptxT36Cr7Zk2Bsg0XqwbOvK
5d+NTDREkSnUbie4GeutujmX3Dsx88UiV6UY/4lHJa6I5leHUNOHahRbpbWeOfs/
WkBKOclmOV2xlTVuPw==
-----END CERTIFICATE-----
`
var rsaKeyPEM = `-----BEGIN RSA PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDoZtrm0dXV0Aqi
4Bpc7f95sNRTiu/AJSD8I1onY9PnEsPg3VVxvytsVJbYdcqr4w99V3AgpH/UNzMS
gAZ/8lZBNbsSDOVesJ3euVqMRfYPvd9pYl6QPRRpSDPm+2tNdn3QFAvta9EgJ3sW
URnoU85w+W6aLI2bNSq3AaE771p3VbkGolpEjo9h+i42TBHo1rhPNKPkGupR8/QX
AOLMpInRdeaHyDwb2a3DE5I3dG7VAVzrVfJ6W6Q84YoFX+rpEE2SVM17SAjy6xQy
VjKgLvK2mk0xbtfa+h0B6VK7bmODHZqeP18NVm6HsBcXn7iclLgAC3SfWU1jucZK
x1lqzw9tAgMBAAECggEABWzxS1Y2wckblnXY57Z+sl6YdmLV+gxj2r8Qib7g4ZIk
lIlWR1OJNfw7kU4eryib4fc6nOh6O4AWZyYqAK6tqNQSS/eVG0LQTLTTEldHyVJL
dvBe+MsUQOj4nTndZW+QvFzbcm2D8lY5n2nBSxU5ypVoKZ1EqQzytFcLZpTN7d89
EPj0qDyrV4NZlWAwL1AygCwnlwhMQjXEalVF1ylXwU3QzyZ/6MgvF6d3SSUlh+sq
XefuyigXw484cQQgbzopv6niMOmGP3of+yV4JQqUSb3IDmmT68XjGd2Dkxl4iPki
6ZwXf3CCi+c+i/zVEcufgZ3SLf8D99kUGE7v7fZ6AQKBgQD1ZX3RAla9hIhxCf+O
3D+I1j2LMrdjAh0ZKKqwMR4JnHX3mjQI6LwqIctPWTU8wYFECSh9klEclSdCa64s
uI/GNpcqPXejd0cAAdqHEEeG5sHMDt0oFSurL4lyud0GtZvwlzLuwEweuDtvT9cJ
Wfvl86uyO36IW8JdvUprYDctrQKBgQDycZ697qutBieZlGkHpnYWUAeImVA878sJ
w44NuXHvMxBPz+lbJGAg8Cn8fcxNAPqHIraK+kx3po8cZGQywKHUWsxi23ozHoxo
+bGqeQb9U661TnfdDspIXia+xilZt3mm5BPzOUuRqlh4Y9SOBpSWRmEhyw76w4ZP
OPxjWYAgwQKBgA/FehSYxeJgRjSdo+MWnK66tjHgDJE8bYpUZsP0JC4R9DL5oiaA
brd2fI6Y+SbyeNBallObt8LSgzdtnEAbjIH8uDJqyOmknNePRvAvR6mP4xyuR+Bv
m+Lgp0DMWTw5J9CKpydZDItc49T/mJ5tPhdFVd+am0NAQnmr1MCZ6nHxAoGABS3Y
LkaC9FdFUUqSU8+Chkd/YbOkuyiENdkvl6t2e52jo5DVc1T7mLiIrRQi4SI8N9bN
/3oJWCT+uaSLX2ouCtNFunblzWHBrhxnZzTeqVq4SLc8aESAnbslKL4i8/+vYZlN
s8xtiNcSvL+lMsOBORSXzpj/4Ot8WwTkn1qyGgECgYBKNTypzAHeLE6yVadFp3nQ
Ckq9yzvP/ib05rvgbvrne00YeOxqJ9gtTrzgh7koqJyX1L4NwdkEza4ilDWpucn0
xiUZS4SoaJq6ZvcBYS62Yr1t8n09iG47YL8ibgtmH3L+svaotvpVxVK+d7BLevA/
ZboOWVe3icTy64BT3OQhmg==
-----END RSA PRIVATE KEY-----
`
func newMemClientServerPair(t *testing.T) (net.Conn, io.Closer) {
memServer := imapmemserver.New()
user := imapmemserver.NewUser(testUsername, testPassword)
user.Create("INBOX", nil)
memServer.AddUser(user)
cert, err := tls.X509KeyPair([]byte(rsaCertPEM), []byte(rsaKeyPEM))
if err != nil {
t.Fatalf("tls.X509KeyPair() = %v", err)
}
server := imapserver.New(&imapserver.Options{
NewSession: func(conn *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) {
return memServer.NewSession(), nil, nil
},
TLSConfig: &tls.Config{
Certificates: []tls.Certificate{cert},
},
InsecureAuth: true,
Caps: imap.CapSet{
imap.CapIMAP4rev1: {},
imap.CapIMAP4rev2: {},
},
})
ln, err := net.Listen("tcp", "localhost:0")
if err != nil {
t.Fatalf("net.Listen() = %v", err)
}
go func() {
if err := server.Serve(ln); err != nil {
t.Errorf("Serve() = %v", err)
}
}()
conn, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
t.Fatalf("net.Dial() = %v", err)
}
return conn, server
}
func newClientServerPair(t *testing.T, initialState imap.ConnState) (*imapclient.Client, io.Closer) {
var useDovecot bool
switch os.Getenv("GOIMAP_TEST_DOVECOT") {
case "0", "":
// ok
case "1":
useDovecot = true
default:
t.Fatalf("invalid GOIMAP_TEST_DOVECOT env var")
}
var (
conn net.Conn
server io.Closer
)
if useDovecot {
if initialState < imap.ConnStateAuthenticated {
t.Skip("Dovecot connections are pre-authenticated")
}
conn, server = newDovecotClientServerPair(t)
} else {
conn, server = newMemClientServerPair(t)
}
var debugWriter swapWriter
debugWriter.Swap(io.Discard)
var options imapclient.Options
if testing.Verbose() {
options.DebugWriter = &debugWriter
}
client := imapclient.New(conn, &options)
if initialState >= imap.ConnStateAuthenticated {
// Dovecot connections are pre-authenticated
if !useDovecot {
if err := client.Login(testUsername, testPassword).Wait(); err != nil {
t.Fatalf("Login().Wait() = %v", err)
}
}
appendCmd := client.Append("INBOX", int64(len(simpleRawMessage)), nil)
appendCmd.Write([]byte(simpleRawMessage))
appendCmd.Close()
if _, err := appendCmd.Wait(); err != nil {
t.Fatalf("AppendCommand.Wait() = %v", err)
}
}
if initialState >= imap.ConnStateSelected {
if _, err := client.Select("INBOX", nil).Wait(); err != nil {
t.Fatalf("Select().Wait() = %v", err)
}
}
// Turn on debug logs after we're done initializing the test
debugWriter.Swap(os.Stderr)
return client, server
}
// swapWriter is an io.Writer which can be swapped at runtime.
type swapWriter struct {
w io.Writer
mutex sync.Mutex
}
func (sw *swapWriter) Write(b []byte) (int, error) {
sw.mutex.Lock()
w := sw.w
sw.mutex.Unlock()
return w.Write(b)
}
func (sw *swapWriter) Swap(w io.Writer) {
sw.mutex.Lock()
sw.w = w
sw.mutex.Unlock()
}
func TestLogin(t *testing.T) {
client, server := newClientServerPair(t, imap.ConnStateNotAuthenticated)
defer client.Close()
defer server.Close()
if err := client.Login(testUsername, testPassword).Wait(); err != nil {
t.Errorf("Login().Wait() = %v", err)
}
}
func TestLogout(t *testing.T) {
client, server := newClientServerPair(t, imap.ConnStateAuthenticated)
defer server.Close()
if _, ok := server.(*dovecotServer); ok {
t.Skip("Dovecot connections don't reply to LOGOUT")
}
if err := client.Logout().Wait(); err != nil {
t.Errorf("Logout().Wait() = %v", err)
}
if err := client.Close(); err != nil {
t.Errorf("Close() = %v", err)
}
}
// https://github.com/emersion/go-imap/issues/562
func TestFetch_invalid(t *testing.T) {
client, server := newClientServerPair(t, imap.ConnStateSelected)
defer client.Close()
defer server.Close()
_, err := client.Fetch(imap.UIDSet(nil), nil).Collect()
if err == nil {
t.Fatalf("UIDFetch().Collect() = %v", err)
}
}
func TestFetch_closeUnreadBody(t *testing.T) {
client, server := newClientServerPair(t, imap.ConnStateSelected)
defer client.Close()
defer server.Close()
fetchCmd := client.Fetch(imap.SeqSetNum(1), &imap.FetchOptions{
BodySection: []*imap.FetchItemBodySection{
{
Specifier: imap.PartSpecifierNone,
Peek: true,
},
},
})
if err := fetchCmd.Close(); err != nil {
t.Fatalf("UIDFetch().Close() = %v", err)
}
}
func TestWaitGreeting_eof(t *testing.T) {
// bad server: connected but without greeting
clientConn, serverConn := net.Pipe()
client := imapclient.New(clientConn, nil)
defer client.Close()
if err := serverConn.Close(); err != nil {
t.Fatalf("serverConn.Close() = %v", err)
}
if err := client.WaitGreeting(); err == nil {
t.Fatalf("WaitGreeting() should fail")
}
}

37
imapclient/copy.go Normal file
View File

@@ -0,0 +1,37 @@
package imapclient
import (
"fmt"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
// Copy sends a COPY command.
func (c *Client) Copy(numSet imap.NumSet, mailbox string) *CopyCommand {
cmd := &CopyCommand{}
enc := c.beginCommand(uidCmdName("COPY", imapwire.NumSetKind(numSet)), cmd)
enc.SP().NumSet(numSet).SP().Mailbox(mailbox)
enc.end()
return cmd
}
// CopyCommand is a COPY command.
type CopyCommand struct {
commandBase
data imap.CopyData
}
func (cmd *CopyCommand) Wait() (*imap.CopyData, error) {
return &cmd.data, cmd.wait()
}
func readRespCodeCopyUID(dec *imapwire.Decoder) (uidValidity uint32, srcUIDs, dstUIDs imap.UIDSet, err error) {
if !dec.ExpectNumber(&uidValidity) || !dec.ExpectSP() || !dec.ExpectUIDSet(&srcUIDs) || !dec.ExpectSP() || !dec.ExpectUIDSet(&dstUIDs) {
return 0, nil, nil, dec.Err()
}
if srcUIDs.Dynamic() || dstUIDs.Dynamic() {
return 0, nil, nil, fmt.Errorf("imapclient: server returned dynamic number set in COPYUID response")
}
return uidValidity, srcUIDs, dstUIDs, nil
}

21
imapclient/create.go Normal file
View File

@@ -0,0 +1,21 @@
package imapclient
import (
"github.com/emersion/go-imap/v2"
)
// Create sends a CREATE command.
//
// A nil options pointer is equivalent to a zero options value.
func (c *Client) Create(mailbox string, options *imap.CreateOptions) *Command {
cmd := &Command{}
enc := c.beginCommand("CREATE", cmd)
enc.SP().Mailbox(mailbox)
if options != nil && len(options.SpecialUse) > 0 {
enc.SP().Special('(').Atom("USE").SP().List(len(options.SpecialUse), func(i int) {
enc.MailboxAttr(options.SpecialUse[i])
}).Special(')')
}
enc.end()
return cmd
}

57
imapclient/create_test.go Normal file
View File

@@ -0,0 +1,57 @@
package imapclient_test
import (
"testing"
"github.com/emersion/go-imap/v2"
)
func testCreate(t *testing.T, name string, utf8Accept bool) {
client, server := newClientServerPair(t, imap.ConnStateAuthenticated)
defer client.Close()
defer server.Close()
if utf8Accept {
if !client.Caps().Has(imap.CapUTF8Accept) {
t.Skipf("missing UTF8=ACCEPT support")
}
if data, err := client.Enable(imap.CapUTF8Accept).Wait(); err != nil {
t.Fatalf("Enable(CapUTF8Accept) = %v", err)
} else if !data.Caps.Has(imap.CapUTF8Accept) {
t.Fatalf("server refused to enable UTF8=ACCEPT")
}
}
if err := client.Create(name, nil).Wait(); err != nil {
t.Fatalf("Create() = %v", err)
}
listCmd := client.List("", name, nil)
mailboxes, err := listCmd.Collect()
if err != nil {
t.Errorf("List() = %v", err)
} else if len(mailboxes) != 1 || mailboxes[0].Mailbox != name {
t.Errorf("List() = %v, want exactly one entry with correct name", mailboxes)
}
}
func TestCreate(t *testing.T) {
t.Run("basic", func(t *testing.T) {
testCreate(t, "Test mailbox", false)
})
t.Run("unicode_utf7", func(t *testing.T) {
testCreate(t, "Cafè", false)
})
t.Run("unicode_utf8", func(t *testing.T) {
testCreate(t, "Cafè", true)
})
// '&' is the UTF-7 escape character
t.Run("ampersand_utf7", func(t *testing.T) {
testCreate(t, "Angus & Julia", false)
})
t.Run("ampersand_utf8", func(t *testing.T) {
testCreate(t, "Angus & Julia", true)
})
}

View File

@@ -0,0 +1,64 @@
package imapclient_test
import (
"io"
"net"
"os"
"os/exec"
"path/filepath"
"testing"
)
func newDovecotClientServerPair(t *testing.T) (net.Conn, io.Closer) {
tempDir := t.TempDir()
cfgFilename := filepath.Join(tempDir, "dovecot.conf")
cfg := `log_path = "` + tempDir + `/dovecot.log"
ssl = no
mail_home = "` + tempDir + `/%u"
mail_location = maildir:~/Mail
namespace inbox {
separator = /
prefix =
inbox = yes
}
mail_plugins = $mail_plugins acl
protocol imap {
mail_plugins = $mail_plugins imap_acl
}
plugin {
acl = vfile
}
`
if err := os.WriteFile(cfgFilename, []byte(cfg), 0666); err != nil {
t.Fatalf("failed to write Dovecot config: %v", err)
}
clientConn, serverConn := net.Pipe()
cmd := exec.Command("doveadm", "-c", cfgFilename, "exec", "imap")
cmd.Env = []string{"USER=" + testUsername, "PATH=" + os.Getenv("PATH")}
cmd.Dir = tempDir
cmd.Stdin = serverConn
cmd.Stdout = serverConn
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatalf("failed to start Dovecot: %v", err)
}
return clientConn, &dovecotServer{cmd, serverConn}
}
type dovecotServer struct {
cmd *exec.Cmd
conn net.Conn
}
func (srv *dovecotServer) Close() error {
if err := srv.conn.Close(); err != nil {
return err
}
return srv.cmd.Wait()
}

69
imapclient/enable.go Normal file
View File

@@ -0,0 +1,69 @@
package imapclient
import (
"fmt"
"github.com/emersion/go-imap/v2"
)
// Enable sends an ENABLE command.
//
// This command requires support for IMAP4rev2 or the ENABLE extension.
func (c *Client) Enable(caps ...imap.Cap) *EnableCommand {
// Enabling an extension may change the IMAP syntax, so only allow the
// extensions we support here
for _, name := range caps {
switch name {
case imap.CapIMAP4rev2, imap.CapUTF8Accept, imap.CapMetadata, imap.CapMetadataServer:
// ok
default:
done := make(chan error)
close(done)
err := fmt.Errorf("imapclient: cannot enable %q: not supported", name)
return &EnableCommand{commandBase: commandBase{done: done, err: err}}
}
}
cmd := &EnableCommand{}
enc := c.beginCommand("ENABLE", cmd)
for _, c := range caps {
enc.SP().Atom(string(c))
}
enc.end()
return cmd
}
func (c *Client) handleEnabled() error {
caps, err := readCapabilities(c.dec)
if err != nil {
return err
}
c.mutex.Lock()
for name := range caps {
c.enabled[name] = struct{}{}
}
c.mutex.Unlock()
if cmd := findPendingCmdByType[*EnableCommand](c); cmd != nil {
cmd.data.Caps = caps
}
return nil
}
// EnableCommand is an ENABLE command.
type EnableCommand struct {
commandBase
data EnableData
}
func (cmd *EnableCommand) Wait() (*EnableData, error) {
return &cmd.data, cmd.wait()
}
// EnableData is the data returned by the ENABLE command.
type EnableData struct {
// Capabilities that were successfully enabled
Caps imap.CapSet
}

365
imapclient/example_test.go Normal file
View File

@@ -0,0 +1,365 @@
package imapclient_test
import (
"io"
"log"
"time"
"github.com/emersion/go-message/mail"
"github.com/emersion/go-sasl"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
)
func ExampleClient() {
c, err := imapclient.DialTLS("mail.example.org:993", nil)
if err != nil {
log.Fatalf("failed to dial IMAP server: %v", err)
}
defer c.Close()
if err := c.Login("root", "asdf").Wait(); err != nil {
log.Fatalf("failed to login: %v", err)
}
mailboxes, err := c.List("", "%", nil).Collect()
if err != nil {
log.Fatalf("failed to list mailboxes: %v", err)
}
log.Printf("Found %v mailboxes", len(mailboxes))
for _, mbox := range mailboxes {
log.Printf(" - %v", mbox.Mailbox)
}
selectedMbox, err := c.Select("INBOX", nil).Wait()
if err != nil {
log.Fatalf("failed to select INBOX: %v", err)
}
log.Printf("INBOX contains %v messages", selectedMbox.NumMessages)
if selectedMbox.NumMessages > 0 {
seqSet := imap.SeqSetNum(1)
fetchOptions := &imap.FetchOptions{Envelope: true}
messages, err := c.Fetch(seqSet, fetchOptions).Collect()
if err != nil {
log.Fatalf("failed to fetch first message in INBOX: %v", err)
}
log.Printf("subject of first message in INBOX: %v", messages[0].Envelope.Subject)
}
if err := c.Logout().Wait(); err != nil {
log.Fatalf("failed to logout: %v", err)
}
}
func ExampleClient_pipelining() {
var c *imapclient.Client
uid := imap.UID(42)
fetchOptions := &imap.FetchOptions{Envelope: true}
// Login, select and fetch a message in a single roundtrip
loginCmd := c.Login("root", "root")
selectCmd := c.Select("INBOX", nil)
fetchCmd := c.Fetch(imap.UIDSetNum(uid), fetchOptions)
if err := loginCmd.Wait(); err != nil {
log.Fatalf("failed to login: %v", err)
}
if _, err := selectCmd.Wait(); err != nil {
log.Fatalf("failed to select INBOX: %v", err)
}
if messages, err := fetchCmd.Collect(); err != nil {
log.Fatalf("failed to fetch message: %v", err)
} else {
log.Printf("Subject: %v", messages[0].Envelope.Subject)
}
}
func ExampleClient_Append() {
var c *imapclient.Client
buf := []byte("From: <root@nsa.gov>\r\n\r\nHi <3")
size := int64(len(buf))
appendCmd := c.Append("INBOX", size, nil)
if _, err := appendCmd.Write(buf); err != nil {
log.Fatalf("failed to write message: %v", err)
}
if err := appendCmd.Close(); err != nil {
log.Fatalf("failed to close message: %v", err)
}
if _, err := appendCmd.Wait(); err != nil {
log.Fatalf("APPEND command failed: %v", err)
}
}
func ExampleClient_Status() {
var c *imapclient.Client
options := imap.StatusOptions{NumMessages: true}
if data, err := c.Status("INBOX", &options).Wait(); err != nil {
log.Fatalf("STATUS command failed: %v", err)
} else {
log.Printf("INBOX contains %v messages", *data.NumMessages)
}
}
func ExampleClient_List_stream() {
var c *imapclient.Client
// ReturnStatus requires server support for IMAP4rev2 or LIST-STATUS
listCmd := c.List("", "%", &imap.ListOptions{
ReturnStatus: &imap.StatusOptions{
NumMessages: true,
NumUnseen: true,
},
})
for {
mbox := listCmd.Next()
if mbox == nil {
break
}
log.Printf("Mailbox %q contains %v messages (%v unseen)", mbox.Mailbox, mbox.Status.NumMessages, mbox.Status.NumUnseen)
}
if err := listCmd.Close(); err != nil {
log.Fatalf("LIST command failed: %v", err)
}
}
func ExampleClient_Store() {
var c *imapclient.Client
seqSet := imap.SeqSetNum(1)
storeFlags := imap.StoreFlags{
Op: imap.StoreFlagsAdd,
Flags: []imap.Flag{imap.FlagFlagged},
Silent: true,
}
if err := c.Store(seqSet, &storeFlags, nil).Close(); err != nil {
log.Fatalf("STORE command failed: %v", err)
}
}
func ExampleClient_Fetch() {
var c *imapclient.Client
seqSet := imap.SeqSetNum(1)
bodySection := &imap.FetchItemBodySection{Specifier: imap.PartSpecifierHeader}
fetchOptions := &imap.FetchOptions{
Flags: true,
Envelope: true,
BodySection: []*imap.FetchItemBodySection{bodySection},
}
messages, err := c.Fetch(seqSet, fetchOptions).Collect()
if err != nil {
log.Fatalf("FETCH command failed: %v", err)
}
msg := messages[0]
header := msg.FindBodySection(bodySection)
log.Printf("Flags: %v", msg.Flags)
log.Printf("Subject: %v", msg.Envelope.Subject)
log.Printf("Header:\n%v", string(header))
}
func ExampleClient_Fetch_streamBody() {
var c *imapclient.Client
seqSet := imap.SeqSetNum(1)
bodySection := &imap.FetchItemBodySection{}
fetchOptions := &imap.FetchOptions{
UID: true,
BodySection: []*imap.FetchItemBodySection{bodySection},
}
fetchCmd := c.Fetch(seqSet, fetchOptions)
defer fetchCmd.Close()
for {
msg := fetchCmd.Next()
if msg == nil {
break
}
for {
item := msg.Next()
if item == nil {
break
}
switch item := item.(type) {
case imapclient.FetchItemDataUID:
log.Printf("UID: %v", item.UID)
case imapclient.FetchItemDataBodySection:
b, err := io.ReadAll(item.Literal)
if err != nil {
log.Fatalf("failed to read body section: %v", err)
}
log.Printf("Body:\n%v", string(b))
}
}
}
if err := fetchCmd.Close(); err != nil {
log.Fatalf("FETCH command failed: %v", err)
}
}
func ExampleClient_Fetch_parseBody() {
var c *imapclient.Client
// Send a FETCH command to fetch the message body
seqSet := imap.SeqSetNum(1)
bodySection := &imap.FetchItemBodySection{}
fetchOptions := &imap.FetchOptions{
BodySection: []*imap.FetchItemBodySection{bodySection},
}
fetchCmd := c.Fetch(seqSet, fetchOptions)
defer fetchCmd.Close()
msg := fetchCmd.Next()
if msg == nil {
log.Fatalf("FETCH command did not return any message")
}
// Find the body section in the response
var bodySectionData imapclient.FetchItemDataBodySection
ok := false
for {
item := msg.Next()
if item == nil {
break
}
bodySectionData, ok = item.(imapclient.FetchItemDataBodySection)
if ok {
break
}
}
if !ok {
log.Fatalf("FETCH command did not return body section")
}
// Read the message via the go-message library
mr, err := mail.CreateReader(bodySectionData.Literal)
if err != nil {
log.Fatalf("failed to create mail reader: %v", err)
}
// Print a few header fields
h := mr.Header
if date, err := h.Date(); err != nil {
log.Printf("failed to parse Date header field: %v", err)
} else {
log.Printf("Date: %v", date)
}
if to, err := h.AddressList("To"); err != nil {
log.Printf("failed to parse To header field: %v", err)
} else {
log.Printf("To: %v", to)
}
if subject, err := h.Text("Subject"); err != nil {
log.Printf("failed to parse Subject header field: %v", err)
} else {
log.Printf("Subject: %v", subject)
}
// Process the message's parts
for {
p, err := mr.NextPart()
if err == io.EOF {
break
} else if err != nil {
log.Fatalf("failed to read message part: %v", err)
}
switch h := p.Header.(type) {
case *mail.InlineHeader:
// This is the message's text (can be plain-text or HTML)
b, _ := io.ReadAll(p.Body)
log.Printf("Inline text: %v", string(b))
case *mail.AttachmentHeader:
// This is an attachment
filename, _ := h.Filename()
log.Printf("Attachment: %v", filename)
}
}
if err := fetchCmd.Close(); err != nil {
log.Fatalf("FETCH command failed: %v", err)
}
}
func ExampleClient_Search() {
var c *imapclient.Client
data, err := c.UIDSearch(&imap.SearchCriteria{
Body: []string{"Hello world"},
}, nil).Wait()
if err != nil {
log.Fatalf("UID SEARCH command failed: %v", err)
}
log.Fatalf("UIDs matching the search criteria: %v", data.AllUIDs())
}
func ExampleClient_Idle() {
options := imapclient.Options{
UnilateralDataHandler: &imapclient.UnilateralDataHandler{
Expunge: func(seqNum uint32) {
log.Printf("message %v has been expunged", seqNum)
},
Mailbox: func(data *imapclient.UnilateralDataMailbox) {
if data.NumMessages != nil {
log.Printf("a new message has been received")
}
},
},
}
c, err := imapclient.DialTLS("mail.example.org:993", &options)
if err != nil {
log.Fatalf("failed to dial IMAP server: %v", err)
}
defer c.Close()
if err := c.Login("root", "asdf").Wait(); err != nil {
log.Fatalf("failed to login: %v", err)
}
if _, err := c.Select("INBOX", nil).Wait(); err != nil {
log.Fatalf("failed to select INBOX: %v", err)
}
// Start idling
idleCmd, err := c.Idle()
if err != nil {
log.Fatalf("IDLE command failed: %v", err)
}
// Wait for 30s to receive updates from the server
time.Sleep(30 * time.Second)
// Stop idling
if err := idleCmd.Close(); err != nil {
log.Fatalf("failed to stop idling: %v", err)
}
}
func ExampleClient_Authenticate_oauth() {
var (
c *imapclient.Client
username string
token string
)
if !c.Caps().Has(imap.AuthCap(sasl.OAuthBearer)) {
log.Fatal("OAUTHBEARER not supported by the server")
}
saslClient := sasl.NewOAuthBearerClient(&sasl.OAuthBearerOptions{
Username: username,
Token: token,
})
if err := c.Authenticate(saslClient); err != nil {
log.Fatalf("authentication failed: %v", err)
}
}

84
imapclient/expunge.go Normal file
View File

@@ -0,0 +1,84 @@
package imapclient
import (
"github.com/emersion/go-imap/v2"
)
// Expunge sends an EXPUNGE command.
func (c *Client) Expunge() *ExpungeCommand {
cmd := &ExpungeCommand{seqNums: make(chan uint32, 128)}
c.beginCommand("EXPUNGE", cmd).end()
return cmd
}
// UIDExpunge sends a UID EXPUNGE command.
//
// This command requires support for IMAP4rev2 or the UIDPLUS extension.
func (c *Client) UIDExpunge(uids imap.UIDSet) *ExpungeCommand {
cmd := &ExpungeCommand{seqNums: make(chan uint32, 128)}
enc := c.beginCommand("UID EXPUNGE", cmd)
enc.SP().NumSet(uids)
enc.end()
return cmd
}
func (c *Client) handleExpunge(seqNum uint32) error {
c.mutex.Lock()
if c.state == imap.ConnStateSelected && c.mailbox.NumMessages > 0 {
c.mailbox = c.mailbox.copy()
c.mailbox.NumMessages--
}
c.mutex.Unlock()
cmd := findPendingCmdByType[*ExpungeCommand](c)
if cmd != nil {
cmd.seqNums <- seqNum
} else if handler := c.options.unilateralDataHandler().Expunge; handler != nil {
handler(seqNum)
}
return nil
}
// ExpungeCommand is an EXPUNGE command.
//
// The caller must fully consume the ExpungeCommand. A simple way to do so is
// to defer a call to FetchCommand.Close.
type ExpungeCommand struct {
commandBase
seqNums chan uint32
}
// Next advances to the next expunged message sequence number.
//
// On success, the message sequence number is returned. On error or if there
// are no more messages, 0 is returned. To check the error value, use Close.
func (cmd *ExpungeCommand) Next() uint32 {
return <-cmd.seqNums
}
// Close releases the command.
//
// Calling Close unblocks the IMAP client decoder and lets it read the next
// responses. Next will always return nil after Close.
func (cmd *ExpungeCommand) Close() error {
for cmd.Next() != 0 {
// ignore
}
return cmd.wait()
}
// Collect accumulates expunged sequence numbers into a list.
//
// This is equivalent to calling Next repeatedly and then Close.
func (cmd *ExpungeCommand) Collect() ([]uint32, error) {
var l []uint32
for {
seqNum := cmd.Next()
if seqNum == 0 {
break
}
l = append(l, seqNum)
}
return l, cmd.Close()
}

View File

@@ -0,0 +1,36 @@
package imapclient_test
import (
"testing"
"github.com/emersion/go-imap/v2"
)
func TestExpunge(t *testing.T) {
client, server := newClientServerPair(t, imap.ConnStateSelected)
defer client.Close()
defer server.Close()
seqNums, err := client.Expunge().Collect()
if err != nil {
t.Fatalf("Expunge() = %v", err)
} else if len(seqNums) != 0 {
t.Errorf("Expunge().Collect() = %v, want []", seqNums)
}
seqSet := imap.SeqSetNum(1)
storeFlags := imap.StoreFlags{
Op: imap.StoreFlagsAdd,
Flags: []imap.Flag{imap.FlagDeleted},
}
if err := client.Store(seqSet, &storeFlags, nil).Close(); err != nil {
t.Fatalf("Store() = %v", err)
}
seqNums, err = client.Expunge().Collect()
if err != nil {
t.Fatalf("Expunge() = %v", err)
} else if len(seqNums) != 1 || seqNums[0] != 1 {
t.Errorf("Expunge().Collect() = %v, want [1]", seqNums)
}
}

1326
imapclient/fetch.go Normal file

File diff suppressed because it is too large Load Diff

39
imapclient/fetch_test.go Normal file
View File

@@ -0,0 +1,39 @@
package imapclient_test
import (
"strings"
"testing"
"github.com/emersion/go-imap/v2"
)
func TestFetch(t *testing.T) {
client, server := newClientServerPair(t, imap.ConnStateSelected)
defer client.Close()
defer server.Close()
seqSet := imap.SeqSetNum(1)
bodySection := &imap.FetchItemBodySection{}
fetchOptions := &imap.FetchOptions{
BodySection: []*imap.FetchItemBodySection{bodySection},
}
messages, err := client.Fetch(seqSet, fetchOptions).Collect()
if err != nil {
t.Fatalf("failed to fetch first message: %v", err)
} else if len(messages) != 1 {
t.Fatalf("len(messages) = %v, want 1", len(messages))
}
msg := messages[0]
if len(msg.BodySection) != 1 {
t.Fatalf("len(msg.BodySection) = %v, want 1", len(msg.BodySection))
}
b := msg.FindBodySection(bodySection)
if b == nil {
t.Fatalf("FindBodySection() = nil")
}
body := strings.ReplaceAll(string(b), "\r\n", "\n")
if body != simpleRawMessage {
t.Errorf("body mismatch: got \n%v\n but want \n%v", body, simpleRawMessage)
}
}

163
imapclient/id.go Normal file
View File

@@ -0,0 +1,163 @@
package imapclient
import (
"fmt"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
// ID sends an ID command.
//
// The ID command is introduced in RFC 2971. It requires support for the ID
// extension.
//
// An example ID command:
//
// ID ("name" "go-imap" "version" "1.0" "os" "Linux" "os-version" "7.9.4" "vendor" "Yahoo")
func (c *Client) ID(idData *imap.IDData) *IDCommand {
cmd := &IDCommand{}
enc := c.beginCommand("ID", cmd)
if idData == nil {
enc.SP().NIL()
enc.end()
return cmd
}
enc.SP().Special('(')
isFirstKey := true
if idData.Name != "" {
addIDKeyValue(enc, &isFirstKey, "name", idData.Name)
}
if idData.Version != "" {
addIDKeyValue(enc, &isFirstKey, "version", idData.Version)
}
if idData.OS != "" {
addIDKeyValue(enc, &isFirstKey, "os", idData.OS)
}
if idData.OSVersion != "" {
addIDKeyValue(enc, &isFirstKey, "os-version", idData.OSVersion)
}
if idData.Vendor != "" {
addIDKeyValue(enc, &isFirstKey, "vendor", idData.Vendor)
}
if idData.SupportURL != "" {
addIDKeyValue(enc, &isFirstKey, "support-url", idData.SupportURL)
}
if idData.Address != "" {
addIDKeyValue(enc, &isFirstKey, "address", idData.Address)
}
if idData.Date != "" {
addIDKeyValue(enc, &isFirstKey, "date", idData.Date)
}
if idData.Command != "" {
addIDKeyValue(enc, &isFirstKey, "command", idData.Command)
}
if idData.Arguments != "" {
addIDKeyValue(enc, &isFirstKey, "arguments", idData.Arguments)
}
if idData.Environment != "" {
addIDKeyValue(enc, &isFirstKey, "environment", idData.Environment)
}
enc.Special(')')
enc.end()
return cmd
}
func addIDKeyValue(enc *commandEncoder, isFirstKey *bool, key, value string) {
if isFirstKey == nil {
panic("isFirstKey cannot be nil")
} else if !*isFirstKey {
enc.SP().Quoted(key).SP().Quoted(value)
} else {
enc.Quoted(key).SP().Quoted(value)
}
*isFirstKey = false
}
func (c *Client) handleID() error {
data, err := c.readID(c.dec)
if err != nil {
return fmt.Errorf("in id: %v", err)
}
if cmd := findPendingCmdByType[*IDCommand](c); cmd != nil {
cmd.data = *data
}
return nil
}
func (c *Client) readID(dec *imapwire.Decoder) (*imap.IDData, error) {
var data = imap.IDData{}
if !dec.ExpectSP() {
return nil, dec.Err()
}
if dec.ExpectNIL() {
return &data, nil
}
currKey := ""
err := dec.ExpectList(func() error {
var keyOrValue string
if !dec.String(&keyOrValue) {
return fmt.Errorf("in id key-val list: %v", dec.Err())
}
if currKey == "" {
currKey = keyOrValue
return nil
}
switch currKey {
case "name":
data.Name = keyOrValue
case "version":
data.Version = keyOrValue
case "os":
data.OS = keyOrValue
case "os-version":
data.OSVersion = keyOrValue
case "vendor":
data.Vendor = keyOrValue
case "support-url":
data.SupportURL = keyOrValue
case "address":
data.Address = keyOrValue
case "date":
data.Date = keyOrValue
case "command":
data.Command = keyOrValue
case "arguments":
data.Arguments = keyOrValue
case "environment":
data.Environment = keyOrValue
default:
// Ignore unknown key
// Yahoo server sends "host" and "remote-host" keys
// which are not defined in RFC 2971
}
currKey = ""
return nil
})
if err != nil {
return nil, err
}
return &data, nil
}
type IDCommand struct {
commandBase
data imap.IDData
}
func (r *IDCommand) Wait() (*imap.IDData, error) {
return &r.data, r.wait()
}

157
imapclient/idle.go Normal file
View File

@@ -0,0 +1,157 @@
package imapclient
import (
"fmt"
"sync/atomic"
"time"
)
const idleRestartInterval = 28 * time.Minute
// Idle sends an IDLE command.
//
// Unlike other commands, this method blocks until the server acknowledges it.
// On success, the IDLE command is running and other commands cannot be sent.
// The caller must invoke IdleCommand.Close to stop IDLE and unblock the
// client.
//
// This command requires support for IMAP4rev2 or the IDLE extension. The IDLE
// command is restarted automatically to avoid getting disconnected due to
// inactivity timeouts.
func (c *Client) Idle() (*IdleCommand, error) {
child, err := c.idle()
if err != nil {
return nil, err
}
cmd := &IdleCommand{
stop: make(chan struct{}),
done: make(chan struct{}),
}
go cmd.run(c, child)
return cmd, nil
}
// IdleCommand is an IDLE command.
//
// Initially, the IDLE command is running. The server may send unilateral
// data. The client cannot send any command while IDLE is running.
//
// Close must be called to stop the IDLE command.
type IdleCommand struct {
stopped atomic.Bool
stop chan struct{}
done chan struct{}
err error
lastChild *idleCommand
}
func (cmd *IdleCommand) run(c *Client, child *idleCommand) {
defer close(cmd.done)
timer := time.NewTimer(idleRestartInterval)
defer timer.Stop()
defer func() {
if child != nil {
if err := child.Close(); err != nil && cmd.err == nil {
cmd.err = err
}
}
}()
for {
select {
case <-timer.C:
timer.Reset(idleRestartInterval)
if cmd.err = child.Close(); cmd.err != nil {
return
}
if child, cmd.err = c.idle(); cmd.err != nil {
return
}
case <-c.decCh:
cmd.lastChild = child
return
case <-cmd.stop:
cmd.lastChild = child
return
}
}
}
// Close stops the IDLE command.
//
// This method blocks until the command to stop IDLE is written, but doesn't
// wait for the server to respond. Callers can use Wait for this purpose.
func (cmd *IdleCommand) Close() error {
if cmd.stopped.Swap(true) {
return fmt.Errorf("imapclient: IDLE already closed")
}
close(cmd.stop)
<-cmd.done
return cmd.err
}
// Wait blocks until the IDLE command has completed.
func (cmd *IdleCommand) Wait() error {
<-cmd.done
if cmd.err != nil {
return cmd.err
}
return cmd.lastChild.Wait()
}
func (c *Client) idle() (*idleCommand, error) {
cmd := &idleCommand{}
contReq := c.registerContReq(cmd)
cmd.enc = c.beginCommand("IDLE", cmd)
cmd.enc.flush()
_, err := contReq.Wait()
if err != nil {
cmd.enc.end()
return nil, err
}
return cmd, nil
}
// idleCommand represents a singular IDLE command, without the restart logic.
type idleCommand struct {
commandBase
enc *commandEncoder
}
// Close stops the IDLE command.
//
// This method blocks until the command to stop IDLE is written, but doesn't
// wait for the server to respond. Callers can use Wait for this purpose.
func (cmd *idleCommand) Close() error {
if cmd.err != nil {
return cmd.err
}
if cmd.enc == nil {
return fmt.Errorf("imapclient: IDLE command closed twice")
}
cmd.enc.client.setWriteTimeout(cmdWriteTimeout)
_, err := cmd.enc.client.bw.WriteString("DONE\r\n")
if err == nil {
err = cmd.enc.client.bw.Flush()
}
cmd.enc.end()
cmd.enc = nil
return err
}
// Wait blocks until the IDLE command has completed.
//
// Wait can only be called after Close.
func (cmd *idleCommand) Wait() error {
if cmd.enc != nil {
panic("imapclient: idleCommand.Close must be called before Wait")
}
return cmd.wait()
}

42
imapclient/idle_test.go Normal file
View File

@@ -0,0 +1,42 @@
package imapclient_test
import (
"testing"
"github.com/emersion/go-imap/v2"
)
func TestIdle(t *testing.T) {
client, server := newClientServerPair(t, imap.ConnStateSelected)
defer client.Close()
defer server.Close()
idleCmd, err := client.Idle()
if err != nil {
t.Fatalf("Idle() = %v", err)
}
// TODO: test unilateral updates
if err := idleCmd.Close(); err != nil {
t.Errorf("Close() = %v", err)
}
}
func TestIdle_closedConn(t *testing.T) {
client, server := newClientServerPair(t, imap.ConnStateSelected)
defer client.Close()
defer server.Close()
idleCmd, err := client.Idle()
if err != nil {
t.Fatalf("Idle() = %v", err)
}
defer idleCmd.Close()
if err := client.Close(); err != nil {
t.Fatalf("client.Close() = %v", err)
}
if err := idleCmd.Wait(); err == nil {
t.Errorf("IdleCommand.Wait() = nil, want an error")
}
}

259
imapclient/list.go Normal file
View File

@@ -0,0 +1,259 @@
package imapclient
import (
"fmt"
"strings"
"unicode/utf8"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
func getSelectOpts(options *imap.ListOptions) []string {
if options == nil {
return nil
}
var l []string
if options.SelectSubscribed {
l = append(l, "SUBSCRIBED")
}
if options.SelectRemote {
l = append(l, "REMOTE")
}
if options.SelectRecursiveMatch {
l = append(l, "RECURSIVEMATCH")
}
if options.SelectSpecialUse {
l = append(l, "SPECIAL-USE")
}
return l
}
func getReturnOpts(options *imap.ListOptions) []string {
if options == nil {
return nil
}
var l []string
if options.ReturnSubscribed {
l = append(l, "SUBSCRIBED")
}
if options.ReturnChildren {
l = append(l, "CHILDREN")
}
if options.ReturnStatus != nil {
l = append(l, "STATUS")
}
if options.ReturnSpecialUse {
l = append(l, "SPECIAL-USE")
}
return l
}
// List sends a LIST command.
//
// The caller must fully consume the ListCommand. A simple way to do so is to
// defer a call to ListCommand.Close.
//
// A nil options pointer is equivalent to a zero options value.
//
// A non-zero options value requires support for IMAP4rev2 or the LIST-EXTENDED
// extension.
func (c *Client) List(ref, pattern string, options *imap.ListOptions) *ListCommand {
cmd := &ListCommand{
mailboxes: make(chan *imap.ListData, 64),
returnStatus: options != nil && options.ReturnStatus != nil,
}
enc := c.beginCommand("LIST", cmd)
if selectOpts := getSelectOpts(options); len(selectOpts) > 0 {
enc.SP().List(len(selectOpts), func(i int) {
enc.Atom(selectOpts[i])
})
}
enc.SP().Mailbox(ref).SP().Mailbox(pattern)
if returnOpts := getReturnOpts(options); len(returnOpts) > 0 {
enc.SP().Atom("RETURN").SP().List(len(returnOpts), func(i int) {
opt := returnOpts[i]
enc.Atom(opt)
if opt == "STATUS" {
returnStatus := statusItems(options.ReturnStatus)
enc.SP().List(len(returnStatus), func(j int) {
enc.Atom(returnStatus[j])
})
}
})
}
enc.end()
return cmd
}
func (c *Client) handleList() error {
data, err := readList(c.dec)
if err != nil {
return fmt.Errorf("in LIST: %v", err)
}
cmd := c.findPendingCmdFunc(func(cmd command) bool {
switch cmd := cmd.(type) {
case *ListCommand:
return true // TODO: match pattern, check if already handled
case *SelectCommand:
return cmd.mailbox == data.Mailbox && cmd.data.List == nil
default:
return false
}
})
switch cmd := cmd.(type) {
case *ListCommand:
if cmd.returnStatus {
if cmd.pendingData != nil {
cmd.mailboxes <- cmd.pendingData
}
cmd.pendingData = data
} else {
cmd.mailboxes <- data
}
case *SelectCommand:
cmd.data.List = data
}
return nil
}
// ListCommand is a LIST command.
type ListCommand struct {
commandBase
mailboxes chan *imap.ListData
returnStatus bool
pendingData *imap.ListData
}
// Next advances to the next mailbox.
//
// On success, the mailbox LIST data is returned. On error or if there are no
// more mailboxes, nil is returned.
func (cmd *ListCommand) Next() *imap.ListData {
return <-cmd.mailboxes
}
// Close releases the command.
//
// Calling Close unblocks the IMAP client decoder and lets it read the next
// responses. Next will always return nil after Close.
func (cmd *ListCommand) Close() error {
for cmd.Next() != nil {
// ignore
}
return cmd.wait()
}
// Collect accumulates mailboxes into a list.
//
// This is equivalent to calling Next repeatedly and then Close.
func (cmd *ListCommand) Collect() ([]*imap.ListData, error) {
var l []*imap.ListData
for {
data := cmd.Next()
if data == nil {
break
}
l = append(l, data)
}
return l, cmd.Close()
}
func readList(dec *imapwire.Decoder) (*imap.ListData, error) {
var data imap.ListData
var err error
data.Attrs, err = internal.ExpectMailboxAttrList(dec)
if err != nil {
return nil, fmt.Errorf("in mbx-list-flags: %w", err)
}
if !dec.ExpectSP() {
return nil, dec.Err()
}
data.Delim, err = readDelim(dec)
if err != nil {
return nil, err
}
if !dec.ExpectSP() || !dec.ExpectMailbox(&data.Mailbox) {
return nil, dec.Err()
}
if dec.SP() {
err := dec.ExpectList(func() error {
var tag string
if !dec.ExpectAString(&tag) || !dec.ExpectSP() {
return dec.Err()
}
var err error
switch strings.ToUpper(tag) {
case "CHILDINFO":
data.ChildInfo, err = readChildInfoExtendedItem(dec)
if err != nil {
return fmt.Errorf("in childinfo-extended-item: %v", err)
}
case "OLDNAME":
data.OldName, err = readOldNameExtendedItem(dec)
if err != nil {
return fmt.Errorf("in oldname-extended-item: %v", err)
}
default:
if !dec.DiscardValue() {
return fmt.Errorf("in tagged-ext-val: %v", err)
}
}
return nil
})
if err != nil {
return nil, fmt.Errorf("in mbox-list-extended: %v", err)
}
}
return &data, nil
}
func readChildInfoExtendedItem(dec *imapwire.Decoder) (*imap.ListDataChildInfo, error) {
var childInfo imap.ListDataChildInfo
err := dec.ExpectList(func() error {
var opt string
if !dec.ExpectAString(&opt) {
return dec.Err()
}
if strings.ToUpper(opt) == "SUBSCRIBED" {
childInfo.Subscribed = true
}
return nil
})
return &childInfo, err
}
func readOldNameExtendedItem(dec *imapwire.Decoder) (string, error) {
var name string
if !dec.ExpectSpecial('(') || !dec.ExpectMailbox(&name) || !dec.ExpectSpecial(')') {
return "", dec.Err()
}
return name, nil
}
func readDelim(dec *imapwire.Decoder) (rune, error) {
var delimStr string
if dec.Quoted(&delimStr) {
delim, size := utf8.DecodeRuneInString(delimStr)
if delim == utf8.RuneError || size != len(delimStr) {
return 0, fmt.Errorf("mailbox delimiter must be a single rune")
}
return delim, nil
} else if !dec.ExpectNIL() {
return 0, dec.Err()
} else {
return 0, nil
}
}

42
imapclient/list_test.go Normal file
View File

@@ -0,0 +1,42 @@
package imapclient_test
import (
"reflect"
"testing"
"github.com/emersion/go-imap/v2"
)
func TestList(t *testing.T) {
client, server := newClientServerPair(t, imap.ConnStateAuthenticated)
defer client.Close()
defer server.Close()
options := imap.ListOptions{
ReturnStatus: &imap.StatusOptions{
NumMessages: true,
},
}
mailboxes, err := client.List("", "%", &options).Collect()
if err != nil {
t.Fatalf("List() = %v", err)
}
if len(mailboxes) != 1 {
t.Fatalf("List() returned %v mailboxes, want 1", len(mailboxes))
}
mbox := mailboxes[0]
wantNumMessages := uint32(1)
want := &imap.ListData{
Delim: '/',
Mailbox: "INBOX",
Status: &imap.StatusData{
Mailbox: "INBOX",
NumMessages: &wantNumMessages,
},
}
if !reflect.DeepEqual(mbox, want) {
t.Errorf("got %#v but want %#v", mbox, want)
}
}

205
imapclient/metadata.go Normal file
View File

@@ -0,0 +1,205 @@
package imapclient
import (
"fmt"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
type GetMetadataDepth int
const (
GetMetadataDepthZero GetMetadataDepth = 0
GetMetadataDepthOne GetMetadataDepth = 1
GetMetadataDepthInfinity GetMetadataDepth = -1
)
func (depth GetMetadataDepth) String() string {
switch depth {
case GetMetadataDepthZero:
return "0"
case GetMetadataDepthOne:
return "1"
case GetMetadataDepthInfinity:
return "infinity"
default:
panic(fmt.Errorf("imapclient: unknown GETMETADATA depth %d", depth))
}
}
// GetMetadataOptions contains options for the GETMETADATA command.
type GetMetadataOptions struct {
MaxSize *uint32
Depth GetMetadataDepth
}
func (options *GetMetadataOptions) names() []string {
if options == nil {
return nil
}
var l []string
if options.MaxSize != nil {
l = append(l, "MAXSIZE")
}
if options.Depth != GetMetadataDepthZero {
l = append(l, "DEPTH")
}
return l
}
// GetMetadata sends a GETMETADATA command.
//
// This command requires support for the METADATA or METADATA-SERVER extension.
func (c *Client) GetMetadata(mailbox string, entries []string, options *GetMetadataOptions) *GetMetadataCommand {
cmd := &GetMetadataCommand{mailbox: mailbox}
enc := c.beginCommand("GETMETADATA", cmd)
enc.SP().Mailbox(mailbox)
if opts := options.names(); len(opts) > 0 {
enc.SP().List(len(opts), func(i int) {
opt := opts[i]
enc.Atom(opt).SP()
switch opt {
case "MAXSIZE":
enc.Number(*options.MaxSize)
case "DEPTH":
enc.Atom(options.Depth.String())
default:
panic(fmt.Errorf("imapclient: unknown GETMETADATA option %q", opt))
}
})
}
enc.SP().List(len(entries), func(i int) {
enc.String(entries[i])
})
enc.end()
return cmd
}
// SetMetadata sends a SETMETADATA command.
//
// To remove an entry, set it to nil.
//
// This command requires support for the METADATA or METADATA-SERVER extension.
func (c *Client) SetMetadata(mailbox string, entries map[string]*[]byte) *Command {
cmd := &Command{}
enc := c.beginCommand("SETMETADATA", cmd)
enc.SP().Mailbox(mailbox).SP().Special('(')
i := 0
for k, v := range entries {
if i > 0 {
enc.SP()
}
enc.String(k).SP()
if v == nil {
enc.NIL()
} else {
enc.String(string(*v)) // TODO: use literals if required
}
i++
}
enc.Special(')')
enc.end()
return cmd
}
func (c *Client) handleMetadata() error {
data, err := readMetadataResp(c.dec)
if err != nil {
return fmt.Errorf("in metadata-resp: %v", err)
}
cmd := c.findPendingCmdFunc(func(anyCmd command) bool {
cmd, ok := anyCmd.(*GetMetadataCommand)
return ok && cmd.mailbox == data.Mailbox
})
if cmd != nil && len(data.EntryValues) > 0 {
cmd := cmd.(*GetMetadataCommand)
cmd.data.Mailbox = data.Mailbox
if cmd.data.Entries == nil {
cmd.data.Entries = make(map[string]*[]byte)
}
// The server might send multiple METADATA responses for a single
// METADATA command
for k, v := range data.EntryValues {
cmd.data.Entries[k] = v
}
} else if handler := c.options.unilateralDataHandler().Metadata; handler != nil && len(data.EntryList) > 0 {
handler(data.Mailbox, data.EntryList)
}
return nil
}
// GetMetadataCommand is a GETMETADATA command.
type GetMetadataCommand struct {
commandBase
mailbox string
data GetMetadataData
}
func (cmd *GetMetadataCommand) Wait() (*GetMetadataData, error) {
return &cmd.data, cmd.wait()
}
// GetMetadataData is the data returned by the GETMETADATA command.
type GetMetadataData struct {
Mailbox string
Entries map[string]*[]byte
}
type metadataResp struct {
Mailbox string
EntryList []string
EntryValues map[string]*[]byte
}
func readMetadataResp(dec *imapwire.Decoder) (*metadataResp, error) {
var data metadataResp
if !dec.ExpectMailbox(&data.Mailbox) || !dec.ExpectSP() {
return nil, dec.Err()
}
isList, err := dec.List(func() error {
var name string
if !dec.ExpectAString(&name) || !dec.ExpectSP() {
return dec.Err()
}
// TODO: decode as []byte
var (
value *[]byte
s string
)
if dec.String(&s) || dec.Literal(&s) {
b := []byte(s)
value = &b
} else if !dec.ExpectNIL() {
return dec.Err()
}
if data.EntryValues == nil {
data.EntryValues = make(map[string]*[]byte)
}
data.EntryValues[name] = value
return nil
})
if err != nil {
return nil, err
} else if !isList {
var name string
if !dec.ExpectAString(&name) {
return nil, dec.Err()
}
data.EntryList = append(data.EntryList, name)
for dec.SP() {
if !dec.ExpectAString(&name) {
return nil, dec.Err()
}
data.EntryList = append(data.EntryList, name)
}
}
return &data, nil
}

74
imapclient/move.go Normal file
View File

@@ -0,0 +1,74 @@
package imapclient
import (
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
// Move sends a MOVE command.
//
// If the server doesn't support IMAP4rev2 nor the MOVE extension, a fallback
// with COPY + STORE + EXPUNGE commands is used.
func (c *Client) Move(numSet imap.NumSet, mailbox string) *MoveCommand {
// If the server doesn't support MOVE, fallback to [UID] COPY,
// [UID] STORE +FLAGS.SILENT \Deleted and [UID] EXPUNGE
cmdName := "MOVE"
if !c.Caps().Has(imap.CapMove) {
cmdName = "COPY"
}
cmd := &MoveCommand{}
enc := c.beginCommand(uidCmdName(cmdName, imapwire.NumSetKind(numSet)), cmd)
enc.SP().NumSet(numSet).SP().Mailbox(mailbox)
enc.end()
if cmdName == "COPY" {
cmd.store = c.Store(numSet, &imap.StoreFlags{
Op: imap.StoreFlagsAdd,
Silent: true,
Flags: []imap.Flag{imap.FlagDeleted},
}, nil)
if uidSet, ok := numSet.(imap.UIDSet); ok && c.Caps().Has(imap.CapUIDPlus) {
cmd.expunge = c.UIDExpunge(uidSet)
} else {
cmd.expunge = c.Expunge()
}
}
return cmd
}
// MoveCommand is a MOVE command.
type MoveCommand struct {
commandBase
data MoveData
// Fallback
store *FetchCommand
expunge *ExpungeCommand
}
func (cmd *MoveCommand) Wait() (*MoveData, error) {
if err := cmd.wait(); err != nil {
return nil, err
}
if cmd.store != nil {
if err := cmd.store.Close(); err != nil {
return nil, err
}
}
if cmd.expunge != nil {
if err := cmd.expunge.Close(); err != nil {
return nil, err
}
}
return &cmd.data, nil
}
// MoveData contains the data returned by a MOVE command.
type MoveData struct {
// requires UIDPLUS or IMAP4rev2
UIDValidity uint32
SourceUIDs imap.NumSet
DestUIDs imap.NumSet
}

110
imapclient/namespace.go Normal file
View File

@@ -0,0 +1,110 @@
package imapclient
import (
"fmt"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
// Namespace sends a NAMESPACE command.
//
// This command requires support for IMAP4rev2 or the NAMESPACE extension.
func (c *Client) Namespace() *NamespaceCommand {
cmd := &NamespaceCommand{}
c.beginCommand("NAMESPACE", cmd).end()
return cmd
}
func (c *Client) handleNamespace() error {
data, err := readNamespaceResponse(c.dec)
if err != nil {
return fmt.Errorf("in namespace-response: %v", err)
}
if cmd := findPendingCmdByType[*NamespaceCommand](c); cmd != nil {
cmd.data = *data
}
return nil
}
// NamespaceCommand is a NAMESPACE command.
type NamespaceCommand struct {
commandBase
data imap.NamespaceData
}
func (cmd *NamespaceCommand) Wait() (*imap.NamespaceData, error) {
return &cmd.data, cmd.wait()
}
func readNamespaceResponse(dec *imapwire.Decoder) (*imap.NamespaceData, error) {
var (
data imap.NamespaceData
err error
)
data.Personal, err = readNamespace(dec)
if err != nil {
return nil, err
}
if !dec.ExpectSP() {
return nil, dec.Err()
}
data.Other, err = readNamespace(dec)
if err != nil {
return nil, err
}
if !dec.ExpectSP() {
return nil, dec.Err()
}
data.Shared, err = readNamespace(dec)
if err != nil {
return nil, err
}
return &data, nil
}
func readNamespace(dec *imapwire.Decoder) ([]imap.NamespaceDescriptor, error) {
var l []imap.NamespaceDescriptor
err := dec.ExpectNList(func() error {
descr, err := readNamespaceDescr(dec)
if err != nil {
return fmt.Errorf("in namespace-descr: %v", err)
}
l = append(l, *descr)
return nil
})
return l, err
}
func readNamespaceDescr(dec *imapwire.Decoder) (*imap.NamespaceDescriptor, error) {
var descr imap.NamespaceDescriptor
if !dec.ExpectSpecial('(') || !dec.ExpectString(&descr.Prefix) || !dec.ExpectSP() {
return nil, dec.Err()
}
var err error
descr.Delim, err = readDelim(dec)
if err != nil {
return nil, err
}
// Skip namespace-response-extensions
for dec.SP() {
if !dec.DiscardValue() {
return nil, dec.Err()
}
}
if !dec.ExpectSpecial(')') {
return nil, dec.Err()
}
return &descr, nil
}

176
imapclient/quota.go Normal file
View File

@@ -0,0 +1,176 @@
package imapclient
import (
"fmt"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
// GetQuota sends a GETQUOTA command.
//
// This command requires support for the QUOTA extension.
func (c *Client) GetQuota(root string) *GetQuotaCommand {
cmd := &GetQuotaCommand{root: root}
enc := c.beginCommand("GETQUOTA", cmd)
enc.SP().String(root)
enc.end()
return cmd
}
// GetQuotaRoot sends a GETQUOTAROOT command.
//
// This command requires support for the QUOTA extension.
func (c *Client) GetQuotaRoot(mailbox string) *GetQuotaRootCommand {
cmd := &GetQuotaRootCommand{mailbox: mailbox}
enc := c.beginCommand("GETQUOTAROOT", cmd)
enc.SP().Mailbox(mailbox)
enc.end()
return cmd
}
// SetQuota sends a SETQUOTA command.
//
// This command requires support for the SETQUOTA extension.
func (c *Client) SetQuota(root string, limits map[imap.QuotaResourceType]int64) *Command {
// TODO: consider returning the QUOTA response data?
cmd := &Command{}
enc := c.beginCommand("SETQUOTA", cmd)
enc.SP().String(root).SP().Special('(')
i := 0
for typ, limit := range limits {
if i > 0 {
enc.SP()
}
enc.Atom(string(typ)).SP().Number64(limit)
i++
}
enc.Special(')')
enc.end()
return cmd
}
func (c *Client) handleQuota() error {
data, err := readQuotaResponse(c.dec)
if err != nil {
return fmt.Errorf("in quota-response: %v", err)
}
cmd := c.findPendingCmdFunc(func(cmd command) bool {
switch cmd := cmd.(type) {
case *GetQuotaCommand:
return cmd.root == data.Root
case *GetQuotaRootCommand:
for _, root := range cmd.roots {
if root == data.Root {
return true
}
}
return false
default:
return false
}
})
switch cmd := cmd.(type) {
case *GetQuotaCommand:
cmd.data = data
case *GetQuotaRootCommand:
cmd.data = append(cmd.data, *data)
}
return nil
}
func (c *Client) handleQuotaRoot() error {
mailbox, roots, err := readQuotaRoot(c.dec)
if err != nil {
return fmt.Errorf("in quotaroot-response: %v", err)
}
cmd := c.findPendingCmdFunc(func(anyCmd command) bool {
cmd, ok := anyCmd.(*GetQuotaRootCommand)
if !ok {
return false
}
return cmd.mailbox == mailbox
})
if cmd != nil {
cmd := cmd.(*GetQuotaRootCommand)
cmd.roots = roots
}
return nil
}
// GetQuotaCommand is a GETQUOTA command.
type GetQuotaCommand struct {
commandBase
root string
data *QuotaData
}
func (cmd *GetQuotaCommand) Wait() (*QuotaData, error) {
if err := cmd.wait(); err != nil {
return nil, err
}
return cmd.data, nil
}
// GetQuotaRootCommand is a GETQUOTAROOT command.
type GetQuotaRootCommand struct {
commandBase
mailbox string
roots []string
data []QuotaData
}
func (cmd *GetQuotaRootCommand) Wait() ([]QuotaData, error) {
if err := cmd.wait(); err != nil {
return nil, err
}
return cmd.data, nil
}
// QuotaData is the data returned by a QUOTA response.
type QuotaData struct {
Root string
Resources map[imap.QuotaResourceType]QuotaResourceData
}
// QuotaResourceData contains the usage and limit for a quota resource.
type QuotaResourceData struct {
Usage int64
Limit int64
}
func readQuotaResponse(dec *imapwire.Decoder) (*QuotaData, error) {
var data QuotaData
if !dec.ExpectAString(&data.Root) || !dec.ExpectSP() {
return nil, dec.Err()
}
data.Resources = make(map[imap.QuotaResourceType]QuotaResourceData)
err := dec.ExpectList(func() error {
var (
name string
resData QuotaResourceData
)
if !dec.ExpectAtom(&name) || !dec.ExpectSP() || !dec.ExpectNumber64(&resData.Usage) || !dec.ExpectSP() || !dec.ExpectNumber64(&resData.Limit) {
return fmt.Errorf("in quota-resource: %v", dec.Err())
}
data.Resources[imap.QuotaResourceType(name)] = resData
return nil
})
return &data, err
}
func readQuotaRoot(dec *imapwire.Decoder) (mailbox string, roots []string, err error) {
if !dec.ExpectMailbox(&mailbox) {
return "", nil, dec.Err()
}
for dec.SP() {
var root string
if !dec.ExpectAString(&root) {
return "", nil, dec.Err()
}
roots = append(roots, root)
}
return mailbox, roots, nil
}

401
imapclient/search.go Normal file
View File

@@ -0,0 +1,401 @@
package imapclient
import (
"fmt"
"strings"
"time"
"unicode"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
func returnSearchOptions(options *imap.SearchOptions) []string {
if options == nil {
return nil
}
m := map[string]bool{
"MIN": options.ReturnMin,
"MAX": options.ReturnMax,
"ALL": options.ReturnAll,
"COUNT": options.ReturnCount,
}
var l []string
for k, ret := range m {
if ret {
l = append(l, k)
}
}
return l
}
func (c *Client) search(numKind imapwire.NumKind, criteria *imap.SearchCriteria, options *imap.SearchOptions) *SearchCommand {
// The IMAP4rev2 SEARCH charset defaults to UTF-8. When UTF8=ACCEPT is
// enabled, specifying any CHARSET is invalid. For IMAP4rev1 the default is
// undefined and only US-ASCII support is required. What's more, some
// servers completely reject the CHARSET keyword. So, let's check if we
// actually have UTF-8 strings in the search criteria before using that.
// TODO: there might be a benefit in specifying CHARSET UTF-8 for IMAP4rev1
// servers even if we only send ASCII characters: the server then must
// decode encoded headers and Content-Transfer-Encoding before matching the
// criteria.
var charset string
if !c.Caps().Has(imap.CapIMAP4rev2) && !c.enabled.Has(imap.CapUTF8Accept) && !searchCriteriaIsASCII(criteria) {
charset = "UTF-8"
}
var all imap.NumSet
switch numKind {
case imapwire.NumKindSeq:
all = imap.SeqSet(nil)
case imapwire.NumKindUID:
all = imap.UIDSet(nil)
}
cmd := &SearchCommand{}
cmd.data.All = all
enc := c.beginCommand(uidCmdName("SEARCH", numKind), cmd)
if returnOpts := returnSearchOptions(options); len(returnOpts) > 0 {
enc.SP().Atom("RETURN").SP().List(len(returnOpts), func(i int) {
enc.Atom(returnOpts[i])
})
}
enc.SP()
if charset != "" {
enc.Atom("CHARSET").SP().Atom(charset).SP()
}
writeSearchKey(enc.Encoder, criteria)
enc.end()
return cmd
}
// Search sends a SEARCH command.
func (c *Client) Search(criteria *imap.SearchCriteria, options *imap.SearchOptions) *SearchCommand {
return c.search(imapwire.NumKindSeq, criteria, options)
}
// UIDSearch sends a UID SEARCH command.
func (c *Client) UIDSearch(criteria *imap.SearchCriteria, options *imap.SearchOptions) *SearchCommand {
return c.search(imapwire.NumKindUID, criteria, options)
}
func (c *Client) handleSearch() error {
cmd := findPendingCmdByType[*SearchCommand](c)
for c.dec.SP() {
if c.dec.Special('(') {
var name string
if !c.dec.ExpectAtom(&name) || !c.dec.ExpectSP() {
return c.dec.Err()
} else if strings.ToUpper(name) != "MODSEQ" {
return fmt.Errorf("in search-sort-mod-seq: expected %q, got %q", "MODSEQ", name)
}
var modSeq uint64
if !c.dec.ExpectModSeq(&modSeq) || !c.dec.ExpectSpecial(')') {
return c.dec.Err()
}
if cmd != nil {
cmd.data.ModSeq = modSeq
}
break
}
var num uint32
if !c.dec.ExpectNumber(&num) {
return c.dec.Err()
}
if cmd != nil {
switch all := cmd.data.All.(type) {
case imap.SeqSet:
all.AddNum(num)
cmd.data.All = all
case imap.UIDSet:
all.AddNum(imap.UID(num))
cmd.data.All = all
}
}
}
return nil
}
func (c *Client) handleESearch() error {
if !c.dec.ExpectSP() {
return c.dec.Err()
}
tag, data, err := readESearchResponse(c.dec)
if err != nil {
return err
}
cmd := c.findPendingCmdFunc(func(anyCmd command) bool {
cmd, ok := anyCmd.(*SearchCommand)
if !ok {
return false
}
if tag != "" {
return cmd.tag == tag
} else {
return true
}
})
if cmd != nil {
cmd := cmd.(*SearchCommand)
cmd.data = *data
}
return nil
}
// SearchCommand is a SEARCH command.
type SearchCommand struct {
commandBase
data imap.SearchData
}
func (cmd *SearchCommand) Wait() (*imap.SearchData, error) {
return &cmd.data, cmd.wait()
}
func writeSearchKey(enc *imapwire.Encoder, criteria *imap.SearchCriteria) {
firstItem := true
encodeItem := func() *imapwire.Encoder {
if !firstItem {
enc.SP()
}
firstItem = false
return enc
}
for _, seqSet := range criteria.SeqNum {
encodeItem().NumSet(seqSet)
}
for _, uidSet := range criteria.UID {
encodeItem().Atom("UID").SP().NumSet(uidSet)
}
if !criteria.Since.IsZero() && !criteria.Before.IsZero() && criteria.Before.Sub(criteria.Since) == 24*time.Hour {
encodeItem().Atom("ON").SP().String(criteria.Since.Format(internal.DateLayout))
} else {
if !criteria.Since.IsZero() {
encodeItem().Atom("SINCE").SP().String(criteria.Since.Format(internal.DateLayout))
}
if !criteria.Before.IsZero() {
encodeItem().Atom("BEFORE").SP().String(criteria.Before.Format(internal.DateLayout))
}
}
if !criteria.SentSince.IsZero() && !criteria.SentBefore.IsZero() && criteria.SentBefore.Sub(criteria.SentSince) == 24*time.Hour {
encodeItem().Atom("SENTON").SP().String(criteria.SentSince.Format(internal.DateLayout))
} else {
if !criteria.SentSince.IsZero() {
encodeItem().Atom("SENTSINCE").SP().String(criteria.SentSince.Format(internal.DateLayout))
}
if !criteria.SentBefore.IsZero() {
encodeItem().Atom("SENTBEFORE").SP().String(criteria.SentBefore.Format(internal.DateLayout))
}
}
for _, kv := range criteria.Header {
switch k := strings.ToUpper(kv.Key); k {
case "BCC", "CC", "FROM", "SUBJECT", "TO":
encodeItem().Atom(k)
default:
encodeItem().Atom("HEADER").SP().String(kv.Key)
}
enc.SP().String(kv.Value)
}
for _, s := range criteria.Body {
encodeItem().Atom("BODY").SP().String(s)
}
for _, s := range criteria.Text {
encodeItem().Atom("TEXT").SP().String(s)
}
for _, flag := range criteria.Flag {
if k := flagSearchKey(flag); k != "" {
encodeItem().Atom(k)
} else {
encodeItem().Atom("KEYWORD").SP().Flag(flag)
}
}
for _, flag := range criteria.NotFlag {
if k := flagSearchKey(flag); k != "" {
encodeItem().Atom("UN" + k)
} else {
encodeItem().Atom("UNKEYWORD").SP().Flag(flag)
}
}
if criteria.Larger > 0 {
encodeItem().Atom("LARGER").SP().Number64(criteria.Larger)
}
if criteria.Smaller > 0 {
encodeItem().Atom("SMALLER").SP().Number64(criteria.Smaller)
}
if modSeq := criteria.ModSeq; modSeq != nil {
encodeItem().Atom("MODSEQ")
if modSeq.MetadataName != "" && modSeq.MetadataType != "" {
enc.SP().Quoted(modSeq.MetadataName).SP().Atom(string(modSeq.MetadataType))
}
enc.SP()
if modSeq.ModSeq != 0 {
enc.ModSeq(modSeq.ModSeq)
} else {
enc.Atom("0")
}
}
for _, not := range criteria.Not {
encodeItem().Atom("NOT").SP()
enc.Special('(')
writeSearchKey(enc, &not)
enc.Special(')')
}
for _, or := range criteria.Or {
encodeItem().Atom("OR").SP()
enc.Special('(')
writeSearchKey(enc, &or[0])
enc.Special(')')
enc.SP()
enc.Special('(')
writeSearchKey(enc, &or[1])
enc.Special(')')
}
if firstItem {
enc.Atom("ALL")
}
}
func flagSearchKey(flag imap.Flag) string {
switch flag {
case imap.FlagAnswered, imap.FlagDeleted, imap.FlagDraft, imap.FlagFlagged, imap.FlagSeen:
return strings.ToUpper(strings.TrimPrefix(string(flag), "\\"))
default:
return ""
}
}
func readESearchResponse(dec *imapwire.Decoder) (tag string, data *imap.SearchData, err error) {
data = &imap.SearchData{}
if dec.Special('(') { // search-correlator
var correlator string
if !dec.ExpectAtom(&correlator) || !dec.ExpectSP() || !dec.ExpectAString(&tag) || !dec.ExpectSpecial(')') {
return "", nil, dec.Err()
}
if correlator != "TAG" {
return "", nil, fmt.Errorf("in search-correlator: name must be TAG, but got %q", correlator)
}
}
var name string
if !dec.SP() {
return tag, data, nil
} else if !dec.ExpectAtom(&name) {
return "", nil, dec.Err()
}
data.UID = name == "UID"
if data.UID {
if !dec.SP() {
return tag, data, nil
} else if !dec.ExpectAtom(&name) {
return "", nil, dec.Err()
}
}
for {
if !dec.ExpectSP() {
return "", nil, dec.Err()
}
switch strings.ToUpper(name) {
case "MIN":
var num uint32
if !dec.ExpectNumber(&num) {
return "", nil, dec.Err()
}
data.Min = num
case "MAX":
var num uint32
if !dec.ExpectNumber(&num) {
return "", nil, dec.Err()
}
data.Max = num
case "ALL":
numKind := imapwire.NumKindSeq
if data.UID {
numKind = imapwire.NumKindUID
}
if !dec.ExpectNumSet(numKind, &data.All) {
return "", nil, dec.Err()
}
if data.All.Dynamic() {
return "", nil, fmt.Errorf("imapclient: server returned a dynamic ALL number set in SEARCH response")
}
case "COUNT":
var num uint32
if !dec.ExpectNumber(&num) {
return "", nil, dec.Err()
}
data.Count = num
case "MODSEQ":
var modSeq uint64
if !dec.ExpectModSeq(&modSeq) {
return "", nil, dec.Err()
}
data.ModSeq = modSeq
default:
if !dec.DiscardValue() {
return "", nil, dec.Err()
}
}
if !dec.SP() {
break
} else if !dec.ExpectAtom(&name) {
return "", nil, dec.Err()
}
}
return tag, data, nil
}
func searchCriteriaIsASCII(criteria *imap.SearchCriteria) bool {
for _, kv := range criteria.Header {
if !isASCII(kv.Key) || !isASCII(kv.Value) {
return false
}
}
for _, s := range criteria.Body {
if !isASCII(s) {
return false
}
}
for _, s := range criteria.Text {
if !isASCII(s) {
return false
}
}
for _, not := range criteria.Not {
if !searchCriteriaIsASCII(&not) {
return false
}
}
for _, or := range criteria.Or {
if !searchCriteriaIsASCII(&or[0]) || !searchCriteriaIsASCII(&or[1]) {
return false
}
}
return true
}
func isASCII(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] > unicode.MaxASCII {
return false
}
}
return true
}

61
imapclient/search_test.go Normal file
View File

@@ -0,0 +1,61 @@
package imapclient_test
import (
"reflect"
"testing"
"github.com/emersion/go-imap/v2"
)
func TestSearch(t *testing.T) {
client, server := newClientServerPair(t, imap.ConnStateSelected)
defer client.Close()
defer server.Close()
criteria := imap.SearchCriteria{
Header: []imap.SearchCriteriaHeaderField{{
Key: "Message-Id",
Value: "<191101702316132@example.com>",
}},
}
data, err := client.Search(&criteria, nil).Wait()
if err != nil {
t.Fatalf("Search().Wait() = %v", err)
}
seqSet, ok := data.All.(imap.SeqSet)
if !ok {
t.Fatalf("SearchData.All = %T, want SeqSet", data.All)
}
nums, _ := seqSet.Nums()
want := []uint32{1}
if !reflect.DeepEqual(nums, want) {
t.Errorf("SearchData.All.Nums() = %v, want %v", nums, want)
}
}
func TestESearch(t *testing.T) {
client, server := newClientServerPair(t, imap.ConnStateSelected)
defer client.Close()
defer server.Close()
if !client.Caps().Has(imap.CapESearch) {
t.Skip("server doesn't support ESEARCH")
}
criteria := imap.SearchCriteria{
Header: []imap.SearchCriteriaHeaderField{{
Key: "Message-Id",
Value: "<191101702316132@example.com>",
}},
}
options := imap.SearchOptions{
ReturnCount: true,
}
data, err := client.Search(&criteria, &options).Wait()
if err != nil {
t.Fatalf("Search().Wait() = %v", err)
}
if want := uint32(1); data.Count != want {
t.Errorf("Count = %v, want %v", data.Count, want)
}
}

100
imapclient/select.go Normal file
View File

@@ -0,0 +1,100 @@
package imapclient
import (
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal"
)
// Select sends a SELECT or EXAMINE command.
//
// A nil options pointer is equivalent to a zero options value.
func (c *Client) Select(mailbox string, options *imap.SelectOptions) *SelectCommand {
cmdName := "SELECT"
if options != nil && options.ReadOnly {
cmdName = "EXAMINE"
}
cmd := &SelectCommand{mailbox: mailbox}
enc := c.beginCommand(cmdName, cmd)
enc.SP().Mailbox(mailbox)
if options != nil && options.CondStore {
enc.SP().Special('(').Atom("CONDSTORE").Special(')')
}
enc.end()
return cmd
}
// Unselect sends an UNSELECT command.
//
// This command requires support for IMAP4rev2 or the UNSELECT extension.
func (c *Client) Unselect() *Command {
cmd := &unselectCommand{}
c.beginCommand("UNSELECT", cmd).end()
return &cmd.Command
}
// UnselectAndExpunge sends a CLOSE command.
//
// CLOSE implicitly performs a silent EXPUNGE command.
func (c *Client) UnselectAndExpunge() *Command {
cmd := &unselectCommand{}
c.beginCommand("CLOSE", cmd).end()
return &cmd.Command
}
func (c *Client) handleFlags() error {
flags, err := internal.ExpectFlagList(c.dec)
if err != nil {
return err
}
c.mutex.Lock()
if c.state == imap.ConnStateSelected {
c.mailbox = c.mailbox.copy()
c.mailbox.PermanentFlags = flags
}
c.mutex.Unlock()
cmd := findPendingCmdByType[*SelectCommand](c)
if cmd != nil {
cmd.data.Flags = flags
} else if handler := c.options.unilateralDataHandler().Mailbox; handler != nil {
handler(&UnilateralDataMailbox{Flags: flags})
}
return nil
}
func (c *Client) handleExists(num uint32) error {
cmd := findPendingCmdByType[*SelectCommand](c)
if cmd != nil {
cmd.data.NumMessages = num
} else {
c.mutex.Lock()
if c.state == imap.ConnStateSelected {
c.mailbox = c.mailbox.copy()
c.mailbox.NumMessages = num
}
c.mutex.Unlock()
if handler := c.options.unilateralDataHandler().Mailbox; handler != nil {
handler(&UnilateralDataMailbox{NumMessages: &num})
}
}
return nil
}
// SelectCommand is a SELECT command.
type SelectCommand struct {
commandBase
mailbox string
data imap.SelectData
}
func (cmd *SelectCommand) Wait() (*imap.SelectData, error) {
return &cmd.data, cmd.wait()
}
type unselectCommand struct {
Command
}

20
imapclient/select_test.go Normal file
View File

@@ -0,0 +1,20 @@
package imapclient_test
import (
"testing"
"github.com/emersion/go-imap/v2"
)
func TestSelect(t *testing.T) {
client, server := newClientServerPair(t, imap.ConnStateAuthenticated)
defer client.Close()
defer server.Close()
data, err := client.Select("INBOX", nil).Wait()
if err != nil {
t.Fatalf("Select() = %v", err)
} else if data.NumMessages != 1 {
t.Errorf("SelectData.NumMessages = %v, want %v", data.NumMessages, 1)
}
}

84
imapclient/sort.go Normal file
View File

@@ -0,0 +1,84 @@
package imapclient
import (
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
type SortKey string
const (
SortKeyArrival SortKey = "ARRIVAL"
SortKeyCc SortKey = "CC"
SortKeyDate SortKey = "DATE"
SortKeyFrom SortKey = "FROM"
SortKeySize SortKey = "SIZE"
SortKeySubject SortKey = "SUBJECT"
SortKeyTo SortKey = "TO"
)
type SortCriterion struct {
Key SortKey
Reverse bool
}
// SortOptions contains options for the SORT command.
type SortOptions struct {
SearchCriteria *imap.SearchCriteria
SortCriteria []SortCriterion
}
func (c *Client) sort(numKind imapwire.NumKind, options *SortOptions) *SortCommand {
cmd := &SortCommand{}
enc := c.beginCommand(uidCmdName("SORT", numKind), cmd)
enc.SP().List(len(options.SortCriteria), func(i int) {
criterion := options.SortCriteria[i]
if criterion.Reverse {
enc.Atom("REVERSE").SP()
}
enc.Atom(string(criterion.Key))
})
enc.SP().Atom("UTF-8").SP()
writeSearchKey(enc.Encoder, options.SearchCriteria)
enc.end()
return cmd
}
func (c *Client) handleSort() error {
cmd := findPendingCmdByType[*SortCommand](c)
for c.dec.SP() {
var num uint32
if !c.dec.ExpectNumber(&num) {
return c.dec.Err()
}
if cmd != nil {
cmd.nums = append(cmd.nums, num)
}
}
return nil
}
// Sort sends a SORT command.
//
// This command requires support for the SORT extension.
func (c *Client) Sort(options *SortOptions) *SortCommand {
return c.sort(imapwire.NumKindSeq, options)
}
// UIDSort sends a UID SORT command.
//
// See Sort.
func (c *Client) UIDSort(options *SortOptions) *SortCommand {
return c.sort(imapwire.NumKindUID, options)
}
// SortCommand is a SORT command.
type SortCommand struct {
commandBase
nums []uint32
}
func (cmd *SortCommand) Wait() ([]uint32, error) {
err := cmd.wait()
return cmd.nums, err
}

83
imapclient/starttls.go Normal file
View File

@@ -0,0 +1,83 @@
package imapclient
import (
"bufio"
"bytes"
"crypto/tls"
"io"
"net"
)
// startTLS sends a STARTTLS command.
//
// Unlike other commands, this method blocks until the command completes.
func (c *Client) startTLS(config *tls.Config) error {
upgradeDone := make(chan struct{})
cmd := &startTLSCommand{
tlsConfig: config,
upgradeDone: upgradeDone,
}
enc := c.beginCommand("STARTTLS", cmd)
enc.flush()
defer enc.end()
// Once a client issues a STARTTLS command, it MUST NOT issue further
// commands until a server response is seen and the TLS negotiation is
// complete
if err := cmd.wait(); err != nil {
return err
}
// The decoder goroutine will invoke Client.upgradeStartTLS
<-upgradeDone
return cmd.tlsConn.Handshake()
}
// upgradeStartTLS finishes the STARTTLS upgrade after the server has sent an
// OK response. It runs in the decoder goroutine.
func (c *Client) upgradeStartTLS(startTLS *startTLSCommand) {
defer close(startTLS.upgradeDone)
// 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.Client(cleartextConn, startTLS.tlsConfig)
rw := c.options.wrapReadWriter(tlsConn)
c.br.Reset(rw)
// Unfortunately we can't re-use the bufio.Writer here, it races with
// Client.StartTLS
c.bw = bufio.NewWriter(rw)
startTLS.tlsConn = tlsConn
}
type startTLSCommand struct {
commandBase
tlsConfig *tls.Config
upgradeDone chan<- struct{}
tlsConn *tls.Conn
}
type startTLSConn struct {
net.Conn
r io.Reader
}
func (conn startTLSConn) Read(b []byte) (int, error) {
return conn.r.Read(b)
}

View File

@@ -0,0 +1,27 @@
package imapclient_test
import (
"crypto/tls"
"testing"
"github.com/emersion/go-imap/v2/imapclient"
)
func TestStartTLS(t *testing.T) {
conn, server := newMemClientServerPair(t)
defer conn.Close()
defer server.Close()
options := imapclient.Options{
TLSConfig: &tls.Config{InsecureSkipVerify: true},
}
client, err := imapclient.NewStartTLS(conn, &options)
if err != nil {
t.Fatalf("NewStartTLS() = %v", err)
}
defer client.Close()
if err := client.Noop().Wait(); err != nil {
t.Fatalf("Noop().Wait() = %v", err)
}
}

164
imapclient/status.go Normal file
View File

@@ -0,0 +1,164 @@
package imapclient
import (
"fmt"
"strings"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
func statusItems(options *imap.StatusOptions) []string {
m := map[string]bool{
"MESSAGES": options.NumMessages,
"UIDNEXT": options.UIDNext,
"UIDVALIDITY": options.UIDValidity,
"UNSEEN": options.NumUnseen,
"DELETED": options.NumDeleted,
"SIZE": options.Size,
"APPENDLIMIT": options.AppendLimit,
"DELETED-STORAGE": options.DeletedStorage,
"HIGHESTMODSEQ": options.HighestModSeq,
}
var l []string
for k, req := range m {
if req {
l = append(l, k)
}
}
return l
}
// Status sends a STATUS command.
//
// A nil options pointer is equivalent to a zero options value.
func (c *Client) Status(mailbox string, options *imap.StatusOptions) *StatusCommand {
if options == nil {
options = new(imap.StatusOptions)
}
if options.NumRecent {
panic("StatusOptions.NumRecent is not supported in imapclient")
}
cmd := &StatusCommand{mailbox: mailbox}
enc := c.beginCommand("STATUS", cmd)
enc.SP().Mailbox(mailbox).SP()
items := statusItems(options)
enc.List(len(items), func(i int) {
enc.Atom(items[i])
})
enc.end()
return cmd
}
func (c *Client) handleStatus() error {
data, err := readStatus(c.dec)
if err != nil {
return fmt.Errorf("in status: %v", err)
}
cmd := c.findPendingCmdFunc(func(cmd command) bool {
switch cmd := cmd.(type) {
case *StatusCommand:
return cmd.mailbox == data.Mailbox
case *ListCommand:
return cmd.returnStatus && cmd.pendingData != nil && cmd.pendingData.Mailbox == data.Mailbox
default:
return false
}
})
switch cmd := cmd.(type) {
case *StatusCommand:
cmd.data = *data
case *ListCommand:
cmd.pendingData.Status = data
cmd.mailboxes <- cmd.pendingData
cmd.pendingData = nil
}
return nil
}
// StatusCommand is a STATUS command.
type StatusCommand struct {
commandBase
mailbox string
data imap.StatusData
}
func (cmd *StatusCommand) Wait() (*imap.StatusData, error) {
return &cmd.data, cmd.wait()
}
func readStatus(dec *imapwire.Decoder) (*imap.StatusData, error) {
var data imap.StatusData
if !dec.ExpectMailbox(&data.Mailbox) || !dec.ExpectSP() {
return nil, dec.Err()
}
err := dec.ExpectList(func() error {
if err := readStatusAttVal(dec, &data); err != nil {
return fmt.Errorf("in status-att-val: %v", dec.Err())
}
return nil
})
return &data, err
}
func readStatusAttVal(dec *imapwire.Decoder, data *imap.StatusData) error {
var name string
if !dec.ExpectAtom(&name) || !dec.ExpectSP() {
return dec.Err()
}
var ok bool
switch strings.ToUpper(name) {
case "MESSAGES":
var num uint32
ok = dec.ExpectNumber(&num)
data.NumMessages = &num
case "UIDNEXT":
var uidNext imap.UID
ok = dec.ExpectUID(&uidNext)
data.UIDNext = uidNext
case "UIDVALIDITY":
ok = dec.ExpectNumber(&data.UIDValidity)
case "UNSEEN":
var num uint32
ok = dec.ExpectNumber(&num)
data.NumUnseen = &num
case "DELETED":
var num uint32
ok = dec.ExpectNumber(&num)
data.NumDeleted = &num
case "SIZE":
var size int64
ok = dec.ExpectNumber64(&size)
data.Size = &size
case "APPENDLIMIT":
var num uint32
if dec.Number(&num) {
ok = true
} else {
ok = dec.ExpectNIL()
num = ^uint32(0)
}
data.AppendLimit = &num
case "DELETED-STORAGE":
var storage int64
ok = dec.ExpectNumber64(&storage)
data.DeletedStorage = &storage
case "HIGHESTMODSEQ":
ok = dec.ExpectModSeq(&data.HighestModSeq)
default:
if !dec.DiscardValue() {
return dec.Err()
}
}
if !ok {
return dec.Err()
}
return nil
}

34
imapclient/status_test.go Normal file
View File

@@ -0,0 +1,34 @@
package imapclient_test
import (
"reflect"
"testing"
"github.com/emersion/go-imap/v2"
)
func TestStatus(t *testing.T) {
client, server := newClientServerPair(t, imap.ConnStateAuthenticated)
defer client.Close()
defer server.Close()
options := imap.StatusOptions{
NumMessages: true,
NumUnseen: true,
}
data, err := client.Status("INBOX", &options).Wait()
if err != nil {
t.Fatalf("Status() = %v", err)
}
wantNumMessages := uint32(1)
wantNumUnseen := uint32(1)
want := &imap.StatusData{
Mailbox: "INBOX",
NumMessages: &wantNumMessages,
NumUnseen: &wantNumUnseen,
}
if !reflect.DeepEqual(data, want) {
t.Errorf("Status() = %#v but want %#v", data, want)
}
}

44
imapclient/store.go Normal file
View File

@@ -0,0 +1,44 @@
package imapclient
import (
"fmt"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
// Store sends a STORE command.
//
// Unless StoreFlags.Silent is set, the server will return the updated values.
//
// A nil options pointer is equivalent to a zero options value.
func (c *Client) Store(numSet imap.NumSet, store *imap.StoreFlags, options *imap.StoreOptions) *FetchCommand {
cmd := &FetchCommand{
numSet: numSet,
msgs: make(chan *FetchMessageData, 128),
}
enc := c.beginCommand(uidCmdName("STORE", imapwire.NumSetKind(numSet)), cmd)
enc.SP().NumSet(numSet).SP()
if options != nil && options.UnchangedSince != 0 {
enc.Special('(').Atom("UNCHANGEDSINCE").SP().ModSeq(options.UnchangedSince).Special(')').SP()
}
switch store.Op {
case imap.StoreFlagsSet:
// nothing to do
case imap.StoreFlagsAdd:
enc.Special('+')
case imap.StoreFlagsDel:
enc.Special('-')
default:
panic(fmt.Errorf("imapclient: unknown store flags op: %v", store.Op))
}
enc.Atom("FLAGS")
if store.Silent {
enc.Atom(".SILENT")
}
enc.SP().List(len(store.Flags), func(i int) {
enc.Flag(store.Flags[i])
})
enc.end()
return cmd
}

40
imapclient/store_test.go Normal file
View File

@@ -0,0 +1,40 @@
package imapclient_test
import (
"testing"
"github.com/emersion/go-imap/v2"
)
func TestStore(t *testing.T) {
client, server := newClientServerPair(t, imap.ConnStateSelected)
defer client.Close()
defer server.Close()
seqSet := imap.SeqSetNum(1)
storeFlags := imap.StoreFlags{
Op: imap.StoreFlagsAdd,
Flags: []imap.Flag{imap.FlagDeleted},
}
msgs, err := client.Store(seqSet, &storeFlags, nil).Collect()
if err != nil {
t.Fatalf("Store().Collect() = %v", err)
} else if len(msgs) != 1 {
t.Fatalf("len(msgs) = %v, want %v", len(msgs), 1)
}
msg := msgs[0]
if msg.SeqNum != 1 {
t.Errorf("msg.SeqNum = %v, want %v", msg.SeqNum, 1)
}
found := false
for _, f := range msg.Flags {
if f == imap.FlagDeleted {
found = true
break
}
}
if !found {
t.Errorf("msg.Flags is missing deleted flag: %v", msg.Flags)
}
}

85
imapclient/thread.go Normal file
View File

@@ -0,0 +1,85 @@
package imapclient
import (
"fmt"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/internal/imapwire"
)
// ThreadOptions contains options for the THREAD command.
type ThreadOptions struct {
Algorithm imap.ThreadAlgorithm
SearchCriteria *imap.SearchCriteria
}
func (c *Client) thread(numKind imapwire.NumKind, options *ThreadOptions) *ThreadCommand {
cmd := &ThreadCommand{}
enc := c.beginCommand(uidCmdName("THREAD", numKind), cmd)
enc.SP().Atom(string(options.Algorithm)).SP().Atom("UTF-8").SP()
writeSearchKey(enc.Encoder, options.SearchCriteria)
enc.end()
return cmd
}
// Thread sends a THREAD command.
//
// This command requires support for the THREAD extension.
func (c *Client) Thread(options *ThreadOptions) *ThreadCommand {
return c.thread(imapwire.NumKindSeq, options)
}
// UIDThread sends a UID THREAD command.
//
// See Thread.
func (c *Client) UIDThread(options *ThreadOptions) *ThreadCommand {
return c.thread(imapwire.NumKindUID, options)
}
func (c *Client) handleThread() error {
cmd := findPendingCmdByType[*ThreadCommand](c)
for c.dec.SP() {
data, err := readThreadList(c.dec)
if err != nil {
return fmt.Errorf("in thread-list: %v", err)
}
if cmd != nil {
cmd.data = append(cmd.data, *data)
}
}
return nil
}
// ThreadCommand is a THREAD command.
type ThreadCommand struct {
commandBase
data []ThreadData
}
func (cmd *ThreadCommand) Wait() ([]ThreadData, error) {
err := cmd.wait()
return cmd.data, err
}
type ThreadData struct {
Chain []uint32
SubThreads []ThreadData
}
func readThreadList(dec *imapwire.Decoder) (*ThreadData, error) {
var data ThreadData
err := dec.ExpectList(func() error {
var num uint32
if len(data.SubThreads) == 0 && dec.Number(&num) {
data.Chain = append(data.Chain, num)
} else {
sub, err := readThreadList(dec)
if err != nil {
return err
}
data.SubThreads = append(data.SubThreads, *sub)
}
return nil
})
return &data, err
}