protonmail-bridge-agent-skill 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,16 +37,20 @@ npx skills add lukeramsden/protonmail-bridge-agent-skill --list
37
37
  ./pmail setup # account email + Bridge password -> Keychain
38
38
  ./pmail sync --all --full # backfill the whole archive (resumable)
39
39
  ./pmail search "invoice" # ranked full-text search, sub-second
40
+ ./pmail search "INV-2026-00" --substring # substring / attachment-name search
40
41
  ./pmail list INBOX --unseen
41
42
  ./pmail read 34655
42
43
  ./pmail save-attach 34655 1
43
44
  ```
44
45
 
45
- All commands print JSON on stdout; progress and errors go to stderr.
46
+ All commands print JSON on stdout; progress and errors go to stderr. `list` and
47
+ `search` return `{mailbox, source, count, messages: [...]}` — use `jq '.messages[]'`
48
+ for the rows (full table in `skills/protonmail-bridge/SKILL.md`).
46
49
 
47
50
  ## What the skill provides
48
51
 
49
- - **Full-archive FTS search** — SQLite FTS5 with bm25 ranking and match snippets; phrases, `OR`/`NOT`, and filters (`--from`, `--since`, `--unseen`, `--mailbox`)
52
+ - **Full-archive FTS search** — SQLite FTS5 with bm25 ranking and match snippets; phrases, `OR`/`NOT`, prefix `*`, and filters (`--from`, `--since`, `--unseen`, `--mailbox`)
53
+ - **Substring search** — `--substring` does a case-insensitive `LIKE` scan for partial tokens and attachment filenames/MIME types; metadata fields via a covering index (sub-second), bodies opt-in with `--field body`
50
54
  - **Resumable backfill** — cursor-persisted batches; interrupt and resume freely; automatic wipe/resync if Bridge's UIDVALIDITY changes
51
55
  - **Cache-first reads** — `list`, `read`, and `search` serve from the local cache with an implicit incremental sync at most once per minute; `--live` and `--no-sync` escape hatches
52
56
  - **Attachment download** — `save-attach` extracts parts to the cache's attachments directory
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "protonmail-bridge-agent-skill",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Agent skill: read-only Proton Mail access via Proton Mail Bridge IMAP with a local SQLite FTS cache (pmail CLI, Python stdlib only).",
5
5
  "license": "MIT",
6
6
  "author": "lukeramsden",
@@ -12,7 +12,8 @@ The CLI is `pmail` in this skill's directory. Run commands from the skill
12
12
  directory as `./pmail <command> ...` (or invoke it by its absolute path).
13
13
 
14
14
  All data commands print **JSON on stdout**; progress and errors go to stderr.
15
- Pipe through `jq` when you need to reshape output.
15
+ Pipe through `jq` when you need to reshape output — see [Output shapes](#output-shapes)
16
+ for which key holds the rows (`list`/`search` return a wrapper object, not an array).
16
17
 
17
18
  **If pmail fails or behaves unexpectedly, run the doctor script first:**
18
19
 
@@ -41,11 +42,33 @@ pmail mailboxes # folders + labels, message counts, cache
41
42
  pmail list [mailbox] [--limit N] [--unseen] [--live]
42
43
  pmail read <uid> [--mailbox M] [--max-chars N] [--raw]
43
44
  pmail search <words...> [--mailbox M] [--from X] [--since YYYY-MM-DD] [--unseen] [--limit N] [--live]
45
+ pmail search <text> --substring [--field subject|from|to|attachment|body|all]... # LIKE scan, see below
44
46
  pmail sync [--mailbox M | --all] [--full] [--skip-all-mail]
45
47
  pmail status # cache state per mailbox, db size
46
48
  pmail save-attach <uid> <index> [--mailbox M] # index comes from `read` output
47
49
  ```
48
50
 
51
+ ## Output shapes
52
+
53
+ Every command returns a **single JSON object** (except `mailboxes`, which is
54
+ an array). Rows live under one key; check `count` before concluding that
55
+ nothing matched — `jq '.[]'` on a wrapper object is *not* an empty result, it
56
+ is the wrong selector.
57
+
58
+ | Command | Top-level keys | Rows | `jq` |
59
+ |---|---|---|---|
60
+ | `list` | `mailbox, source, count, messages` | `messages[]` → `uid, date, from, to, subject, flags, source` | `jq '.messages[]'` |
61
+ | `search` (FTS) | `mailbox, source:"cache-fts", query, count, messages` | `messages[]` → `uid, date, from, subject, flags, snippet, rank` | `jq '.messages[]'` |
62
+ | `search --substring` | `mailbox, source:"cache-substring", query, fields, scanned, elapsed_s, count, messages` | `messages[]` → `uid, date, from, subject, flags, matched[], attachments[], [snippet]` | `jq '.messages[]'` |
63
+ | `search --live` | `mailbox, source:"live", total_hits, count, messages` | `messages[]` → `uid, date, from, to, subject, flags` | `jq '.messages[]'` |
64
+ | `read` | `mailbox, uid, date, from, to, cc, subject, flags, body_source, truncated, attachments, body, source` | one message; `attachments[]` → `index, name, mime, size` (the `index` is what `save-attach` takes) | `jq '.attachments[]'` |
65
+ | `mailboxes` | *(array)* | each → `mailbox, attributes, messages, cached` | `jq '.[]'` |
66
+ | `status` | `db_path, db_size_mb, mailboxes, attachments_saved` | `mailboxes[]` → `mailbox, cached, server_messages, backfill_complete, ...` | `jq '.mailboxes[]'` |
67
+ | `sync` | `synced` | `synced[]` → per-mailbox counts | `jq '.synced[]'` |
68
+ | `save-attach` | `saved, name, mime, size` | `saved` is the absolute path written | — |
69
+
70
+ `read --raw` is the exception: it writes the raw RFC822 bytes, not JSON.
71
+
49
72
  ## Behavior notes
50
73
 
51
74
  - **Mailboxes**: `INBOX`, `Archive`, `Sent`, `Drafts`, `Trash`, `Spam`, `Starred`,
@@ -61,9 +84,31 @@ pmail save-attach <uid> <index> [--mailbox M] # index comes from `read` output
61
84
  uncached server-side search.
62
85
  - **Search syntax**: cached search is SQLite FTS5 — plain words are AND-ed,
63
86
  `"quoted phrases"` and `OR`/`NOT` work. Results are bm25-ranked with snippets
64
- (sub-second over an ~80k-message archive). FTS matches whole tokens;
65
- `--live` IMAP search matches substrings and also scans headers, so hit counts
66
- differ slightly between the two.
87
+ (sub-second over an ~80k-message archive). FTS matches **whole tokens**: the
88
+ tokenizer splits on punctuation, so `inv-2026` finds `INV-2026-0042` (tokens
89
+ `inv`, `2026`, `0042`), but `2026-00` or `0042.pdf` will not. Use a trailing
90
+ `*` for prefix matches (`inv*`, `ramsd*`). Attachment filenames are **not**
91
+ in the FTS index. `--live` IMAP search matches substrings and also scans
92
+ headers, so hit counts differ slightly between the two.
93
+ - **Substring search** (`--substring`): a case-insensitive `LIKE` scan of the
94
+ cache for text that FTS cannot express — part of a token, a reference number
95
+ fragment, a domain fragment, or an **attachment filename / MIME type**.
96
+ By default it scans `subject`, `from`, `to` and `attachment` via a covering
97
+ index (well under a second on ~80k messages; the index is built once on first
98
+ use, ~1 minute on a multi-GB cache). Add `--field body` or `--field all` to
99
+ also scan message bodies — that reads every cached body and takes tens of
100
+ seconds on a large archive; prefer FTS (with `*` prefixes) for body text
101
+ whenever possible. Results are newest-first (not ranked); each row lists
102
+ which `matched` fields hit. `--from`, `--since`, `--unseen`, `--mailbox` and
103
+ `--limit` all apply. Not combinable with `--live`.
104
+ ```bash
105
+ pmail search 'INV-2026-00' --substring # subject/from/to/attachment name
106
+ pmail search '.xlsx' --substring --field attachment # attachments by extension
107
+ pmail search 'acme.co' --substring --field all --since 2026-01-01 # includes bodies (slow)
108
+ ```
109
+ - **Do not query `mail.db` directly** for substring searches: a raw `LIKE` over
110
+ the table reads every multi-KB row and can take minutes on a large cache, and
111
+ it bypasses the CLI's guarantees. Use `--substring` instead.
67
112
  - **Bodies**: most real-world mail is HTML-only; pmail indexes the stripped text
68
113
  of the HTML part so search covers those messages too.
69
114
  - **Read-only guarantee**: bodies are fetched with BODY.PEEK and mailboxes are
@@ -618,8 +618,12 @@ def cmd_read(args):
618
618
 
619
619
  def cmd_search(args):
620
620
  db = open_db()
621
+ if args.substring and args.live:
622
+ die("--substring scans the local cache and cannot be combined with --live")
621
623
  if not args.live:
622
624
  implicit_sync(db, args.mailbox, args.no_sync)
625
+ if args.substring:
626
+ return search_substring(db, args)
623
627
  if cached_count(db, args.mailbox) > 0:
624
628
  return search_fts(db, args)
625
629
  log("pmail: cache empty for this mailbox; falling back to --live search")
@@ -694,6 +698,117 @@ def search_fts(db, args):
694
698
  emit({"mailbox": args.mailbox, "source": "cache-fts", "query": args.query,
695
699
  "count": len(out), "messages": out})
696
700
 
701
+ # Column per --field name. `attachment` matches the JSON-encoded attachment
702
+ # list (filenames and MIME types). `body` is the slow one: a full scan of
703
+ # every cached body.
704
+ SUBSTRING_FIELDS = {
705
+ "subject": "m.subject",
706
+ "from": "m.from_addr",
707
+ "to": "m.to_addr",
708
+ "attachment": "m.attachments",
709
+ "body": "m.body_text",
710
+ }
711
+ SUBSTRING_FAST_FIELDS = ["subject", "from", "to", "attachment"]
712
+
713
+ # Covering index so a metadata-only substring scan never touches the table
714
+ # rows (which carry multi-KB bodies). Built lazily on first --substring use.
715
+ META_INDEX = """CREATE INDEX IF NOT EXISTS messages_meta ON messages
716
+ (mailbox, date, uid, from_addr, to_addr, subject, flags, attachments)"""
717
+
718
+ def ensure_meta_index(db):
719
+ if db.execute("SELECT 1 FROM sqlite_master WHERE type='index' AND name='messages_meta'"
720
+ ).fetchone():
721
+ return
722
+ log("pmail: building metadata index for substring search (one-off, may take a minute)")
723
+ db.execute(META_INDEX)
724
+ db.commit()
725
+
726
+ def like_pattern(text):
727
+ """Escape LIKE metacharacters so the user's text matches literally."""
728
+ esc = text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
729
+ return f"%{esc}%"
730
+
731
+ def substring_snippet(text, needle, width=80):
732
+ """Case-insensitive context window around the first match of needle."""
733
+ if not text:
734
+ return ""
735
+ i = text.lower().find(needle.lower())
736
+ if i < 0:
737
+ return ""
738
+ lo, hi = max(0, i - width // 2), min(len(text), i + len(needle) + width // 2)
739
+ s = ("..." if lo > 0 else "") + text[lo:hi] + ("..." if hi < len(text) else "")
740
+ return INVISIBLES_RE.sub("", re.sub(r"\s+", " ", s)).strip()
741
+
742
+ def search_substring(db, args):
743
+ """Case-insensitive substring scan (SQL LIKE) over cached columns.
744
+
745
+ Unlike FTS this matches inside tokens ("INV-2026-0042", part of an
746
+ attachment filename, a domain fragment). It cannot use the FTS index:
747
+ metadata fields scan in well under a second on an ~80k-message cache,
748
+ but `body` reads every cached body and can take tens of seconds."""
749
+ needle = args.query
750
+ if not needle:
751
+ die("--substring needs the text to look for")
752
+ fields = args.field or SUBSTRING_FAST_FIELDS
753
+ if "all" in fields:
754
+ fields = list(SUBSTRING_FIELDS)
755
+ fields = list(dict.fromkeys(fields)) # dedupe, keep order
756
+ if "body" in fields:
757
+ log(f"pmail: substring scan includes body text over "
758
+ f"{cached_count(db, args.mailbox)} cached message(s); this may take a while")
759
+ else:
760
+ ensure_meta_index(db)
761
+ pattern = like_pattern(needle)
762
+ match = " OR ".join(f"{SUBSTRING_FIELDS[f]} LIKE ? ESCAPE '\\'" for f in fields)
763
+ where, params = [f"({match})"], [pattern] * len(fields)
764
+ where.append("m.mailbox=?"); params.append(args.mailbox)
765
+ if args.sender:
766
+ where.append("m.from_addr LIKE ?"); params.append(f"%{args.sender}%")
767
+ if args.since:
768
+ try:
769
+ ts = int(datetime.fromisoformat(args.since).replace(tzinfo=timezone.utc).timestamp())
770
+ where.append("m.date >= ?"); params.append(ts)
771
+ except ValueError:
772
+ die(f"--since must be ISO date (e.g. 2026-01-01), got '{args.since}'")
773
+ if args.unseen:
774
+ where.append("m.flags NOT LIKE '%\\Seen%'")
775
+ sql = f"""
776
+ SELECT m.uid, m.date, m.from_addr, m.to_addr, m.subject, m.flags,
777
+ m.attachments, {'m.body_text' if 'body' in fields else 'NULL'}
778
+ FROM messages m
779
+ WHERE {' AND '.join(where)}
780
+ ORDER BY m.date DESC, m.uid DESC LIMIT ?"""
781
+ params.append(args.limit)
782
+ t0 = time.time()
783
+ rows = db.execute(sql, params).fetchall()
784
+ out = []
785
+ for uid, date, from_addr, to_addr, subject, flags, attachments, body in rows:
786
+ atts = json.loads(attachments or "[]")
787
+ matched = []
788
+ nl = needle.lower()
789
+ if "subject" in fields and nl in (subject or "").lower():
790
+ matched.append("subject")
791
+ if "from" in fields and nl in (from_addr or "").lower():
792
+ matched.append("from")
793
+ if "to" in fields and nl in (to_addr or "").lower():
794
+ matched.append("to")
795
+ if "attachment" in fields and any(
796
+ nl in (a.get("name", "") + " " + a.get("mime", "")).lower() for a in atts):
797
+ matched.append("attachment")
798
+ if "body" in fields and body and nl in body.lower():
799
+ matched.append("body")
800
+ row = {"uid": uid, "date": iso(date), "from": from_addr, "subject": subject,
801
+ "flags": flags, "matched": matched,
802
+ "attachments": [{"index": a.get("index"), "name": a.get("name"),
803
+ "mime": a.get("mime")} for a in atts]}
804
+ if "body" in matched:
805
+ row["snippet"] = substring_snippet(body, needle)
806
+ out.append(row)
807
+ emit({"mailbox": args.mailbox, "source": "cache-substring", "query": needle,
808
+ "fields": fields, "scanned": cached_count(db, args.mailbox),
809
+ "elapsed_s": round(time.time() - t0, 2),
810
+ "count": len(out), "messages": out})
811
+
697
812
  def cmd_sync(args):
698
813
  db = open_db()
699
814
  m = connect()
@@ -809,6 +924,14 @@ def build_parser():
809
924
  sp.add_argument("--since", help="ISO date, e.g. 2026-01-01")
810
925
  sp.add_argument("--unseen", action="store_true")
811
926
  sp.add_argument("--limit", type=int, default=DEFAULT_LIMIT)
927
+ sp.add_argument("--substring", action="store_true",
928
+ help="case-insensitive substring (LIKE) scan of the cache instead of "
929
+ "FTS; matches inside tokens, e.g. part of an attachment filename")
930
+ sp.add_argument("--field", action="append",
931
+ choices=[*SUBSTRING_FIELDS, "all"],
932
+ help="with --substring: column(s) to scan (repeatable). Default: "
933
+ "subject, from, to, attachment (fast). 'body' or 'all' scans "
934
+ "every cached body and is slow")
812
935
  sp.add_argument("--live", action="store_true")
813
936
  sp.add_argument("--no-sync", action="store_true")
814
937
  sp.set_defaults(fn=cmd_search)
@@ -838,6 +961,8 @@ def main():
838
961
  args.query = " ".join(args.words)
839
962
  if not args.live and not args.query and not (args.sender or args.subject):
840
963
  die("search needs at least one word (cached search is text-based)")
964
+ if args.field and not args.substring:
965
+ die("--field only applies with --substring")
841
966
  args.fn(args)
842
967
 
843
968
  if __name__ == "__main__":