diff --git a/mo/web/__init__.py b/mo/web/__init__.py index 17463da17556545826931732d07366629e17071e..e8dbd2277d57709f546f74935c428234a1e86e6a 100644 --- a/mo/web/__init__.py +++ b/mo/web/__init__.py @@ -261,6 +261,7 @@ except ImportError: # Většina webu je v samostatných modulech import mo.web.api +import mo.web.api_dsn import mo.web.acct import mo.web.doc import mo.web.jinja diff --git a/mo/web/api_dsn.py b/mo/web/api_dsn.py new file mode 100644 index 0000000000000000000000000000000000000000..d5be8b3b9c89174b02837e4a55fdab704b156015 --- /dev/null +++ b/mo/web/api_dsn.py @@ -0,0 +1,118 @@ +# Web: API pro zpracování mailových Delivery Status Notifications + +import email +from email.errors import MessageError +import email.headerregistry +import email.message +import email.parser +import email.policy +from flask import request, Response +from flask.json import jsonify +import re +from typing import Optional +import werkzeug.exceptions + +import mo.config as config +import mo.email +import mo.util_format +from mo.web import app + + +class DSN: + msg: email.message.EmailMessage + message_id: str + verdict: str + dsn_action: str + dsn_status: str + + def __init__(self, body: bytes): + self.msg = email.message_from_bytes(body, policy=email.policy.default) # FIXME: Types + self.message_id = (self.msg['Message-Id'] or '?').strip() + self.dsn_action = '?' + self.dsn_status = '?' + self.verdict = self.parse_dsn() + + def parse_dsn(self) -> str: + if self.msg.get_content_type() != 'multipart/report': + return 'unexpected content-type' + + if not self.msg.is_multipart(): + return 'not multipart' + + report_type = self.msg['Content-type'].params.get('report-type', '?') + if report_type != 'delivery-status': + return 'unknown report-type' + + if len(self.msg.get_payload()) < 2: + return 'too few parts' + + part2: email.message.EmailMessage = self.msg.get_payload(1) + if part2.get_content_type() != 'message/delivery-status': + return 'unexpected part 2 type' + + dsn = part2.get_payload() + if len(dsn) < 2: + return 'DSN too short' + + # main = dsn[0] + + per_addr = dsn[1] + self.dsn_action = per_addr.get('Action', '?') + self.dsn_status = per_addr.get('Status', '?') + + if self.dsn_action != 'failed': + return 'not failed' + + return 'ok' + + def find_token(self) -> Optional[str]: + to = self.msg.get('To') + if to is None: + return None + + std_from = email.headerregistry.Address(addr_spec=config.MAIL_CONTACT) + + for addr in to.addresses: + if m := re.fullmatch(r'([^+]+)\+(.*)', addr.username): + if m[1] == std_from.username and addr.domain == std_from.domain: + return m[2] + + return None + + +@app.route('/api/email-dsn', methods=('POST',)) +def api_email_dsn() -> Response: + # FIXME: Authorization? + # FIXME: Add in Flask 3.1: request.max_content_length = 1048576 + body = request.get_data(cache=False) + + try: + dsn = DSN(body) + except MessageError as e: + app.logger.info(f'DSN: Nemohu naparsovat zprávu: {e}') + raise werkzeug.exceptions.UnprocessableEntity() + + app.logger.info(f'DSN: Message-ID: {dsn.message_id}') + app.logger.info(f'DSN: Parse: action={dsn.dsn_action} status={dsn.dsn_status} -> {dsn.verdict}') + + if dsn.verdict == 'not failed': + return jsonify({}) + elif dsn.verdict != 'ok': + raise werkzeug.exceptions.UnprocessableEntity() + + if not (token := dsn.find_token()): + app.logger.info('DSN: Token not found') + raise werkzeug.exceptions.UnprocessableEntity() + app.logger.info(f'DSN: Token: {token}') + + try: + user, rr, when = mo.email.validate_dsn_token(token) + user_info = f'#{user.user_id}' if user is not None else '-' + rr_info = f'#{rr.reg_id}' if rr is not None else '-' + age = mo.util_format.time_duration_numeric(mo.now - when) + app.logger.info(f'DSN: user={user_info} registration={rr_info} age={age}') + except ValueError as e: + app.logger.info(f'DSN: {e}') + pass + + return jsonify({})