Menu
Public repository / e0753e49bfea7ef672aabf8df9a3a27b8bdd9dc7
AkurAI-Mail / recovered/ec2-2026-09-07/opt-checkout.diff
Latest change e0753e49bfea7ef672aabf8df9a3a27b8bdd9dc7 - Recover EC2-only ops scripts, checks and live divergent copies by Ólafur Búi Ólafsson
diff --git a/VERSION b/VERSION
index 267577d..17b2ccd 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.4.1
+0.4.3
diff --git a/bun.lock b/bun.lock
index 25044fe..8f997ae 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,10 +5,11 @@
"": {
"name": "akurai-mail-ui",
"dependencies": {
+ "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "2.67.0",
"@sveltejs/vite-plugin-svelte": "7.1.2",
+ "lucide-svelte": "^1.0.1",
"svelte": "5.56.4",
- "svelte-adapter-bun": "1.0.1",
"vite": "8.1.0",
},
"devDependencies": {
@@ -77,6 +78,8 @@
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.10", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA=="],
+ "@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.10", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew=="],
+
"@sveltejs/kit": ["@sveltejs/kit@2.67.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", "acorn": "^8.16.0", "cookie": "^0.6.0", "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-JXHbsDwRes1Wgyof3q5ApzzpbCWvinKXMQCiV67TFO6xlZPYLoK0fq3xQMqSicDMgCtFGqLkrQXkseOcASdZ8A=="],
"@sveltejs/load-config": ["@sveltejs/load-config@0.2.0", "", {}, "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg=="],
@@ -149,6 +152,8 @@
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
+ "lucide-svelte": ["lucide-svelte@1.0.1", "", { "peerDependencies": { "svelte": "^3 || ^4 || ^5.0.0-next.42" } }, "sha512-WvzZgk0pqzgda+AErLvgWxHkfg/+GgUwqKMRHvzt0IqyMdmyEDzDCk3Z+Wo/3y753oIgx8u9Q4eUbWkghFa8Jg=="],
+
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
@@ -179,8 +184,6 @@
"svelte": ["svelte@5.56.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw=="],
- "svelte-adapter-bun": ["svelte-adapter-bun@1.0.1", "", { "dependencies": { "rolldown": "^1.0.0-beta.38" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0", "typescript": "^5" } }, "sha512-tNOvfm8BGgG+rmEA7hkmqtq07v7zoo4skLQc+hIoQ79J+1fkEMpJEA2RzCIe3aPc8JdrsMJkv3mpiZPMsgahjA=="],
-
"svelte-check": ["svelte-check@4.7.1", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.0", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-FGUOmAqxXdN/H9Zm8slrqO7SLtFisXRB7rfOsHNJ3MLTD2po/+Stg8XyErkpumPHbuUiYTcqrEIzxpVWKTLqtg=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
diff --git a/package.json b/package.json
index 94f38e9..2076237 100644
--- a/package.json
+++ b/package.json
@@ -11,11 +11,12 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"dependencies": {
+ "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "2.67.0",
"@sveltejs/vite-plugin-svelte": "7.1.2",
+ "lucide-svelte": "^1.0.1",
"svelte": "5.56.4",
- "vite": "8.1.0",
- "svelte-adapter-bun": "1.0.1"
+ "vite": "8.1.0"
},
"devDependencies": {
"@types/node": "^26.0.0",
diff --git a/scripts/akurai-mail-server b/scripts/akurai-mail-server
index 29a05fd..60aacbb 100755
--- a/scripts/akurai-mail-server
+++ b/scripts/akurai-mail-server
@@ -1,9 +1,10 @@
#!/usr/bin/env python3
-import argparse, base64, crypt, email, hashlib, html, json, mailbox, mimetypes, os, re, secrets, shutil, socket, string, struct, subprocess, sys, time
+import argparse, base64, email, hashlib, html, json, mailbox, mimetypes, os, re, secrets, shutil, socket, string, struct, subprocess, sys, time
from email.message import EmailMessage
from email.policy import default
from email.utils import formatdate, make_msgid, parseaddr
from pathlib import Path
+import ctypes.util, select, urllib.error, urllib.parse, urllib.request
STATE = Path("/var/lib/akurai-mail/state.json")
WEBMAIL_STATE = Path("/var/lib/akurai-mail/webmail.json")
@@ -13,6 +14,11 @@ POSTFIX_VIRTUAL = Path("/etc/postfix/virtual")
DOMAIN = "olibuijr.com"
HOSTNAME = "mail.olibuijr.com"
PUBLIC_IP = "3.94.46.219"
+DOMAINS_STATE = Path("/var/lib/akurai-mail/domains.json")
+OPENDKIM_KEYTABLE = Path("/etc/opendkim/KeyTable")
+OPENDKIM_SIGNINGTABLE = Path("/etc/opendkim/SigningTable")
+OPENDKIM_TRUSTEDHOSTS = Path("/etc/opendkim/TrustedHosts")
+OPENDKIM_KEYS_DIR = Path("/etc/opendkim/keys")
SPAM_POLICY_DEFAULT = {
"enabled": True,
"autoMove": True,
@@ -23,8 +29,13 @@ SPAM_POLICY_DEFAULT = {
"subjectKeywords": ["crypto", "lottery", "winner", "urgent payment", "gift card", "wire transfer"],
"attachmentExtensions": ["exe", "scr", "bat", "cmd", "js", "vbs", "ps1", "jar"],
"maxLinks": 10,
+ "version": 1,
"stats": {"scanned": 0, "moved": 0, "lastScan": 0},
}
+POLICY_LIST_FIELDS = {"blocklist": "sender", "allowlist": "sender", "domainBlocklist": "domain"}
+POLICY_ENTRY_FIELDS = {"kind", "value", "disposition", "reason", "source", "createdAt", "createdBy", "expiresAt", "evidence"}
+POLICY_SOURCES = {"manual", "message-feedback", "provider-feedback", "blocklist"}
+EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
DEFAULT = {"domain": DOMAIN, "hostname": HOSTNAME, "publicIp": PUBLIC_IP, "users": [], "aliases": [], "events": [], "metricsHistory": [], "spamPolicy": SPAM_POLICY_DEFAULT}
WEBMAIL_DEFAULT = {"contacts": [], "signatures": {}, "vacation": {}, "vacationSent": {}, "rules": [], "preferences": {"density": "comfortable", "theme": "light"}, "audit": []}
FOLDERS = {
@@ -53,20 +64,76 @@ def save(data):
tmp = STATE.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2) + "\n")
tmp.replace(STATE)
+def _policy_value(kind, value):
+ value = str(value or "").strip().lower().lstrip("@")
+ if kind == "sender":
+ if not EMAIL_RE.fullmatch(value):
+ raise ValueError("invalid sender address")
+ elif kind == "domain":
+ if not re.fullmatch(r"(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}", value):
+ raise ValueError("invalid domain")
+ else:
+ raise ValueError("invalid policy kind")
+ return value
+
+def msg_id(folder, subdir, name):
+ raw = json.dumps([folder, subdir, name], separators=(",", ":")).encode()
+ return base64.urlsafe_b64encode(raw).decode().rstrip("=")
+
+def decode_msg_id(value):
+ padded = value + "=" * (-len(value) % 4)
+ return json.loads(base64.urlsafe_b64decode(padded.encode()).decode())
+
+def clean_addr(value):
+ return str(value or "").replace("\n", " ").replace("\r", " ").strip()
+
+def normalized_email(value):
+ return parseaddr(str(value or ""))[1].lower().strip()
+
+def sender_domain(value):
+ address = normalized_email(value)
+ return address.rsplit("@", 1)[1] if "@" in address else ""
+
+def _policy_entries(policy, kind, value):
+ return [e for field in POLICY_LIST_FIELDS if POLICY_LIST_FIELDS[field] == kind for e in policy.get(field, []) if e.get("kind") == kind and e.get("value") == value]
+
+
+
+def normalize_policy_entry(entry, field=None):
+ if isinstance(entry, str):
+ kind = POLICY_LIST_FIELDS.get(field, "sender")
+ return {"kind": kind, "value": _policy_value(kind, entry), "disposition": "block" if field != "allowlist" else "allow", "reason": "legacy policy entry", "source": "manual", "createdAt": 0, "createdBy": "legacy", "expiresAt": None, "evidence": []}
+ if not isinstance(entry, dict) or set(entry) - POLICY_ENTRY_FIELDS:
+ raise ValueError("invalid policy entry")
+ out = dict(entry)
+ out["kind"] = str(out.get("kind", ""))
+ out["value"] = _policy_value(out["kind"], out.get("value"))
+ if out.get("disposition") not in {"allow", "block"} or not str(out.get("reason", "")).strip() or out.get("source") not in POLICY_SOURCES:
+ raise ValueError("invalid policy entry fields")
+ out.setdefault("createdAt", 0); out.setdefault("createdBy", "unknown"); out.setdefault("expiresAt", None); out.setdefault("evidence", [])
+ if not isinstance(out["evidence"], list) or len(out["evidence"]) > 32:
+ raise ValueError("invalid policy evidence")
+ return out
def spam_policy(data):
policy = data.setdefault("spamPolicy", {})
for key, value in SPAM_POLICY_DEFAULT.items():
- if isinstance(value, dict):
- policy.setdefault(key, value.copy())
- elif isinstance(value, list):
- policy.setdefault(key, value.copy())
- else:
- policy.setdefault(key, value)
+ policy.setdefault(key, value.copy() if isinstance(value, (list, dict)) else value)
+ for field in POLICY_LIST_FIELDS:
+ entries = [normalize_policy_entry(item, field) for item in policy.get(field, [])]
+ policy[field] = entries
+ policy["version"] = int(policy.get("version", 1))
return policy
+def audit(data, text, **metadata):
+ row = {"at": int(time.time()), "text": str(text)}
+ row.update({k: v for k, v in metadata.items() if k not in {"body", "message", "password", "token"}})
+ data.setdefault("audit", []).insert(0, row)
+ data["audit"] = data["audit"][:100]
+
+
def load_webmail():
if not WEBMAIL_STATE.exists():
save_webmail(WEBMAIL_DEFAULT.copy())
@@ -84,10 +151,6 @@ def save_webmail(data):
tmp.replace(WEBMAIL_STATE)
-def audit(data, text):
- data.setdefault("audit", []).insert(0, {"at": int(time.time()), "text": text})
- data["audit"] = data["audit"][:100]
-
def event(data, text):
data.setdefault("events", []).insert(0, {"at": int(time.time()), "text": text})
@@ -104,7 +167,7 @@ def run(args):
def services():
- names = ["postfix", "dovecot", "opendkim", "nginx", "akurai-mail-ui", "fail2ban"]
+ names = ["postfix", "dovecot", "opendkim", "nginx", "akurai-mail-ui", "akurai-mail-hook", "fail2ban"]
out = {}
for name in names:
r = run(["systemctl", "is-active", name])
@@ -321,18 +384,6 @@ def folder_root(address, folder):
for name in ("cur", "new", "tmp"):
(path / name).mkdir(parents=True, exist_ok=True)
return path
-
-
-def msg_id(folder, subdir, name):
- raw = json.dumps([folder, subdir, name], separators=(",", ":")).encode()
- return base64.urlsafe_b64encode(raw).decode().rstrip("=")
-
-
-def decode_msg_id(value):
- padded = value + "=" * (-len(value) % 4)
- return json.loads(base64.urlsafe_b64decode(padded.encode()).decode())
-
-
def message_path(address, value):
folder, subdir, name = decode_msg_id(value)
root = folder_root(address, folder)
@@ -340,21 +391,6 @@ def message_path(address, value):
if not path.exists() or subdir not in ("cur", "new"):
raise ValueError("message not found")
return folder, subdir, path
-
-
-def clean_addr(value):
- return str(value or "").replace("\n", " ").replace("\r", " ").strip()
-
-
-def normalized_email(value):
- return parseaddr(str(value or ""))[1].lower().strip()
-
-
-def sender_domain(value):
- address = normalized_email(value)
- return address.rsplit("@", 1)[1] if "@" in address else ""
-
-
def message_links(text):
return len(re.findall(r"https?://|www\.", text, flags=re.I))
@@ -362,17 +398,17 @@ def message_links(text):
def spam_score(msg, policy):
sender = normalized_email(msg.get("from"))
domain = sender_domain(msg.get("from"))
- allowlist = {str(x).lower() for x in policy.get("allowlist", [])}
- if sender in allowlist or domain in allowlist:
+ sender_entries = _policy_entries(policy, "sender", sender)
+ domain_entries = _policy_entries(policy, "domain", domain)
+ if any(e["disposition"] == "allow" for e in sender_entries):
return 0, ["allowlisted"]
- score = 0
- reasons = []
- if sender and sender in {str(x).lower() for x in policy.get("blocklist", [])}:
- score += 10
- reasons.append("blocked sender")
- if domain and domain in {str(x).lower().lstrip("@") for x in policy.get("domainBlocklist", [])}:
+ score = 10 if any(e["disposition"] == "block" for e in sender_entries) else 0
+ reasons = ["blocked sender"] if score else []
+ if any(e["disposition"] == "block" for e in domain_entries):
score += 8
reasons.append("blocked domain")
+ if not score and any(e["disposition"] == "allow" for e in domain_entries):
+ return 0, ["allowlisted domain"]
subject = str(msg.get("subject") or "")
body = plain_from_message(msg)
search = f"{subject}\n{body}".lower()
@@ -385,6 +421,25 @@ def spam_score(msg, policy):
if signal in auth:
score += 3
reasons.append(signal)
+ spam_flag = str(msg.get("x-spam-flag") or "").strip().lower()
+ if spam_flag == "yes":
+ score += 6
+ reasons.append("spamassassin flag")
+ sa_status = str(msg.get("x-spam-status") or "")
+ sa_match = re.search(r"score=(-?\d+(?:\.\d+)?)", sa_status)
+ if not sa_match:
+ sa_match = re.search(r"(-?\d+(?:\.\d+)?)", str(msg.get("x-spam-score") or ""))
+ if sa_match:
+ try:
+ sa = float(sa_match.group(1))
+ except ValueError:
+ sa = 0.0
+ if sa >= 5:
+ score += 5
+ reasons.append(f"sa score {sa:g}")
+ elif sa >= 3:
+ score += 2
+ reasons.append(f"sa score {sa:g}")
ext_block = {str(x).lower().lstrip(".") for x in policy.get("attachmentExtensions", [])}
for part in msg.walk():
filename = part.get_filename()
@@ -463,6 +518,9 @@ def message_summary(address, folder, subdir, path):
"attachments": [a for a in attachments if a],
"spamScore": score,
"spamReasons": reasons,
+ "messageId": str(msg.get("message-id") or "").strip(),
+ "inReplyTo": str(msg.get("in-reply-to") or "").strip(),
+ "references": re.findall(r"<[^<>]+>", str(msg.get("references") or "")),
}
@@ -522,8 +580,417 @@ def folder_counts(address):
return counts
+def decode_page_cursor(value):
+ if not value:
+ return None
+ try:
+ raw = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)).decode()
+ timestamp, message_id = raw.split("|", 1)
+ return float(timestamp), message_id
+ except (ValueError, UnicodeDecodeError, TypeError):
+ raise ValueError("invalid page cursor")
+
+
+def encode_page_cursor(row):
+ raw = f"{row['timestamp']}|{row['id']}".encode()
+ return base64.urlsafe_b64encode(raw).decode().rstrip("=")
+
+
+def page_rows(rows, cursor, limit):
+ marker = decode_page_cursor(cursor)
+ if marker:
+ rows = [
+ row for row in rows
+ if (row["timestamp"], row["id"]) < marker
+ ]
+ limit = max(1, min(int(limit), 100))
+ page = rows[:limit]
+ next_cursor = encode_page_cursor(page[-1]) if len(rows) > len(page) else None
+ return page, next_cursor
+
+
+def normalize_subject(subject):
+ """Strip Re:/Fwd: prefixes for conversation grouping."""
+ s = str(subject or "").strip()
+ while True:
+ m = re_module.match(r"(?i)^\s*(re|fwd?|aw|sv|vs|wg)\s*[\:\-\]]\s*", s)
+ if not m:
+ break
+ s = s[m.end():]
+ return s.strip() or s
+
+
+def is_autoreply(msg):
+ """Detect auto-replies and no-reply senders."""
+ from_addr = normalized_email(msg.get("from", ""))
+ if not from_addr:
+ return True
+ for pattern in ("no-?reply@", "noreply@", "mailer-daemon@", "mailer@",
+ "postmaster@", "bounce", "auto-reply", "autoreply"):
+ if re_module.search(pattern, from_addr):
+ return True
+ subject = str(msg.get("subject", "")).lower()
+ for phrase in ("out of office", "automatic reply", "auto reply",
+ "vacation", "away from", "undeliverable", "delivery status",
+ "out of the office", "on leave"):
+ if phrase in subject:
+ return True
+ return False
+
+
+def is_self_mail(msg, user_address):
+ """Check if the message was sent by the user themselves."""
+ from_addr = normalized_email(msg.get("from", ""))
+ return from_addr == normalized_email(user_address)
+
+
+def extract_deadline(text):
+ """Extract explicit deadline/dates from text. Returns (date_str, confidence)."""
+ if not text:
+ return None
+ # Look for patterns like "by Friday", "due 2024-01-15", "deadline: tomorrow"
+ patterns = [
+ (r"(?i)(?:deadline|due|by|before|needs? (?:to be done )?by)\s*[: ]\s*(\d{4}-\d{2}-\d{2})", 0.9),
+ (r"(?i)(?:deadline|due|by|before)\s*[: ]\s*(tomorrow)", 0.7),
+ (r"(?i)(?:deadline|due|by|before)\s*[: ]\s*(today)", 0.7),
+ (r"(?i)urgent.*?(?:reply|respond|action)", 0.5),
+ (r"(?i)(?:need|needs|needed)\s+(?:this|by)\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)", 0.6),
+ ]
+ for pat, conf in patterns:
+ m = re_module.search(pat, text)
+ if m:
+ return (m.group(1) if m.lastindex else m.group(0), conf)
+ return None
+
+
+def priority_keywords(text):
+ """Score priority from body/subject text."""
+ if not text:
+ return 0, []
+ text_lower = text.lower()
+ high_terms = ["urgent", "asap", "critical", "emergency", "immediate action",
+ "time sensitive", "deadline", "overdue", "escalation"]
+ low_terms = ["newsletter", "unsubscribe", "promotion", "sale", "offer",
+ "weekly digest", "monthly update", "notification", "receipt",
+ "order confirmation", "shipping", "tracking", "verification code"]
+ high_hits = [t for t in high_terms if t in text_lower]
+ low_hits = [t for t in low_terms if t in text_lower]
+ if high_hits:
+ return 3, high_hits[:3]
+ if low_hits:
+ return -1, low_hits[:3]
+ return 0, []
+
+
+def compute_signals(address, folder, messages_by_conversation):
+ """Compute per-conversation signals deterministically.
+
+ Returns a dict keyed by conversation_id with signal objects.
+ Each signal has: value, provenance, confidence, evidence, messageIds.
+ """
+ signals = {}
+ now = time.time()
+
+ for conv_id, msgs in messages_by_conversation.items():
+ if not msgs:
+ continue
+
+ # Sort by timestamp ascending
+ sorted_msgs = sorted(msgs, key=lambda m: m.get("timestamp", 0))
+ msg_ids = [m["id"] for m in sorted_msgs]
+
+ # Separate inbound (from others) and outbound (from user)
+ inbound = [m for m in sorted_msgs if not is_self_mail(m, address)]
+ outbound = [m for m in sorted_msgs if is_self_mail(m, address)]
+
+ # Last activity
+ last_msg = sorted_msgs[-1]
+ last_inbound = inbound[-1] if inbound else None
+ last_outbound = outbound[-1] if outbound else None
+
+ last_inbound_at = last_inbound.get("timestamp", 0) if last_inbound else 0
+ last_outbound_at = last_outbound.get("timestamp", 0) if last_outbound else 0
+
+ # Participants
+ participants = set()
+ for m in sorted_msgs:
+ addr = normalized_email(m.get("from", ""))
+ if addr:
+ participants.add(addr)
+ for to_field in [m.get("to", "")]:
+ for a in re_module.split(r"[,;]", str(to_field)):
+ na = normalized_email(a)
+ if na:
+ participants.add(na)
+
+ # Reply-needed calculation
+ reply_needed = None
+ if last_inbound and not is_autoreply({"from": last_inbound.get("from", ""),
+ "subject": last_inbound.get("subject", "")}):
+ # Last message is from someone else (not user)
+ if not is_self_mail(last_msg, address):
+ # User hasn't replied after the last inbound
+ if last_outbound_at < last_inbound_at:
+ age_seconds = now - last_inbound_at
+ age_days = max(0, int(age_seconds / 86400))
+ age_hours = int((age_seconds % 86400) / 3600)
+
+ # Build evidence
+ evidence = [f"Last message from {normalized_email(last_msg.get('from', ''))} on {last_msg.get('date', 'unknown')}"]
+ if age_days > 0:
+ evidence.append(f"Unanswered for {age_days}d {age_hours}h")
+ elif age_hours > 0:
+ evidence.append(f"Unanswered for {age_hours}h")
+
+ confidence = 0.95
+ if age_days < 1:
+ confidence = 0.6 # Recently received, might still be reading
+ elif age_days > 7:
+ confidence = 0.85 # Might have been handled elsewhere
+
+ reply_needed = {
+ "value": True,
+ "provenance": "deterministic",
+ "confidence": confidence,
+ "evidence": evidence,
+ "messageIds": msg_ids,
+ "unansweredAge": {"days": age_days, "hours": age_hours},
+ }
+ else:
+ # User has already replied
+ reply_needed = {
+ "value": False,
+ "provenance": "deterministic",
+ "confidence": 0.95,
+ "evidence": ["Already answered"],
+ "messageIds": msg_ids,
+ }
+ else:
+ # Last message is from user — no reply needed
+ reply_needed = {
+ "value": False,
+ "provenance": "deterministic",
+ "confidence": 1.0,
+ "evidence": ["Last message is your own — sent mail or draft"],
+ "messageIds": msg_ids,
+ }
+ elif last_inbound and is_autoreply({"from": last_inbound.get("from", ""),
+ "subject": last_inbound.get("subject", "")}):
+ reply_needed = {
+ "value": False,
+ "provenance": "deterministic",
+ "confidence": 0.95,
+ "evidence": ["Last message is an auto-reply or no-reply sender"],
+ "messageIds": msg_ids,
+ }
+ elif not last_inbound:
+ # Only outbound (sent mail, drafts)
+ reply_needed = {
+ "value": False,
+ "provenance": "deterministic",
+ "confidence": 1.0,
+ "evidence": ["Conversation has only your messages — sent or draft"],
+ "messageIds": msg_ids,
+ }
+
+ # Follow-up-due calculation
+ follow_up_due = None
+ if last_outbound and last_outbound_at > last_inbound_at:
+ # User sent last message, no reply received
+ age_seconds = now - last_outbound_at
+ age_days = max(0, int(age_seconds / 86400))
+ if age_days >= 2:
+ follow_up_due = {
+ "value": True,
+ "provenance": "deterministic",
+ "confidence": 0.85,
+ "evidence": [f"Sent message {age_days}d ago with no reply"],
+ "messageIds": msg_ids,
+ }
+ elif age_days >= 1:
+ follow_up_due = {
+ "value": True,
+ "provenance": "deterministic",
+ "confidence": 0.5,
+ "evidence": [f"Sent message {age_days}d ago with no reply yet"],
+ "messageIds": msg_ids,
+ }
+
+ # Check for explicit deadlines
+ deadline_detected = None
+ all_text = " ".join([
+ str(m.get("subject", "")) + " " + str(m.get("preview", ""))
+ for m in sorted_msgs
+ ])
+ deadline = extract_deadline(all_text)
+ if deadline:
+ deadline_detected = {
+ "value": deadline[0],
+ "provenance": "deterministic",
+ "confidence": deadline[1],
+ "evidence": [f"Deadline mention found: \"{deadline[0]}\""],
+ "messageIds": msg_ids,
+ }
+ # Upgrade follow-up if deadline is close
+ if follow_up_due is None:
+ follow_up_due = {
+ "value": True,
+ "provenance": "deterministic",
+ "confidence": deadline[1] * 0.8,
+ "evidence": [f"Deadline detected: {deadline[0]}"],
+ "messageIds": msg_ids,
+ }
+
+ # Priority calculation
+ prio_score, prio_evidence = priority_keywords(all_text)
+ priority = {
+ "value": "normal",
+ "provenance": "deterministic",
+ "confidence": 0.7,
+ "evidence": [],
+ "messageIds": msg_ids,
+ }
+ if prio_score >= 3:
+ priority["value"] = "high"
+ priority["confidence"] = 0.85
+ priority["evidence"] = [f"High-priority keywords: {', '.join(prio_evidence)}"]
+ elif prio_score < 0:
+ priority["value"] = "low"
+ priority["confidence"] = 0.8
+ priority["evidence"] = [f"Low-priority indicators: {', '.join(prio_evidence)}"]
+
+ # Summary
+ participant_list = sorted([p for p in participants if p != normalized_email(address)])[:5]
+ summary_text = f"{len(sorted_msgs)} message{'s' if len(sorted_msgs) != 1 else ''}"
+ if participant_list:
+ summary_text += f" with {', '.join(participant_list[:3])}"
+ subj = normalize_subject(sorted_msgs[0].get("subject", ""))
+ if subj:
+ summary_text += f" about \"{subj[:80]}\""
+
+ conv_signals = {
+ "conversationId": conv_id,
+ "participants": list(participants),
+ "messageCount": len(sorted_msgs),
+ "lastInboundAt": last_inbound.get("date", "") if last_inbound else None,
+ "lastInboundFrom": normalized_email(last_inbound.get("from", "")) if last_inbound else None,
+ "lastOutboundAt": last_outbound.get("date", "") if last_outbound else None,
+ "replyNeeded": reply_needed,
+ "followUpDue": follow_up_due,
+ "priority": priority,
+ "deadlineDetected": deadline_detected,
+ "summary": {
+ "value": summary_text,
+ "provenance": "deterministic",
+ "confidence": 1.0,
+ "evidence": ["Computed from thread metadata"],
+ "messageIds": msg_ids,
+ },
+ }
+
+ signals[conv_id] = conv_signals
+
+ return signals
+
+
+def webmail_signals(args):
+ """Compute and return conversation signals for a mailbox folder."""
+ data = load_webmail()
+ address = args.email
+ folder = args.folder
+ root = folder_root(address, folder)
+
+ # Read all messages
+ all_messages = {}
+ for subdir in ("new", "cur"):
+ for path in (root / subdir).glob("*"):
+ if path.name.startswith("."):
+ continue
+ try:
+ item = message_summary(address, folder, subdir, path)
+ all_messages[item["id"]] = item
+ except Exception:
+ continue
+
+ if not all_messages:
+ return print(json.dumps({"ok": True, "signals": {}, "messageSignals": {}}))
+
+ # Group by conversation (normalized subject)
+ conversations = {}
+ for mid, msg in all_messages.items():
+ conv_key = normalize_subject(msg.get("subject", ""))
+ if not conv_key:
+ conv_key = f"__no_subject_{mid}"
+ conversations.setdefault(conv_key, []).append(msg)
+
+ # Compute signals
+ corrections = data.get("signalCorrections", {})
+ signals = compute_signals(address, folder, conversations)
+
+ # Apply user corrections on top
+ for conv_id, corr in corrections.items():
+ if conv_id in signals:
+ for signal_type, correction in corr.items():
+ if signal_type in signals[conv_id]:
+ sig = signals[conv_id][signal_type]
+ if sig is not None and isinstance(sig, dict):
+ if correction.get("dismissed"):
+ sig["dismissed"] = correction["dismissed"]
+ if correction.get("correctedValue") is not None:
+ sig["correctedValue"] = correction["correctedValue"]
+ sig["corrected"] = True
+ sig["correctedAt"] = correction.get("at", 0)
+ sig["provenance"] = "user-corrected"
+
+ # Create per-message mapping
+ message_signals = {}
+ for conv_id, conv_sigs in signals.items():
+ for mid in conv_sigs.get("summary", {}).get("messageIds", []):
+ message_signals[mid] = conv_id
+
+ print(json.dumps({
+ "ok": True,
+ "signals": signals,
+ "messageSignals": message_signals,
+ }))
+
+
+def webmail_signal_correct(args):
+ """Persist a signal correction or dismissal."""
+ data = load_webmail()
+ address = args.email
+ payload = parse_json_arg(args.payload)
+ conversation_id = payload.get("conversationId")
+ signal_type = payload.get("signalType")
+ action = payload.get("action") # "dismiss" or "correct"
+ value = payload.get("value") # only for correct
+
+ if not conversation_id or not signal_type or action not in ("dismiss", "correct"):
+ raise ValueError("conversationId, signalType, and action (dismiss/correct) required")
+
+ corrections = data.setdefault("signalCorrections", {})
+ conv_corr = corrections.setdefault(conversation_id, {})
+ signal_corr = conv_corr.setdefault(signal_type, {})
+
+ if action == "dismiss":
+ signal_corr["dismissed"] = True
+ signal_corr["at"] = int(time.time())
+ elif action == "correct":
+ signal_corr["correctedValue"] = value
+ signal_corr["dismissed"] = False
+ signal_corr["at"] = int(time.time())
+
+ save_webmail(data)
+ print(json.dumps({"ok": True, "action": action}))
+
+
def webmail_state(args):
data = load_webmail()
+ action = getattr(args, "action", "")
+ if action == "signals":
+ return webmail_signals(args)
+ if action == "signal-correct":
+ return webmail_signal_correct(args)
address = args.email
folder = args.folder
process_incoming(address, folder)
@@ -542,13 +1009,16 @@ def webmail_state(args):
if query and query not in haystack:
continue
rows.append(item)
- rows.sort(key=lambda row: row["timestamp"], reverse=True)
+ rows.sort(key=lambda row: (row["timestamp"], row["id"]), reverse=True)
+ page, next_cursor = page_rows(rows, args.cursor, args.limit)
print(json.dumps({
"ok": True,
"mailbox": address,
"folder": folder,
"folders": folder_counts(address),
- "messages": rows[: int(args.limit)],
+ "messages": page,
+ "total": len(rows),
+ "nextCursor": next_cursor,
"contacts": data.get("contacts", []),
"signatures": data.get("signatures", {}),
"vacation": data.get("vacation", {}),
@@ -627,7 +1097,7 @@ def send_message(args):
ctype = item.get("type") or mimetypes.guess_type(name)[0] or "application/octet-stream"
maintype, subtype = ctype.split("/", 1) if "/" in ctype else ("application", "octet-stream")
msg.add_attachment(content, maintype=maintype, subtype=subtype, filename=name)
- proc = subprocess.run(["/usr/sbin/sendmail", "-t", "-oi"], input=msg.as_bytes(), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ proc = subprocess.run(["/usr/sbin/sendmail", "-f", sender, "-t", "-oi"], input=msg.as_bytes(), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if proc.returncode != 0:
raise ValueError(proc.stderr.decode(errors="replace")[:400] or "sendmail failed")
save_to_folder(sender, "Sent", msg)
@@ -655,7 +1125,7 @@ def send_auto_reply(sender, recipient, original):
msg["Date"] = formatdate(localtime=True)
msg["Message-ID"] = make_msgid(domain=DOMAIN)
msg.set_content(str(vacation.get("body") or "I am currently away."))
- proc = subprocess.run(["/usr/sbin/sendmail", "-t", "-oi"], input=msg.as_bytes(), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ proc = subprocess.run(["/usr/sbin/sendmail", "-f", recipient, "-t", "-oi"], input=msg.as_bytes(), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if proc.returncode == 0:
sent[key] = int(time.time())
audit(data, f"Vacation reply sent to {source}")
@@ -953,15 +1423,44 @@ def require_root():
os.execvp("sudo", ["sudo", "-n", sys.argv[0], *sys.argv[1:]])
+
+def dovecot_password_hash(password):
+ result = subprocess.run(
+ ["doveadm", "pw", "-s", "BLF-CRYPT", "-p", password],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ value = result.stdout.strip()
+ if not value.startswith("{BLF-CRYPT}"):
+ raise RuntimeError("doveadm returned an unexpected password scheme")
+ return value.removeprefix("{BLF-CRYPT}")
+
+
+def dovecot_password_matches(password, stored):
+ if not stored:
+ return False
+ value = stored if stored.startswith("{BLF-CRYPT}") else f"{{BLF-CRYPT}}{stored}"
+ result = subprocess.run(
+ ["doveadm", "pw", "-t", value, "-p", password],
+ capture_output=True,
+ text=True,
+ )
+ return result.returncode == 0
+
def ensure_mailbox(email, password):
local, _, domain = email.partition("@")
- if domain != DOMAIN or not local:
- raise ValueError(f"mailbox must be in {DOMAIN}")
+ known_domains = {DOMAIN}
+ if DOMAINS_STATE.exists():
+ dd = load_domains()
+ known_domains.update(d["domain"] for d in dd.get("domains", []))
+ if domain not in known_domains or not local:
+ raise ValueError(f"mailbox must be in a configured domain (have: {', '.join(sorted(known_domains))})")
mail_root = Path("/var/vmail") / DOMAIN / local
mail_root.mkdir(parents=True, exist_ok=True)
subprocess.run(["chown", "-R", "vmail:mail", str(mail_root)], check=False)
subprocess.run(["chmod", "0770", str(mail_root)], check=False)
- hash_value = crypt.crypt(password, crypt.mksalt(crypt.METHOD_BLOWFISH))
+ hash_value = dovecot_password_hash(password)
existing = []
if DOVECOT_USERS.exists():
existing = [line for line in DOVECOT_USERS.read_text().splitlines() if line and not line.startswith(email + ":")]
@@ -972,13 +1471,47 @@ def ensure_mailbox(email, password):
vmailbox = []
if POSTFIX_VMAILBOX.exists():
vmailbox = [line for line in POSTFIX_VMAILBOX.read_text().splitlines() if line and not line.startswith(email + " ")]
- vmailbox.append(f"{email} {DOMAIN}/{local}/")
+ vmailbox.append(f"{email} {domain}/{local}/")
POSTFIX_VMAILBOX.write_text("\n".join(vmailbox) + "\n")
subprocess.run(["postmap", str(POSTFIX_VMAILBOX)], check=False)
subprocess.run(["systemctl", "reload", "dovecot"], check=False)
subprocess.run(["systemctl", "reload", "postfix"], check=False)
+def top_processes(n=15):
+ """Return top N processes by CPU, including PID, user, cpu%, mem%, command."""
+ r = run(["ps", "aux", "--sort=-pcpu"])
+ lines = r.stdout.strip().splitlines()[1:n+1]
+ out = []
+ for line in lines:
+ parts = line.split(None, 10)
+ if len(parts) < 11:
+ continue
+ out.append({
+ "pid": int(parts[1]),
+ "user": parts[0],
+ "cpu": float(parts[2]),
+ "mem": float(parts[3]),
+ "vsz": int(parts[4]),
+ "rss": int(parts[5]),
+ "command": parts[10][:120],
+ })
+ return out
+
+
+def metrics_quick(args):
+ """Lightweight metrics endpoint for realtime polling."""
+ data = load()
+ m = vm_metrics(data)
+ save(data)
+ print(json.dumps({
+ "metrics": m,
+ "services": services(),
+ "processes": top_processes(),
+ "at": int(time.time()),
+ }))
+
+
def status(args):
data = load()
data["services"] = services()
@@ -1050,6 +1583,229 @@ def dns(args):
]}))
+def load_domains():
+ if not DOMAINS_STATE.exists():
+ default_domains = {"domains": [{"domain": DOMAIN, "addedAt": 0, "status": "active"}]}
+ save_domains(default_domains)
+ with DOMAINS_STATE.open() as f:
+ data = json.load(f)
+ return data
+
+
+def save_domains(data):
+ DOMAINS_STATE.parent.mkdir(parents=True, exist_ok=True)
+ tmp = DOMAINS_STATE.with_suffix(".tmp")
+ tmp.write_text(json.dumps(data, indent=2) + "\n")
+ tmp.replace(DOMAINS_STATE)
+
+
+def domain_dns_records(domain):
+ records = [
+ {"type": "MX", "name": "@", "value": f"10 {HOSTNAME}."},
+ {"type": "TXT", "name": "@", "value": f"v=spf1 mx a:{HOSTNAME} -all"},
+ {"type": "TXT", "name": "_dmarc", "value": f"v=DMARC1; p=quarantine; rua=mailto:postmaster@{domain}"},
+ ]
+ dkim_key = read_dkim_public_key(domain)
+ if dkim_key:
+ records.append({"type": "TXT", "name": "mail._domainkey", "value": dkim_key})
+ else:
+ records.append({"type": "TXT", "name": "mail._domainkey", "value": "(DKIM key not yet generated)"})
+ return records
+
+
+def read_dkim_public_key(domain):
+ key_file = OPENDKIM_KEYS_DIR / domain / "mail.txt"
+ if not key_file.exists():
+ return ""
+ text = key_file.read_text()
+ # Parse the bind-format TXT record into a single value
+ # e.g. mail._domainkey IN TXT ( "v=DKIM1; ..." "p=..." ) ; ...
+ parts = re.findall(r'"([^"]*)"', text)
+ return "".join(parts)
+
+
+def dns_check_domain(domain):
+ checks = [
+ {"name": domain, "type": "MX", "expect": f"10 {HOSTNAME.rstrip('.')}"},
+ ]
+ # Check SPF
+ checks.append({"name": domain, "type": "TXT", "expect_contains": "v=spf1"})
+ # Check DMARC
+ checks.append({"name": f"_dmarc.{domain}", "type": "TXT", "expect_contains": "v=DMARC1"})
+ # Check DKIM
+ dkim_key = read_dkim_public_key(domain)
+ if dkim_key:
+ checks.append({"name": f"mail._domainkey.{domain}", "type": "TXT", "expect_contains": "v=DKIM1"})
+
+ for check in checks:
+ try:
+ values = dns_query(check["name"], check["type"])
+ check["values"] = values
+ if "expect" in check:
+ check["ok"] = check["expect"] in values
+ elif "expect_contains" in check:
+ check["ok"] = any(check["expect_contains"] in v for v in values)
+ else:
+ check["ok"] = len(values) > 0
+ except Exception as e:
+ check["values"] = []
+ check["ok"] = False
+ check["error"] = str(e)
+ # Clean internal fields
+ check.pop("expect", None)
+ check.pop("expect_contains", None)
+ return checks
+
+
+def configure_domain_mail(domain):
+ """Configure Postfix virtual_mailbox_domains and OpenDKIM for a new domain."""
+ # Add to Postfix virtual_mailbox_domains if not already there
+ vmd_file = Path("/etc/postfix/vmailbox_domains")
+ existing = []
+ if vmd_file.exists():
+ existing = [line.strip() for line in vmd_file.read_text().splitlines() if line.strip()]
+ if domain not in existing:
+ existing.append(domain)
+ vmd_file.write_text("\n".join(existing) + "\n")
+
+ # Generate DKIM keys if they don't exist
+ key_dir = OPENDKIM_KEYS_DIR / domain
+ if not (key_dir / "mail.private").exists():
+ key_dir.mkdir(parents=True, exist_ok=True)
+ subprocess.run(
+ ["opendkim-genkey", "-s", "mail", "-d", domain, "-D", str(key_dir)],
+ check=True,
+ )
+ subprocess.run(["chown", "-R", "opendkim:opendkim", str(key_dir)], check=False)
+ subprocess.run(["chmod", "0600", str(key_dir / "mail.private")], check=False)
+
+ # Update OpenDKIM KeyTable
+ keytable_entry = f"mail._domainkey.{domain} {domain}:mail:{key_dir}/mail.private"
+ if OPENDKIM_KEYTABLE.exists():
+ lines = [line for line in OPENDKIM_KEYTABLE.read_text().splitlines() if domain not in line]
+ else:
+ lines = []
+ lines.append(keytable_entry)
+ OPENDKIM_KEYTABLE.write_text("\n".join(lines) + "\n")
+
+ # Update OpenDKIM SigningTable
+ signing_entry = f"*@{domain} mail._domainkey.{domain}"
+ if OPENDKIM_SIGNINGTABLE.exists():
+ lines = [line for line in OPENDKIM_SIGNINGTABLE.read_text().splitlines() if domain not in line]
+ else:
+ lines = []
+ lines.append(signing_entry)
+ OPENDKIM_SIGNINGTABLE.write_text("\n".join(lines) + "\n")
+
+ # Update TrustedHosts
+ if OPENDKIM_TRUSTEDHOSTS.exists():
+ lines = OPENDKIM_TRUSTEDHOSTS.read_text().splitlines()
+ else:
+ lines = ["127.0.0.1", "::1", "localhost"]
+ if domain not in lines:
+ lines.append(domain)
+ OPENDKIM_TRUSTEDHOSTS.write_text("\n".join(lines) + "\n")
+
+ # Create vmail directory for the domain
+ vmail_dir = Path("/var/vmail") / domain
+ vmail_dir.mkdir(parents=True, exist_ok=True)
+ subprocess.run(["chown", "-R", "vmail:mail", str(vmail_dir)], check=False)
+
+ # Reload services
+ subprocess.run(["systemctl", "reload", "opendkim"], check=False)
+ subprocess.run(["systemctl", "reload", "postfix"], check=False)
+
+
+def domain_list(args):
+ data = load_domains()
+ domains = data.get("domains", [])
+ result = []
+ for d in domains:
+ domain = d["domain"]
+ dns_records = domain_dns_records(domain)
+ dns_checks = dns_check_domain(domain)
+ all_ok = all(c.get("ok", False) for c in dns_checks)
+ result.append({
+ "domain": domain,
+ "addedAt": d.get("addedAt", 0),
+ "status": "verified" if all_ok else "pending",
+ "dnsRecords": dns_records,
+ "dnsChecks": dns_checks,
+ })
+ print(json.dumps({"ok": True, "domains": result}))
+
+
+def domain_add(args):
+ require_root()
+ domain = args.domain.strip().lower()
+ if not re.match(r"^[a-z0-9]([a-z0-9\-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9\-]*[a-z0-9])?)+$", domain):
+ print(json.dumps({"ok": False, "error": "invalid domain name"}))
+ return 0
+
+ data = load_domains()
+ existing = [d["domain"] for d in data.get("domains", [])]
+ if domain in existing:
+ print(json.dumps({"ok": False, "error": "domain already exists"}))
+ return 0
+
+ configure_domain_mail(domain)
+
+ data.setdefault("domains", []).append({"domain": domain, "addedAt": int(time.time()), "status": "active"})
+ save_domains(data)
+
+ state = load()
+ event(state, f"Domain added: {domain}")
+ save(state)
+
+ dns_records = domain_dns_records(domain)
+ print(json.dumps({"ok": True, "domain": domain, "dnsRecords": dns_records}))
+
+
+def domain_check(args):
+ domain = args.domain.strip().lower()
+ dns_records = domain_dns_records(domain)
+ dns_checks = dns_check_domain(domain)
+ all_ok = all(c.get("ok", False) for c in dns_checks)
+ print(json.dumps({"ok": True, "domain": domain, "status": "verified" if all_ok else "pending", "dnsRecords": dns_records, "dnsChecks": dns_checks}))
+
+
+def domain_remove(args):
+ require_root()
+ domain = args.domain.strip().lower()
+ if domain == DOMAIN:
+ print(json.dumps({"ok": False, "error": "cannot remove the primary domain"}))
+ return 0
+
+ data = load_domains()
+ data["domains"] = [d for d in data.get("domains", []) if d["domain"] != domain]
+ save_domains(data)
+
+ # Remove from OpenDKIM tables (leave keys on disk for safety)
+ for table_file in [OPENDKIM_KEYTABLE, OPENDKIM_SIGNINGTABLE]:
+ if table_file.exists():
+ lines = [line for line in table_file.read_text().splitlines() if domain not in line]
+ table_file.write_text("\n".join(lines) + "\n")
+
+ if OPENDKIM_TRUSTEDHOSTS.exists():
+ lines = [line for line in OPENDKIM_TRUSTEDHOSTS.read_text().splitlines() if line.strip() != domain]
+ OPENDKIM_TRUSTEDHOSTS.write_text("\n".join(lines) + "\n")
+
+ # Remove from vmailbox_domains
+ vmd_file = Path("/etc/postfix/vmailbox_domains")
+ if vmd_file.exists():
+ lines = [line for line in vmd_file.read_text().splitlines() if line.strip() != domain]
+ vmd_file.write_text("\n".join(lines) + "\n")
+
+ subprocess.run(["systemctl", "reload", "opendkim"], check=False)
+ subprocess.run(["systemctl", "reload", "postfix"], check=False)
+
+ state = load()
+ event(state, f"Domain removed: {domain}")
+ save(state)
+
+ print(json.dumps({"ok": True, "domain": domain, "removed": True}))
+
+
def validate_login(args):
if not DOVECOT_USERS.exists():
print(json.dumps({"ok": False, "error": "mailbox auth unavailable"}))
@@ -1058,27 +1814,239 @@ def validate_login(args):
if not line.startswith(args.email + ":"):
continue
stored = line.split(":", 2)[1]
- if stored.startswith("{BLF-CRYPT}"):
- stored = stored.removeprefix("{BLF-CRYPT}")
- ok = bool(stored and crypt.crypt(args.password, stored) == stored)
+ ok = dovecot_password_matches(args.password, stored)
print(json.dumps({"ok": True, "email": args.email} if ok else {"ok": False, "error": "invalid mailbox credentials"}))
return 0
print(json.dumps({"ok": False, "error": "mailbox not found"}))
return 0
+def _hook_seen_path():
+ return Path(os.environ.get("AKURAI_MAIL_HOOK_SEEN", "/var/lib/akurai-mail-hook/seen.json"))
+
+
+def _hook_seen():
+ path = _hook_seen_path()
+ try:
+ return set(json.loads(path.read_text()))
+ except (FileNotFoundError, json.JSONDecodeError, TypeError):
+ return set()
+
+
+def _mark_hook_seen(key):
+ path = _hook_seen_path()
+ seen = _hook_seen()
+ seen.add(key)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temp = path.with_suffix(".tmp")
+ temp.write_text(json.dumps(sorted(seen)[-10000:]))
+ temp.replace(path)
+
+
+def post_multica(account, message_path):
+ """Create one assigned Multica issue for authenticated mail from Klara."""
+ url = os.environ.get("AKURAI_MAIL_MULTICA_URL", "").rstrip("/")
+ token = os.environ.get("AKURAI_MAIL_MULTICA_TOKEN", "").strip()
+ try:
+ routes = json.loads(os.environ.get("AKURAI_MAIL_KLARA_ROUTES", "{}"))
+ except json.JSONDecodeError:
+ print("[hook] AKURAI_MAIL_KLARA_ROUTES is invalid JSON", file=sys.stderr)
+ return False
+ workspace_id = os.environ.get("AKURAI_MAIL_MULTICA_WORKSPACE_ID", "").strip()
+ project_id = os.environ.get("AKURAI_MAIL_KLARA_PROJECT_ID", "").strip()
+ assignee_id = routes.get(account, "")
+ if not url or not token or not workspace_id or not project_id or not assignee_id:
+ return True
+ try:
+ with Path(message_path).open("rb") as source:
+ msg = email.message_from_binary_file(source, policy=default)
+ message_id = clean_addr(msg.get("Message-ID", ""))
+ key = hashlib.sha256((account + "\0" + (message_id or str(message_path))).encode()).hexdigest()
+ if key in _hook_seen():
+ return True
+ sender = clean_addr(parseaddr(msg.get("From", ""))[1]).lower()
+ if sender != "klara.rakel@icloud.com":
+ _mark_hook_seen(key)
+ return True
+ subject = clean_addr(msg.get("Subject", "") or "(no subject)")
+ description = (
+ "Authenticated inbound email from Klara Rakel Grétarsdóttir.\n\n"
+ f"From: {sender}\nTo: {account}\nMessage-ID: {message_id or '(missing)'}\n"
+ f"Subject: {subject}\n\n{plain_from_message(msg)[:12000]}"
+ )
+ body = json.dumps({
+ "title": f"Email from Klara: {subject}"[:240],
+ "description": description,
+ "status": "todo",
+ "priority": "high",
+ "project_id": project_id,
+ "workspace_id": workspace_id,
+ "assignee_type": "agent",
+ "assignee_id": assignee_id,
+ }).encode()
+ req = urllib.request.Request(
+ url + "/api/issues?workspace_id=" + urllib.parse.quote(workspace_id),
+ data=body,
+ method="POST",
+ headers={"content-type": "application/json", "authorization": "Bearer " + token},
+ )
+ with urllib.request.urlopen(req, timeout=10) as resp:
+ result = json.loads(resp.read() or "{}")
+ _mark_hook_seen(key)
+ print("[hook] created Multica issue %s for %s" % (result.get("identifier", result.get("id", "unknown")), account), file=sys.stderr)
+ return True
+ except urllib.error.HTTPError as exc:
+ print("[hook] Multica returned HTTP %s: %s" % (exc.code, exc.read().decode(errors="replace")[:1000]), file=sys.stderr)
+ except Exception as exc:
+ print("[hook] Multica issue creation failed: %s" % exc, file=sys.stderr)
+ return False
+
+
+def hook_notify(args):
+ """Compatibility probe: report whether direct Multica delivery is configured."""
+ configured = bool(os.environ.get("AKURAI_MAIL_MULTICA_URL") and os.environ.get("AKURAI_MAIL_MULTICA_TOKEN"))
+ print(json.dumps({"ok": configured}))
+ return 0 if configured else 1
+
+
+def hook_watch(args):
+ """Long-running inotify watcher: create Multica issues from Klara's inbound mail."""
+ libc = ctypes.CDLL(ctypes.util.find_library("c") or "libc.so.6", use_errno=True)
+ fd = libc.inotify_init()
+ if fd < 0:
+ raise OSError(ctypes.get_errno(), "inotify_init failed")
+ IN_CREATE, IN_MOVED_TO, IN_CLOSE_WRITE = 0x100, 0x80, 0x8
+ event_mask = IN_CREATE | IN_MOVED_TO | IN_CLOSE_WRITE
+ root = os.environ.get("AKURAI_MAIL_WATCH_ROOT", "/var/vmail")
+ watched = {} # wd -> (Path new_dir, str account_email)
+
+ def refresh():
+ known = {str(p) for p, _ in watched.values()}
+ for new_dir in sorted(Path(root).glob("*/*/Maildir/new")):
+ if str(new_dir) in known or not new_dir.is_dir():
+ continue
+ parts = new_dir.parts
+ email = "%s@%s" % (parts[-3], parts[-4])
+ wd = libc.inotify_add_watch(fd, str(new_dir).encode(), event_mask)
+ if wd >= 0:
+ watched[wd] = (new_dir, email)
+ print("[hook] watching %s (%s)" % (new_dir, email), file=sys.stderr)
+
+ refresh()
+ if not watched:
+ print("[hook] no Maildir/new directories under %s yet" % root, file=sys.stderr)
+ last_scan = time.time()
+ while True:
+ ready, _, _ = select.select([fd], [], [], 2)
+ if time.time() - last_scan >= 60:
+ refresh()
+ last_scan = time.time()
+ if not ready:
+ for new_dir, account in watched.values():
+ for message_path in new_dir.iterdir():
+ if message_path.is_file() and not message_path.name.startswith("."):
+ post_multica(account, message_path)
+ continue
+ raw = os.read(fd, 65536)
+ touched = {}
+ pos = 0
+ while pos + 16 <= len(raw):
+ wd, _mask, _cookie, namelen = struct.unpack_from("iIII", raw, pos)
+ pos += 16
+ name = raw[pos:pos + namelen].split(b"\0", 1)[0]
+ pos += namelen
+ entry = watched.get(wd)
+ if entry is None or name.startswith(b"."):
+ continue
+ touched[entry[1]] = entry[0] / os.fsdecode(name)
+ if not touched:
+ continue
+ time.sleep(0.3)
+ for account, message_path in touched.items():
+ post_multica(account, message_path)
+
+
+def harden_antispam(args):
+ """Idempotent anti-spam hardening: SpamAssassin Bayes + autolearn, weekly
+ rule updates, and a systemd timer that sweeps SA-tagged spam to Junk.
+ Safe by design: tag/file/learn only, no reject rules, no Dovecot changes."""
+ require_root()
+ steps = []
+ marker = "# --- akurai-mail harden-antispam ---"
+ sa_local = Path("/etc/spamassassin/local.cf")
+ text = sa_local.read_text() if sa_local.exists() else ""
+ if marker not in text:
+ block = "\n".join([
+ "", marker,
+ "use_bayes 1",
+ "bayes_auto_learn 1",
+ "bayes_auto_expire 1",
+ "bayes_auto_learn_threshold_nonspam 0.1",
+ "bayes_auto_learn_threshold_spam 12.0",
+ "",
+ ])
+ sa_local.write_text(text + block)
+ steps.append("spamassassin: enabled bayes + autolearn")
+ else:
+ steps.append("spamassassin: bayes config already present")
+ sad = Path("/etc/default/spamassassin")
+ if sad.exists():
+ d = sad.read_text()
+ if not re.search(r"(?m)^CRON=1\s*$", d):
+ d = re.sub(r"(?m)^CRON=.*$", "CRON=1", d)
+ if "CRON=1" not in d:
+ d += "\nCRON=1\n"
+ sad.write_text(d)
+ steps.append("spamassassin: enabled sa-update cron")
+ else:
+ steps.append("spamassassin: sa-update cron already on")
+ subprocess.run(["sa-update", "--no-gpg"], capture_output=True)
+ for svc in ("spamassassin.service", "spamd.service", "spamass-milter.service"):
+ subprocess.run(["systemctl", "reload-or-restart", svc], capture_output=True)
+ steps.append("spamassassin: rules refreshed and services reloaded")
+ svc_unit = Path("/etc/systemd/system/akurai-mail-antispam.service")
+ tmr_unit = Path("/etc/systemd/system/akurai-mail-antispam.timer")
+ svc_unit.write_text(
+ "[Unit]\n"
+ "Description=AkurAI mail anti-spam sweep (SA-tagged mail to Junk)\n"
+ "After=dovecot.service\n\n"
+ "[Service]\n"
+ "Type=oneshot\n"
+ "ExecStart=/usr/local/sbin/akurai-mail-server anti-spam-scan\n"
+ )
+ tmr_unit.write_text(
+ "[Unit]\n"
+ "Description=Run AkurAI mail anti-spam sweep every 10 minutes\n\n"
+ "[Timer]\n"
+ "OnBootSec=5min\n"
+ "OnUnitActiveSec=10min\n"
+ "Persistent=true\n\n"
+ "[Install]\n"
+ "WantedBy=timers.target\n"
+ )
+ subprocess.run(["systemctl", "daemon-reload"], capture_output=True)
+ subprocess.run(["systemctl", "enable", "--now", "akurai-mail-antispam.timer"], capture_output=True)
+ steps.append("timer: akurai-mail-antispam.timer enabled (every 10min)")
+ print(json.dumps({"ok": True, "hardened": steps}))
+
+
def main():
p = argparse.ArgumentParser()
sub = p.add_subparsers(required=True)
s = sub.add_parser("status"); s.set_defaults(fn=status)
+ s = sub.add_parser("metrics"); s.set_defaults(fn=metrics_quick)
s = sub.add_parser("add-user"); s.add_argument("email"); s.add_argument("--password"); s.set_defaults(fn=add_user)
s = sub.add_parser("set-password"); s.add_argument("email"); s.add_argument("--password"); s.set_defaults(fn=set_password)
s = sub.add_parser("add-alias"); s.add_argument("alias"); s.add_argument("target"); s.set_defaults(fn=add_alias)
- s = sub.add_parser("dns"); s.set_defaults(fn=dns)
+ s = sub.add_parser("webmail-state"); s.add_argument("email"); s.add_argument("--folder", default="Inbox"); s.add_argument("--query", default=""); s.add_argument("--limit", default="40"); s.add_argument("--cursor", default=""); s.add_argument("--action", default=""); s.add_argument("--payload", default="{}"); s.set_defaults(fn=webmail_state)
s = sub.add_parser("anti-spam-config"); s.add_argument("payload", nargs="?", default="{}"); s.set_defaults(fn=anti_spam_config)
- s = sub.add_parser("anti-spam-scan"); s.add_argument("email", nargs="?"); s.set_defaults(fn=anti_spam_scan)
+ s = sub.add_parser("anti-spam-scan"); s.add_argument("--email", default=""); s.set_defaults(fn=anti_spam_scan)
+ s = sub.add_parser("harden-antispam"); s.set_defaults(fn=harden_antispam)
+ s = sub.add_parser("domain-list"); s.set_defaults(fn=domain_list)
+ s = sub.add_parser("domain-add"); s.add_argument("domain"); s.set_defaults(fn=domain_add)
+ s = sub.add_parser("domain-check"); s.add_argument("domain"); s.set_defaults(fn=domain_check)
+ s = sub.add_parser("domain-remove"); s.add_argument("domain"); s.set_defaults(fn=domain_remove)
s = sub.add_parser("validate-login"); s.add_argument("email"); s.add_argument("password"); s.set_defaults(fn=validate_login)
- s = sub.add_parser("webmail-state"); s.add_argument("email"); s.add_argument("--folder", default="Inbox"); s.add_argument("--query", default=""); s.add_argument("--limit", default="100"); s.set_defaults(fn=webmail_state)
s = sub.add_parser("webmail-read"); s.add_argument("email"); s.add_argument("id"); s.add_argument("--mark-read", action="store_true"); s.set_defaults(fn=read_message)
s = sub.add_parser("webmail-send"); s.add_argument("email"); s.add_argument("payload"); s.set_defaults(fn=send_message)
s = sub.add_parser("webmail-draft"); s.add_argument("email"); s.add_argument("payload"); s.set_defaults(fn=save_draft)
@@ -1088,6 +2056,10 @@ def main():
s = sub.add_parser("webmail-export"); s.add_argument("email"); s.set_defaults(fn=webmail_export)
s = sub.add_parser("webmail-import"); s.add_argument("email"); s.add_argument("payload"); s.set_defaults(fn=webmail_import)
s = sub.add_parser("webmail-attachment"); s.add_argument("email"); s.add_argument("id"); s.add_argument("index"); s.set_defaults(fn=attachment_download)
+ s = sub.add_parser("webmail-signals"); s.add_argument("email"); s.add_argument("--folder", default="Inbox"); s.set_defaults(fn=webmail_signals)
+ s = sub.add_parser("webmail-signal-correct"); s.add_argument("email"); s.add_argument("payload"); s.set_defaults(fn=webmail_signal_correct)
+ s = sub.add_parser("hook-watch"); s.set_defaults(fn=hook_watch)
+ s = sub.add_parser("hook-notify"); s.add_argument("account", nargs="?", default=""); s.set_defaults(fn=hook_notify)
a = p.parse_args()
try:
r = a.fn(a)