feat: Sammelpostfach per SSO (OAuth/XOAUTH2) statt Passwort

Einmal "Connect mailbox" (OIDC mit PKCE, prompt=login, Anmeldung als technisches Konto des
Postfachs), Prüfung der Mail-Adresse im Token, Refresh-Token verschlüsselt in DATA_DIR,
Access-Token im Speicher; ein gunicorn-Worker wegen der Token-Rotation. Ohne OIDC_* weiter mit
EMAIL_PASSWORD.

Claude-Session: https://claude.ai/code/session_01SqzF6qT3TQ8ej3wigEuMSu
This commit is contained in:
Rsclub2_2 committed 2026-10-29 14:29:22 +01:00
1 parent b741389754
commit 68940e831a
7 files changed
+404 -6

No files matched your search

+55 -3
View File
@@ -15,7 +15,9 @@ from email.header import decode_header, make_header
from bs4 import BeautifulSoup
from dotenv import load_dotenv
from flask import Flask, redirect, render_template, request, url_for
from flask import Flask, abort, redirect, render_template, request, url_for
from oauth import MailboxOAuth, OAuthError, xoauth2_string
load_dotenv() # optional .env for local runs
@@ -32,6 +34,9 @@ logger = logging.getLogger(__name__)
template_dir = os.path.abspath(os.path.dirname(__file__))
app = Flask(__name__, template_folder=template_dir)
# 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
)
@@ -78,8 +83,12 @@ def find_unsubscribe_links(text: str) -> list[str]:
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)
mail.login(EMAIL_USERNAME, EMAIL_PASSWORD)
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
@@ -119,7 +128,50 @@ def move_emails_to_trash(uids: list[str]) -> int:
@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")