diff --git a/imapclient/cmds.go b/imapclient/cmds.go index f6538fb..5d58751 100644 --- a/imapclient/cmds.go +++ b/imapclient/cmds.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "fmt" "hash" + "io" "strings" "time" @@ -265,14 +266,50 @@ func (c *Conn) Status(mailbox string, attrs ...StatusAttr) (untagged []Untagged, return c.Transactf("status %s (%s)", astring(mailbox), strings.Join(l, " ")) } +// Append represents a parameter to the APPEND or REPLACE commands. +type Append struct { + Flags []string + Received *time.Time + Size int64 + Data io.Reader // Must return Size bytes. +} + // Append adds message to mailbox with flags and optional receive time. -func (c *Conn) Append(mailbox string, flags []string, received *time.Time, message []byte) (untagged []Untagged, result Result, rerr error) { +// +// Multiple messages are only possible when the server has announced the +// MULTIAPPEND capability. +func (c *Conn) Append(mailbox string, message Append, more ...Append) (untagged []Untagged, result Result, rerr error) { defer c.recover(&rerr) - var date string - if received != nil { - date = ` "` + received.Format("_2-Jan-2006 15:04:05 -0700") + `"` + + if _, ok := c.CapAvailable[CapMultiAppend]; !ok && len(more) > 0 { + c.xerrorf("can only append multiple messages when server has announced MULTIAPPEND capability") } - return c.Transactf("append %s (%s)%s {%d+}\r\n%s", astring(mailbox), strings.Join(flags, " "), date, len(message), message) + + tag := c.nextTag() + c.LastTag = tag + + _, err := fmt.Fprintf(c.bw, "%s append %s", tag, astring(mailbox)) + c.xcheckf(err, "write command") + + msgs := append([]Append{message}, more...) + for _, m := range msgs { + var date string + if m.Received != nil { + date = ` "` + m.Received.Format("_2-Jan-2006 15:04:05 -0700") + `"` + } + + // todo: use literal8 if needed, with "UTF8()" if required. + // todo: for larger messages, use a synchronizing literal. + + fmt.Fprintf(c.bw, " (%s)%s {%d+}\r\n", strings.Join(m.Flags, " "), date, m.Size) + c.xflush() + _, err := io.Copy(c, m.Data) + c.xcheckf(err, "write message data") + } + + fmt.Fprintf(c.bw, "\r\n") + c.xflush() + return c.Response() } // note: No idle command. Idle is better implemented by writing the request and reading and handling the responses as they come in. diff --git a/imapserver/authenticate_test.go b/imapserver/authenticate_test.go index 2cb26f3..fad2c23 100644 --- a/imapserver/authenticate_test.go +++ b/imapserver/authenticate_test.go @@ -342,7 +342,7 @@ func TestAuthenticateTLSClientCert(t *testing.T) { tc.client.Enable("imap4rev2") received, err := time.Parse(time.RFC3339, "2022-11-16T10:01:00+01:00") tc.check(err, "parse time") - tc.client.Append("inbox", nil, &received, []byte(exampleMsg)) + tc.client.Append("inbox", makeAppendTime(exampleMsg, received)) tc.client.Select("inbox") tc.close() diff --git a/imapserver/copy_test.go b/imapserver/copy_test.go index 2ab7966..f1d63c4 100644 --- a/imapserver/copy_test.go +++ b/imapserver/copy_test.go @@ -25,12 +25,12 @@ func TestCopy(t *testing.T) { tc.transactf("bad", "copy 1 inbox ") // Leftover. // Seqs 1,2 and UIDs 3,4. - tc.client.Append("inbox", nil, nil, []byte(exampleMsg)) - tc.client.Append("inbox", nil, nil, []byte(exampleMsg)) + tc.client.Append("inbox", makeAppend(exampleMsg)) + tc.client.Append("inbox", makeAppend(exampleMsg)) tc.client.StoreFlagsSet("1:2", true, `\Deleted`) tc.client.Expunge() - tc.client.Append("inbox", nil, nil, []byte(exampleMsg)) - tc.client.Append("inbox", nil, nil, []byte(exampleMsg)) + tc.client.Append("inbox", makeAppend(exampleMsg)) + tc.client.Append("inbox", makeAppend(exampleMsg)) tc.transactf("no", "copy 1 nonexistent") tc.xcode("TRYCREATE") diff --git a/imapserver/delete_test.go b/imapserver/delete_test.go index d8eff6e..c5924ac 100644 --- a/imapserver/delete_test.go +++ b/imapserver/delete_test.go @@ -54,7 +54,7 @@ func TestDelete(t *testing.T) { // Let's try again with a message present. tc.client.Create("msgs", nil) - tc.client.Append("msgs", nil, nil, []byte(exampleMsg)) + tc.client.Append("msgs", makeAppend(exampleMsg)) tc.transactf("ok", "delete msgs") // Delete for inbox/* is allowed. diff --git a/imapserver/expunge_test.go b/imapserver/expunge_test.go index 472579c..0ecb232 100644 --- a/imapserver/expunge_test.go +++ b/imapserver/expunge_test.go @@ -31,9 +31,9 @@ func TestExpunge(t *testing.T) { tc.client.Unselect() tc.client.Select("inbox") - tc.client.Append("inbox", nil, nil, []byte(exampleMsg)) - tc.client.Append("inbox", nil, nil, []byte(exampleMsg)) - tc.client.Append("inbox", nil, nil, []byte(exampleMsg)) + tc.client.Append("inbox", makeAppend(exampleMsg)) + tc.client.Append("inbox", makeAppend(exampleMsg)) + tc.client.Append("inbox", makeAppend(exampleMsg)) tc.transactf("ok", "expunge") // Still nothing to remove. tc.xuntagged() @@ -51,9 +51,9 @@ func TestExpunge(t *testing.T) { tc.xuntagged() // Only UID 2 is still left. We'll add 3 more. Getting us to UIDs 2,4,5,6. - tc.client.Append("inbox", nil, nil, []byte(exampleMsg)) - tc.client.Append("inbox", nil, nil, []byte(exampleMsg)) - tc.client.Append("inbox", nil, nil, []byte(exampleMsg)) + tc.client.Append("inbox", makeAppend(exampleMsg)) + tc.client.Append("inbox", makeAppend(exampleMsg)) + tc.client.Append("inbox", makeAppend(exampleMsg)) tc.transactf("bad", "uid expunge") // Missing uid set. tc.transactf("bad", "uid expunge 1 leftover") // Leftover data. diff --git a/imapserver/fetch_test.go b/imapserver/fetch_test.go index 3d1fd26..fdde292 100644 --- a/imapserver/fetch_test.go +++ b/imapserver/fetch_test.go @@ -19,7 +19,7 @@ func TestFetch(t *testing.T) { tc.client.Enable("imap4rev2") received, err := time.Parse(time.RFC3339, "2022-11-16T10:01:00+01:00") tc.check(err, "parse time") - tc.client.Append("inbox", nil, &received, []byte(exampleMsg)) + tc.client.Append("inbox", makeAppendTime(exampleMsg, received)) tc.client.Select("inbox") uid1 := imapclient.FetchUID(1) @@ -288,7 +288,7 @@ func TestFetch(t *testing.T) { Ext: &imapclient.BodyExtensionMpart{Params: [][2]string{{"boundary", "unique-boundary-1"}}}, }, } - tc.client.Append("inbox", nil, &received, []byte(nestedMessage)) + tc.client.Append("inbox", makeAppendTime(nestedMessage, received)) tc.transactf("ok", "fetch 2 bodystructure") tc.xuntagged(imapclient.UntaggedFetch{Seq: 2, Attrs: []imapclient.FetchAttr{uid2, bodystructure2}}) diff --git a/imapserver/move_test.go b/imapserver/move_test.go index 8c62619..f49fcfd 100644 --- a/imapserver/move_test.go +++ b/imapserver/move_test.go @@ -31,12 +31,12 @@ func TestMove(t *testing.T) { tc.transactf("bad", "move 1 inbox ") // Leftover. // Seqs 1,2 and UIDs 3,4. - tc.client.Append("inbox", nil, nil, []byte(exampleMsg)) - tc.client.Append("inbox", nil, nil, []byte(exampleMsg)) + tc.client.Append("inbox", makeAppend(exampleMsg)) + tc.client.Append("inbox", makeAppend(exampleMsg)) tc.client.StoreFlagsSet("1:2", true, `\Deleted`) tc.client.Expunge() - tc.client.Append("inbox", nil, nil, []byte(exampleMsg)) - tc.client.Append("inbox", nil, nil, []byte(exampleMsg)) + tc.client.Append("inbox", makeAppend(exampleMsg)) + tc.client.Append("inbox", makeAppend(exampleMsg)) tc.client.Unselect() tc.client.Examine("inbox") @@ -69,8 +69,8 @@ func TestMove(t *testing.T) { tc3.xuntagged(imapclient.UntaggedExpunge(1), imapclient.UntaggedExpunge(1)) // UIDs 5,6 - tc.client.Append("inbox", nil, nil, []byte(exampleMsg)) - tc.client.Append("inbox", nil, nil, []byte(exampleMsg)) + tc.client.Append("inbox", makeAppend(exampleMsg)) + tc.client.Append("inbox", makeAppend(exampleMsg)) tc2.transactf("ok", "noop") // Drain. tc3.transactf("ok", "noop") // Drain. diff --git a/imapserver/search_test.go b/imapserver/search_test.go index 8175038..b0dae76 100644 --- a/imapserver/search_test.go +++ b/imapserver/search_test.go @@ -67,13 +67,13 @@ func TestSearch(t *testing.T) { received := time.Date(2020, time.January, 1, 10, 0, 0, 0, time.UTC) saveDate := time.Now() for i := 0; i < 5; i++ { - tc.client.Append("inbox", nil, &received, []byte(exampleMsg)) + tc.client.Append("inbox", makeAppendTime(exampleMsg, received)) } tc.client.StoreFlagsSet("1:4", true, `\Deleted`) tc.client.Expunge() received = time.Date(2022, time.January, 1, 9, 0, 0, 0, time.UTC) - tc.client.Append("inbox", nil, &received, []byte(searchMsg)) + tc.client.Append("inbox", makeAppendTime(searchMsg, received)) received = time.Date(2022, time.January, 1, 9, 0, 0, 0, time.UTC) mostFlags := []string{ @@ -90,7 +90,7 @@ func TestSearch(t *testing.T) { `custom1`, `Custom2`, } - tc.client.Append("inbox", mostFlags, &received, []byte(searchMsg)) + tc.client.Append("inbox", imapclient.Append{Flags: mostFlags, Received: &received, Size: int64(len(searchMsg)), Data: strings.NewReader(searchMsg)}) // We now have sequence numbers 1,2,3 and UIDs 5,6,7. diff --git a/imapserver/selectexamine_test.go b/imapserver/selectexamine_test.go index 58645d6..3189940 100644 --- a/imapserver/selectexamine_test.go +++ b/imapserver/selectexamine_test.go @@ -59,7 +59,7 @@ func testSelectExamine(t *testing.T, examine bool) { tc.xcode(okcode) // Append a message. It will be reported as UNSEEN. - tc.client.Append("inbox", nil, nil, []byte(exampleMsg)) + tc.client.Append("inbox", makeAppend(exampleMsg)) tc.transactf("ok", "%s inbox", cmd) tc.xuntagged(uclosed, uflags, upermflags, urecent, uunseen, uexists1, uuidval1, uuidnext2, ulist) tc.xcode(okcode) diff --git a/imapserver/server.go b/imapserver/server.go index dd8d79b..5b72471 100644 --- a/imapserver/server.go +++ b/imapserver/server.go @@ -2340,7 +2340,7 @@ func (c *conn) cmdLogin(tag, cmd string, p *parser) { defer func() { if account != nil { err := account.Close() - c.log.Check(err, "close account") + c.xsanity(err, "close account") } }() @@ -2886,7 +2886,7 @@ func (c *conn) cmdDelete(tag, cmd string, p *parser) { for _, mID := range removeMessageIDs { p := c.account.MessagePath(mID) err := os.Remove(p) - c.log.Check(err, "removing message file for mailbox delete", slog.String("path", p)) + c.xsanity(err, "removing message file %q for mailbox delete", p) } c.ok(tag, cmd) @@ -3562,7 +3562,7 @@ func (c *conn) cmdAppend(tag, cmd string, p *parser) { for _, a := range appends { c.uidAppend(a.m.UID) } - // todo spec: with condstore/qresync, is there a mechanism to the client know the modseq for the appended uid? in theory an untagged fetch with the modseq after the OK APPENDUID could make sense, but this probably isn't allowed. + // todo spec: with condstore/qresync, is there a mechanism to let the client know the modseq for the appended uid? in theory an untagged fetch with the modseq after the OK APPENDUID could make sense, but this probably isn't allowed. c.bwritelinef("* %d EXISTS", len(c.uids)) } @@ -4382,7 +4382,7 @@ func (c *conn) cmdxMove(isUID bool, tag, cmd string, p *parser) { } } - if c.enabled[capQresync] { + if qresync { // ../rfc/9051:6744 ../rfc/7162:1334 c.writeresultf("%s OK [HIGHESTMODSEQ %d] move", tag, modseq.Client()) } else { diff --git a/imapserver/server_test.go b/imapserver/server_test.go index f0960da..62d1539 100644 --- a/imapserver/server_test.go +++ b/imapserver/server_test.go @@ -341,6 +341,14 @@ func xparseUIDRange(s string) imapclient.NumRange { return nr } +func makeAppend(msg string) imapclient.Append { + return imapclient.Append{Size: int64(len(msg)), Data: strings.NewReader(msg)} +} + +func makeAppendTime(msg string, tm time.Time) imapclient.Append { + return imapclient.Append{Received: &tm, Size: int64(len(msg)), Data: strings.NewReader(msg)} +} + var connCounter int64 func start(t *testing.T) *testconn { @@ -732,8 +740,8 @@ func TestSequence(t *testing.T) { tc.transactf("ok", "uid fetch 1 all") // non-existing messages are OK for uids. tc.transactf("ok", "uid fetch * all") // * is like uidnext, a non-existing message. - tc.client.Append("inbox", nil, nil, []byte(exampleMsg)) - tc.client.Append("inbox", nil, nil, []byte(exampleMsg)) + tc.client.Append("inbox", makeAppend(exampleMsg)) + tc.client.Append("inbox", makeAppend(exampleMsg)) tc.transactf("ok", "fetch 2:1,1 uid") // We reorder 2:1 to 1:2, but we don't deduplicate numbers. tc.xuntagged( imapclient.UntaggedFetch{Seq: 1, Attrs: []imapclient.FetchAttr{imapclient.FetchUID(1)}}, @@ -753,7 +761,7 @@ func DisabledTestReference(t *testing.T) { defer tc.close() tc.client.Login("mjl@mox.example", password0) tc.client.Select("inbox") - tc.client.Append("inbox", nil, nil, []byte(exampleMsg)) + tc.client.Append("inbox", makeAppend(exampleMsg)) tc2 := startNoSwitchboard(t) defer tc2.close() diff --git a/imapserver/store_test.go b/imapserver/store_test.go index 9144b83..b869cbb 100644 --- a/imapserver/store_test.go +++ b/imapserver/store_test.go @@ -14,7 +14,7 @@ func TestStore(t *testing.T) { tc.client.Login("mjl@mox.example", password0) tc.client.Enable("imap4rev2") - tc.client.Append("inbox", nil, nil, []byte(exampleMsg)) + tc.client.Append("inbox", makeAppend(exampleMsg)) tc.client.Select("inbox") uid1 := imapclient.FetchUID(1) diff --git a/imapserver/unselect_test.go b/imapserver/unselect_test.go index 8cb10b9..7374c2d 100644 --- a/imapserver/unselect_test.go +++ b/imapserver/unselect_test.go @@ -18,7 +18,7 @@ func TestUnselect(t *testing.T) { tc.transactf("no", "fetch 1 all") // Invalid when not selected. tc.client.Select("inbox") - tc.client.Append("inbox", nil, nil, []byte(exampleMsg)) + tc.client.Append("inbox", makeAppend(exampleMsg)) tc.client.StoreFlagsAdd("1", true, `\Deleted`) tc.transactf("ok", "unselect") tc.transactf("ok", "status inbox (messages)")