From cc7bb550095c00175330cfc548f6e8f900e49e63 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Mon, 13 Jan 2025 18:09:59 +0100
Subject: [PATCH] =?UTF-8?q?DSN:=20Prvotn=C3=AD=20verze=20API=20na=20parsov?=
=?UTF-8?q?=C3=A1n=C3=AD=20DSN?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
mo/web/__init__.py | 1 +
mo/web/api_dsn.py | 118 +++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 119 insertions(+)
create mode 100644 mo/web/api_dsn.py
diff --git a/mo/web/__init__.py b/mo/web/__init__.py
index 17463da1..e8dbd227 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 00000000..d5be8b3b
--- /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({})
--
GitLab