diff --git a/mo/web/api_dsn.py b/mo/web/api_dsn.py index 64de762c82662260dbc34b36ff9b9f8eb34d8d41..99194148f339e5526240fa03d1400914b91f0987 100644 --- a/mo/web/api_dsn.py +++ b/mo/web/api_dsn.py @@ -10,29 +10,32 @@ from flask import request, Response from flask.json import jsonify import re from typing import Optional +from unidecode import unidecode import werkzeug.exceptions import mo.config as config +import mo.db as db import mo.email import mo.util_format from mo.web import app -class DSN: +class DSNParser: msg: email.message.EmailMessage - message_id: str verdict: str + parsed: db.EmailDSN 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.parsed = db.EmailDSN() + self.parsed.message_id = (self.msg['Message-Id'] or '?').strip() self.dsn_action = '?' - self.dsn_status = '?' self.verdict = self.parse_dsn() def parse_dsn(self) -> str: + """Parse DSN as specified by RFC 3464.""" + if self.msg.get_content_type() != 'multipart/report': return 'unexpected content-type' @@ -56,9 +59,25 @@ class DSN: # main = dsn[0] - per_addr = dsn[1] + per_addr: email.message.EmailMessage = dsn[1] self.dsn_action = per_addr.get('Action', '?') - self.dsn_status = per_addr.get('Status', '?') + + status = per_addr.get('Status') + if status and re.fullmatch(r'\d\.\d\.\d{1,8}', status): + self.parsed.status = status + + remote_mta = per_addr.get('Remote-MTA') or per_addr.get('Reporting-MTA') + if remote_mta and (rm := re.fullmatch(r'dns;\s+([!-~]{1,256})', remote_mta, re.IGNORECASE)): + self.parsed.remote_mta = rm[1] + + diag_code = per_addr.get('Diagnostic-Code') + if diag_code and (dm := re.fullmatch(r'smtp;\s+(.*)', diag_code, re.IGNORECASE)): + dc = unidecode(dm[1]) # Mělo by to být v ASCII, ale pro jistotu... + dc = re.sub(r'\s{2,}', ' ', dc) + MAX_DIAG_LEN = 1000 + if len(dc) > MAX_DIAG_LEN: + dc = dc[:MAX_DIAG_LEN] + " [...]" + self.parsed.diag_code = dc if self.dsn_action != 'failed': return 'not failed' @@ -80,39 +99,64 @@ class DSN: return None +def process_dsn_user(dsn: db.EmailDSN) -> None: + dsn.user.dsn_id = dsn.dsn_id + + +def process_dsn_reg(dsn: db.EmailDSN) -> None: + if (dsn.reg.user_id is not None + and dsn.user_id is not None + and dsn.reg.user_id != dsn.user_id): + app.logger.warning('DSN: Nesouhlasí user_id s registrací') + + dsn.reg.dsn_id = dsn.dsn_id + + @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) + parser = DSNParser(body) + dsn = parser.parsed 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}') + app.logger.info(f'DSN: Parse: action={parser.dsn_action} status={dsn.status or "-"} -> {parser.verdict}') - if dsn.verdict == 'not failed': + if parser.verdict == 'not failed': return jsonify({}) - elif dsn.verdict != 'ok': + elif parser.verdict != 'ok': raise werkzeug.exceptions.UnprocessableEntity() - if not (token := dsn.find_token()): + if not (token := parser.find_token()): app.logger.info('DSN: Token not found') raise werkzeug.exceptions.UnprocessableEntity() app.logger.info(f'DSN: Token: {token}') try: - user, rr, email, 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 '-' + dsn.token = token + dsn.user, dsn.reg, email, when = mo.email.validate_dsn_token(token) + user_info = f'#{dsn.user.user_id}' if dsn.user is not None else '-' + rr_info = f'#{dsn.reg.reg_id}' if dsn.reg 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} email={email} age={age}') except ValueError as e: app.logger.info(f'DSN: {e}') - pass + raise werkzeug.exceptions.UnprocessableEntity() + + sess = db.get_session() + if sess.query(db.EmailDSN).filter_by(token=token).one_or_none(): + app.logger.info('DSN: Already known') + else: + sess.add(dsn) + sess.flush() # aby dsn získala dsn_id + if dsn.reg: + process_dsn_reg(dsn) + elif dsn.user: + process_dsn_user(dsn) + sess.commit() return jsonify({})