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:
7 files changed
+404
-6
No files matched your search
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user