Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions cmd/msgvault/cmd/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,185 @@ func TestSyncCmd_BrokenOAuthDoesNotBlockIMAP(t *testing.T) {
}
}

// TestSyncFullCmd_MalformedDateRejectsBeforeSync verifies that a
// malformed --after flag is rejected before any source is synced,
// even in a mixed Gmail+IMAP setup where Gmail would otherwise
// succeed first.
func TestSyncFullCmd_MalformedDateRejectsBeforeSync(t *testing.T) {
tmpDir := t.TempDir()
dbPath := tmpDir + "/msgvault.db"

s, err := store.Open(dbPath)
if err != nil {
t.Fatalf("open store: %v", err)
}
if err := s.InitSchema(); err != nil {
t.Fatalf("init schema: %v", err)
}

// Create both Gmail and IMAP sources. The Gmail source is
// made fully syncable (OAuth config + token) so that without
// the early validation it would be selected and synced before
// the IMAP source rejects the malformed date.
_, err = s.GetOrCreateSource("gmail", "g@example.com")
if err != nil {
t.Fatalf("create gmail source: %v", err)
}
_, err = s.GetOrCreateSource("imap", "i@example.com")
if err != nil {
t.Fatalf("create imap source: %v", err)
}
_ = s.Close()

// Write OAuth client secrets and a fake token so the Gmail
// source passes discovery checks (HasAnyConfig + HasToken).
secretsPath := filepath.Join(tmpDir, "client_secret.json")
if err := os.WriteFile(secretsPath, []byte(fakeClientSecrets), 0600); err != nil {
t.Fatalf("write client secrets: %v", err)
}
tokensDir := filepath.Join(tmpDir, "tokens")
if err := os.MkdirAll(tokensDir, 0700); err != nil {
t.Fatalf("create tokens dir: %v", err)
}
fakeToken := `{"access_token":"fake","token_type":"Bearer"}`
if err := os.WriteFile(filepath.Join(tokensDir, "g@example.com.json"), []byte(fakeToken), 0600); err != nil {
t.Fatalf("write fake token: %v", err)
}

savedCfg := cfg
savedLogger := logger
savedAfter := syncAfter
defer func() {
cfg = savedCfg
logger = savedLogger
syncAfter = savedAfter
}()

cfg = &config.Config{
HomeDir: tmpDir,
Data: config.DataConfig{DataDir: tmpDir},
OAuth: config.OAuthConfig{ClientSecrets: secretsPath},
}
logger = slog.New(slog.NewTextHandler(os.Stderr, nil))

syncAfter = "not-a-date"

testCmd := &cobra.Command{
Use: "sync-full [email]",
Args: cobra.MaximumNArgs(1),
RunE: syncFullCmd.RunE,
}

root := newTestRootCmd()
root.AddCommand(testCmd)
root.SetArgs([]string{"sync-full"})

getOutput := captureStdout(t)
err = root.Execute()
output := getOutput()

if err == nil {
t.Fatal("expected error for malformed date")
}
if !strings.Contains(err.Error(), "--after") {
t.Errorf("error should mention --after; got: %s", err.Error())
}
// No source should have been attempted — the date error
// must fire before source discovery, not after Gmail syncs.
if strings.Contains(output, "Starting full sync") {
t.Error("no sync should start when date flag is invalid")
}
}

// TestSyncFullCmd_MalformedIMAPDateFlagErrors verifies that malformed
// --after/--before flags produce a clear error for IMAP sources
// instead of silently syncing the entire mailbox.
func TestSyncFullCmd_MalformedIMAPDateFlagErrors(t *testing.T) {
tmpDir := t.TempDir()
dbPath := tmpDir + "/msgvault.db"

s, err := store.Open(dbPath)
if err != nil {
t.Fatalf("open store: %v", err)
}
if err := s.InitSchema(); err != nil {
t.Fatalf("init schema: %v", err)
}

src, err := s.GetOrCreateSource("imap", "i@example.com")
if err != nil {
t.Fatalf("create imap source: %v", err)
}
// Store a minimal IMAP config so buildAPIClient reaches
// the date-parsing code instead of failing on missing config.
if err := s.UpdateSourceSyncConfig(src.ID, `{"host":"localhost","port":993,"username":"i@example.com","tls":true}`); err != nil {
t.Fatalf("set sync config: %v", err)
}
_ = s.Close()

savedCfg := cfg
savedLogger := logger
savedAfter := syncAfter
savedBefore := syncBefore
defer func() {
cfg = savedCfg
logger = savedLogger
syncAfter = savedAfter
syncBefore = savedBefore
}()

cfg = &config.Config{
HomeDir: tmpDir,
Data: config.DataConfig{DataDir: tmpDir},
}
logger = slog.New(slog.NewTextHandler(os.Stderr, nil))

for _, tc := range []struct {
name string
after string
before string
errStr string
}{
{"bad after", "not-a-date", "", "--after"},
{"bad before", "", "2024/01/01", "--before"},
{"bad both", "Jan 1", "tomorrow", "--after"},
} {
t.Run(tc.name, func(t *testing.T) {
syncAfter = tc.after
syncBefore = tc.before

testCmd := &cobra.Command{
Use: "sync-full [email]",
Args: cobra.MaximumNArgs(1),
RunE: syncFullCmd.RunE,
}

root := newTestRootCmd()
root.AddCommand(testCmd)
root.SetArgs([]string{
"sync-full", "i@example.com",
})

err := root.Execute()
if err == nil {
t.Fatal("expected error for malformed date")
}
if !strings.Contains(err.Error(), tc.errStr) {
t.Errorf(
"error should mention %q; got: %s",
tc.errStr, err.Error(),
)
}
if !strings.Contains(err.Error(), "YYYY-MM-DD") {
t.Errorf(
"error should mention expected format; got: %s",
err.Error(),
)
}
})
}
}

// TestSyncCmd_GmailOnlyBrokenOAuthSurfacesError verifies that when
// only Gmail sources exist and OAuth is broken, the actual error is
// returned, not "no accounts are ready to sync".
Expand Down
38 changes: 36 additions & 2 deletions cmd/msgvault/cmd/syncfull.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@ Examples:
if syncLimit < 0 {
return fmt.Errorf("--limit must be a non-negative number")
}
if syncAfter != "" {
if _, err := time.Parse("2006-01-02", syncAfter); err != nil {
return fmt.Errorf("invalid --after date %q (expected YYYY-MM-DD): %w", syncAfter, err)
}
}
if syncBefore != "" {
if _, err := time.Parse("2006-01-02", syncBefore); err != nil {
return fmt.Errorf("invalid --before date %q (expected YYYY-MM-DD): %w", syncBefore, err)
}
}

// Open database
dbPath := cfg.DatabaseDSN()
Expand Down Expand Up @@ -221,6 +231,25 @@ func buildAPIClient(ctx context.Context, src *store.Source, getOAuthMgr func(str
var opts []imaplib.Option
opts = append(opts, imaplib.WithLogger(logger))

var since, before time.Time
if syncAfter != "" {
t, err := time.Parse("2006-01-02", syncAfter)
if err != nil {
return nil, fmt.Errorf("invalid --after date %q (expected YYYY-MM-DD): %w", syncAfter, err)
}
since = t
}
if syncBefore != "" {
t, err := time.Parse("2006-01-02", syncBefore)
if err != nil {
return nil, fmt.Errorf("invalid --before date %q (expected YYYY-MM-DD): %w", syncBefore, err)
}
before = t
}
if !since.IsZero() || !before.IsZero() {
opts = append(opts, imaplib.WithDateFilter(since, before))
}

switch imapCfg.EffectiveAuthMethod() {
case imaplib.AuthXOAuth2:
if cfg.Microsoft.ClientID == "" {
Expand Down Expand Up @@ -258,10 +287,15 @@ func runFullSync(ctx context.Context, s *store.Store, getOAuthMgr func(string) (
}
defer func() { _ = apiClient.Close() }()

// Build query from flags (Gmail only).
// Build query from flags (Gmail only; IMAP date filters are
// handled via WithDateFilter on the client).
query := buildSyncQuery()
if query != "" && src.SourceType == "imap" {
fmt.Printf("Warning: --query/--before/--after are not supported for IMAP sources and will be ignored.\n\n")
// --after/--before are handled natively by IMAP SEARCH;
// only warn about --query which has no IMAP equivalent.
if syncQuery != "" {
fmt.Printf("Warning: --query is not supported for IMAP sources and will be ignored.\n\n")
}
query = ""
}

Expand Down
23 changes: 21 additions & 2 deletions internal/imap/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strconv"
"strings"
"sync"
"time"

imap "github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
Expand All @@ -28,6 +29,14 @@ func WithTokenSource(fn func(ctx context.Context) (string, error)) Option {
return func(c *Client) { c.tokenSource = fn }
}

// WithDateFilter restricts IMAP SEARCH to messages within the given date range.
func WithDateFilter(since, before time.Time) Option {
return func(c *Client) {
c.since = since
c.before = before
}
}

// fetchChunkSize is the maximum number of UIDs per UID FETCH command.
// Large FETCH sets cause server-side timeouts on big mailboxes; chunking
// keeps each round-trip short.
Expand All @@ -54,6 +63,8 @@ type Client struct {
allMailFolder string // mailbox with \All attribute (empty if not detected)
msgIDToLabels map[string][]string // RFC822 Message-ID → mailbox memberships
seenRFC822IDs map[string]bool // dedup across All Mail + Trash/Spam
since time.Time // IMAP SINCE date filter (zero = no filter)
before time.Time // IMAP BEFORE date filter (zero = no filter)
}

// NewClient creates a new IMAP client.
Expand Down Expand Up @@ -261,8 +272,16 @@ func (c *Client) enumerateMailbox(
}
}

criteria := &imap.SearchCriteria{}
if !c.since.IsZero() {
criteria.Since = c.since
}
if !c.before.IsZero() {
criteria.Before = c.before
}

searchData, err := c.conn.UIDSearch(
&imap.SearchCriteria{},
criteria,
nil,
).Wait()
if err != nil {
Expand All @@ -278,7 +297,7 @@ func (c *Client) enumerateMailbox(
return nil, selErr
}
searchData, err = c.conn.UIDSearch(
&imap.SearchCriteria{},
criteria,
nil,
).Wait()
if err != nil {
Expand Down