Härtung und Sammelpostfach per SSO (#1)

This commit was merged in pull request #1.
This commit is contained in:
Rsclub2_2 committed 2026-10-29 14:57:29 +01:00
1 parent 99ec2b956b
commit 7ee574b9dd
9 files changed
+626 -112

No files matched your search

+11 -9
View File
@@ -1,18 +1,20 @@
# Verwende das offizielle Python-Image als Basis
FROM python:3.8-slim
FROM python:3.12-slim
# Setze das Arbeitsverzeichnis innerhalb des Containers
WORKDIR /app
# Installiere die erforderlichen Abhängigkeiten
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Kopiere die Anwendungsdateien in den Container
COPY . .
COPY app.py oauth.py index.html result.html ./
COPY static ./static
# Starte die Anwendung, wenn der Container gestartet wird
CMD ["python", "app.py"]
RUN useradd -m -u 1000 app
USER app
#Expose den Port 5000 (Nur Dokumentation für Entwickler)
EXPOSE 5000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/health')"
# gunicorn instead of the Flask development server (no Werkzeug debugger); one worker, because the
# OAuth refresh token rotates and must not be refreshed by two processes at once
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "1", "--threads", "4", "--timeout", "120", "app:app"]
+12
View File
@@ -22,3 +22,15 @@ Run following command: ```docker run -p 5000:5000 -e EMAIL_USERNAME=your_email@e
Copyright 2023 Philipp Wagner
This project is licensed under the GPLv3 License - see the [LICENSE](LICENSE) file for details.
## Betrieb
Konfiguration per Umgebung: `EMAIL_SERVER`, `EMAIL_PORT` (993), `EMAIL_USERNAME`, `EMAIL_PASSWORD`
(Sammelpostfach), optional `EMAIL_FOLDER` (`INBOX`) und `TRASH_FOLDER` (`Trash`).
Statt `EMAIL_PASSWORD` geht SSO (OAuth/XOAUTH2): `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`,
`OIDC_REDIRECT_URI` (`https://<host>/oidc/callback`), `TOKEN_KEY` (Fernet-Schlüssel) und `DATA_DIR` (`/data`).
Dann zeigt die Startseite „Connect mailbox“: einmal im privaten Fenster als technisches Konto des
Sammelpostfachs am SSO anmelden; das Refresh-Token liegt verschlüsselt in `DATA_DIR/oauth-token`.
Der Container startet gunicorn auf Port 5000 (kein Flask-Debugger) und gehört hinter einen
Reverse Proxy mit Anmeldung (bei Wagnersnetz Caddy `forward_auth` über Authelia).
Tests: `pytest tests`.
+167 -98
View File
@@ -1,138 +1,207 @@
import imaplib
"""
E-Mail Unsubscribe Link Finder.
Reads a collection mailbox (forwarded newsletters), lists the unsubscribe links per mail and moves
handled mails to the trash. Runs behind the reverse proxy (Caddy forward_auth / Authelia); the
mailbox credentials come from the environment.
"""
import email
import re
import imaplib
import logging
import os
from flask import Flask, render_template, request, redirect, url_for
from dotenv import load_dotenv
import re
from email.header import decode_header, make_header
from bs4 import BeautifulSoup
from dotenv import load_dotenv
from flask import Flask, abort, redirect, render_template, request, url_for
app = Flask(__name__)
load_dotenv() # Laden der Umgebungsvariablen aus der .env-Datei
from oauth import MailboxOAuth, OAuthError, xoauth2_string
# Email-Server-Konfiguration
EMAIL_USERNAME = os.environ.get("EMAIL_USERNAME")
EMAIL_PASSWORD = os.environ.get("EMAIL_PASSWORD")
EMAIL_SERVER = os.environ.get("EMAIL_SERVER")
load_dotenv() # optional .env for local runs
EMAIL_USERNAME = os.environ.get("EMAIL_USERNAME", "")
EMAIL_PASSWORD = os.environ.get("EMAIL_PASSWORD", "")
EMAIL_SERVER = os.environ.get("EMAIL_SERVER", "")
EMAIL_PORT = int(os.environ.get("EMAIL_PORT", "993"))
EMAIL_FOLDER = os.environ.get("EMAIL_FOLDER", "INBOX")
TRASH_FOLDER = os.environ.get("TRASH_FOLDER", "Trash")
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"))
logger = logging.getLogger(__name__)
template_dir = os.path.abspath(os.path.dirname(__file__))
app = Flask(__name__, template_folder=template_dir)
link_pattern = r"<a href=\"(.*?)\">(.*?)</a>"
unsubscribe_pattern = r"(?i)\babbestellen\b|\bunsubscribe\b|\bdeabonnieren\b|\babbestellung\b|\babmelden\b"
def get_text_from_email(email_message):
# OAuth (SSO) for the collection mailbox if OIDC_* and TOKEN_KEY are set; otherwise EMAIL_PASSWORD
OAUTH = MailboxOAuth.from_env()
UNSUBSCRIBE_PATTERN = re.compile(
r"\babbestellen\b|\bunsubscribe\b|\bdeabonnieren\b|\babbestellung\b|\babmelden\b", re.IGNORECASE
)
URL_PATTERN = re.compile(r"https?://[^\s<>\"']+")
UID_PATTERN = re.compile(r"^\d+$")
def is_safe_link(url: str) -> bool:
"""Only plain web links: mails are untrusted, javascript:/data: links must never become clickable."""
return url.lower().startswith(("https://", "http://"))
def get_text_from_email(message) -> str:
text = ""
for part in email_message.walk():
content_type = part.get_content_type()
content_disposition = str(part.get("Content-Disposition"))
if "text" in content_type and "attachment" not in content_disposition:
for part in message.walk():
disposition = str(part.get("Content-Disposition"))
if "text" in part.get_content_type() and "attachment" not in disposition:
body = part.get_payload(decode=True)
if body:
text += body.decode(errors="ignore")
return text
def find_unsubscribe_links(text, html_links):
soup = BeautifulSoup(text, 'html.parser')
unsubscribe_links = []
def get_subject(message) -> str:
raw = message.get("Subject", "")
try:
return str(make_header(decode_header(raw)))
except Exception:
return raw
for link in soup.find_all('a', href=True):
if re.search(unsubscribe_pattern, str(link)) and str(link['href']) not in html_links:
print(link['href'])
test1=str(link['href'])
print(test1)
if test1 not in unsubscribe_links:
print(test1)
unsubscribe_links.append(test1)
def find_unsubscribe_links(text: str) -> list[str]:
"""Unsubscribe links from HTML anchors (link text or URL matches) and from plain-text URLs."""
links: list[str] = []
soup = BeautifulSoup(text, "html.parser")
for anchor in soup.find_all("a", href=True):
href = str(anchor["href"]).strip()
if UNSUBSCRIBE_PATTERN.search(str(anchor)) and is_safe_link(href) and href not in links:
links.append(href)
for url in URL_PATTERN.findall(text):
if UNSUBSCRIBE_PATTERN.search(url) and url not in links:
links.append(url)
return links
# Zusätzlich nach einfachen URLs suchen und auf das Unsubscribe-Pattern prüfen
simple_urls = re.findall(r"(https?://\S+)", text)
for url in simple_urls:
if re.search(unsubscribe_pattern, url) and url not in html_links:
if url not in unsubscribe_links:
unsubscribe_links.append(url)
return unsubscribe_links
def connect() -> imaplib.IMAP4_SSL:
token = OAUTH.access_token() if OAUTH else None # before connecting: fail fast without a token
mail = imaplib.IMAP4_SSL(EMAIL_SERVER, EMAIL_PORT)
if token:
mail.authenticate("XOAUTH2", lambda _: xoauth2_string(EMAIL_USERNAME, token).encode())
else:
mail.login(EMAIL_USERNAME, EMAIL_PASSWORD)
mail.select(EMAIL_FOLDER)
return mail
def get_subject_from_email(email_message):
subject = email_message.get("Subject")
return subject
def move_emails_to_trash(email_ids):
def collect_links(mail) -> dict:
"""{uid: {"subject": ..., "links": [...]}} for all mails with unsubscribe links (UIDs stay stable)."""
_, data = mail.uid("search", None, "ALL")
result = {}
for uid in data[0].split():
_, msg_data = mail.uid("fetch", uid, "(BODY.PEEK[])")
if not msg_data or not isinstance(msg_data[0], tuple):
continue
message = email.message_from_bytes(msg_data[0][1])
body = get_text_from_email(message)
links = find_unsubscribe_links(body) if body else []
if links:
result[uid.decode()] = {"subject": get_subject(message), "links": links}
return result
username = EMAIL_USERNAME
password = EMAIL_PASSWORD
server = EMAIL_SERVER
mail = imaplib.IMAP4_SSL(server)
mail.login(username, password)
mail.select("inbox")
def move_emails_to_trash(uids: list[str]) -> int:
uids = [u for u in uids if UID_PATTERN.match(u)]
if not uids:
return 0
mail = connect()
try:
for uid in uids:
# Mailcow/Dovecot: copy to trash, flag deleted, expunge
mail.uid("copy", uid, TRASH_FOLDER)
mail.uid("store", uid, "+FLAGS", "(\\Deleted)")
mail.expunge()
finally:
mail.logout()
logger.info("Moved %d mail(s) to %s", len(uids), TRASH_FOLDER)
return len(uids)
for email_id in email_ids:
# Verschiebe die E-Mail in den Papierkorb (Trash)
mail.copy(email_id, "Trash")
# Setze die \Deleted-Flagge, um die E-Mail später zu löschen (für Mailcow)
mail.store(email_id, "+FLAGS", "\\Deleted")
# Speichere die Änderungen auf dem IMAP-Server
mail.expunge()
mail.logout()
@app.route("/", methods=["GET", "POST"])
@app.route("/", methods=["GET"])
def index():
return render_template("index.html", email_address=EMAIL_USERNAME)
return render_template(
"index.html",
email_address=EMAIL_USERNAME,
oauth=OAUTH is not None,
connected=OAUTH.connected() if OAUTH else True,
error=request.args.get("error", ""),
)
@app.route("/connect")
def connect_mailbox():
"""Connect the collection mailbox: sign in at the SSO as its technical account (private window)."""
if not OAUTH:
abort(404)
return redirect(OAUTH.start())
@app.route("/oidc/callback")
def oidc_callback():
if not OAUTH:
abort(404)
if request.args.get("error"):
return redirect(url_for("index", error=request.args["error"]))
try:
OAUTH.finish(request.args.get("state", ""), request.args.get("code", ""))
except OAuthError as e:
logger.warning("Mailbox connect failed: %s", e)
return redirect(url_for("index", error=str(e)))
logger.info("Collection mailbox %s connected via OAuth", EMAIL_USERNAME)
return redirect(url_for("index"))
@app.route("/disconnect", methods=["POST"])
def disconnect_mailbox():
if not OAUTH:
abort(404)
OAUTH.disconnect()
return redirect(url_for("index"))
@app.errorhandler(OAuthError)
def oauth_error(e):
logger.warning("Mailbox access failed: %s", e)
return redirect(url_for("index", error="Mailbox not connected or token expired – connect again"))
@app.route("/result")
def result():
username = EMAIL_USERNAME
password = EMAIL_PASSWORD
server = EMAIL_SERVER
mail = imaplib.IMAP4_SSL(server)
mail.login(username, password)
mail.select("inbox")
_, messages = mail.search(None, "ALL")
links_data = {}
html_links = set()
for message_num in messages[0].split():
_, msg_data = mail.fetch(message_num, "(RFC822)")
msg = email.message_from_bytes(msg_data[0][1])
body = get_text_from_email(msg)
subject = get_subject_from_email(msg)
if body:
unsubscribe_links_in_html = find_unsubscribe_links(body, html_links)
html_links.update(unsubscribe_links_in_html)
unsubscribe_links_in_text = find_unsubscribe_links(body, html_links)
all_unsubscribe_links = list(set(unsubscribe_links_in_html + unsubscribe_links_in_text))
if all_unsubscribe_links:
links_data[message_num.decode()] = {
"subject": subject,
"links": all_unsubscribe_links
}
mail.logout()
mail = connect()
try:
links_data = collect_links(mail)
finally:
mail.logout()
return render_template("result.html", links_data=links_data)
@app.route("/move-to-trash", methods=["POST"])
def move_to_trash():
if request.method == "POST":
email_id = request.form.get("email_id")
if email_id:
move_emails_to_trash([email_id])
email_id = request.form.get("email_id", "")
move_emails_to_trash([email_id])
return redirect(url_for("result"))
@app.route("/move-all-to-trash", methods=["POST"])
def move_all_to_trash():
if request.method == "POST":
email_ids = request.form.getlist("email_id")
if email_ids:
move_emails_to_trash(email_ids)
move_emails_to_trash(request.form.getlist("email_id"))
return redirect(url_for("index"))
@app.route("/health")
def health():
return {"status": "ok"}
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True)
# local development only; the container runs gunicorn (no debugger)
app.run(host="127.0.0.1", port=5000, debug=os.environ.get("FLASK_DEBUG") == "1")
+14
View File
@@ -8,9 +8,23 @@
<div class="container">
<h1>E-Mail Unsubscribe Link Finder</h1>
<p>Email your emails to: {{ email_address }}</p>
{% if error %}<p class="error">{{ error }}</p>{% endif %}
{% if connected %}
<form action="/result" method="get">
<button type="submit" class="btn-primary">Start</button>
</form>
{% if oauth %}
<form action="/disconnect" method="post">
<button type="submit" class="btn-delete">Disconnect mailbox</button>
</form>
{% endif %}
{% else %}
<p>The collection mailbox is not connected yet. Open this page in a private window and sign in
as <strong>{{ email_address }}</strong> (technical account) – not with your own account.</p>
<form action="/connect" method="get">
<button type="submit" class="btn-primary">Connect mailbox</button>
</form>
{% endif %}
</div>
</body>
</html>
+184
View File
@@ -0,0 +1,184 @@
"""
OAuth access to the collection mailbox (XOAUTH2) instead of a stored password.
An admin connects the mailbox once: OIDC code flow with PKCE, signing in at the SSO as the technical
account of the mailbox. The refresh token is stored encrypted in DATA_DIR; access tokens stay in memory.
"""
import base64
import hashlib
import json
import os
import secrets
import threading
import time
from pathlib import Path
from urllib.parse import urlencode
import httpx
from cryptography.fernet import Fernet, InvalidToken
SCOPES = "openid profile email offline_access"
REFRESH_MARGIN_SEC = 60
PENDING_TTL_SEC = 600
class OAuthError(Exception):
pass
def _b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def new_pkce() -> tuple[str, str]:
verifier = _b64url(secrets.token_bytes(48))
return verifier, _b64url(hashlib.sha256(verifier.encode()).digest())
def jwt_claims(token: str) -> dict:
try:
payload = token.split(".")[1]
return json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
except (IndexError, ValueError) as e:
raise OAuthError("Access token is not a JWT") from e
def xoauth2_string(user: str, token: str) -> str:
return f"user={user}\x01auth=Bearer {token}\x01\x01"
class MailboxOAuth:
"""Token handling for exactly one mailbox."""
def __init__(self, issuer, client_id, client_secret, redirect_uri, mailbox, data_dir, key,
transport=None, clock=time.time):
self.client_id = client_id
self.client_secret = client_secret
self.redirect_uri = redirect_uri
self.mailbox = mailbox
self.authorization_endpoint = issuer.rstrip("/") + "/api/oidc/authorization"
self.token_endpoint = issuer.rstrip("/") + "/api/oidc/token"
self.token_file = Path(data_dir) / "oauth-token"
self.cipher = Fernet(key.encode() if isinstance(key, str) else key)
self.transport = transport
self.clock = clock
self._lock = threading.Lock()
self._access: tuple[str, float] | None = None
self._pending: dict[str, tuple[str, float]] = {}
@classmethod
def from_env(cls):
names = ["OIDC_ISSUER", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "OIDC_REDIRECT_URI", "TOKEN_KEY"]
if not all(os.environ.get(n) for n in names):
return None
return cls(
issuer=os.environ["OIDC_ISSUER"],
client_id=os.environ["OIDC_CLIENT_ID"],
client_secret=os.environ["OIDC_CLIENT_SECRET"],
redirect_uri=os.environ["OIDC_REDIRECT_URI"],
mailbox=os.environ.get("EMAIL_USERNAME", ""),
data_dir=os.environ.get("DATA_DIR", "/data"),
key=os.environ["TOKEN_KEY"],
)
# --- stored refresh token ---------------------------------------------------------------
def connected(self) -> bool:
return self._read_refresh() is not None
def _read_refresh(self) -> str | None:
try:
return self.cipher.decrypt(self.token_file.read_bytes()).decode()
except (FileNotFoundError, InvalidToken):
return None
def _write_refresh(self, token: str | None) -> None:
if token is None:
self.token_file.unlink(missing_ok=True)
return
tmp = self.token_file.with_suffix(".tmp")
tmp.write_bytes(self.cipher.encrypt(token.encode()))
tmp.chmod(0o600)
tmp.replace(self.token_file)
# --- connect flow -------------------------------------------------------------------------
def start(self) -> str:
verifier, challenge = new_pkce()
state = secrets.token_urlsafe(32)
now = self.clock()
with self._lock:
self._pending = {k: v for k, v in self._pending.items() if now - v[1] < PENDING_TTL_SEC}
self._pending[state] = (verifier, now)
query = {
"response_type": "code",
"client_id": self.client_id,
"redirect_uri": self.redirect_uri,
"scope": SCOPES,
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
"prompt": "login", # sign in as the mailbox account, not with the admin's session
"login_hint": self.mailbox,
}
return self.authorization_endpoint + "?" + urlencode(query)
def finish(self, state: str, code: str) -> None:
with self._lock:
entry = self._pending.pop(state, None)
if not entry or self.clock() - entry[1] > PENDING_TTL_SEC or not code:
raise OAuthError("Invalid or expired request")
tokens = self._token_request({
"grant_type": "authorization_code",
"code": code,
"redirect_uri": self.redirect_uri,
"code_verifier": entry[0],
})
email = jwt_claims(tokens["access_token"]).get("email", "")
if email.lower() != self.mailbox.lower():
raise OAuthError(f"Signed in as {email or 'unknown'}, expected {self.mailbox}")
if not tokens.get("refresh_token"):
raise OAuthError("SSO returned no refresh token")
with self._lock:
self._store(tokens)
def disconnect(self) -> None:
with self._lock:
self._write_refresh(None)
self._access = None
# --- tokens -------------------------------------------------------------------------------
def _token_request(self, data: dict) -> dict:
try:
with httpx.Client(timeout=20.0, transport=self.transport) as client:
r = client.post(self.token_endpoint, data=data, auth=(self.client_id, self.client_secret))
except httpx.HTTPError as e:
raise OAuthError(f"Token endpoint unreachable: {e}") from e
try:
body = r.json()
except ValueError:
body = {}
if r.status_code != 200 or not body.get("access_token"):
raise OAuthError(f"Token request failed ({r.status_code}): {body.get('error', 'unknown')}")
return body
def _store(self, tokens: dict) -> None:
if tokens.get("refresh_token"):
self._write_refresh(tokens["refresh_token"])
self._access = (tokens["access_token"], self.clock() + float(tokens.get("expires_in") or 300))
def access_token(self) -> str:
with self._lock:
if self._access and self._access[1] - REFRESH_MARGIN_SEC > self.clock():
return self._access[0]
refresh = self._read_refresh()
if not refresh:
raise OAuthError("Mailbox not connected")
try:
tokens = self._token_request({"grant_type": "refresh_token", "refresh_token": refresh})
except OAuthError as e:
if "invalid_grant" in str(e):
self._write_refresh(None) # revoked/expired: connect again
self._access = None
raise
self._store(tokens)
return tokens["access_token"]
+6 -3
View File
@@ -1,3 +1,6 @@
Flask
python-dotenv
beautifulsoup4
Flask==3.1.2
python-dotenv==1.2.1
beautifulsoup4==4.14.2
gunicorn==23.0.0
httpx==0.28.1
cryptography==46.0.3
+5 -2
View File
@@ -9,7 +9,7 @@
<h1>E-Mail Unsubscribe Links Result</h1>
<table>
<tr>
<th>Email ID</th>
<th>UID</th>
<th>Subject</th>
<th>Unsubscribe Link</th>
<th>Move to Trash</th>
@@ -20,7 +20,7 @@
<td>{{ data.subject }}</td>
<td>
{% for link in data.links %}
<a href="{{ link }}" target="_blank">{{ link[:30] + '...' }}</a><br>
<a href="{{ link }}" target="_blank" rel="noopener noreferrer">{{ link[:30] + '...' }}</a><br>
{% endfor %}
</td>
<td>
@@ -36,6 +36,9 @@
<button type="submit" class="btn-primary">Back to Home</button>
</form>
<form action="{{ url_for('move_all_to_trash') }}" method="post">
{% for email_id in links_data %}
<input type="hidden" name="email_id" value="{{ email_id }}">
{% endfor %}
<button type="submit" class="btn-delete">Move All to Trash</button>
</form>
</div>
+86
View File
@@ -0,0 +1,86 @@
"""Tests for link extraction, link safety and the trash actions (fake IMAP server)."""
from email.message import EmailMessage
import pytest
import app as unsub
def _mail(html: str, subject: str = "Newsletter") -> bytes:
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = "news@example.org"
msg.set_content("plain")
msg.add_alternative(html, subtype="html")
return msg.as_bytes()
def test_finds_anchor_and_plain_links_without_duplicates():
html = (
'<a href="https://ex.org/u?id=1">Abmelden</a> <a href="https://ex.org/home">Home</a>'
" https://ex.org/unsubscribe/2 https://ex.org/unsubscribe/2"
)
assert unsub.find_unsubscribe_links(html) == ["https://ex.org/u?id=1", "https://ex.org/unsubscribe/2"]
@pytest.mark.parametrize("href", ["javascript:alert(1)", "data:text/html,<script>x</script>", "JavaScript:x"])
def test_dangerous_links_are_dropped(href):
assert unsub.find_unsubscribe_links(f'<a href="{href}">Unsubscribe</a>') == []
def test_encoded_subject_is_decoded():
msg = EmailMessage()
msg["Subject"] = "Grüße vom Newsletter"
parsed = unsub.email.message_from_bytes(msg.as_bytes())
assert unsub.get_subject(parsed) == "Grüße vom Newsletter"
class FakeIMAP:
def __init__(self, mails: dict[bytes, bytes]):
self.mails = mails
self.calls = []
def uid(self, command, *args):
self.calls.append((command, args))
if command == "search":
return "OK", [b" ".join(self.mails)]
if command == "fetch":
return "OK", [(b"1 (BODY[] {n}", self.mails[args[0]]), b")"]
return "OK", [b""]
def expunge(self):
self.calls.append(("expunge", ()))
def logout(self):
pass
def test_collect_links_keys_by_uid():
fake = FakeIMAP({b"41": _mail('<a href="https://ex.org/u">unsubscribe</a>'), b"42": _mail("<p>no links</p>")})
result = unsub.collect_links(fake)
assert list(result) == ["41"] and result["41"]["links"] == ["https://ex.org/u"]
def test_move_to_trash_uses_uids_and_ignores_garbage(monkeypatch):
fake = FakeIMAP({})
monkeypatch.setattr(unsub, "connect", lambda: fake)
assert unsub.move_emails_to_trash(["7", "1:*", "8 OR ALL"]) == 1
assert ("copy", ("7", "Trash")) in fake.calls
assert ("store", ("7", "+FLAGS", "(\\Deleted)")) in fake.calls
assert not any(c[0] in ("copy", "store") and c[1][0] != "7" for c in fake.calls)
def test_move_all_form_posts_every_uid(monkeypatch):
moved = []
monkeypatch.setattr(unsub, "move_emails_to_trash", lambda uids: moved.extend(uids) or len(uids))
client = unsub.app.test_client()
assert client.post("/move-all-to-trash", data={"email_id": ["41", "42"]}).status_code == 302
assert moved == ["41", "42"]
def test_result_page_renders_hidden_ids_for_move_all(monkeypatch):
monkeypatch.setattr(unsub, "connect", lambda: FakeIMAP({b"41": _mail('<a href="https://ex.org/u">Abmelden</a>')}))
html = unsub.app.test_client().get("/result").get_data(as_text=True)
assert html.count('name="email_id" value="41"') == 2 # single form + move-all form
assert 'rel="noopener noreferrer"' in html
+141
View File
@@ -0,0 +1,141 @@
"""Tests for the OAuth connection of the collection mailbox."""
import base64
import json
from urllib.parse import parse_qs, urlparse
import httpx
import pytest
from cryptography.fernet import Fernet
import oauth
def _jwt(claims):
enc = lambda d: base64.urlsafe_b64encode(json.dumps(d).encode()).rstrip(b"=").decode() # noqa: E731
return f"{enc({'alg': 'RS256'})}.{enc(claims)}.sig"
class Clock:
now = 1000.0
def __call__(self):
return self.now
def _mb(tmp_path, responses, seen=None, clock=None):
def handler(request):
if seen is not None:
seen.append(parse_qs(request.content.decode()))
return responses.pop(0)
return oauth.MailboxOAuth(
"https://sso.example.org", "unsubscriber", "secret", "https://u.example.org/oidc/callback",
"unsubscribe-bot@example.org", tmp_path, Fernet.generate_key().decode(),
transport=httpx.MockTransport(handler), clock=clock or Clock(),
)
def _start_state(mb):
url = urlparse(mb.start())
q = parse_qs(url.query)
assert q["prompt"] == ["login"] and q["login_hint"] == ["unsubscribe-bot@example.org"]
assert "offline_access" in q["scope"][0] and q["code_challenge_method"] == ["S256"]
return q["state"][0]
def test_connect_stores_encrypted_refresh_token(tmp_path):
mb = _mb(tmp_path, [httpx.Response(200, json={
"access_token": _jwt({"email": "Unsubscribe-Bot@example.org"}), "refresh_token": "rt-1", "expires_in": 3600})])
mb.finish(_start_state(mb), "code")
assert mb.connected()
assert b"rt-1" not in (tmp_path / "oauth-token").read_bytes()
assert oct((tmp_path / "oauth-token").stat().st_mode)[-3:] == "600"
def test_connect_with_wrong_account_is_rejected(tmp_path):
mb = _mb(tmp_path, [httpx.Response(200, json={
"access_token": _jwt({"email": "philipp@example.org"}), "refresh_token": "rt", "expires_in": 3600})])
with pytest.raises(oauth.OAuthError, match="expected unsubscribe-bot"):
mb.finish(_start_state(mb), "code")
assert not mb.connected()
def test_unknown_state_is_rejected(tmp_path):
with pytest.raises(oauth.OAuthError):
_mb(tmp_path, []).finish("forged", "code")
def test_access_token_refreshes_and_rotates(tmp_path):
seen, clock = [], Clock()
mb = _mb(tmp_path, [
httpx.Response(200, json={"access_token": _jwt({"email": "unsubscribe-bot@example.org"}),
"refresh_token": "rt-1", "expires_in": 3600}),
httpx.Response(200, json={"access_token": "at-2", "refresh_token": "rt-2", "expires_in": 3600}),
], seen, clock)
mb.finish(_start_state(mb), "code")
first = mb.access_token()
assert len(seen) == 1 # cached from the code exchange
clock.now += 3600
assert mb.access_token() == "at-2" != first
assert seen[1]["refresh_token"] == ["rt-1"]
assert mb._read_refresh() == "rt-2"
def test_revoked_token_disconnects(tmp_path):
clock = Clock()
mb = _mb(tmp_path, [
httpx.Response(200, json={"access_token": _jwt({"email": "unsubscribe-bot@example.org"}),
"refresh_token": "rt-1", "expires_in": 10}),
httpx.Response(400, json={"error": "invalid_grant"}),
], clock=clock)
mb.finish(_start_state(mb), "code")
clock.now += 100
with pytest.raises(oauth.OAuthError):
mb.access_token()
assert not mb.connected()
def test_not_connected(tmp_path):
with pytest.raises(oauth.OAuthError):
_mb(tmp_path, []).access_token()
def test_app_uses_xoauth2_when_oauth_configured(monkeypatch):
import app as unsub
calls = {}
class FakeIMAP:
def __init__(self, host, port):
pass
def authenticate(self, mech, cb):
calls["auth"] = (mech, cb(b""))
def login(self, *a):
calls["login"] = a
def select(self, folder):
pass
class FakeOAuth:
def access_token(self):
return "tok"
monkeypatch.setattr(unsub.imaplib, "IMAP4_SSL", FakeIMAP)
monkeypatch.setattr(unsub, "OAUTH", FakeOAuth())
monkeypatch.setattr(unsub, "EMAIL_USERNAME", "unsubscribe-bot@example.org")
unsub.connect()
assert calls == {"auth": ("XOAUTH2", oauth.xoauth2_string("unsubscribe-bot@example.org", "tok").encode())}
def test_index_shows_connect_when_not_connected(monkeypatch):
import app as unsub
class FakeOAuth:
def connected(self):
return False
monkeypatch.setattr(unsub, "OAUTH", FakeOAuth())
html = unsub.app.test_client().get("/").get_data(as_text=True)
assert 'action="/connect"' in html and 'action="/result"' not in html