fix(imapx): preserve source internal date on APPEND
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapclient"
|
||||
@@ -48,7 +49,7 @@ func CopyFolder(ctx context.Context, src, dst *imapclient.Client, srcFolder, dst
|
||||
// 1) Collect envelope+uid+size for every message (cheap pass, no bodies).
|
||||
metaSet := imap.SeqSet{imap.SeqRange{Start: 1, Stop: sel.NumMessages}}
|
||||
metas, err := src.Fetch(metaSet, &imap.FetchOptions{
|
||||
UID: true, Envelope: true, RFC822Size: true, Flags: true,
|
||||
UID: true, Envelope: true, RFC822Size: true, Flags: true, InternalDate: true,
|
||||
}).Collect()
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("fetch meta: %w", err)
|
||||
@@ -75,7 +76,7 @@ func CopyFolder(ctx context.Context, src, dst *imapclient.Client, srcFolder, dst
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := streamOne(src, dst, dstFolder, m.UID, m.Flags); err != nil {
|
||||
if err := streamOne(src, dst, dstFolder, m.UID, m.Flags, m.InternalDate); err != nil {
|
||||
res.Errors++
|
||||
continue
|
||||
}
|
||||
@@ -94,7 +95,7 @@ func CopyFolder(ctx context.Context, src, dst *imapclient.Client, srcFolder, dst
|
||||
// streamOne FETCHes BODY[] for one message and APPENDs it into dst without
|
||||
// spooling to disk. The body is buffered in RAM only for the duration of
|
||||
// this single FETCH->APPEND round trip.
|
||||
func streamOne(src, dst *imapclient.Client, dstFolder string, uid imap.UID, flags []imap.Flag) error {
|
||||
func streamOne(src, dst *imapclient.Client, dstFolder string, uid imap.UID, flags []imap.Flag, internalDate time.Time) error {
|
||||
bodySection := &imap.FetchItemBodySection{}
|
||||
fetchCmd := src.Fetch(imap.UIDSetNum(uid), &imap.FetchOptions{
|
||||
BodySection: []*imap.FetchItemBodySection{bodySection},
|
||||
@@ -126,7 +127,7 @@ func streamOne(src, dst *imapclient.Client, dstFolder string, uid imap.UID, flag
|
||||
return fmt.Errorf("empty body uid %v", uid)
|
||||
}
|
||||
|
||||
appendCmd := dst.Append(dstFolder, int64(len(body)), &imap.AppendOptions{Flags: keepFlags(flags)})
|
||||
appendCmd := dst.Append(dstFolder, int64(len(body)), &imap.AppendOptions{Flags: keepFlags(flags), Time: internalDate})
|
||||
if _, err := io.Copy(appendCmd, bytes.NewReader(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap/v2"
|
||||
)
|
||||
|
||||
// seedInbox logs in as login/pass and APPENDs n minimal messages with unique
|
||||
@@ -41,6 +44,131 @@ func seedInbox(t *testing.T, ep Endpoint, login, pass string, n int) {
|
||||
}
|
||||
}
|
||||
|
||||
// seedInboxWithDate APPENDs a single message with a given subject and a
|
||||
// KNOWN IMAP internal date (via AppendOptions.Time), so the test can assert
|
||||
// the date survives the copy instead of silently becoming "now" on dst.
|
||||
func seedInboxWithDate(t *testing.T, ep Endpoint, login, pass, subject string, when time.Time) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
c, err := Connect(ctx, ep)
|
||||
if err != nil {
|
||||
t.Fatalf("seedInboxWithDate connect: %v", err)
|
||||
}
|
||||
defer func() { _ = c.Logout().Wait() }()
|
||||
|
||||
if err := c.Login(login, pass).Wait(); err != nil {
|
||||
t.Fatalf("seedInboxWithDate login: %v", err)
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"From: sender@localhost\r\nTo: %s\r\nSubject: %s\r\nMessage-Id: <%s@localhost>\r\n\r\nBody\r\n",
|
||||
login, subject, subject,
|
||||
)
|
||||
buf := []byte(msg)
|
||||
appendCmd := c.Append("INBOX", int64(len(buf)), &imap.AppendOptions{Time: when})
|
||||
if _, err := appendCmd.Write(buf); err != nil {
|
||||
t.Fatalf("seedInboxWithDate write: %v", err)
|
||||
}
|
||||
if err := appendCmd.Close(); err != nil {
|
||||
t.Fatalf("seedInboxWithDate close: %v", err)
|
||||
}
|
||||
if _, err := appendCmd.Wait(); err != nil {
|
||||
t.Fatalf("seedInboxWithDate append: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// fetchInternalDateBySubject connects, selects INBOX and returns the
|
||||
// InternalDate of the first message whose Envelope.Subject matches.
|
||||
func fetchInternalDateBySubject(t *testing.T, ep Endpoint, login, pass, subject string) time.Time {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
c, err := Connect(ctx, ep)
|
||||
if err != nil {
|
||||
t.Fatalf("fetchInternalDateBySubject connect: %v", err)
|
||||
}
|
||||
defer func() { _ = c.Logout().Wait() }()
|
||||
|
||||
if err := c.Login(login, pass).Wait(); err != nil {
|
||||
t.Fatalf("fetchInternalDateBySubject login: %v", err)
|
||||
}
|
||||
|
||||
sel, err := c.Select("INBOX", &imap.SelectOptions{ReadOnly: true}).Wait()
|
||||
if err != nil {
|
||||
t.Fatalf("fetchInternalDateBySubject select: %v", err)
|
||||
}
|
||||
if sel.NumMessages == 0 {
|
||||
t.Fatalf("fetchInternalDateBySubject: INBOX empty")
|
||||
}
|
||||
|
||||
set := imap.SeqSet{imap.SeqRange{Start: 1, Stop: sel.NumMessages}}
|
||||
msgs, err := c.Fetch(set, &imap.FetchOptions{
|
||||
Envelope: true, InternalDate: true,
|
||||
}).Collect()
|
||||
if err != nil {
|
||||
t.Fatalf("fetchInternalDateBySubject fetch: %v", err)
|
||||
}
|
||||
|
||||
for _, m := range msgs {
|
||||
if m.Envelope != nil && m.Envelope.Subject == subject {
|
||||
return m.InternalDate
|
||||
}
|
||||
}
|
||||
t.Fatalf("fetchInternalDateBySubject: subject %q not found among %d messages", subject, len(msgs))
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// TestCopyFolderPreservesInternalDate proves CopyFolder threads the source
|
||||
// message's IMAP internal date through to APPEND on dst, instead of letting
|
||||
// dst stamp it with "now" at APPEND time.
|
||||
func TestCopyFolderPreservesInternalDate(t *testing.T) {
|
||||
ep := testEP(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const subject = "datecheck"
|
||||
knownTime := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
seedInboxWithDate(t, ep, "datesrc@localhost", "p", subject, knownTime)
|
||||
|
||||
src, err := Connect(ctx, ep)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = src.Logout().Wait() }()
|
||||
if err := src.Login("datesrc@localhost", "p").Wait(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dst, err := Connect(ctx, ep)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = dst.Logout().Wait() }()
|
||||
if err := dst.Login("datedst@localhost", "p").Wait(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
deps := CopyDeps{
|
||||
IsMigrated: func(string) (bool, error) { return false, nil },
|
||||
MarkMigrated: func(_, _ string) error { return nil },
|
||||
OnProgress: func(_, _ int) {},
|
||||
}
|
||||
|
||||
r, err := CopyFolder(ctx, src, dst, "INBOX", "INBOX", deps)
|
||||
if err != nil {
|
||||
t.Fatalf("CopyFolder: %v", err)
|
||||
}
|
||||
if r.Copied != 1 {
|
||||
t.Fatalf("copied=%d want 1", r.Copied)
|
||||
}
|
||||
|
||||
got := fetchInternalDateBySubject(t, ep, "datedst@localhost", "p", subject).UTC().Truncate(time.Second)
|
||||
want := knownTime.Truncate(time.Second)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("internal date not preserved: got %v want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Требует два ящика на greenmail. Первый запуск копирует N, второй — 0 (все skipped).
|
||||
func TestCopyFolderIdempotent(t *testing.T) {
|
||||
ep := testEP(t) // plain greenmail
|
||||
|
||||
Reference in New Issue
Block a user