Add OnActivity callback to CopyDeps to prevent stall timeouts during large message transfers Implement touchReader and touchWriter wrappers to call OnActivity during FETCH and APPEND operations Add slow message logging to identify performance bottlenecks Add test case to verify activity reporting during message transfers Clean up orchestrator account reset code formatting
271 lines
7.7 KiB
Go
271 lines
7.7 KiB
Go
package imapx
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/emersion/go-imap/v2"
|
|
)
|
|
|
|
// seedInbox logs in as login/pass and APPENDs n minimal messages with unique
|
|
// Message-IDs into INBOX via a dedicated connection.
|
|
func seedInbox(t *testing.T, ep Endpoint, login, pass string, n int) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
c, err := Connect(ctx, ep)
|
|
if err != nil {
|
|
t.Fatalf("seedInbox connect: %v", err)
|
|
}
|
|
defer func() { _ = c.Logout().Wait() }()
|
|
|
|
if err := c.Login(login, pass).Wait(); err != nil {
|
|
t.Fatalf("seedInbox login: %v", err)
|
|
}
|
|
|
|
for i := 0; i < n; i++ {
|
|
msg := fmt.Sprintf(
|
|
"From: sender@localhost\r\nTo: %s\r\nSubject: seed %d\r\nMessage-Id: <seed-%d-%p@localhost>\r\n\r\nBody %d\r\n",
|
|
login, i, i, &i, i,
|
|
)
|
|
buf := []byte(msg)
|
|
appendCmd := c.Append("INBOX", int64(len(buf)), nil)
|
|
if _, err := appendCmd.Write(buf); err != nil {
|
|
t.Fatalf("seedInbox write %d: %v", i, err)
|
|
}
|
|
if err := appendCmd.Close(); err != nil {
|
|
t.Fatalf("seedInbox close %d: %v", i, err)
|
|
}
|
|
if _, err := appendCmd.Wait(); err != nil {
|
|
t.Fatalf("seedInbox append %d: %v", i, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// TestCopyFolderReportsActivityDuringBody proves CopyFolder invokes OnActivity
|
|
// while a message body is being transferred (FETCH/APPEND), not only between
|
|
// messages. This is what keeps the orchestrator's stall watchdog from killing a
|
|
// single large-but-live message whose transfer legitimately exceeds the stall
|
|
// timeout: without an in-body activity signal, one slow message looks identical
|
|
// to a wedged connection.
|
|
func TestCopyFolderReportsActivityDuringBody(t *testing.T) {
|
|
ep := testEP(t)
|
|
ctx := context.Background()
|
|
|
|
seedInbox(t, ep, "actsrc@localhost", "p", 1)
|
|
|
|
src, err := Connect(ctx, ep)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = src.Logout().Wait() }()
|
|
if err := src.Login("actsrc@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("actdst@localhost", "p").Wait(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var activity int
|
|
deps := CopyDeps{
|
|
IsMigrated: func(string) (bool, error) { return false, nil },
|
|
MarkMigrated: func(_, _ string) error { return nil },
|
|
OnProgress: func(_, _ int) {},
|
|
OnActivity: func() { activity++ },
|
|
}
|
|
|
|
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)
|
|
}
|
|
if activity == 0 {
|
|
t.Fatal("OnActivity never called during body transfer")
|
|
}
|
|
}
|
|
|
|
// Требует два ящика на greenmail. Первый запуск копирует N, второй — 0 (все skipped).
|
|
func TestCopyFolderIdempotent(t *testing.T) {
|
|
ep := testEP(t) // plain greenmail
|
|
ctx := context.Background()
|
|
|
|
// подготовка: APPEND 2 письма в INBOX источника через отдельное соединение
|
|
seedInbox(t, ep, "src@localhost", "p", 2)
|
|
|
|
src, err := Connect(ctx, ep)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = src.Logout().Wait() }()
|
|
if err := src.Login("src@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("dst@localhost", "p").Wait(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
seen := map[string]bool{}
|
|
deps := CopyDeps{
|
|
IsMigrated: func(k string) (bool, error) { return seen[k], nil },
|
|
MarkMigrated: func(_, k string) error { seen[k] = true; return nil },
|
|
OnProgress: func(_, _ int) {},
|
|
}
|
|
|
|
r1, err := CopyFolder(ctx, src, dst, "INBOX", "INBOX", deps)
|
|
if err != nil {
|
|
t.Fatalf("run1: %v", err)
|
|
}
|
|
if r1.Copied != 2 {
|
|
t.Fatalf("run1 copied=%d want 2", r1.Copied)
|
|
}
|
|
|
|
r2, err := CopyFolder(ctx, src, dst, "INBOX", "INBOX", deps)
|
|
if err != nil {
|
|
t.Fatalf("run2: %v", err)
|
|
}
|
|
if r2.Copied != 0 || r2.Skipped != 2 {
|
|
t.Fatalf("run2 copied=%d skipped=%d want 0/2", r2.Copied, r2.Skipped)
|
|
}
|
|
}
|