fix: update version.txt for 1.26.2 release
This commit is contained in:
parent
6d5adeef9c
commit
fb68a1b011
8 changed files with 691 additions and 0 deletions
232
mail_deliv_probe.py
Executable file
232
mail_deliv_probe.py
Executable file
|
|
@ -0,0 +1,232 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Send a tagged probe mail via sink SMTP, then IMAP-search INBOX vs Junk/spam.
|
||||
|
||||
SUT remains Taler mail domains elsewhere; this only exercises mon* sinks
|
||||
(and optional g0v) for inbox-vs-spam visibility.
|
||||
|
||||
Env:
|
||||
MAIL_DELIV_IMAP_PASS / MAIL_DELIV_ALERT_PASS required (shared default)
|
||||
MAIL_DELIV_PASS_mon001 / _mon002 / _mon003 optional per-mailbox override
|
||||
MAIL_DELIV_PROBE_FROM default mon001@hacktivism.ch
|
||||
MAIL_DELIV_PROBE_TO default mon001 mon002 mon003 (keep all — odd-looking addrs)
|
||||
MAIL_DELIV_PROBE_SMTP default mail.cyon.ch:587
|
||||
MAIL_DELIV_IMAP_HOST default mail.cyon.ch
|
||||
MAIL_DELIV_PROBE_WAIT_S default 25
|
||||
MAIL_DELIV_PROBE_ALERT_TO optional extra alert recipients on spam/miss
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import imaplib
|
||||
import os
|
||||
import smtplib
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from email.message import EmailMessage
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _env(name: str, default: str = "") -> str:
|
||||
return (os.environ.get(name) or default).strip()
|
||||
|
||||
|
||||
def _pass_for(addr: str) -> str:
|
||||
"""Shared pass, or MAIL_DELIV_PASS_<localpart> (e.g. MAIL_DELIV_PASS_mon002)."""
|
||||
local = addr.split("@", 1)[0].replace(".", "_").replace("+", "_")
|
||||
specific = _env(f"MAIL_DELIV_PASS_{local}") or _env(f"MAIL_DELIV_IMAP_PASS_{local}")
|
||||
if specific:
|
||||
return specific
|
||||
return _env("MAIL_DELIV_IMAP_PASS") or _env("MAIL_DELIV_ALERT_PASS")
|
||||
|
||||
|
||||
def smtp_send(host: str, port: int, user: str, pw: str, to: str, subj: str, body: str, msgid: str) -> None:
|
||||
msg = EmailMessage()
|
||||
msg["From"] = user
|
||||
msg["To"] = to
|
||||
msg["Subject"] = subj
|
||||
msg["Message-ID"] = msgid
|
||||
msg["X-Taler-Mon-Probe"] = msgid.strip("<>")
|
||||
msg.set_content(body)
|
||||
ctx = ssl.create_default_context()
|
||||
with smtplib.SMTP(host, port, timeout=45) as s:
|
||||
s.ehlo()
|
||||
s.starttls(context=ctx)
|
||||
s.ehlo()
|
||||
s.login(user, pw)
|
||||
s.send_message(msg)
|
||||
|
||||
|
||||
def imap_find(host: str, user: str, pw: str, token: str) -> dict:
|
||||
"""Return {folder: count} for folders where token appears in Message-ID/Subject/body."""
|
||||
ctx = ssl.create_default_context()
|
||||
M = imaplib.IMAP4_SSL(host, 993, ssl_context=ctx)
|
||||
M.login(user, pw)
|
||||
typ, bl = M.list()
|
||||
folders = []
|
||||
for raw in bl or []:
|
||||
line = raw.decode(errors="replace")
|
||||
# LIST (\HasNoChildren) "." INBOX.Junk
|
||||
if '"/"' in line or ' "." ' in line or ' "."' in line:
|
||||
name = line.split(' "." ')[-1].strip().strip('"')
|
||||
elif ' "/" ' in line:
|
||||
name = line.split(' "/" ')[-1].strip().strip('"')
|
||||
else:
|
||||
parts = line.rsplit(" ", 1)
|
||||
name = parts[-1].strip().strip('"') if parts else ""
|
||||
if name:
|
||||
folders.append(name)
|
||||
# ensure common names
|
||||
for n in ("INBOX", "INBOX.Junk", "INBOX.spam", "Junk", "Spam"):
|
||||
if n not in folders:
|
||||
folders.append(n)
|
||||
|
||||
hits: dict[str, int] = {}
|
||||
needle = token.encode()
|
||||
for folder in folders:
|
||||
try:
|
||||
typ, _ = M.select(folder, readonly=True)
|
||||
if typ != "OK":
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
# SEARCH TEXT is broad; prefer HEADER Message-ID
|
||||
found = 0
|
||||
for crit in (f'(HEADER Message-ID "{token}")', f'(HEADER X-Taler-Mon-Probe "{token}")', f'(SUBJECT "{token}")'):
|
||||
try:
|
||||
typ, data = M.search(None, crit)
|
||||
if typ == "OK" and data and data[0]:
|
||||
ids = data[0].split()
|
||||
found = max(found, len(ids))
|
||||
except Exception:
|
||||
continue
|
||||
if found:
|
||||
hits[folder] = found
|
||||
M.logout()
|
||||
return hits
|
||||
|
||||
|
||||
def classify(hits: dict[str, int]) -> str:
|
||||
if not hits:
|
||||
return "missing"
|
||||
# prefer INBOX without junk
|
||||
junkish = []
|
||||
inboxish = []
|
||||
for f, n in hits.items():
|
||||
fl = f.lower()
|
||||
if "junk" in fl or "spam" in fl:
|
||||
junkish.append(f)
|
||||
elif fl == "inbox" or fl.endswith(".inbox") or fl == "inbox.inbox":
|
||||
inboxish.append(f)
|
||||
elif "inbox" in fl and "junk" not in fl and "spam" not in fl and "trash" not in fl:
|
||||
inboxish.append(f)
|
||||
if inboxish and not junkish:
|
||||
return "inbox"
|
||||
if junkish and not inboxish:
|
||||
return "spam"
|
||||
if inboxish and junkish:
|
||||
return "both"
|
||||
return "other:" + ",".join(hits.keys())
|
||||
|
||||
|
||||
def main() -> int:
|
||||
frm = _env("MAIL_DELIV_PROBE_FROM", "mon001@hacktivism.ch")
|
||||
# Keep mon001–003 even if one address looks odd / one pass differs.
|
||||
tos = _env(
|
||||
"MAIL_DELIV_PROBE_TO",
|
||||
"mon001@hacktivism.ch mon002@hacktivism.ch mon003@hacktivism.ch",
|
||||
).split()
|
||||
pw_from = _pass_for(frm)
|
||||
if not pw_from:
|
||||
print("FAIL missing MAIL_DELIV_IMAP_PASS (or per-mailbox MAIL_DELIV_PASS_*)", file=sys.stderr)
|
||||
return 2
|
||||
smtp = _env("MAIL_DELIV_PROBE_SMTP", "mail.cyon.ch:587")
|
||||
imap_host = _env("MAIL_DELIV_IMAP_HOST", "mail.cyon.ch")
|
||||
wait_s = float(_env("MAIL_DELIV_PROBE_WAIT_S", "25") or "25")
|
||||
host, _, port_s = smtp.partition(":")
|
||||
port = int(port_s or "587")
|
||||
|
||||
token = uuid.uuid4().hex
|
||||
msgid = f"<{token}@taler-monitoring.probe>"
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
subj = f"[taler-mon probe] {token[:12]}"
|
||||
body = (
|
||||
f"taler-monitoring regular mon* probe\n"
|
||||
f"token={token}\n"
|
||||
f"when={now}\n"
|
||||
f"Hallo Hernâni, hallo Florian — Inbox-vs-Spam check.\n"
|
||||
)
|
||||
|
||||
results = []
|
||||
sent_ok = []
|
||||
for to in tos:
|
||||
try:
|
||||
smtp_send(host, port, frm, pw_from, to, subj, body, msgid)
|
||||
print(f"SENT to={to} token={token[:12]}")
|
||||
sent_ok.append(to)
|
||||
except Exception as e:
|
||||
print(f"SEND_FAIL to={to} {type(e).__name__}: {e}")
|
||||
results.append((to, "send_fail", {}))
|
||||
|
||||
time.sleep(max(5.0, wait_s))
|
||||
|
||||
rc = 0
|
||||
for to in tos:
|
||||
pw = _pass_for(to)
|
||||
if not pw:
|
||||
print(f"IMAP_SKIP to={to} no password (set MAIL_DELIV_PASS_{to.split('@')[0]})")
|
||||
results.append((to, "no_pass", {}))
|
||||
rc = 1
|
||||
continue
|
||||
try:
|
||||
hits = imap_find(imap_host, to, pw, token)
|
||||
kind = classify(hits)
|
||||
print(f"IMAP to={to} result={kind} hits={hits}")
|
||||
results.append((to, kind, hits))
|
||||
if kind != "inbox":
|
||||
rc = 1
|
||||
except Exception as e:
|
||||
# Keep name in rotation; soft note — odd addr / wrong pass still listed
|
||||
print(f"IMAP_FAIL to={to} {type(e).__name__}: {e}")
|
||||
results.append((to, "imap_fail", {}))
|
||||
rc = 1
|
||||
|
||||
# optional notify alert recipients if any non-inbox
|
||||
alert_to = _env("MAIL_DELIV_PROBE_ALERT_TO") or _env("MAIL_DELIV_ALERT_TO")
|
||||
bad = [r for r in results if r[1] not in ("inbox",)]
|
||||
if alert_to and bad:
|
||||
summary = "\n".join(f"- {t}: {k} {h}" for t, k, h in results)
|
||||
for ato in alert_to.split():
|
||||
try:
|
||||
smtp_send(
|
||||
host,
|
||||
port,
|
||||
frm,
|
||||
pw_from,
|
||||
ato,
|
||||
f"[taler-mon probe] spam/miss {token[:12]}",
|
||||
f"Probe issues:\n{summary}\n",
|
||||
f"<{uuid.uuid4().hex}@taler-monitoring.alert>",
|
||||
)
|
||||
print(f"ALERT → {ato}")
|
||||
except Exception as e:
|
||||
print(f"ALERT_FAIL → {ato} {e}")
|
||||
|
||||
# stamp state
|
||||
state = Path(
|
||||
_env(
|
||||
"TALER_MONITORD_STATE_DIR",
|
||||
str(Path.home() / ".local/state/taler-monitord"),
|
||||
)
|
||||
)
|
||||
state.mkdir(parents=True, exist_ok=True)
|
||||
(state / "mail-deliv-probe.last").write_text(
|
||||
f"ts={now}\ntoken={token}\nrc={rc}\n" + "\n".join(f"{t}\t{k}\t{h}" for t, k, h in results) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return rc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in a new issue