Live testing against the deployed account found that Zoho does not reliably index the custom X-Orga-Draft header for SEARCH HEADER, so list() always returned empty even though the draft existed (create, show, update, delete all worked fine via Message-ID search). Switch list() to the standards-backed SEARCH SINCE an early epoch date and keep filtering ownership client-side, matching the pattern already used elsewhere in this file. Verified live: list finds the draft after create, stays correct after update, and is empty after delete. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cj1QGUAQQ19LPUbAgxsE5b
468 lines
18 KiB
Python
468 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""Secret-holding Orga mail-draft service, scoped to the account's Drafts folder.
|
|
|
|
Writes go through IMAP APPEND (create), APPEND + UID STORE/UID EXPUNGE
|
|
(update: new message first, then remove the old one) and UID STORE/UID
|
|
EXPUNGE (delete). UIDPLUS is mandatory: the service never falls back to a
|
|
mailbox-wide EXPUNGE. The service resolves the Drafts folder itself via
|
|
IMAP LIST (preferring the RFC 6154 \\Drafts special-use flag, falling back to
|
|
a small set of known names) and never accepts a folder name from the caller,
|
|
so it structurally cannot touch any other mailbox. No SMTP access, no
|
|
mail-send: putting a message in Drafts is as far as this goes (see
|
|
hosts/blunix/localServices/orga-agent/README.md, "Deliberately unavailable:
|
|
mail-send").
|
|
|
|
Every draft this service creates carries an `X-Orga-Draft: 1` marker header;
|
|
update/delete/show/list only ever act on messages carrying that marker, so a
|
|
draft the human started composing themselves in their own mail client is
|
|
never touched. The caller-facing "id" is the message's own Message-ID
|
|
(generated by this service, format `<hex@orga-mail-draft.internal>`) instead
|
|
of the old local UUID -- it stays stable across `update` calls the same way
|
|
the UUID used to.
|
|
"""
|
|
|
|
import datetime as dt
|
|
import email
|
|
import email.policy
|
|
import email.utils
|
|
import hashlib
|
|
import imaplib
|
|
import json
|
|
import os
|
|
import re
|
|
import socket
|
|
import sys
|
|
import uuid
|
|
from email.message import EmailMessage
|
|
from pathlib import Path
|
|
|
|
MAX_REQUEST = 65_536
|
|
CONTROL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
|
HEADER_UNSAFE = re.compile(r"[\r\n]")
|
|
ADDRESS = re.compile(r"[^\s@<>,;]{1,64}@[^\s@<>,;]{1,255}\.[^\s@<>,;]{2,24}\Z")
|
|
DRAFT_ID = re.compile(r"<[0-9a-f]{32}@orga-mail-draft\.internal>\Z")
|
|
CONTENT_FIELDS = ("to", "cc", "bcc", "subject", "body", "attachments")
|
|
|
|
# RFC 6154 SPECIAL-USE is the primary way to find the Drafts folder --
|
|
# language- and encoding-agnostic. These ASCII/German names are only a
|
|
# fallback for a server that doesn't advertise SPECIAL-USE.
|
|
FOLDER_LINE = re.compile(rb'^\((?P<flags>[^)]*)\)\s+(?:"(?P<delim>[^"]*)"|NIL)\s+(?P<name>.+)$')
|
|
FALLBACK_DRAFTS_NAMES = ("Drafts", "Draft", "Entwürfe", "INBOX.Drafts", "INBOX/Drafts", "INBOX.Entwürfe")
|
|
|
|
# SEARCH HEADER on a custom X-header is not reliably indexed by every real
|
|
# IMAP server (confirmed against live Zoho: SEARCH HEADER X-Orga-Draft 1
|
|
# returned no matches for a message that demonstrably had that header).
|
|
# SINCE this fixed epoch is a broad, standards-supported stand-in for "all
|
|
# messages in the folder"; ownership is then verified client-side.
|
|
EPOCH_SEARCH_DATE = "01-Jan-1970"
|
|
|
|
|
|
class RequestError(Exception):
|
|
pass
|
|
|
|
|
|
def secret(path_variable):
|
|
value = Path(os.environ[path_variable]).read_text(encoding="utf-8").strip()
|
|
if not value:
|
|
raise RuntimeError("configured credential is empty")
|
|
return value
|
|
|
|
|
|
def text(value, name, maximum, allow_empty=False):
|
|
if not isinstance(value, str) or len(value) > maximum or CONTROL.search(value):
|
|
raise RequestError(f"invalid {name}")
|
|
if not allow_empty and not value.strip():
|
|
raise RequestError(f"missing {name}")
|
|
return value
|
|
|
|
|
|
def header_text(value, name, maximum):
|
|
value = text(value, name, maximum)
|
|
if HEADER_UNSAFE.search(value):
|
|
raise RequestError(f"invalid {name}")
|
|
return value
|
|
|
|
|
|
def address_list(value, name, minimum):
|
|
if not isinstance(value, list) or len(value) > 50:
|
|
raise RequestError(f"invalid {name}")
|
|
result = [text(item, name, 320) for item in value]
|
|
for item in result:
|
|
if not ADDRESS.fullmatch(item):
|
|
raise RequestError(f"invalid address in {name}: {item!r}")
|
|
if len(result) < minimum:
|
|
raise RequestError(f"missing {name}")
|
|
return result
|
|
|
|
|
|
def attachment_list(value):
|
|
if not isinstance(value, list) or len(value) > 50:
|
|
raise RequestError("invalid attachments")
|
|
return [header_text(item, "attachments", 300) for item in value]
|
|
|
|
|
|
def draft_id(value):
|
|
if not isinstance(value, str) or not DRAFT_ID.fullmatch(value):
|
|
raise RequestError("invalid id")
|
|
return value
|
|
|
|
|
|
def content_hash(content):
|
|
canonical = json.dumps(content, sort_keys=True, ensure_ascii=False)
|
|
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def parsed_content(request):
|
|
if set(request) - {"operation", "id", *CONTENT_FIELDS}:
|
|
raise RequestError("unsupported draft request")
|
|
content = {
|
|
"to": address_list(request.get("to"), "to", minimum=1),
|
|
"cc": address_list(request.get("cc", []), "cc", minimum=0),
|
|
"bcc": address_list(request.get("bcc", []), "bcc", minimum=0),
|
|
"subject": header_text(request.get("subject"), "subject", 500),
|
|
"body": text(request.get("body"), "body", 50_000),
|
|
"attachments": attachment_list(request.get("attachments", [])),
|
|
}
|
|
return content
|
|
|
|
|
|
def mail_login():
|
|
connection = imaplib.IMAP4_SSL(os.environ["ORGA_MAIL_HOST"], timeout=20)
|
|
connection.login(os.environ["ORGA_MAIL_USER"], secret("ORGA_MAIL_PASSWORD_FILE"))
|
|
return connection
|
|
|
|
|
|
def discover_drafts_folder(connection):
|
|
status, values = connection.list()
|
|
if status != "OK":
|
|
raise RuntimeError("IMAP LIST failed while locating the drafts folder")
|
|
candidates = []
|
|
for raw in values or []:
|
|
line = raw if isinstance(raw, bytes) else raw[0]
|
|
match = FOLDER_LINE.match(line)
|
|
if not match:
|
|
continue
|
|
wire_name = match.group("name").decode("utf-8", errors="replace").strip()
|
|
display_name = wire_name[1:-1] if wire_name.startswith('"') and wire_name.endswith('"') else wire_name
|
|
if any(flag.lower() == b"\\drafts" for flag in match.group("flags").split()):
|
|
return wire_name
|
|
candidates.append((display_name, wire_name))
|
|
for fallback in FALLBACK_DRAFTS_NAMES:
|
|
for display_name, wire_name in candidates:
|
|
if display_name == fallback:
|
|
return wire_name
|
|
raise RuntimeError(
|
|
"could not identify the drafts folder via IMAP LIST "
|
|
"(no \\Drafts special-use flag and no recognized fallback name)"
|
|
)
|
|
|
|
|
|
def select_folder(connection, folder, readonly):
|
|
status, _ = connection.select(folder, readonly=readonly)
|
|
if status != "OK":
|
|
raise RuntimeError(f"could not select the drafts folder {folder!r}")
|
|
|
|
|
|
def new_message_id():
|
|
return f"<{uuid.uuid4().hex}@orga-mail-draft.internal>"
|
|
|
|
|
|
def build_message(content, message_id, revision, created_at, updated_at):
|
|
digest = content_hash(content)
|
|
message = EmailMessage()
|
|
message["From"] = os.environ["ORGA_MAIL_USER"]
|
|
message["To"] = ", ".join(content["to"])
|
|
if content["cc"]:
|
|
message["Cc"] = ", ".join(content["cc"])
|
|
if content["bcc"]:
|
|
message["Bcc"] = ", ".join(content["bcc"])
|
|
message["Subject"] = content["subject"]
|
|
message["Date"] = email.utils.formatdate(localtime=False)
|
|
message["Message-ID"] = message_id
|
|
message["X-Orga-Draft"] = "1"
|
|
message["X-Orga-Draft-Revision"] = str(revision)
|
|
message["X-Orga-Draft-Created"] = created_at
|
|
message["X-Orga-Draft-Updated"] = updated_at
|
|
message["X-Orga-Draft-Content-Hash"] = digest
|
|
for attachment in content["attachments"]:
|
|
message["X-Orga-Draft-Attachment"] = attachment
|
|
message.set_content(content["body"])
|
|
return message.as_bytes(), digest
|
|
|
|
|
|
def append_message(connection, folder, raw):
|
|
status, _ = connection.append(folder, "(\\Draft)", None, raw)
|
|
if status != "OK":
|
|
raise RuntimeError("IMAP APPEND failed while writing the draft")
|
|
|
|
|
|
def uid_sort_key(uid_bytes):
|
|
try:
|
|
return int(uid_bytes)
|
|
except (TypeError, ValueError):
|
|
return -1
|
|
|
|
|
|
def revision(message):
|
|
try:
|
|
value = int(message.get("X-Orga-Draft-Revision", ""))
|
|
except ValueError:
|
|
return None
|
|
return value if value >= 1 else None
|
|
|
|
|
|
def owned_matches(connection, the_id, fetch_spec="(BODY.PEEK[])"):
|
|
status, values = connection.uid("search", None, "HEADER", "Message-ID", the_id)
|
|
if status != "OK":
|
|
raise RuntimeError("IMAP UID SEARCH failed")
|
|
matches = []
|
|
for uid_bytes in (values[0] or b"").split():
|
|
status, data = connection.uid("fetch", uid_bytes, fetch_spec)
|
|
if status != "OK" or not data or not isinstance(data[0], tuple):
|
|
continue
|
|
message = email.message_from_bytes(data[0][1], policy=email.policy.default)
|
|
if (
|
|
message.get("X-Orga-Draft") == "1"
|
|
and str(message.get("Message-ID", "")).strip() == the_id
|
|
and revision(message) is not None
|
|
):
|
|
matches.append((uid_bytes, message))
|
|
if not matches:
|
|
raise RequestError("draft was not found")
|
|
# Revision is monotonically increasing for this service. The timestamp
|
|
# and UID make recovery deterministic even for malformed server clocks.
|
|
matches.sort(
|
|
key=lambda item: (
|
|
revision(item[1]),
|
|
item[1].get("X-Orga-Draft-Updated") or "",
|
|
uid_sort_key(item[0]),
|
|
),
|
|
reverse=True,
|
|
)
|
|
return matches
|
|
|
|
|
|
def load_owned_message(connection, the_id):
|
|
return owned_matches(connection, the_id)[0]
|
|
|
|
|
|
def require_uidplus(connection):
|
|
status, values = connection.capability()
|
|
if status != "OK" or not any(b"UIDPLUS" in value.upper().split() for value in values or []):
|
|
raise RuntimeError("IMAP UIDPLUS is required for safe draft deletion")
|
|
|
|
|
|
def delete_uid(connection, uid_bytes):
|
|
require_uidplus(connection)
|
|
status, _ = connection.uid("STORE", uid_bytes, "+FLAGS", "(\\Deleted)")
|
|
if status != "OK":
|
|
raise RuntimeError("IMAP STORE failed while removing the previous draft")
|
|
status, _ = connection.uid("EXPUNGE", uid_bytes)
|
|
if status != "OK":
|
|
raise RuntimeError("IMAP UID EXPUNGE failed while removing the previous draft")
|
|
|
|
|
|
def split_addresses(header_value):
|
|
if header_value is None:
|
|
return []
|
|
return [address.addr_spec for address in header_value.addresses]
|
|
|
|
|
|
def extract_body(message):
|
|
if message.is_multipart():
|
|
return ""
|
|
content = message.get_content()
|
|
# set_content() always appends exactly one trailing newline to a
|
|
# text/plain payload; undo that one so a round trip returns exactly
|
|
# what was originally submitted, whatever its own trailing whitespace.
|
|
return content[:-1] if content.endswith("\n") else content
|
|
|
|
|
|
def message_to_draft(the_id, message):
|
|
return {
|
|
"id": the_id,
|
|
"revision": revision(message),
|
|
"created_at": message.get("X-Orga-Draft-Created") or "",
|
|
"updated_at": message.get("X-Orga-Draft-Updated") or "",
|
|
"content_hash": message.get("X-Orga-Draft-Content-Hash", ""),
|
|
"to": split_addresses(message.get("To")),
|
|
"cc": split_addresses(message.get("Cc")),
|
|
"bcc": split_addresses(message.get("Bcc")),
|
|
"subject": str(message.get("Subject", "")),
|
|
"body": extract_body(message),
|
|
"attachments": [str(value) for value in message.get_all("X-Orga-Draft-Attachment", [])],
|
|
}
|
|
|
|
|
|
def create_draft(connection, folder, content):
|
|
now = dt.datetime.now(dt.timezone.utc).isoformat()
|
|
message_id = new_message_id()
|
|
raw, digest = build_message(content, message_id, revision=1, created_at=now, updated_at=now)
|
|
append_message(connection, folder, raw)
|
|
return {
|
|
"id": message_id, "revision": 1,
|
|
"created_at": now, "updated_at": now,
|
|
"content_hash": digest, **content,
|
|
}
|
|
|
|
|
|
def update_draft(connection, folder, the_id, content):
|
|
# Look up and validate the old message -- and remember its UID -- before
|
|
# appending the replacement, which reuses the same Message-ID. Doing it
|
|
# in this order (append first, delete old second) means a failure after
|
|
# the append still leaves a valid draft (a harmless duplicate to clean
|
|
# up), instead of a failure after a delete-first step losing the draft
|
|
# outright.
|
|
old_uid, old_message = load_owned_message(connection, the_id)
|
|
# Verify the safety prerequisite before APPEND. Otherwise a server that
|
|
# lacks UIDPLUS could leave a new duplicate although the update must fail.
|
|
require_uidplus(connection)
|
|
next_revision = revision(old_message) + 1
|
|
created_at = old_message.get("X-Orga-Draft-Created") or dt.datetime.now(dt.timezone.utc).isoformat()
|
|
updated_at = dt.datetime.now(dt.timezone.utc).isoformat()
|
|
raw, digest = build_message(content, the_id, next_revision, created_at, updated_at)
|
|
append_message(connection, folder, raw)
|
|
delete_uid(connection, old_uid)
|
|
# A previous partial update can have left older, valid own revisions.
|
|
# Clean them up only after the new revision is safely present, and only
|
|
# through the same UID-bound deletion primitive.
|
|
for stale_uid, stale_message in owned_matches(connection, the_id):
|
|
if revision(stale_message) < next_revision:
|
|
delete_uid(connection, stale_uid)
|
|
return {
|
|
"id": the_id, "revision": next_revision,
|
|
"created_at": created_at, "updated_at": updated_at,
|
|
"content_hash": digest, **content,
|
|
}
|
|
|
|
|
|
def delete_draft(connection, the_id):
|
|
# Every deletion is individually UID-bound; unrelated \Deleted messages
|
|
# in the selected folder cannot be expunged by this operation.
|
|
for uid_bytes, _ in owned_matches(connection, the_id):
|
|
delete_uid(connection, uid_bytes)
|
|
|
|
|
|
def list_drafts(connection):
|
|
status, values = connection.uid("search", None, "SINCE", EPOCH_SEARCH_DATE)
|
|
if status != "OK":
|
|
raise RuntimeError("IMAP UID SEARCH failed")
|
|
newest = {}
|
|
for uid_bytes in (values[0] or b"").split():
|
|
status, data = connection.uid("fetch", uid_bytes, "(BODY.PEEK[HEADER])")
|
|
if status != "OK" or not data or not isinstance(data[0], tuple):
|
|
continue
|
|
message = email.message_from_bytes(data[0][1], policy=email.policy.default)
|
|
if message.get("X-Orga-Draft") != "1":
|
|
continue
|
|
message_id = str(message.get("Message-ID", "")).strip()
|
|
if not DRAFT_ID.fullmatch(message_id) or revision(message) is None:
|
|
continue
|
|
candidate = (revision(message), message.get("X-Orga-Draft-Updated") or "", uid_sort_key(uid_bytes))
|
|
previous = newest.get(message_id)
|
|
if previous is not None and candidate <= previous[0]:
|
|
continue
|
|
newest[message_id] = (candidate, {
|
|
"id": message_id,
|
|
"revision": revision(message),
|
|
"subject": str(message.get("Subject", "")),
|
|
"updated_at": message.get("X-Orga-Draft-Updated") or "",
|
|
})
|
|
drafts = [item[1] for item in newest.values()]
|
|
drafts.sort(key=lambda draft: draft["updated_at"])
|
|
return drafts
|
|
|
|
|
|
def draft_request(request):
|
|
operation = request.get("operation")
|
|
if operation == "capabilities" and set(request) == {"operation"}:
|
|
return {
|
|
"ok": True, "capability": "mail-draft", "writes": "imap-drafts-folder-only",
|
|
"network": True, "operations": ["create", "update", "show", "list", "delete"],
|
|
}
|
|
|
|
content = None
|
|
the_id = None
|
|
if operation == "list" and set(request) == {"operation"}:
|
|
pass
|
|
elif operation == "create":
|
|
content = parsed_content(request)
|
|
elif operation == "update":
|
|
if "id" not in request:
|
|
raise RequestError("missing id")
|
|
the_id = draft_id(request["id"])
|
|
content = parsed_content(request)
|
|
elif operation == "show" and set(request) == {"operation", "id"}:
|
|
the_id = draft_id(request["id"])
|
|
elif operation == "delete" and set(request) == {"operation", "id"}:
|
|
the_id = draft_id(request["id"])
|
|
else:
|
|
raise RequestError("unsupported draft request")
|
|
|
|
connection = mail_login()
|
|
try:
|
|
folder = discover_drafts_folder(connection)
|
|
|
|
if operation == "list":
|
|
select_folder(connection, folder, readonly=True)
|
|
return {"ok": True, "trust": "UNTRUSTED_EXTERNAL_DATA", "drafts": list_drafts(connection)}
|
|
|
|
if operation == "create":
|
|
select_folder(connection, folder, readonly=False)
|
|
return {"ok": True, "operation": "create", "draft": create_draft(connection, folder, content)}
|
|
|
|
if operation == "update":
|
|
select_folder(connection, folder, readonly=False)
|
|
return {"ok": True, "operation": "update", "draft": update_draft(connection, folder, the_id, content)}
|
|
|
|
if operation == "show":
|
|
select_folder(connection, folder, readonly=True)
|
|
_, message = load_owned_message(connection, the_id)
|
|
return {
|
|
"ok": True,
|
|
"trust": "UNTRUSTED_EXTERNAL_DATA",
|
|
"draft": message_to_draft(the_id, message),
|
|
}
|
|
|
|
if operation == "delete":
|
|
select_folder(connection, folder, readonly=False)
|
|
delete_draft(connection, the_id)
|
|
return {"ok": True, "operation": "delete", "id": the_id}
|
|
finally:
|
|
try:
|
|
connection.logout()
|
|
except imaplib.IMAP4.error:
|
|
pass
|
|
|
|
|
|
def serve():
|
|
listener = socket.socket(fileno=0)
|
|
while True:
|
|
connection, _ = listener.accept()
|
|
with connection:
|
|
try:
|
|
raw = b""
|
|
while not raw.endswith(b"\n") and len(raw) <= MAX_REQUEST:
|
|
chunk = connection.recv(4096)
|
|
if not chunk:
|
|
break
|
|
raw += chunk
|
|
if len(raw) > MAX_REQUEST:
|
|
raise RequestError("request is too large")
|
|
request = json.loads(raw)
|
|
if not isinstance(request, dict):
|
|
raise RequestError("request must be an object")
|
|
response = draft_request(request)
|
|
except (RequestError, json.JSONDecodeError) as error:
|
|
response = {"ok": False, "error": str(error)}
|
|
except Exception:
|
|
response = {"ok": False, "error": "upstream service request failed"}
|
|
connection.sendall(json.dumps(response, ensure_ascii=False).encode("utf-8"))
|
|
|
|
|
|
def main():
|
|
serve()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main() or 0)
|