import os from . import config as configmod from . import db as dbmod from . import ids from . import paths from . import registry from . import routing from . import schema as schemamod ALLOWED_ATTACHMENT_ROOTS = [paths.ROOT] + [ os.path.expanduser(part) for part in os.environ.get("RELAY2_ATTACHMENT_ROOTS", "").split(os.pathsep) if part.strip() ] def reject(msg_or_path, code, detail="", source_path=None): paths.ensure_tree() message_id = None if isinstance(msg_or_path, dict): message_id = msg_or_path.get("id") name = ids.safe_component(f"{message_id or 'unknown'}.json") body = { "message_id": message_id, "reason": code, "detail": detail, "rejected_at": schemamod.utcnow(), } paths.write_json(os.path.join(paths.DEAD, "invalid", name), body) if source_path and os.path.exists(source_path): paths.move_atomic(source_path, os.path.join(paths.REJECTED, os.path.basename(source_path))) return body def accept(con, msg, source_path=None): cfg = configmod.config() known = {r[0] for r in con.execute("SELECT id FROM messages").fetchall()} try: notes = schemamod.validate(msg, cfg=cfg, known_ids=None, roots=ALLOWED_ATTACHMENT_ROOTS) except schemamod.Invalid as exc: reject(msg, exc.code, exc.detail, source_path=source_path) dbmod.log_event(con, "MESSAGE_REJECTED", msg.get("id"), (msg.get("sender") or {}).get("role"), exc.code) return {"ok": False, "code": exc.code, "detail": exc.detail} if msg["id"] in known: return _resume(con, msg, source_path) applied = routing.apply_type_policy(msg, cfg) missing = routing.required_field_errors(applied) if missing: reject(msg, "MISSING_REQUIRED_FIELD", ", ".join(missing), source_path=source_path) dbmod.log_event(con, "MESSAGE_REJECTED", msg["id"], (msg.get("sender") or {}).get("role"), f"missing {missing}") return {"ok": False, "code": "MISSING_REQUIRED_FIELD", "detail": ", ".join(missing)} resolved, unknown = [], [] for field in ("to", "cc"): out = [] for name in (msg.get("recipients") or {}).get(field) or []: role, note = registry.resolve_role(name) if role is None: unknown.append(note) continue if note: notes.append(note) out.append(role) resolved.append((role, field)) msg["recipients"][field] = out if unknown and not resolved: reject(msg, "UNKNOWN_RECIPIENT", "; ".join(unknown), source_path=source_path) dbmod.log_event(con, "MESSAGE_REJECTED", msg["id"], (msg.get("sender") or {}).get("role"), "; ".join(unknown)) return {"ok": False, "code": "UNKNOWN_RECIPIENT", "detail": "; ".join(unknown)} notes.extend(unknown) projected = routing.project(msg, cfg) msg["recipients"]["projected"] = projected accepted_at = schemamod.utcnow() canonical = paths.canonical_path(msg["id"], msg["created_at"]) paths.write_json(canonical, msg) dbmod.index_message(con, msg, accepted_at=accepted_at) dbmod.log_event(con, "MESSAGE_ACCEPTED", msg["id"], (msg.get("sender") or {}).get("role"), {"recipients": len(resolved) + len(projected), "notes": notes}, byte_count=len((msg["body"].get("content") or "").encode("utf-8"))) delivered = [] for role, kind in resolved: if _deliver_pointer(con, msg, role, kind, None, accepted_at): delivered.append(role) for entry in projected: if _deliver_pointer(con, msg, entry["role"], "projected", entry["reason"], accepted_at): delivered.append(entry["role"]) dbmod.log_event(con, "MESSAGE_PROJECTED", msg["id"], entry["role"], entry["reason"]) supersedes = (msg.get("semantics") or {}).get("supersedes") if supersedes: _supersede(con, supersedes, msg["id"]) if source_path and os.path.exists(source_path): paths.move_atomic(source_path, os.path.join(paths.SENT, os.path.basename(source_path))) return {"ok": True, "id": msg["id"], "canonical": canonical, "delivered": delivered, "notes": notes} def _resume(con, msg, source_path): row = con.execute("SELECT canonical_path FROM messages WHERE id = ?", (msg["id"],)).fetchone() canonical = row["canonical_path"] if row else None if canonical and os.path.exists(canonical): try: msg = schemamod.load(canonical) except (OSError, ValueError): pass when = schemamod.utcnow() delivered = [] for field, kind in (("to", "to"), ("cc", "cc")): for role in (msg.get("recipients") or {}).get(field) or []: if _deliver_pointer(con, msg, role, kind, None, when): delivered.append(role) for entry in (msg.get("recipients") or {}).get("projected") or []: if _deliver_pointer(con, msg, entry["role"], "projected", entry.get("reason"), when): delivered.append(entry["role"]) dbmod.log_event(con, "MESSAGE_DUPLICATE", msg["id"], (msg.get("sender") or {}).get("role"), f"resumed, {len(delivered)} delivery(ies) completed") if source_path and os.path.exists(source_path): paths.move_atomic(source_path, os.path.join(paths.SENT, os.path.basename(source_path))) return {"ok": True, "duplicate": True, "id": msg["id"], "canonical": canonical, "delivered": delivered} def _deliver_pointer(con, msg, role, kind, reason, when): ids.safe_component(role) fresh = dbmod.upsert_delivery(con, msg["id"], role, kind=kind, reason=reason, delivered_at=when) if not fresh: return False note = msg.get("notification") or {} pointer = { "id": msg["id"], "priority": msg["priority"], "sender": (msg.get("sender") or {}).get("role"), "subject": note.get("subject"), "preview": note.get("preview"), "type": (msg.get("semantics") or {}).get("type"), "thread": (msg.get("routing") or {}).get("thread"), "project": (msg.get("routing") or {}).get("project"), "created_at": msg["created_at"], "kind": kind, "reason": reason, "action_required": bool((msg.get("semantics") or {}).get("action_required")), "expires_at": (msg.get("semantics") or {}).get("expires_at"), "canonical": os.path.relpath( paths.canonical_path(msg["id"], msg["created_at"]), paths.inbox_dir(role, "unread"), ), } paths.write_json(paths.pointer_path(role, "unread", msg["id"]), pointer) write_receipt(msg["id"], role, "delivered") dbmod.log_event(con, "MESSAGE_DELIVERED", msg["id"], role, kind) return True def _supersede(con, old_id, new_id): con.execute("UPDATE messages SET superseded_by = ? WHERE id = ?", (new_id, old_id)) dbmod.log_event(con, "MESSAGE_SUPERSEDED", old_id, None, new_id) def write_receipt(message_id, recipient, state, session=None, ts=None): ids.safe_component(message_id) ids.safe_component(recipient) ids.safe_component(state) body = { "message_id": message_id, "recipient": recipient, "state": state, "timestamp": ts or schemamod.utcnow(), "session": session, } paths.write_json( os.path.join(paths.receipt_dir(message_id, recipient), f"{state}.json"), body ) return body def set_state(con, message_id, recipient, state, session=None): if state not in dbmod.RECEIPT_STATES: raise ValueError(f"not a receipt state: {state}") row = con.execute( "SELECT * FROM deliveries WHERE message_id = ? AND recipient = ?", (message_id, recipient), ).fetchone() if not row: return None when = schemamod.utcnow() con.execute( f"UPDATE deliveries SET {state}_at = COALESCE({state}_at, ?), state = ?, " "session = COALESCE(?, session) WHERE message_id = ? AND recipient = ?", (when, state, session, message_id, recipient), ) write_receipt(message_id, recipient, state, session=session, ts=when) dbmod.log_event(con, f"MESSAGE_{state.upper()}", message_id, recipient) folder = {"read": "read", "acknowledged": "read", "resolved": "read", "archived": "archived"}.get(state) if folder: _move_pointer(message_id, recipient, folder) return when def _move_pointer(message_id, recipient, folder): for current in ("unread", "read", "archived"): src = paths.pointer_path(recipient, current, message_id) if os.path.exists(src): if current == folder: return src return paths.move_atomic(src, paths.pointer_path(recipient, folder, message_id)) return None