notmuch-browser 0.2.0rc1__py3-none-any.whl
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.
- notmuch_browser/__init__.py +3 -0
- notmuch_browser/__main__.py +67 -0
- notmuch_browser/app.py +320 -0
- notmuch_browser/notmuch.py +269 -0
- notmuch_browser/render.py +201 -0
- notmuch_browser/static/assets/boot.72d11448.js +1 -0
- notmuch_browser/static/assets/main.26bc01b9.css +1319 -0
- notmuch_browser/static/assets/main.684e6e09.js +1 -0
- notmuch_browser/static/index.html +1 -0
- notmuch_browser-0.2.0rc1.dist-info/METADATA +106 -0
- notmuch_browser-0.2.0rc1.dist-info/RECORD +14 -0
- notmuch_browser-0.2.0rc1.dist-info/WHEEL +4 -0
- notmuch_browser-0.2.0rc1.dist-info/entry_points.txt +2 -0
- notmuch_browser-0.2.0rc1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Command line entry point: `notmuch-browser`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
import webbrowser
|
|
10
|
+
|
|
11
|
+
from aiohttp import web
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .app import create_app, default_static_dir
|
|
15
|
+
from .notmuch import Notmuch, NotmuchError
|
|
16
|
+
|
|
17
|
+
HOST = "127.0.0.1" # the server never binds anything else
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
21
|
+
parser = argparse.ArgumentParser(
|
|
22
|
+
prog="notmuch-browser",
|
|
23
|
+
description="Browse and tag a notmuch database in a local web UI.",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument("--port", type=int, default=8765, help="port on 127.0.0.1 (default 8765)")
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--notmuch-config",
|
|
28
|
+
metavar="FILE",
|
|
29
|
+
help="notmuch configuration file (default: the usual notmuch lookup)",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument("--no-browser", action="store_true", help="do not open a browser window")
|
|
32
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
33
|
+
return parser.parse_args(argv)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main(argv: list[str] | None = None) -> int:
|
|
37
|
+
args = parse_args(argv)
|
|
38
|
+
if shutil.which("notmuch") is None:
|
|
39
|
+
print("error: the notmuch command was not found on PATH", file=sys.stderr)
|
|
40
|
+
return 1
|
|
41
|
+
nm = Notmuch(config=args.notmuch_config)
|
|
42
|
+
try:
|
|
43
|
+
version = asyncio.run(nm.check_version())
|
|
44
|
+
except NotmuchError as exc:
|
|
45
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
46
|
+
return 1
|
|
47
|
+
if not (default_static_dir() / "index.html").is_file():
|
|
48
|
+
print("warning: the UI bundle is missing, run `npm ci && npm run build`", file=sys.stderr)
|
|
49
|
+
url = f"http://{HOST}:{args.port}/"
|
|
50
|
+
app = create_app(nm)
|
|
51
|
+
|
|
52
|
+
async def announce(_app: web.Application) -> None:
|
|
53
|
+
print(f"notmuch {'.'.join(map(str, version))}, serving on {url} (Ctrl+C to stop)", flush=True)
|
|
54
|
+
if not args.no_browser:
|
|
55
|
+
webbrowser.open(url)
|
|
56
|
+
|
|
57
|
+
app.on_startup.append(announce)
|
|
58
|
+
try:
|
|
59
|
+
web.run_app(app, host=HOST, port=args.port, print=None)
|
|
60
|
+
except OSError as exc:
|
|
61
|
+
print(f"error: cannot listen on {HOST}:{args.port}: {exc.strerror}", file=sys.stderr)
|
|
62
|
+
return 1
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
sys.exit(main())
|
notmuch_browser/app.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""The aiohttp application: a small REST API over notmuch plus the bundled UI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from importlib import resources
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
|
|
11
|
+
from aiohttp import web
|
|
12
|
+
|
|
13
|
+
from . import render
|
|
14
|
+
from .notmuch import (
|
|
15
|
+
BadQuery,
|
|
16
|
+
DatabaseBusy,
|
|
17
|
+
NotFound,
|
|
18
|
+
Notmuch,
|
|
19
|
+
NotmuchError,
|
|
20
|
+
message_query,
|
|
21
|
+
thread_query,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
PAGE_SIZE = 50
|
|
25
|
+
COUNTS_TTL = 30.0
|
|
26
|
+
MAX_BATCH = 1000
|
|
27
|
+
MAX_TAGS_PER_REQUEST = 20
|
|
28
|
+
NM = web.AppKey("nm", Notmuch)
|
|
29
|
+
STATIC = web.AppKey("static", Path)
|
|
30
|
+
COUNTS = web.AppKey("counts", dict)
|
|
31
|
+
|
|
32
|
+
APP_CSP = (
|
|
33
|
+
"default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
|
|
34
|
+
"connect-src 'self'; frame-src 'self'; base-uri 'none'; form-action 'none'; "
|
|
35
|
+
"frame-ancestors 'self'"
|
|
36
|
+
)
|
|
37
|
+
ATTACHMENT_CSP = "default-src 'none'; sandbox"
|
|
38
|
+
# Types a browser could execute if it ever rendered them. They are downloaded as opaque bytes.
|
|
39
|
+
RISKY_TYPES = ("text/html", "application/xhtml+xml", "image/svg+xml", "text/xml", "application/xml")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def default_static_dir() -> Path:
|
|
43
|
+
return Path(str(resources.files("notmuch_browser") / "static"))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ------------------------------------------------------------------ middleware
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def listening_port(request: web.Request) -> int | None:
|
|
50
|
+
transport = request.transport
|
|
51
|
+
sockname = transport.get_extra_info("sockname") if transport else None
|
|
52
|
+
return sockname[1] if sockname else None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def forbidden(reason: str) -> web.Response:
|
|
56
|
+
return web.json_response({"error": reason, "kind": "forbidden"}, status=403)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_inline_image(request: web.Request) -> bool:
|
|
60
|
+
"""The one cross-site request the app serves itself.
|
|
61
|
+
|
|
62
|
+
HTML mail is shown in a sandboxed frame without allow-same-origin, so the browser
|
|
63
|
+
treats that frame as a foreign origin and labels its inline `cid:` image requests
|
|
64
|
+
cross-site. They are plain image loads of a message part, which a foreign page could
|
|
65
|
+
only use to learn that a part exists and how large the image is, and only if it
|
|
66
|
+
already knew the message id. Everything else cross-site stays refused.
|
|
67
|
+
"""
|
|
68
|
+
return (
|
|
69
|
+
request.method == "GET"
|
|
70
|
+
and request.headers.get("Sec-Fetch-Dest") == "image"
|
|
71
|
+
and request.path.startswith("/api/message/")
|
|
72
|
+
and "/part/" in request.path
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@web.middleware
|
|
77
|
+
async def security(request: web.Request, handler):
|
|
78
|
+
port = listening_port(request)
|
|
79
|
+
host = request.headers.get("Host", "")
|
|
80
|
+
if port is None or host not in (f"127.0.0.1:{port}", f"localhost:{port}"):
|
|
81
|
+
return forbidden("unexpected Host header")
|
|
82
|
+
if request.headers.get("Sec-Fetch-Site") == "cross-site" and not is_inline_image(request):
|
|
83
|
+
return forbidden("cross-site request")
|
|
84
|
+
if request.method not in ("GET", "HEAD"):
|
|
85
|
+
if request.headers.get("Origin") != f"http://{host}":
|
|
86
|
+
return forbidden("unexpected Origin header")
|
|
87
|
+
if request.content_type != "application/json":
|
|
88
|
+
return web.json_response(
|
|
89
|
+
{"error": "content type must be application/json", "kind": "bad_request"},
|
|
90
|
+
status=415,
|
|
91
|
+
)
|
|
92
|
+
try:
|
|
93
|
+
response = await handler(request)
|
|
94
|
+
except web.HTTPException:
|
|
95
|
+
raise
|
|
96
|
+
except BadQuery as exc:
|
|
97
|
+
return web.json_response({"error": str(exc), "kind": "bad_request"}, status=400)
|
|
98
|
+
except NotFound as exc:
|
|
99
|
+
return web.json_response({"error": str(exc), "kind": "not_found"}, status=404)
|
|
100
|
+
except DatabaseBusy as exc:
|
|
101
|
+
return web.json_response(
|
|
102
|
+
{"error": str(exc), "kind": "busy"}, status=503, headers={"Retry-After": "5"}
|
|
103
|
+
)
|
|
104
|
+
except NotmuchError as exc:
|
|
105
|
+
return web.json_response({"error": str(exc), "kind": "error"}, status=500)
|
|
106
|
+
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
|
107
|
+
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
|
108
|
+
response.headers.setdefault("Content-Security-Policy", APP_CSP)
|
|
109
|
+
if request.path.startswith("/api/"):
|
|
110
|
+
response.headers.setdefault("Cache-Control", "no-store")
|
|
111
|
+
return response
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# --------------------------------------------------------------------- helpers
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def int_param(request: web.Request, name: str, default: int, low: int, high: int) -> int:
|
|
118
|
+
raw = request.query.get(name)
|
|
119
|
+
if raw is None:
|
|
120
|
+
return default
|
|
121
|
+
try:
|
|
122
|
+
value = int(raw)
|
|
123
|
+
except ValueError:
|
|
124
|
+
raise BadQuery(f"{name} must be an integer") from None
|
|
125
|
+
if not low <= value <= high:
|
|
126
|
+
raise BadQuery(f"{name} must be between {low} and {high}")
|
|
127
|
+
return value
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def read_json(request: web.Request) -> dict:
|
|
131
|
+
try:
|
|
132
|
+
body = await request.json()
|
|
133
|
+
except ValueError:
|
|
134
|
+
raise BadQuery("request body is not valid JSON") from None
|
|
135
|
+
if not isinstance(body, dict):
|
|
136
|
+
raise BadQuery("request body must be a JSON object")
|
|
137
|
+
return body
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def tag_list(body: dict, key: str) -> list[str]:
|
|
141
|
+
value = body.get(key, [])
|
|
142
|
+
if not isinstance(value, list) or len(value) > MAX_TAGS_PER_REQUEST:
|
|
143
|
+
raise BadQuery(f"{key} must be a short list of tag names")
|
|
144
|
+
if not all(isinstance(t, str) for t in value):
|
|
145
|
+
raise BadQuery(f"{key} must contain strings")
|
|
146
|
+
return value
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def invalidate_counts(app: web.Application) -> None:
|
|
150
|
+
app[COUNTS].clear()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ------------------------------------------------------------------- handlers
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
async def api_search(request: web.Request) -> web.Response:
|
|
157
|
+
nm = request.app[NM]
|
|
158
|
+
query = request.query.get("q", "").strip() or "*"
|
|
159
|
+
offset = int_param(request, "offset", 0, 0, 10_000_000)
|
|
160
|
+
limit = int_param(request, "limit", PAGE_SIZE, 1, PAGE_SIZE)
|
|
161
|
+
rows = await nm.search(query, offset, limit + 1)
|
|
162
|
+
keep = ("thread", "timestamp", "date_relative", "matched", "total", "authors", "subject", "tags")
|
|
163
|
+
threads = [{k: row.get(k) for k in keep} for row in rows[:limit]]
|
|
164
|
+
return web.json_response(
|
|
165
|
+
{"threads": threads, "offset": offset, "limit": limit, "has_more": len(rows) > limit}
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def api_count(request: web.Request) -> web.Response:
|
|
170
|
+
query = request.query.get("q", "").strip() or "*"
|
|
171
|
+
unit = request.query.get("unit", "messages")
|
|
172
|
+
count = await request.app[NM].count(query, unit)
|
|
173
|
+
return web.json_response({"count": count, "unit": unit})
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def api_tags(request: web.Request) -> web.Response:
|
|
177
|
+
return web.json_response({"tags": await request.app[NM].tags()})
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def api_tag_counts(request: web.Request) -> web.Response:
|
|
181
|
+
cache = request.app[COUNTS]
|
|
182
|
+
async with cache.setdefault("lock", asyncio.Lock()):
|
|
183
|
+
fresh = cache.get("at") is not None and time.monotonic() - cache["at"] < COUNTS_TTL
|
|
184
|
+
if not fresh:
|
|
185
|
+
nm = request.app[NM]
|
|
186
|
+
counts = await nm.unread_counts(await nm.tags())
|
|
187
|
+
cache["counts"], cache["at"] = counts, time.monotonic()
|
|
188
|
+
return web.json_response({"counts": cache["counts"], "unit": "unread threads"})
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async def api_thread(request: web.Request) -> web.Response:
|
|
192
|
+
thread_id = request.match_info["id"]
|
|
193
|
+
nested = await request.app[NM].show_thread(thread_id)
|
|
194
|
+
return web.json_response(render.thread_view(thread_id, nested))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def changed_payload(changed: dict) -> web.Response:
|
|
198
|
+
return web.json_response({"changed": changed})
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
async def api_thread_tags(request: web.Request) -> web.Response:
|
|
202
|
+
body = await read_json(request)
|
|
203
|
+
changed = await request.app[NM].apply_tag_changes(
|
|
204
|
+
thread_query(request.match_info["id"]), tag_list(body, "add"), tag_list(body, "remove")
|
|
205
|
+
)
|
|
206
|
+
invalidate_counts(request.app)
|
|
207
|
+
return changed_payload(changed)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def api_message_tags(request: web.Request) -> web.Response:
|
|
211
|
+
body = await read_json(request)
|
|
212
|
+
changed = await request.app[NM].apply_tag_changes(
|
|
213
|
+
message_query(request.match_info["id"]), tag_list(body, "add"), tag_list(body, "remove")
|
|
214
|
+
)
|
|
215
|
+
invalidate_counts(request.app)
|
|
216
|
+
return changed_payload(changed)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
async def api_messages_tags(request: web.Request) -> web.Response:
|
|
220
|
+
body = await read_json(request)
|
|
221
|
+
changes = body.get("changes")
|
|
222
|
+
if not isinstance(changes, list) or not changes or len(changes) > MAX_BATCH:
|
|
223
|
+
raise BadQuery(f"changes must be a list of 1 to {MAX_BATCH} entries")
|
|
224
|
+
entries = []
|
|
225
|
+
for change in changes:
|
|
226
|
+
if not isinstance(change, dict) or not isinstance(change.get("id"), str):
|
|
227
|
+
raise BadQuery("each change needs a message id")
|
|
228
|
+
entries.append(
|
|
229
|
+
{"id": change["id"], "add": tag_list(change, "add"), "remove": tag_list(change, "remove")}
|
|
230
|
+
)
|
|
231
|
+
await request.app[NM].tag_batch(entries)
|
|
232
|
+
invalidate_counts(request.app)
|
|
233
|
+
return web.json_response({"applied": len(entries)})
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def find_part(message: dict, part: int) -> dict:
|
|
237
|
+
for candidate in render.iter_parts(message.get("body", [])):
|
|
238
|
+
if candidate.get("id") == part:
|
|
239
|
+
return candidate
|
|
240
|
+
raise NotFound("no such part")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def attachment_filename(part: dict, number: int) -> str:
|
|
244
|
+
name = part.get("filename") or f"part-{number}"
|
|
245
|
+
return name.replace("\\", "_").replace("/", "_").replace("\r", " ").replace("\n", " ")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
async def api_part(request: web.Request) -> web.Response:
|
|
249
|
+
nm = request.app[NM]
|
|
250
|
+
message_id = request.match_info["id"]
|
|
251
|
+
try:
|
|
252
|
+
number = int(request.match_info["part"])
|
|
253
|
+
except ValueError:
|
|
254
|
+
raise BadQuery("invalid part number") from None
|
|
255
|
+
message = await nm.show_message(message_id)
|
|
256
|
+
part = find_part(message, number)
|
|
257
|
+
data = await nm.get_part(message_id, number)
|
|
258
|
+
ctype = part.get("content-type", "application/octet-stream").split(";")[0].strip().lower()
|
|
259
|
+
if ctype.startswith(RISKY_TYPES) or "/" not in ctype:
|
|
260
|
+
ctype = "application/octet-stream"
|
|
261
|
+
name = attachment_filename(part, number)
|
|
262
|
+
return web.Response(
|
|
263
|
+
body=data,
|
|
264
|
+
content_type=ctype,
|
|
265
|
+
headers={
|
|
266
|
+
"Content-Disposition": f"attachment; filename*=UTF-8''{quote(name, safe='')}",
|
|
267
|
+
"Content-Security-Policy": ATTACHMENT_CSP,
|
|
268
|
+
},
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
async def api_html(request: web.Request) -> web.Response:
|
|
273
|
+
message = await request.app[NM].show_message(request.match_info["id"])
|
|
274
|
+
document = render.html_document_for(message)
|
|
275
|
+
if document is None:
|
|
276
|
+
raise NotFound("message has no HTML part")
|
|
277
|
+
origin = f"http://{request.headers['Host']}"
|
|
278
|
+
csp = (
|
|
279
|
+
f"default-src 'none'; img-src {origin}; style-src 'unsafe-inline'; "
|
|
280
|
+
"base-uri 'none'; form-action 'none'; frame-ancestors 'self'"
|
|
281
|
+
)
|
|
282
|
+
return web.Response(
|
|
283
|
+
text=document, content_type="text/html", headers={"Content-Security-Policy": csp}
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
async def index(request: web.Request) -> web.StreamResponse:
|
|
288
|
+
page = request.app[STATIC] / "index.html"
|
|
289
|
+
if not page.is_file():
|
|
290
|
+
return web.Response(
|
|
291
|
+
status=503,
|
|
292
|
+
text="The UI bundle is missing. Run `npm ci && npm run build` and restart.",
|
|
293
|
+
)
|
|
294
|
+
return web.FileResponse(page, headers={"Cache-Control": "no-cache"})
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ------------------------------------------------------------------------ app
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def create_app(nm: Notmuch, static_dir: Path | None = None) -> web.Application:
|
|
301
|
+
app = web.Application(middlewares=[security])
|
|
302
|
+
app[NM] = nm
|
|
303
|
+
app[STATIC] = static_dir or default_static_dir()
|
|
304
|
+
app[COUNTS] = {}
|
|
305
|
+
app.router.add_get("/api/search", api_search)
|
|
306
|
+
app.router.add_get("/api/count", api_count)
|
|
307
|
+
app.router.add_get("/api/tags", api_tags)
|
|
308
|
+
app.router.add_get("/api/tags/counts", api_tag_counts)
|
|
309
|
+
app.router.add_get("/api/thread/{id}", api_thread)
|
|
310
|
+
app.router.add_post("/api/thread/{id}/tags", api_thread_tags)
|
|
311
|
+
app.router.add_post("/api/message/{id}/tags", api_message_tags)
|
|
312
|
+
app.router.add_post("/api/messages/tags", api_messages_tags)
|
|
313
|
+
app.router.add_get(r"/api/message/{id}/part/{part}", api_part)
|
|
314
|
+
app.router.add_get("/api/message/{id}/html", api_html)
|
|
315
|
+
app.router.add_get("/", index)
|
|
316
|
+
assets = app[STATIC] / "assets"
|
|
317
|
+
if assets.is_dir():
|
|
318
|
+
app.router.add_static("/assets", assets, follow_symlinks=False)
|
|
319
|
+
return app
|
|
320
|
+
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Async wrapper over the notmuch command line.
|
|
2
|
+
|
|
3
|
+
Every call spawns `notmuch` with an argument list, never through a shell. User
|
|
4
|
+
supplied queries always come after a `--` separator. Tag names and identifiers
|
|
5
|
+
are validated before they reach an argument list or a batch line.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
MIN_VERSION = (0, 36) # developed and tested against 0.40
|
|
17
|
+
READ_TIMEOUT = 60.0
|
|
18
|
+
# A running `notmuch new` or another writer holds the Xapian lock and notmuch
|
|
19
|
+
# then waits for it instead of failing. A short write timeout turns that wait
|
|
20
|
+
# into a clear "database busy" error.
|
|
21
|
+
WRITE_TIMEOUT = 15.0
|
|
22
|
+
|
|
23
|
+
TAG_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.@:/=-]{0,63}$")
|
|
24
|
+
THREAD_RE = re.compile(r"^[0-9a-f]{1,32}$")
|
|
25
|
+
_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NotmuchError(Exception):
|
|
29
|
+
"""A notmuch command failed."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DatabaseBusy(NotmuchError):
|
|
33
|
+
"""The notmuch database is locked by another process."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BadQuery(NotmuchError):
|
|
37
|
+
"""The query or an argument is not valid."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class NotFound(NotmuchError):
|
|
41
|
+
"""The thread, message or part does not exist."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def validate_tag(tag: str) -> str:
|
|
45
|
+
if not isinstance(tag, str) or not TAG_RE.match(tag):
|
|
46
|
+
raise BadQuery(f"invalid tag name: {tag!r}")
|
|
47
|
+
return tag
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def thread_query(thread_id: str) -> str:
|
|
51
|
+
if not isinstance(thread_id, str) or not THREAD_RE.match(thread_id):
|
|
52
|
+
raise BadQuery("invalid thread id")
|
|
53
|
+
return f"thread:{thread_id}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def quote_term(value: str) -> str:
|
|
57
|
+
"""Quote a value with the Xapian boolean term rules (double quotes doubled)."""
|
|
58
|
+
if not isinstance(value, str) or not value or _CONTROL_RE.search(value):
|
|
59
|
+
raise BadQuery("invalid identifier")
|
|
60
|
+
return '"' + value.replace('"', '""') + '"'
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def message_query(message_id: str) -> str:
|
|
64
|
+
return "id:" + quote_term(message_id)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def tag_query(tag: str) -> str:
|
|
68
|
+
return "tag:" + quote_term(tag)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class Notmuch:
|
|
73
|
+
config: str | None = None
|
|
74
|
+
binary: str = "notmuch"
|
|
75
|
+
read_timeout: float = READ_TIMEOUT
|
|
76
|
+
write_timeout: float = WRITE_TIMEOUT
|
|
77
|
+
max_processes: int = 4
|
|
78
|
+
_sem: asyncio.Semaphore = field(init=False, repr=False)
|
|
79
|
+
|
|
80
|
+
def __post_init__(self) -> None:
|
|
81
|
+
self._sem = asyncio.Semaphore(self.max_processes)
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------ core
|
|
84
|
+
|
|
85
|
+
def _env(self) -> dict[str, str]:
|
|
86
|
+
env = dict(os.environ)
|
|
87
|
+
if self.config:
|
|
88
|
+
env["NOTMUCH_CONFIG"] = self.config
|
|
89
|
+
return env
|
|
90
|
+
|
|
91
|
+
async def _run(
|
|
92
|
+
self, *args: str, stdin: bytes | None = None, timeout: float | None = None
|
|
93
|
+
) -> bytes:
|
|
94
|
+
timeout = self.read_timeout if timeout is None else timeout
|
|
95
|
+
async with self._sem:
|
|
96
|
+
proc = await asyncio.create_subprocess_exec(
|
|
97
|
+
self.binary,
|
|
98
|
+
*args,
|
|
99
|
+
stdin=asyncio.subprocess.PIPE if stdin is not None else asyncio.subprocess.DEVNULL,
|
|
100
|
+
stdout=asyncio.subprocess.PIPE,
|
|
101
|
+
stderr=asyncio.subprocess.PIPE,
|
|
102
|
+
env=self._env(),
|
|
103
|
+
)
|
|
104
|
+
try:
|
|
105
|
+
out, err = await asyncio.wait_for(proc.communicate(stdin), timeout)
|
|
106
|
+
except asyncio.TimeoutError:
|
|
107
|
+
proc.kill()
|
|
108
|
+
await proc.wait()
|
|
109
|
+
raise DatabaseBusy(
|
|
110
|
+
"notmuch did not answer in time; the database may be locked by "
|
|
111
|
+
"another process such as `notmuch new`"
|
|
112
|
+
) from None
|
|
113
|
+
if proc.returncode != 0:
|
|
114
|
+
message = err.decode("utf-8", "replace").strip() or f"exit status {proc.returncode}"
|
|
115
|
+
lowered = message.lower()
|
|
116
|
+
if "lock" in lowered:
|
|
117
|
+
raise DatabaseBusy(message)
|
|
118
|
+
if "parsing query" in lowered:
|
|
119
|
+
raise BadQuery(message)
|
|
120
|
+
raise NotmuchError(message)
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
async def _json(self, *args: str, timeout: float | None = None):
|
|
124
|
+
out = await self._run(*args, timeout=timeout)
|
|
125
|
+
try:
|
|
126
|
+
return json.loads(out) if out.strip() else []
|
|
127
|
+
except json.JSONDecodeError as exc:
|
|
128
|
+
raise NotmuchError(f"unexpected notmuch output: {exc}") from exc
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------- version
|
|
131
|
+
|
|
132
|
+
async def version(self) -> tuple[int, ...]:
|
|
133
|
+
out = (await self._run("--version")).decode().strip()
|
|
134
|
+
match = re.search(r"(\d+)\.(\d+)", out)
|
|
135
|
+
if not match:
|
|
136
|
+
raise NotmuchError(f"cannot read the notmuch version from {out!r}")
|
|
137
|
+
return int(match.group(1)), int(match.group(2))
|
|
138
|
+
|
|
139
|
+
async def check_version(self) -> tuple[int, ...]:
|
|
140
|
+
found = await self.version()
|
|
141
|
+
if found < MIN_VERSION:
|
|
142
|
+
need = ".".join(map(str, MIN_VERSION))
|
|
143
|
+
have = ".".join(map(str, found))
|
|
144
|
+
raise NotmuchError(f"notmuch {have} is too old, {need} or newer is required")
|
|
145
|
+
return found
|
|
146
|
+
|
|
147
|
+
# ------------------------------------------------------------------ reads
|
|
148
|
+
|
|
149
|
+
async def search(self, query: str, offset: int = 0, limit: int = 50) -> list[dict]:
|
|
150
|
+
return await self._json(
|
|
151
|
+
"search",
|
|
152
|
+
"--format=json",
|
|
153
|
+
"--output=summary",
|
|
154
|
+
"--sort=newest-first",
|
|
155
|
+
f"--offset={int(offset)}",
|
|
156
|
+
f"--limit={int(limit)}",
|
|
157
|
+
"--",
|
|
158
|
+
query,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
async def count(self, query: str, unit: str = "messages") -> int:
|
|
162
|
+
if unit not in ("messages", "threads"):
|
|
163
|
+
raise BadQuery("unit must be messages or threads")
|
|
164
|
+
args = ["count"]
|
|
165
|
+
if unit == "threads":
|
|
166
|
+
args.append("--output=threads")
|
|
167
|
+
out = await self._run(*args, "--", query)
|
|
168
|
+
return int(out.strip() or 0)
|
|
169
|
+
|
|
170
|
+
async def tags(self) -> list[str]:
|
|
171
|
+
return await self._json("search", "--format=json", "--output=tags", "--", "*")
|
|
172
|
+
|
|
173
|
+
async def unread_counts(self, tags: list[str]) -> dict[str, int]:
|
|
174
|
+
async def one(tag: str) -> tuple[str, int]:
|
|
175
|
+
n = await self.count(f"{tag_query(tag)} and tag:unread", "threads")
|
|
176
|
+
return tag, n
|
|
177
|
+
|
|
178
|
+
return dict(await asyncio.gather(*(one(t) for t in tags)))
|
|
179
|
+
|
|
180
|
+
async def show_thread(self, thread_id: str) -> list:
|
|
181
|
+
"""Return the nested notmuch JSON of a whole thread, HTML parts included."""
|
|
182
|
+
data = await self._json(
|
|
183
|
+
"show",
|
|
184
|
+
"--format=json",
|
|
185
|
+
"--entire-thread=true",
|
|
186
|
+
"--include-html",
|
|
187
|
+
"--",
|
|
188
|
+
thread_query(thread_id),
|
|
189
|
+
)
|
|
190
|
+
if not data:
|
|
191
|
+
raise NotFound("no such thread")
|
|
192
|
+
return data
|
|
193
|
+
|
|
194
|
+
async def show_message(self, message_id: str) -> dict:
|
|
195
|
+
data = await self._json(
|
|
196
|
+
"show",
|
|
197
|
+
"--format=json",
|
|
198
|
+
"--entire-thread=false",
|
|
199
|
+
"--include-html",
|
|
200
|
+
"--",
|
|
201
|
+
message_query(message_id),
|
|
202
|
+
)
|
|
203
|
+
for msg in iter_messages(data):
|
|
204
|
+
return msg
|
|
205
|
+
raise NotFound("no such message")
|
|
206
|
+
|
|
207
|
+
async def get_part(self, message_id: str, part: int) -> bytes:
|
|
208
|
+
if not isinstance(part, int) or part < 1:
|
|
209
|
+
raise BadQuery("invalid part number")
|
|
210
|
+
return await self._run(
|
|
211
|
+
"show", "--format=raw", f"--part={part}", "--", message_query(message_id)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# ----------------------------------------------------------------- writes
|
|
215
|
+
|
|
216
|
+
async def _message_ids(self, query: str) -> list[str]:
|
|
217
|
+
return await self._json("search", "--format=json", "--output=messages", "--", query)
|
|
218
|
+
|
|
219
|
+
async def apply_tag_changes(
|
|
220
|
+
self, scope: str, add: list[str], remove: list[str]
|
|
221
|
+
) -> dict[str, dict[str, list[str]]]:
|
|
222
|
+
"""Add and remove tags on every message matching `scope`.
|
|
223
|
+
|
|
224
|
+
Returns the ids of the messages that actually changed, per tag, so a
|
|
225
|
+
caller can undo exactly this change and nothing more.
|
|
226
|
+
"""
|
|
227
|
+
add = [validate_tag(t) for t in dict.fromkeys(add)]
|
|
228
|
+
remove = [validate_tag(t) for t in dict.fromkeys(remove)]
|
|
229
|
+
if set(add) & set(remove):
|
|
230
|
+
raise BadQuery("a tag cannot be added and removed in the same request")
|
|
231
|
+
if not add and not remove:
|
|
232
|
+
raise BadQuery("nothing to do")
|
|
233
|
+
changed: dict[str, dict[str, list[str]]] = {"add": {}, "remove": {}}
|
|
234
|
+
for tag in add:
|
|
235
|
+
changed["add"][tag] = await self._message_ids(f"({scope}) and not {tag_query(tag)}")
|
|
236
|
+
for tag in remove:
|
|
237
|
+
changed["remove"][tag] = await self._message_ids(f"({scope}) and {tag_query(tag)}")
|
|
238
|
+
entries = []
|
|
239
|
+
for tag, ids in changed["add"].items():
|
|
240
|
+
entries += [{"id": i, "add": [tag], "remove": []} for i in ids]
|
|
241
|
+
for tag, ids in changed["remove"].items():
|
|
242
|
+
entries += [{"id": i, "add": [], "remove": [tag]} for i in ids]
|
|
243
|
+
await self.tag_batch(entries)
|
|
244
|
+
return changed
|
|
245
|
+
|
|
246
|
+
async def tag_batch(self, entries: list[dict]) -> None:
|
|
247
|
+
"""Apply `{id, add, remove}` entries with a single `notmuch tag --batch`."""
|
|
248
|
+
lines = []
|
|
249
|
+
for entry in entries:
|
|
250
|
+
ops = [f"+{validate_tag(t)}" for t in entry.get("add", [])]
|
|
251
|
+
ops += [f"-{validate_tag(t)}" for t in entry.get("remove", [])]
|
|
252
|
+
if not ops:
|
|
253
|
+
continue
|
|
254
|
+
lines.append(" ".join(ops) + " -- " + message_query(entry["id"]))
|
|
255
|
+
if not lines:
|
|
256
|
+
return
|
|
257
|
+
await self._run(
|
|
258
|
+
"tag", "--batch", stdin=("\n".join(lines) + "\n").encode(), timeout=self.write_timeout
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def iter_messages(node):
|
|
263
|
+
"""Yield every message dict in the nested `notmuch show` structure."""
|
|
264
|
+
if isinstance(node, dict):
|
|
265
|
+
if "id" in node and "headers" in node:
|
|
266
|
+
yield node
|
|
267
|
+
elif isinstance(node, list):
|
|
268
|
+
for child in node:
|
|
269
|
+
yield from iter_messages(child)
|