From ebc3f948c19bf23bb13689d7761fc633fc320278 Mon Sep 17 00:00:00 2001 From: Andrey Oblivantsev Date: Thu, 13 Aug 2026 12:19:22 +0100 Subject: [PATCH] Add Gmail --query to mail/sync (default in:inbox) (#4) * Add --query to Gmail mail/sync instead of always listing in:inbox. Callers keep the search string; default remains in:inbox. * Document Gmail --query on the mail/sync pipeline. * test(mail): assert Gmail --query reaches ListIDs, not only the CLI flag. ParseCLI coverage left a hole: an empty query still has to become in:inbox and a custom q has to be the string the client lists with. --- AGENTS.md | 1 + bin/mail/sync/cli.go | 4 ++- bin/mail/sync/sync.go | 21 +++++++++--- bin/mail/sync/sync_test.go | 65 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d8ab87a..eae138d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,7 @@ var/ kb.lbug, var/mail/*, caches (gitignored) ```bash bin/mail/sync.go --source onlyoffice,gmail --workers 8 --out var/mail # raw message.json + attachments +bin/mail/sync.go --source gmail --query 'from:example.com' --out var/mail # Gmail search (default in:inbox) bin/mail/import --from-raw var/mail # message.json → message.md (convert only) bin/mail/index_mail # rebuild brain incl. all mail (fresh DB) ``` diff --git a/bin/mail/sync/cli.go b/bin/mail/sync/cli.go index fab2022..5bfad52 100644 --- a/bin/mail/sync/cli.go +++ b/bin/mail/sync/cli.go @@ -32,6 +32,7 @@ func ParseCLI(args []string) (CLIConfig, int, error) { offset = fs.Int("offset", 0, "skip first N messages per source") force = fs.Bool("force", false, "overwrite existing message.json + attachments") dryRun = fs.Bool("dry-run", false, "list message counts without writing") + query = fs.String("query", "in:inbox", "Gmail search query (gmail source only)") srcs = fs.String("source", "onlyoffice", "comma list: onlyoffice,gmail (default onlyoffice)") help = fs.Bool("help", false, "usage") ) @@ -60,6 +61,7 @@ func ParseCLI(args []string) (CLIConfig, int, error) { Offset: *offset, Force: *force, DryRun: *dryRun, + Query: *query, Policy: RetryPolicy{}, } cli := CLIConfig{Sync: cfg, Env: *env, Sources: *srcs} @@ -95,7 +97,7 @@ func Main(args []string) int { return code } if cli.Help { - fmt.Fprintln(os.Stderr, "usage: bin/mail/sync.go [--source onlyoffice,gmail] [--limit N] [--offset N] [--workers N] [--force] [--dry-run]") + fmt.Fprintln(os.Stderr, "usage: bin/mail/sync.go [--source onlyoffice,gmail] [--query GMAIL_Q] [--limit N] [--offset N] [--workers N] [--force] [--dry-run]") return 0 } ctx, cancel := context.WithTimeout(context.Background(), 6*time.Hour) diff --git a/bin/mail/sync/sync.go b/bin/mail/sync/sync.go index 5c4b58e..2311278 100644 --- a/bin/mail/sync/sync.go +++ b/bin/mail/sync/sync.go @@ -112,6 +112,7 @@ type SyncConfig struct { Offset int // skip first N messages per source Force bool // overwrite existing message.json + attachments DryRun bool // list without writing + Query string // Gmail search query; default in:inbox Policy RetryPolicy } @@ -136,9 +137,17 @@ type ooSource struct { c *OOClient page int } +// gmailAPI is the Gmail client surface gmailSource needs. *GmailClient implements it. +type gmailAPI interface { + ListIDs(ctx context.Context, q string, maxIDs int, pageToken string) ([]string, string, error) + GetMessage(ctx context.Context, id string) (*Message, error) + DownloadAttachment(ctx context.Context, msgID, attID string) ([]byte, error) +} + type gmailSource struct { - c *GmailClient - cur string + c gmailAPI + cur string + query string } func (s *ooSource) Folder() string { return "inbox" } @@ -171,7 +180,11 @@ func (s *ooSource) DownloadAttachment(ctx context.Context, msg *Message, att Att } func (s *gmailSource) ListIDs(ctx context.Context, limit int, cursor string) ([]string, string, error) { - ids, next, err := s.c.ListIDs(ctx, "in:inbox", limit, cursor) + q := s.query + if q == "" { + q = "in:inbox" + } + ids, next, err := s.c.ListIDs(ctx, q, limit, cursor) return ids, next, err } @@ -207,7 +220,7 @@ func Run(ctx context.Context, cfg SyncConfig) (*SyncStats, error) { if err != nil { return nil, fmt.Errorf("gmail init: %w", err) } - sources = append(sources, &gmailSource{c: gm}) + sources = append(sources, &gmailSource{c: gm, query: cfg.Query}) } if len(sources) == 0 { return nil, errors.New("sync: no source configured (need OO, Gmail, or both)") diff --git a/bin/mail/sync/sync_test.go b/bin/mail/sync/sync_test.go index 3eb789d..786db16 100644 --- a/bin/mail/sync/sync_test.go +++ b/bin/mail/sync/sync_test.go @@ -221,6 +221,71 @@ func TestCollectParts(t *testing.T) { } } +type fakeGmailAPI struct { + lastQ string + lastLimit int + ids []string +} + +func (f *fakeGmailAPI) ListIDs(_ context.Context, q string, maxIDs int, _ string) ([]string, string, error) { + f.lastQ = q + f.lastLimit = maxIDs + return f.ids, "", nil +} +func (f *fakeGmailAPI) GetMessage(context.Context, string) (*Message, error) { + return nil, errors.New("unused") +} +func (f *fakeGmailAPI) DownloadAttachment(context.Context, string, string) ([]byte, error) { + return nil, errors.New("unused") +} + +func TestGmailSourcePassesQueryToListIDs(t *testing.T) { + fake := &fakeGmailAPI{ids: []string{"m1"}} + src := &gmailSource{c: fake, query: "from:alice@example.com"} + ids, _, err := src.ListIDs(context.Background(), 10, "") + if err != nil { + t.Fatal(err) + } + if fake.lastQ != "from:alice@example.com" { + t.Fatalf("ListIDs q=%q, want from:alice@example.com", fake.lastQ) + } + if fake.lastLimit != 10 { + t.Fatalf("ListIDs limit=%d, want 10", fake.lastLimit) + } + if len(ids) != 1 || ids[0] != "m1" { + t.Fatalf("ids=%v", ids) + } +} + +func TestGmailSourceEmptyQueryDefaultsToInbox(t *testing.T) { + fake := &fakeGmailAPI{} + src := &gmailSource{c: fake, query: ""} + if _, _, err := src.ListIDs(context.Background(), 5, ""); err != nil { + t.Fatal(err) + } + if fake.lastQ != "in:inbox" { + t.Fatalf("empty query q=%q, want in:inbox", fake.lastQ) + } +} + +func TestParseCLIGmailQuery(t *testing.T) { + cli, code, err := ParseCLI([]string{ + "--source", "gmail", + "--query", "from:letrado@example.com", + "--out", t.TempDir(), + "--dry-run", + }) + if err != nil || code != 0 { + t.Fatalf("ParseCLI: code=%d err=%v", code, err) + } + if cli.Sync.Query != "from:letrado@example.com" { + t.Fatalf("query=%q", cli.Sync.Query) + } + if cli.Sync.Gmail == nil { + t.Fatal("gmail source not configured") + } +} + func b64(s string) string { return base64.URLEncoding.EncodeToString([]byte(s)) }