Files
2dph/bin/mail/import
T
eSliderandGitHub 20b78a9a20
Tests / Test (push) Failing after 5s
Tests / Release (semver) (push) Skipped
feat: brain/index.go shebang; mail import is not a brain write (D14). (#12)
Commands live at bin/brain/{index,get,stats,eval,watch}.go and
bin/mail/import.go, bin/markdown/import.go, bin/postgres/query.go.
Python remains the Ladybug write worker. index_mail is a deprecation
shim that rebuilds via --with-mail.
2026-08-13 17:46:25 +01:00

451 lines
18 KiB
Python
Executable File

#!/usr/bin/env python3
"""mail/import - pull OnlyOffice mails into var/mail/ as markdown.
bin/mail/import --from-raw var/mail convert Go-synced message.json to md
bin/mail/import import newest inbox messages
bin/mail/import --folder sent import sent folder
bin/mail/import --since 2026-01-01 only messages after a date
bin/mail/import --limit 50 cap messages per run
bin/mail/import --no-attachments body only, skip attachment conversion
bin/mail/import --ocr OCR scanned PDFs/images via docling
bin/mail/import --dry-run list messages without writing anything
Writes one directory per message: var/mail/{folder}/{message_id}/
message.md frontmatter + markdown body
attachments/ raw attachment files (zips unpacked to _unpacked/)
attachments/*.md converted attachment content
Indexing is a separate step (`bin/brain/index.go --rebuild`): conversion can
crash in native docling and must not leave the brain DB mid-transaction.
Requires ONLYOFFICE_URL/USER/PASS in .env (or env). Idempotent: a message
already present (message.md exists) is skipped unless --force.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
import time
import urllib.parse
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "bin" / "tools"))
from mailconv import ( # noqa: E402
ARCHIVE_SUFFIXES,
IMAGE_SUFFIXES,
LEGACY_OFFICE_SUFFIXES,
TEXT_SUFFIXES,
html_to_markdown,
is_convertible,
normalize_markdown,
subject_to_filename,
zip_extract_safe,
)
import requests # noqa: E402
FOLDER_IDS = {"inbox": 1, "sent": 2, "drafts": 3, "trash": 4, "spam": 5}
DEFAULT_LIMIT = 25
def load_env() -> dict:
env = {k: v for k, v in os.environ.items()}
envfile = ROOT / ".env"
if envfile.exists():
for line in envfile.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
env.setdefault(k.strip(), v.strip().strip("\"'"))
url = env.get("ONLYOFFICE_URL") or env.get("OO_URL")
user = env.get("ONLYOFFICE_USER") or env.get("OO_USER")
password = env.get("ONLYOFFICE_PASS") or env.get("OO_PASSWORD")
missing = [n for n, v in (("ONLYOFFICE_URL", url), ("ONLYOFFICE_USER", user),
("ONLYOFFICE_PASS", password)) if not v]
if missing:
sys.exit(f"mail/import: missing {', '.join(missing)} (need .env or env)")
return {"url": url.rstrip("/"), "user": user, "password": password}
class OOClient:
def __init__(self, conf: dict):
self.base = conf["url"]
self.session = requests.Session()
self.token = None
self._login(conf)
def _login(self, conf: dict) -> None:
r = self.session.post(f"{self.base}/api/2.0/authentication.json",
json={"userName": conf["user"], "password": conf["password"], "type": 0},
timeout=30)
r.raise_for_status()
body = r.json()
self.token = (body.get("response") or {}).get("token", "")
if not self.token:
sys.exit("mail/import: authentication failed (empty token)")
def _headers(self) -> dict:
return {"Authorization": f"Bearer {self.token}", "Accept": "application/json"}
def get(self, path: str, params: dict | None = None):
r = self.session.get(f"{self.base}{path}", params=params, headers=self._headers(), timeout=30)
r.raise_for_status()
return r.json()
def list_messages(self, folder: int, page: int = 1, count: int = DEFAULT_LIMIT) -> list[dict]:
data = self.get("/api/2.0/mail/messages",
params={"folder": folder, "page": page, "count": count})
return data.get("response", [])
def get_message(self, message_id: str) -> dict:
data = self.get(f"/api/2.0/mail/messages/{message_id}")
return data.get("response", {})
def download_attachment(self, attach_id, dest: Path) -> bool:
"""Download one attachment via the portal session cookie (.ashx handler)."""
url = f"{self.base}/addons/mail/httphandlers/download.ashx?attachid={attach_id}"
r = self.session.get(url, timeout=60)
if r.status_code != 200:
return False
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(r.content)
return True
def folder_id(name: str) -> int:
if name in FOLDER_IDS:
return FOLDER_IDS[name]
if name.isdigit():
return int(name)
sys.exit(f"mail/import: unknown folder '{name}' (use {', '.join(FOLDER_IDS)})")
def safe_attachment_name(att: dict) -> str:
name = att.get("fileName") or att.get("storedName") or "attachment"
name = re.sub(r"[^\w.\- ]+", "_", name)
return name
def convert_file_to_md(path: Path, ocr: bool) -> str | None:
"""Convert one attachment file to markdown text; None when not convertible."""
suffix = path.suffix.lower()
if suffix in TEXT_SUFFIXES:
return normalize_markdown(path.read_text(encoding="utf-8", errors="replace"))
if suffix in (".docx", ".pptx", ".xlsx", ".html", ".htm", ".epub", ".eml", ".msg"):
try:
from markitdown import MarkItDown
md = MarkItDown()
result = md.convert(str(path))
return normalize_markdown(result.text_content)
except Exception as e:
return f"\n<!-- conversion failed: {e} -->\n"
if suffix == ".pdf":
return _convert_pdf(path, ocr)
if suffix in IMAGE_SUFFIXES and ocr:
return _convert_pdf(path, ocr)
if suffix in LEGACY_OFFICE_SUFFIXES:
return _convert_legacy(path)
if suffix in ARCHIVE_SUFFIXES:
return None # handled by caller (unpack + recurse)
return None
def _convert_pdf(path: Path, ocr: bool) -> str:
"""Convert one PDF to markdown.
Fast path: poppler's pdftotext (-layout) extracts exact text from
born-digital PDFs in ~15ms vs docling's 1-3s. Only textless PDFs (scanned
pages, layout-heavy) fall back to docling, which runs isolated in a
subprocess because its native onnx/RT-DETR has segfaulted the main process.
"""
text = _pdf_fast_text(path)
if ocr or text is None or not text.strip():
return _convert_pdf_docling(path, ocr)
return normalize_markdown(text)
def _pdf_fast_text(path: Path) -> str | None:
"""pdftotext -layout; None when poppler is unavailable (or the PDF has no text layer)."""
try:
proc = subprocess.run(
["pdftotext", "-layout", str(path), "-"],
capture_output=True, timeout=60)
except (OSError, subprocess.TimeoutExpired):
return None
if proc.returncode != 0:
return None
return proc.stdout.decode("utf-8", errors="replace")
def _convert_pdf_docling(path: Path, ocr: bool) -> str:
try:
proc = subprocess.run(
[sys.executable, os.path.abspath(__file__), "--pdf-worker", str(path),
"--ocr" if ocr else "--no-ocr"],
capture_output=True, text=True, timeout=600)
except subprocess.TimeoutExpired:
return "\n<!-- pdf conversion timed out -->\n"
if proc.returncode != 0:
tail = proc.stderr.strip().splitlines()[-3:]
return f"\n<!-- pdf conversion failed: {proc.returncode}: {' | '.join(tail)} -->\n"
return proc.stdout
def _pdf_worker(path: Path, ocr: bool) -> None:
"""docling worker entry: prints converted markdown on stdout, exits non-zero on error."""
try:
from docling.document_converter import DocumentConverter, PdfFormatOption
from docling.datamodel.pipeline_options import PdfPipelineOptions
opts = PdfPipelineOptions()
opts.do_ocr = bool(ocr)
opts.do_table_structure = True
conv = DocumentConverter(format_options={"pdf": PdfFormatOption(pipeline_options=opts)})
res = conv.convert(str(path))
sys.stdout.write(normalize_markdown(res.document.export_to_markdown()))
sys.exit(0)
except Exception as e:
# errors/stacktraces to stderr; the caller only reports a one-liner
print(f"pdf-worker: {e}", file=sys.stderr)
import traceback
traceback.print_exc(file=sys.stderr)
sys.exit(1)
def _convert_legacy(path: Path) -> str:
"""Legacy .doc/.xls/.ppt -> md via pandoc (installed) or a stub."""
try:
out = subprocess.run(["pandoc", str(path), "-t", "markdown"],
capture_output=True, text=True, timeout=120)
if out.returncode == 0 and out.stdout.strip():
return normalize_markdown(out.stdout)
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
return f"\n<!-- legacy {path.suffix} not convertible (pandoc unavailable) -->\n"
def write_message_md(msg: dict, folder: str, out_dir: Path, target_dir: Path | None = None) -> Path:
import yaml
body_html = msg.get("htmlBody") or ""
body_text = msg.get("textBody") or ""
body_md = ""
if body_html.strip():
body_md = html_to_markdown(body_html)
elif body_text.strip():
body_md = normalize_markdown(body_text)
# accept both OnlyOffice (receivedDate) and Go-sync (receivedAt) date keys
date = msg.get("receivedDate") or msg.get("receivedAt") or ""
if date and not isinstance(date, str):
date = str(date)
meta = {
"id": msg.get("id"),
"source": msg.get("source"),
"folder": folder,
"subject": msg.get("subject", ""),
"from": msg.get("from", ""),
"to": msg.get("to", ""),
"cc": msg.get("cc", ""),
"date": date,
"has_attachments": bool(msg.get("hasAttachments")),
"mime_message_id": msg.get("mimeMessageId", ""),
"calendar_uid": msg.get("calendarUid", ""),
"type": "mail",
}
meta = {k: v for k, v in meta.items() if v not in (None, "")}
frontmatter = "---\n" + yaml.safe_dump(meta, sort_keys=False, allow_unicode=True).strip() + "\n---\n"
content = f"{frontmatter}\n# {meta.get('subject','')}\n\n{body_md}".strip() + "\n"
if target_dir is not None:
msg_dir = target_dir
else:
msg_dir = out_dir / folder / str(meta.get("id"))
msg_dir.mkdir(parents=True, exist_ok=True)
md_path = msg_dir / "message.md"
md_path.write_text(content, encoding="utf-8")
return md_path
def convert_attachments(msg: dict, msg_dir: Path, ocr: bool) -> list[dict]:
"""Download + convert each attachment; returns [{name, md, raw}] summaries.
raw file keeps the API storedName (unique hash, avoids collisions); the
markdown is named after the friendly fileName when available.
In --from-raw mode attachments are already on disk (Go sync wrote them;
.ics already has a structured .md sidecar). Files with an existing .md
sidecar are left as-is, only unconverted raws are converted here.
"""
out: list[dict] = []
atts = msg.get("attachments") or []
att_dir = msg_dir / "attachments"
for att in atts:
aid = att.get("fileId")
display = safe_attachment_name(att)
stored = att.get("storedName")
raw_name = safe_attachment_name({"storedName": stored}) if stored else display
raw = att_dir / raw_name
if aid and not raw.exists() and OOCLIENT is not None:
if not OOCLIENT.download_attachment(aid, raw):
out.append({"name": display, "md": "\n<!-- download failed -->\n", "raw": str(raw)})
continue
if not raw.exists():
out.append({"name": display, "md": "\n<!-- raw missing -->\n", "raw": str(raw)})
continue
md_stem = Path(display).stem or raw.stem
md_path = att_dir / f"{md_stem}.md"
# Go sync pre-wrote structured .md for .ics; keep it.
if not md_path.exists():
md_text = _convert_att_recursive(raw, ocr)
md_path.write_text(f"# Attachment: {display}\n\n{md_text}\n", encoding="utf-8")
else:
md_text = md_path.read_text(encoding="utf-8", errors="replace")
out.append({"name": display, "md": md_text, "raw": str(raw), "md_file": str(md_path)})
return out
def _convert_att_recursive(path: Path, ocr: bool) -> str:
if path.suffix.lower() in ARCHIVE_SUFFIXES:
parts: list[str] = []
unpack = path.parent / "_unpacked" / path.stem
files = zip_extract_safe(path, unpack)
for f in files:
sub = _convert_att_recursive(f, ocr)
if sub and sub.strip():
parts.append(f"## {f.name}\n\n{sub}")
return "\n\n".join(parts) if parts else "\n<!-- empty zip -->\n"
text = convert_file_to_md(path, ocr)
return text or "\n<!-- not convertible -->\n"
# module-level client for attachment downloads in convert_attachments
OOCLIENT: OOClient | None = None
def convert_one(msg_dir: Path, full: dict, folder: str, out_root: Path,
ocr: bool, no_attachments: bool, target_dir: Path | None = None) -> dict:
"""Write message.md + convert attachments for one message dict.
Works for both live API messages and the Go-sync message.json shape
(source field optional; attachments read from attachments/ dir).
target_dir overrides the derived path (used by --from-raw where the
directory layout is authoritative, not the message folder field).
"""
mid = str(full.get("id"))
write_message_md(full, folder, out_root, target_dir=target_dir)
converted: list[dict] = []
if not no_attachments:
converted = convert_attachments(full, msg_dir, ocr)
return {"id": mid, "subject": full.get("subject", ""),
"date": full.get("receivedDate", "") or full.get("receivedAt", ""),
"attachments": len(converted)}
def main(argv: list[str]) -> int:
global OOCLIENT
p = argparse.ArgumentParser(description="pull OnlyOffice mails to var/mail as markdown")
p.add_argument("--folder", default="inbox", help="inbox|sent|drafts|trash|spam or numeric id")
p.add_argument("--limit", type=int, default=DEFAULT_LIMIT, help="max messages per run")
p.add_argument("--offset", type=int, default=0, help="skip N messages")
p.add_argument("--since", default="", help="only messages received after YYYY-MM-DD")
p.add_argument("--id", action="append", default=[], help="import specific message id (repeatable)")
p.add_argument("--from-raw", default="",
help="convert Go-synced dirs (var/mail/<folder>/<id>/message.json) to markdown")
p.add_argument("--no-attachments", action="store_true", help="skip attachment download+convert")
p.add_argument("--ocr", action="store_true", help="OCR scanned PDFs/images via docling")
p.add_argument("--force", action="store_true", help="re-import even if message.md exists")
p.add_argument("--dry-run", action="store_true", help="list messages, write nothing")
p.add_argument("--json", action="store_true")
p.add_argument("--pdf-worker", default="", help=argparse.SUPPRESS)
p.add_argument("--no-ocr", action="store_true", help=argparse.SUPPRESS)
a = p.parse_args(argv)
if a.pdf_worker:
_pdf_worker(Path(a.pdf_worker), ocr=not a.no_ocr)
return 0
conf = load_env()
fid = folder_id(a.folder)
out_root = ROOT / "var" / "mail"
summary: list[dict] = []
if a.from_raw:
OOCLIENT = None
raw_root = Path(a.from_raw)
for msg_dir in sorted(raw_root.rglob("message.json")):
mid = msg_dir.parent.name
entry = {"id": mid, "subject": "", "date": "",
"attachments": 0, "skipped": False}
md_path = msg_dir.parent / "message.md"
if md_path.exists() and not a.force:
entry["skipped"] = True
summary.append(entry)
continue
if a.dry_run:
entry["skipped"] = "dry-run"
summary.append(entry)
continue
full = json.loads(msg_dir.read_text(encoding="utf-8"))
entry.update(convert_one(msg_dir.parent, full, full.get("folder") or a.folder,
raw_root, a.ocr, a.no_attachments,
target_dir=msg_dir.parent))
summary.append(entry)
else:
OOCLIENT = OOClient(conf)
if a.id:
messages = [{"id": i} for i in a.id]
else:
page = 1
messages = []
want = a.offset + a.limit
while len(messages) < want:
count = min(DEFAULT_LIMIT, want - len(messages))
chunk = OOCLIENT.list_messages(fid, page=page, count=count)
if not chunk:
break
messages.extend(chunk)
if len(chunk) < count:
break
page += 1
messages = messages[a.offset:a.offset + a.limit]
if a.since:
messages = [m for m in messages
if (m.get("receivedDate") or "") >= a.since]
for m in messages:
mid = str(m.get("id"))
entry = {"id": mid, "subject": m.get("subject", ""),
"date": m.get("receivedDate", ""), "attachments": 0, "skipped": False}
msg_dir = out_root / a.folder / mid
md_path = msg_dir / "message.md"
if md_path.exists() and not a.force:
entry["skipped"] = True
summary.append(entry)
continue
if a.dry_run:
entry["skipped"] = "dry-run"
summary.append(entry)
continue
full = OOCLIENT.get_message(mid)
entry.update(convert_one(msg_dir, full, a.folder, out_root, a.ocr, a.no_attachments,
target_dir=msg_dir))
summary.append(entry)
if a.json:
print(json.dumps(summary, ensure_ascii=False, indent=2))
else:
imported = [e for e in summary if not e["skipped"]]
print(f"mail/import: folder={a.folder} checked={len(summary)} "
f"imported={len(imported)} (skipped={sum(e['skipped'] is True for e in summary)})")
for e in summary:
flag = "skip" if e["skipped"] is True else ("dry" if e["skipped"] == "dry-run" else "ok ")
print(f" [{flag}] {e['id']} {e['date'][:10]} {e['subject'][:60]}"
f" (atts={e['attachments']})")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))