diff --git a/internal/imapx/copy.go b/internal/imapx/copy.go index c43a4bb..46ac07f 100644 --- a/internal/imapx/copy.go +++ b/internal/imapx/copy.go @@ -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 } diff --git a/internal/imapx/copy_test.go b/internal/imapx/copy_test.go index f6c1dbe..6c73cee 100644 --- a/internal/imapx/copy_test.go +++ b/internal/imapx/copy_test.go @@ -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