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
|
|
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
|
-
|
|
66
|
-
|
|
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
|
|
Binary file
|
|
Binary file
|
|
@@ -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__":
|