From 9b34b3348ad911320195301317eac20f9a5fdb54 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Thu, 23 Jan 2025 18:35:20 +0100
Subject: [PATCH 01/35] DSN: Reprezentace v DB

---
 db/db.ddl               | 21 +++++++++++++++++++++
 db/upgrade-20250123.sql | 21 +++++++++++++++++++++
 mo/db.py                | 21 +++++++++++++++++++++
 3 files changed, 63 insertions(+)
 create mode 100644 db/upgrade-20250123.sql

diff --git a/db/db.ddl b/db/db.ddl
index 8fa36b32..72df2e91 100644
--- a/db/db.ddl
+++ b/db/db.ddl
@@ -26,6 +26,7 @@ CREATE TABLE users (
 	password_hash	varchar(255)	DEFAULT NULL,			-- heš hesla (je-li nastaveno)
 	note		text		NOT NULL DEFAULT '',		-- poznámka viditelná pro orgy
 	email_notify	boolean		NOT NULL DEFAULT true		-- přeje si dostávat mailové notifikace
+	dsn_id		int		DEFAULT NULL,			-- mailová nedoručenka (REFERENCES později)
 );
 
 -- Uživatel s user_id=0 vždy existuje a je to systémový uživatel s právy admina.
@@ -166,6 +167,7 @@ CREATE TABLE contests (
 	place_id		int		NOT NULL REFERENCES places(place_id),
 	state			round_state	NOT NULL DEFAULT 'preparing',	-- používá se, pokud round.state='delegate', jinak kopíruje round.state
 	scoretable_id		int		DEFAULT NULL,			-- odkaz na snapshot představující oficiální výsledkovou listinu soutěže
+										-- (REFERENCES později)
 	tex_hacks		text		NOT NULL DEFAULT '',		-- speciální nastavení pro sazbu výsledkovky
 	online_submit		boolean		NOT NULL DEFAULT true,		-- účastníkům je povoleno elektronické odevzdávání
 	UNIQUE (round_id, place_id)
@@ -415,6 +417,7 @@ CREATE TABLE reg_requests (
 	captcha_token	varchar(255)	DEFAULT NULL,			-- token pro fázi 1 registrace
 	email_token	varchar(255)	UNIQUE NOT NULL,		-- token pro fázi 2 registrace
 	user_id		int		DEFAULT NULL REFERENCES users(user_id) ON DELETE CASCADE,
+	dsn_id		int		DEFAULT NULL,			-- mailová nedoručenka (REFERENCES později)
 	client		varchar(255)	NOT NULL			-- kdo si registraci vyžádal
 );
 
@@ -506,3 +509,21 @@ CREATE TABLE sent_email (
 	sent_at		timestamp with time zone	NOT NULL DEFAULT CURRENT_TIMESTAMP,
 	PRIMARY KEY (user_id, key)
 );
+
+-- Mailové nedoručenky (Delivery Status Notifications)
+
+CREATE TABLE email_dsns (
+	dsn_id		serial		PRIMARY KEY,
+	token		text		UNIQUE NOT NULL,
+	user_id		int		DEFAULT NULL REFERENCES users(user_id) ON DELETE CASCADE ,
+	reg_id		int		DEFAULT NULL REFERENCES reg_requests(reg_id) ON DELETE CASCADE,
+	arrived_at	timestamp with time zone	NOT NULL DEFAULT CURRENT_TIMESTAMP,
+	message_id	text		DEFAULT NULL,			-- MessageID nedoručenky
+	status		text		DEFAULT NULL,			-- SMTP enhanced status code (e.g., 4.1.1)
+	remote_mta	text		DEFAULT NULL,			-- IP adresa nebo doménové jméno odesilatele DSN
+	diag_code	text		DEFAULT NULL,			-- zdůvodnění od odesilatele DSN
+	CHECK (user_id IS NOT NULL OR reg_id IS NOT NULL)
+);
+
+ALTER TABLE reg_requests ADD CONSTRAINT "reg_requests_dsn_id" FOREIGN KEY (dsn_id) REFERENCES email_dsns(dsn_id) ON DELETE SET NULL;
+ALTER TABLE users ADD CONSTRAINT "users_dsn_id" FOREIGN KEY (dsn_id) REFERENCES email_dsns(dsn_id) ON DELETE SET NULL;
diff --git a/db/upgrade-20250123.sql b/db/upgrade-20250123.sql
new file mode 100644
index 00000000..bb93a480
--- /dev/null
+++ b/db/upgrade-20250123.sql
@@ -0,0 +1,21 @@
+ALTER TABLE users ADD COLUMN
+	dsn_id		int		DEFAULT NULL;
+
+ALTER TABLE reg_requests ADD COLUMN
+	dsn_id		int		DEFAULT NULL;
+
+CREATE TABLE email_dsns (
+	dsn_id		serial		PRIMARY KEY,
+	token		text		UNIQUE NOT NULL,
+	user_id		int		DEFAULT NULL REFERENCES users(user_id) ON DELETE CASCADE ,
+	reg_id		int		DEFAULT NULL REFERENCES reg_requests(reg_id) ON DELETE CASCADE,
+	arrived_at	timestamp with time zone	NOT NULL DEFAULT CURRENT_TIMESTAMP,
+	message_id	text		DEFAULT NULL,			-- MessageID nedoručenky
+	status		text		DEFAULT NULL,			-- SMTP extended status code (e.g., 4.1.1)
+	remote_mta	text		DEFAULT NULL,			-- IP adresa nebo doménové jméno odesilatele DSN
+	diag_code	text		DEFAULT NULL,			-- zdůvodnění od odesilatele DSN
+	CHECK (user_id IS NOT NULL OR reg_id IS NOT NULL)
+);
+
+ALTER TABLE reg_requests ADD CONSTRAINT "reg_requests_dsn_id" FOREIGN KEY (dsn_id) REFERENCES email_dsns(dsn_id) ON DELETE SET NULL;
+ALTER TABLE users ADD CONSTRAINT "users_dsn_id" FOREIGN KEY (dsn_id) REFERENCES email_dsns(dsn_id) ON DELETE SET NULL;
diff --git a/mo/db.py b/mo/db.py
index 4e2dfe2a..d11cce41 100644
--- a/mo/db.py
+++ b/mo/db.py
@@ -441,9 +441,11 @@ class User(Base):
     password_hash = Column(String(255), server_default=text("NULL::character varying"))
     note = Column(Text, nullable=False, server_default=text("''::text"))
     email_notify = Column(Boolean, nullable=False, server_default=text("true"))
+    dsn_id = Column(Integer, ForeignKey('email_dsns.dsn_id'))
 
     roles = relationship('UserRole', primaryjoin='UserRole.user_id == User.user_id', back_populates='user')
     participants = relationship('Participant', primaryjoin='Participant.user_id == User.user_id', back_populates='user')
+    dsn = relationship('EmailDSN', primaryjoin='EmailDSN.dsn_id == User.dsn_id', back_populates='user', viewonly=True)
 
     def full_name(self) -> str:
         return self.first_name + ' ' + self.last_name
@@ -932,9 +934,11 @@ class RegRequest(Base):
     email = Column(Text)
     email_token = Column(Text, nullable=False, unique=True)
     user_id = Column(Integer, ForeignKey('users.user_id'))
+    dsn_id = Column(Integer, ForeignKey('email_dsns.dsn_id'))
     client = Column(Text, nullable=False)
 
     user = relationship('User')
+    dsn = relationship('EmailDSN', primaryjoin='EmailDSN.dsn_id == RegRequest.dsn_id')
 
 
 class RegionDescendant(Base):
@@ -1045,6 +1049,23 @@ class SentEmail(Base):
     user = relationship('User')
 
 
+class EmailDSN(Base):
+    __tablename__ = 'email_dsns'
+
+    dsn_id = Column(Integer, primary_key=True, server_default=text("nextval('email_dsn_dsn_id_seq'::regclass)"))
+    token = Column(Text, nullable=False, unique=True)
+    user_id = Column(Integer, ForeignKey('users.user_id'))
+    reg_id = Column(Integer, ForeignKey('reg_requests.reg_id'))
+    arrived_at = Column(DateTime(True), nullable=False, server_default=text("CURRENT_TIMESTAMP"))
+    message_id = Column(Text)
+    status = Column(Text)
+    remote_mta = Column(Text)
+    diag_code = Column(Text)
+
+    user = relationship('User', primaryjoin='User.user_id == EmailDSN.user_id')
+    reg = relationship('RegRequest', primaryjoin='RegRequest.reg_id == EmailDSN.reg_id')
+
+
 _engine: Optional[Engine] = None
 _session: Optional[Session] = None
 flask_db: Any = None
-- 
GitLab


From af227f4851375bf6986b1036b5726a060d75b175 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Thu, 23 Jan 2025 18:35:30 +0100
Subject: [PATCH 02/35] =?UTF-8?q?DSN:=20Ukl=C3=A1d=C3=A1n=C3=AD=20do=20DB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/api_dsn.py | 80 ++++++++++++++++++++++++++++++++++++-----------
 1 file changed, 62 insertions(+), 18 deletions(-)

diff --git a/mo/web/api_dsn.py b/mo/web/api_dsn.py
index 64de762c..99194148 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({})
-- 
GitLab


From b3eba8b82b292d625146f816690721558f50b535 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Fri, 24 Jan 2025 23:36:37 +0100
Subject: [PATCH 03/35] =?UTF-8?q?user=5Furl=20p=C5=99esunuto=20do=20mo.web?=
 =?UTF-8?q?.util?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/jinja.py | 12 +++---------
 mo/web/util.py  |  7 +++++++
 2 files changed, 10 insertions(+), 9 deletions(-)

diff --git a/mo/web/jinja.py b/mo/web/jinja.py
index 96515a7c..2a890c51 100644
--- a/mo/web/jinja.py
+++ b/mo/web/jinja.py
@@ -17,7 +17,7 @@ from mo.util import assert_not_none
 import mo.util_format as util_format
 from mo.web import app
 from mo.web.org_place import place_breadcrumbs
-from mo.web.util import user_html_flags
+from mo.web.util import user_html_flags, user_url
 
 # Konfigurace Jinjy
 
@@ -69,20 +69,14 @@ jg.update(Right=Right)
 jg.update(place_breadcrumbs=place_breadcrumbs)
 # Funkce asset_url se přidává v mo.ext.assets
 
+jf.update(user_url=user_url)
+
 
 @app.template_filter()
 def user_link(u: db.User) -> Markup:
     return Markup('<a href="{url}">{name}{test}</a>').format(url=user_url(u), name=u.full_name(), test=" (test)" if u.is_test else "")
 
 
-@app.template_filter()
-def user_url(u: db.User) -> str:
-    if u.is_admin or u.is_org:
-        return url_for('org_org', id=u.user_id)
-    else:
-        return url_for('org_user', id=u.user_id)
-
-
 @app.template_filter()
 def place_link(p: db.Place) -> Markup:
     return Markup('<a href="{url}">{name}</a>').format(url=place_url(p), name=p.name)
diff --git a/mo/web/util.py b/mo/web/util.py
index 82a43235..63c4bb50 100644
--- a/mo/web/util.py
+++ b/mo/web/util.py
@@ -152,3 +152,10 @@ def user_html_flags(u: db.User) -> Markup:
     if len(r) == 0:
         return Markup("")
     return Markup(" " + "".join(r))
+
+
+def user_url(u: db.User) -> str:
+    if u.is_admin or u.is_org:
+        return url_for('org_org', id=u.user_id)
+    else:
+        return url_for('org_user', id=u.user_id)
-- 
GitLab


From 9be1db318f7ee28e4347619143691b135da64734 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Fri, 24 Jan 2025 23:37:07 +0100
Subject: [PATCH 04/35] =?UTF-8?q?Symbol=20k=20=C3=BA=C4=8Dtu=20pro=20nedor?=
 =?UTF-8?q?u=C4=8Diteln=C3=BD=20mail?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/util.py | 2 ++
 static/mo.css  | 5 +++++
 2 files changed, 7 insertions(+)

diff --git a/mo/web/util.py b/mo/web/util.py
index 63c4bb50..d7987db5 100644
--- a/mo/web/util.py
+++ b/mo/web/util.py
@@ -147,6 +147,8 @@ def user_html_flags(u: db.User) -> Markup:
     r = []
     if u.is_test:
         r.append("<span class='user-test' title='Testovací uživatel'>*</span>")
+    if u.dsn:
+        r.append("<span class='user-bad-email' title='E-mailová adresa nefunguje'>🖂</span>")
     if u.is_inactive:
         r.append("<span class='user-inactive' title='Účet dosud nebyl aktivován'>*</span>")
     if len(r) == 0:
diff --git a/static/mo.css b/static/mo.css
index ce1867ab..58864bb1 100644
--- a/static/mo.css
+++ b/static/mo.css
@@ -322,10 +322,15 @@ table.data tbody tr.job-waiting:hover {
 .user-test {
 	color: green;
 }
+
 .user-inactive {
 	color: red;
 }
 
+.user-bad-email {
+	color: red;
+}
+
 /* Alerts */
 
 /* Multiple alerts displayed in more compact way */
-- 
GitLab


From 6ee07ecc395f9655580be74b3ed38b37d2e09ca9 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Fri, 24 Jan 2025 23:43:58 +0100
Subject: [PATCH 05/35] =?UTF-8?q?DSN:=20Funkce=20na=20reset=20v=C5=A1ech?=
 =?UTF-8?q?=20DSN=20k=20=C3=BA=C4=8Dtu?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/users.py | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/mo/users.py b/mo/users.py
index 2b702c70..98bfa52f 100644
--- a/mo/users.py
+++ b/mo/users.py
@@ -467,3 +467,12 @@ def request_reset_password(user: db.User, client: str) -> Optional[db.RegRequest
             details={'action': 'ask-reset'},
         )
     return rr
+
+
+def reset_user_dsn(user: db.User) -> None:
+    if user.dsn_id:
+        logger.info(f'Uživatel #{user.user_id}: Reset DSN')
+        user.dsn_id = None
+        conn = db.get_session().connection()
+        dsn_table = db.EmailDSN.__table__
+        conn.execute(dsn_table.delete().where(dsn_table.c.user_id == user.user_id))
-- 
GitLab


From 9aef86dbe8896e0c22c44aa2e0cb6940dd006edf Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Fri, 24 Jan 2025 23:44:30 +0100
Subject: [PATCH 06/35] =?UTF-8?q?request.client=20m=C5=AF=C5=BEe=20b=C3=BD?=
 =?UTF-8?q?t=20None?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Pravděpodobně se to nikdy nestane s naším serverem, ale type checker
si stěžuje.
---
 mo/users.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/mo/users.py b/mo/users.py
index 98bfa52f..0754725c 100644
--- a/mo/users.py
+++ b/mo/users.py
@@ -427,7 +427,7 @@ def check_activation_token(token: str) -> Optional[db.User]:
         return user
 
 
-def new_reg_request(type: db.RegReqType, client: str) -> Optional[db.RegRequest]:
+def new_reg_request(type: db.RegReqType, client: Optional[str]) -> Optional[db.RegRequest]:
     sess = db.get_session()
 
     # Zatím jen jednoduchý rate limit, časem možno vylepšit
@@ -442,7 +442,7 @@ def new_reg_request(type: db.RegReqType, client: str) -> Optional[db.RegRequest]
         created_at=mo.now,
         expires_at=mo.now + datetime.timedelta(minutes=config.REG_TOKEN_VALIDITY),
         email_token=email_token,
-        client=client,
+        client=client or 'unknown',
     )
 
 
@@ -454,7 +454,7 @@ def expire_reg_requests():
     sess.commit()
 
 
-def request_reset_password(user: db.User, client: str) -> Optional[db.RegRequest]:
+def request_reset_password(user: db.User, client: Optional[str]) -> Optional[db.RegRequest]:
     logger.info('Login: Požadavek na reset hesla pro <%s>', user.email)
     assert not user.is_admin
     rr = new_reg_request(db.RegReqType.reset_passwd, client)
-- 
GitLab


From 4e08962419880c54fe0293438710e1cf60d6cc34 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Fri, 24 Jan 2025 23:45:34 +0100
Subject: [PATCH 07/35] =?UTF-8?q?DSN:=20Reset,=20pokud=20org=20m=C4=9Bn?=
 =?UTF-8?q?=C3=AD=20=C3=BA=C4=8Dtu=20e-mail?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/org_users.py | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/mo/web/org_users.py b/mo/web/org_users.py
index 11e0240d..fdf5587f 100644
--- a/mo/web/org_users.py
+++ b/mo/web/org_users.py
@@ -524,8 +524,9 @@ def org_user_edit(id: int):
             form.populate_obj(user)
             if sess.is_modified(user):
                 changes = db.get_object_changes(user)
-
-                app.logger.info(f"User {id} modified, changes: {changes}")
+                if 'email' in changes:
+                    mo.users.reset_user_dsn(user)
+                app.logger.info(f"Uživatel #{id} změněn: {changes}")
                 mo.util.log(
                     type=db.LogType.user,
                     what=id,
-- 
GitLab


From 7c20f4e776c45c12eaf4fe2ad56806a20c9e7e3c Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Fri, 24 Jan 2025 23:49:10 +0100
Subject: [PATCH 08/35] =?UTF-8?q?DSN:=20Org=20si=20m=C5=AF=C5=BEe=20nechat?=
 =?UTF-8?q?=20zobrazit=20nedoru=C4=8Denky=20k=20=C3=BA=C4=8Dtu?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/org_users.py                | 20 ++++++++++++++++++++
 mo/web/templates/org_user_dsn.html | 24 ++++++++++++++++++++++++
 mo/web/templates/parts/dsn.html    | 28 ++++++++++++++++++++++++++++
 3 files changed, 72 insertions(+)
 create mode 100644 mo/web/templates/org_user_dsn.html
 create mode 100644 mo/web/templates/parts/dsn.html

diff --git a/mo/web/org_users.py b/mo/web/org_users.py
index fdf5587f..43f104b0 100644
--- a/mo/web/org_users.py
+++ b/mo/web/org_users.py
@@ -823,3 +823,23 @@ def org_user_delete(user_id: int):
         return redirect(url_for('org_orgs') if user.is_org else url_for('org_users'))
 
     return render_template('org_user_delete.html', user=user, form=form, warnings=warnings, errors=errors)
+
+
+@app.route('/org/user/<int:user_id>/dsn')
+@app.route('/org/org/<int:user_id>/dsn', endpoint='org_org_dsn')
+def org_user_dsn(user_id: int):
+    sess = db.get_session()
+    user = mo.users.user_by_uid(user_id)
+    if not user:
+        raise werkzeug.exceptions.NotFound()
+
+    rr = g.gatekeeper.rights_generic()
+    if not rr.can_view_user(user):
+        raise werkzeug.exceptions.Forbidden()
+
+    dsns = (sess.query(db.EmailDSN)
+            .filter_by(user=user)
+            .order_by(db.EmailDSN.arrived_at.desc())
+            .all())
+
+    return render_template('org_user_dsn.html', user=user, dsns=dsns)
diff --git a/mo/web/templates/org_user_dsn.html b/mo/web/templates/org_user_dsn.html
new file mode 100644
index 00000000..f766a824
--- /dev/null
+++ b/mo/web/templates/org_user_dsn.html
@@ -0,0 +1,24 @@
+{% extends "base.html" %}
+{% block title %}Nedoručenky pro {{ user.email }}{% endblock %}
+{% block body %}
+
+{% if dsns %}
+
+<p>
+	Tuto e-mailovou adresu považujeme za nefunkční, protože pošta na ni poslaná
+	se vrátila jako nedoručitelná. Zde jsou zprávy o nedoručitelnosti e-mailů z nedávné doby.
+</p>
+
+{% include "parts/dsn.html" %}
+
+{% else %}
+
+<p><em>Momentálně neevidujeme žádnou nedoručitelnou poštu.</em></p>
+
+{% endif %}
+
+<div class="btn-group">
+	<a class='btn btn-primary' href="{{ user|user_url }}">Zpět na stránku uživatele</a>
+</div>
+
+{% endblock %}
diff --git a/mo/web/templates/parts/dsn.html b/mo/web/templates/parts/dsn.html
new file mode 100644
index 00000000..5feed019
--- /dev/null
+++ b/mo/web/templates/parts/dsn.html
@@ -0,0 +1,28 @@
+{# Výpis mailových nedoručenek #}
+
+{% for dsn in dsns %}
+
+<table class=data>
+	<tr>
+		<th>Čas
+		<td>{{ dsn.arrived_at|time_and_timedelta }}
+	<tr>
+		<th>Kód chyby
+		<td>{{ dsn.status }}
+	<tr>
+		<th>Chybová zpráva
+		<td>{{ dsn.diag_code|or_dash }}
+	<tr>
+		<th>Vzdálený server
+		<td>{{ dsn.remote_mta|or_dash }}
+	{% if g.user and g.user.is_admin %}
+	<tr>
+		<th>DSN token
+		<td>{{ dsn.token|or_dash }}
+	<tr>
+		<th>DSN Message-ID
+		<td>{{ dsn.message_id|or_dash }}
+	{% endif %}
+</table>
+
+{% endfor %}
-- 
GitLab


From 79738749c60e3cd15dd61c1932d2302c3c922cc0 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Fri, 24 Jan 2025 23:50:48 +0100
Subject: [PATCH 09/35] =?UTF-8?q?DSN:=20Odkazy=20z=20orgovsk=C3=A9=20spr?=
 =?UTF-8?q?=C3=A1vy=20u=C5=BEivatel=C5=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/templates/org_org.html       | 3 +++
 mo/web/templates/org_user.html      | 3 +++
 mo/web/templates/org_user_edit.html | 6 +++++-
 3 files changed, 11 insertions(+), 1 deletion(-)

diff --git a/mo/web/templates/org_org.html b/mo/web/templates/org_org.html
index beb75788..b2b96baa 100644
--- a/mo/web/templates/org_org.html
+++ b/mo/web/templates/org_org.html
@@ -7,6 +7,9 @@
 <tr><td>Jméno:<td>{{ user.first_name }}
 <tr><td>Příjmení:<td>{{ user.last_name }}
 <tr><td>E-mail:<td>{{ user.email|mailto }}
+	{% if user.dsn %}
+		<a class="btn btn-xs btn-danger" href="{{ url_for('org_org_dsn', user_id=user.user_id) }}">nedoručitelný</a>
+	{% endif %}
 {% if user.is_admin %}<tr><td>Správce:<td>ano{% endif %}
 {% if user.is_org %}<tr><td>Organizátor:<td>ano{% endif %}
 <tr><td>Účet založen:<td>{{ user.created_at|timeformat }}
diff --git a/mo/web/templates/org_user.html b/mo/web/templates/org_user.html
index 313a2569..30121148 100644
--- a/mo/web/templates/org_user.html
+++ b/mo/web/templates/org_user.html
@@ -7,6 +7,9 @@
 <tr><td>Jméno:<td>{{ user.first_name }}
 <tr><td>Příjmení:<td>{{ user.last_name }}
 <tr><td>E-mail:<td>{{ user.email|mailto }}
+	{% if user.dsn %}
+		<a class="btn btn-xs btn-danger" href="{{ url_for('org_user_dsn', user_id=user.user_id) }}">nedoručitelný</a>
+	{% endif %}
 {% if user.is_admin %}<tr><td>Správce:<td>ano{% endif %}
 {% if user.is_org %}<tr><td>Organizátor:<td>ano{% endif %}
 <tr><td>Účet založen:<td>{{ user.created_at|timeformat }}
diff --git a/mo/web/templates/org_user_edit.html b/mo/web/templates/org_user_edit.html
index 4d78b077..529f49da 100644
--- a/mo/web/templates/org_user_edit.html
+++ b/mo/web/templates/org_user_edit.html
@@ -6,7 +6,11 @@
 <table class=data>
 <tr><td>Jméno:</td><td>{{ user.first_name }}</td></tr>
 <tr><td>Příjmení:</td><td>{{ user.last_name }}</td></tr>
-<tr><td>E-mail:</td><td>{{ user.email|mailto }}</td></tr>
+<tr><td>E-mail:</td><td>{{ user.email|mailto }}
+	{% if user.dsn %}
+		<a class="btn btn-xs btn-danger" href="{{ url_for('org_user_dsn', user_id=user.user_id) }}">nedoručitelný</a>
+	{% endif %}
+	</td></tr>
 {% if user.is_admin %}<tr><td>Správce:</td><td>ano</td></tr>{% endif %}
 {% if user.is_org %}<tr><td>Organizátor:</td><td>ano</td></tr>{% endif %}
 <tr><td>Poznámka:</td><td style="white-space: pre-line;">{{ user.note }}</td></tr>
-- 
GitLab


From 99bd1da6a61763d3a7d54200bcd216cd1b55b351 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Fri, 24 Jan 2025 23:51:42 +0100
Subject: [PATCH 10/35] =?UTF-8?q?DSN:=20Na=20d=C5=AFle=C5=BEit=C3=BDch=20s?=
 =?UTF-8?q?tr=C3=A1nk=C3=A1ch=20upozor=C5=88ujeme=20na=20nefunk=C4=8Dnost?=
 =?UTF-8?q?=20e-mailu?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Vesměs to jsou vstupní body, ale také settings.
---
 mo/web/templates/org_index.html       |  2 ++
 mo/web/templates/parts/check_dsn.html | 13 +++++++++++++
 mo/web/templates/settings.html        |  2 ++
 mo/web/templates/user_index.html      |  2 ++
 mo/web/templates/user_join_list.html  |  2 ++
 5 files changed, 21 insertions(+)
 create mode 100644 mo/web/templates/parts/check_dsn.html

diff --git a/mo/web/templates/org_index.html b/mo/web/templates/org_index.html
index d14d84b7..a0229652 100644
--- a/mo/web/templates/org_index.html
+++ b/mo/web/templates/org_index.html
@@ -3,6 +3,8 @@
 {% block title %}Přístup pro organizátory{% endblock %}
 {% block body %}
 
+{% include "parts/check_dsn.html" %}
+
 {% if overview %}
 
 <h3>Moje soutěže</h3>
diff --git a/mo/web/templates/parts/check_dsn.html b/mo/web/templates/parts/check_dsn.html
new file mode 100644
index 00000000..d32bb5d2
--- /dev/null
+++ b/mo/web/templates/parts/check_dsn.html
@@ -0,0 +1,13 @@
+{% if g.user.dsn %}
+
+<div class="alert alert-danger">
+	<p>E-mailová adresa spojená s tímto účtem není funkční – pošta se vrací jako nedoručitelná.</p>
+
+	<p>
+		<a class='btn btn-xs btn-primary' href='{{ url_for('user_validate_email') }}'>Ověřit e-mail</a>
+		<a class='btn btn-xs btn-primary' href='{{ url_for('user_settings_personal') }}'>Změnit e-mail</a>
+		<a class='btn btn-xs btn-default' href='{{ url_for('user_dsn') }}'>Detaily problémů</a>
+	</p>
+</div>
+
+{% endif %}
diff --git a/mo/web/templates/settings.html b/mo/web/templates/settings.html
index 50335f12..14350ca3 100644
--- a/mo/web/templates/settings.html
+++ b/mo/web/templates/settings.html
@@ -3,6 +3,8 @@
 {% block title %}Uživatel {{ user.full_name() }}{% endblock %}
 {% block body %}
 
+{% include "parts/check_dsn.html" %}
+
 	<h3>Osobní údaje</h3>
 
 	<table class=table>
diff --git a/mo/web/templates/user_index.html b/mo/web/templates/user_index.html
index ad3bf137..46d89241 100644
--- a/mo/web/templates/user_index.html
+++ b/mo/web/templates/user_index.html
@@ -2,6 +2,8 @@
 {% block title %}Vítejte{% endblock %}
 {% block body %}
 
+{% include "parts/check_dsn.html" %}
+
 {% if pions %}
 	<p>Účastníte se následujících kol MO:
 
diff --git a/mo/web/templates/user_join_list.html b/mo/web/templates/user_join_list.html
index d6af6b73..c45c8e62 100644
--- a/mo/web/templates/user_join_list.html
+++ b/mo/web/templates/user_join_list.html
@@ -2,6 +2,8 @@
 {% block title %}Přihláška do MO{% endblock %}
 {% block body %}
 
+{% include "parts/check_dsn.html" %}
+
 {% if available_rounds %}
 	<p>Zde si můžete vybrat, do kterých kategorií olympiády se přihlásíte.
 
-- 
GitLab


From e6c88ee5e5d985d3728e9c0d6f0e0e73a09781cd Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Fri, 24 Jan 2025 23:53:11 +0100
Subject: [PATCH 11/35] =?UTF-8?q?DSN:=20U=C5=BEivatel=20si=20m=C5=AF=C5=BE?=
 =?UTF-8?q?e=20zobrazit=20sv=C3=A9=20nedoru=C4=8Denky?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/acct.py                 | 15 +++++++++++++++
 mo/web/templates/acct_dsn.html | 26 ++++++++++++++++++++++++++
 2 files changed, 41 insertions(+)
 create mode 100644 mo/web/templates/acct_dsn.html

diff --git a/mo/web/acct.py b/mo/web/acct.py
index 02c82928..01f001cc 100644
--- a/mo/web/acct.py
+++ b/mo/web/acct.py
@@ -674,6 +674,21 @@ def handle_forbidden(e):
     return render_template('forbidden.html'), 403
 
 
+@app.route('/acct/dsn')
+def user_dsn():
+    sess = db.get_session()
+    user = g.user
+    if not user:
+        raise NeedLoginError()
+
+    dsns = (sess.query(db.EmailDSN)
+            .filter_by(user=user)
+            .order_by(db.EmailDSN.arrived_at.desc())
+            .all())
+
+    return render_template('acct_dsn.html', user=user, dsns=dsns)
+
+
 if getattr(config, 'INSECURE_TEST_LOGIN', False):
     @app.route('/test-login/<email>')
     def test_login(email: str):
diff --git a/mo/web/templates/acct_dsn.html b/mo/web/templates/acct_dsn.html
new file mode 100644
index 00000000..ba0d4a38
--- /dev/null
+++ b/mo/web/templates/acct_dsn.html
@@ -0,0 +1,26 @@
+{% extends "base.html" %}
+{% block title %}Nedoručitelné e-maily{% endblock %}
+{% block body %}
+
+{% if dsns %}
+
+<p>
+	Tuto e-mailovou adresu považujeme za nefunkční, protože pošta na ni poslaná
+	se vrátila jako nedoručitelná. Zde jsou zprávy o nedoručitelnosti e-mailů z nedávné doby.
+</p>
+
+{% include "parts/dsn.html" %}
+
+{% else %}
+
+<p><em>Momentálně neevidujeme žádnou nedoručitelnou poštu.</em></p>
+
+{% endif %}
+
+<div class="btn-group">
+	<a class='btn btn-primary' href='{{ url_for('user_validate_email') }}'>Ověřit e-mail</a>
+	<a class='btn btn-primary' href='{{ url_for('user_settings_personal') }}'>Změnit e-mail</a>
+	<a class='btn btn-default' href="{{ url_for('user_settings') }}">Zpět na nastavení účtu</a>
+</div>
+
+{% endblock %}
-- 
GitLab


From 731251451b83e991ba35081457d4473be3c60d11 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Fri, 24 Jan 2025 23:53:45 +0100
Subject: [PATCH 12/35] =?UTF-8?q?DB:=20Nov=C3=BD=20typ=20registra=C4=8Dn?=
 =?UTF-8?q?=C3=ADho=20po=C5=BEadavku=20pro=20validaci=20e-mailu?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 db/db.ddl               | 3 ++-
 db/upgrade-20250123.sql | 2 ++
 mo/db.py                | 1 +
 3 files changed, 5 insertions(+), 1 deletion(-)

diff --git a/db/db.ddl b/db/db.ddl
index 72df2e91..7d2775a8 100644
--- a/db/db.ddl
+++ b/db/db.ddl
@@ -404,7 +404,8 @@ CREATE TABLE messages (
 CREATE TYPE reg_req_type AS ENUM (
 	'register',
 	'change_email',
-	'reset_passwd'
+	'reset_passwd',
+	'validate_email'
 );
 
 CREATE TABLE reg_requests (
diff --git a/db/upgrade-20250123.sql b/db/upgrade-20250123.sql
index bb93a480..9ca7e586 100644
--- a/db/upgrade-20250123.sql
+++ b/db/upgrade-20250123.sql
@@ -19,3 +19,5 @@ CREATE TABLE email_dsns (
 
 ALTER TABLE reg_requests ADD CONSTRAINT "reg_requests_dsn_id" FOREIGN KEY (dsn_id) REFERENCES email_dsns(dsn_id) ON DELETE SET NULL;
 ALTER TABLE users ADD CONSTRAINT "users_dsn_id" FOREIGN KEY (dsn_id) REFERENCES email_dsns(dsn_id) ON DELETE SET NULL;
+
+ALTER TYPE reg_req_type ADD VALUE 'validate_email';
diff --git a/mo/db.py b/mo/db.py
index d11cce41..185da8e1 100644
--- a/mo/db.py
+++ b/mo/db.py
@@ -920,6 +920,7 @@ class RegReqType(MOEnum):
     register = auto()
     change_email = auto()
     reset_passwd = auto()
+    validate_email = auto()
 
 
 class RegRequest(Base):
-- 
GitLab


From 4931984f7784fd6732169608a6502752811c4c54 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Fri, 24 Jan 2025 23:55:42 +0100
Subject: [PATCH 13/35] =?UTF-8?q?Registrace:=20=C3=9Aklid?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/acct.py | 23 +++++++++--------------
 1 file changed, 9 insertions(+), 14 deletions(-)

diff --git a/mo/web/acct.py b/mo/web/acct.py
index 01f001cc..af3f33d3 100644
--- a/mo/web/acct.py
+++ b/mo/web/acct.py
@@ -523,9 +523,7 @@ class Reg2:
 
         mo.users.set_password(user, passwd)
         mo.users.login(user)
-        rr.used_at = mo.now
-
-        sess.commit()
+        self.spend_request_and_commit()
         self.user = user
         return True
 
@@ -551,8 +549,7 @@ class Reg2:
             },
         )
 
-        self.rr.used_at = mo.now
-        sess.commit()
+        self.spend_request_and_commit()
         return True
 
     def change_passwd(self, new_passwd: str):
@@ -562,11 +559,9 @@ class Reg2:
         app.logger.info(f'Reg2: Uživatel #{user.user_id} si resetoval heslo')
         mo.users.set_password(user, new_passwd, reset=True)
         mo.users.login(user)
+        self.spend_request_and_commit()
 
-        self.rr.used_at = mo.now
-        sess.commit()
-
-    def spend_request(self):
+    def spend_request_and_commit(self):
         self.rr.used_at = mo.now
         db.get_session().commit()
 
@@ -585,7 +580,7 @@ class Reg2Form(FlaskForm):
     submit = wtforms.SubmitField('Vytvořit účet')
 
 
-# URL je explicitně uvedeno v mo.email.activate_url
+# URL je explicitně uvedeno v mo.email.confirm_url
 @app.route('/acct/confirm/r', methods=('GET', 'POST'))
 def confirm_reg():
     token = request.args.get('token')
@@ -617,7 +612,7 @@ class ConfirmEmailForm(FlaskForm):
     cancel = wtforms.SubmitField('Zrušit požadavek')
 
 
-# URL je explicitně uvedeno v mo.email.activate_url
+# URL je explicitně uvedeno v mo.email.confirm_url
 @app.route('/acct/confirm/e', methods=('GET', 'POST'))
 def confirm_email():
     reg2 = Reg2(request.args.get('token'), db.RegReqType.change_email)
@@ -632,7 +627,7 @@ def confirm_email():
                 flash('E-mail změněn.', 'success')
                 return redirect(url_for('user_settings'))
         elif form.cancel.data:
-            reg2.spend_request()
+            reg2.spend_request_and_commit()
             flash('Požadavek na změnu e-mailu zrušen.', 'success')
             return redirect(url_for('user_settings'))
 
@@ -647,7 +642,7 @@ class CancelResetForm(FlaskForm):
     cancel = wtforms.SubmitField('Zrušit obnovu hesla')
 
 
-# URL je explicitně uvedeno v mo.email.activate_url
+# URL je explicitně uvedeno v mo.email.confirm_url
 @app.route('/acct/confirm/p', methods=('GET', 'POST'))
 def confirm_reset():
     reg2 = Reg2(request.args.get('token'), db.RegReqType.reset_passwd)
@@ -662,7 +657,7 @@ def confirm_reset():
 
     cform = CancelResetForm()
     if cform.validate_on_submit() and cform.cancel.data:
-        reg2.spend_request()
+        reg2.spend_request_and_commit()
         flash('Požadavek na změnu hesla zrušen.', 'success')
         return redirect(url_for('user_settings'))
 
-- 
GitLab


From ce55c653eed851714c1d0c6095f6a1d71505917c Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 25 Jan 2025 00:00:40 +0100
Subject: [PATCH 14/35] =?UTF-8?q?Registrace:=20Oprava=20typ=C5=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/acct.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/mo/web/acct.py b/mo/web/acct.py
index af3f33d3..53d14a07 100644
--- a/mo/web/acct.py
+++ b/mo/web/acct.py
@@ -454,10 +454,11 @@ class Reg2:
         },
     }
 
-    def __init__(self, token: str, expected_type: db.RegReqType):
+    def __init__(self, token: Optional[str], expected_type: db.RegReqType):
         self.reg_type = expected_type
         self.status = self._parse_token(token)
         if self.status == RegStatus.ok:
+            assert token is not None
             self.status = self._load_rr(token)
 
     def _parse_token(self, token: Optional[str]) -> RegStatus:
-- 
GitLab


From 8ac4df73a43f0a3fafe494d973ba16366e9efe51 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 25 Jan 2025 00:01:51 +0100
Subject: [PATCH 15/35] =?UTF-8?q?DSN:=20Resetujeme=20p=C5=99i=20=C3=BAsp?=
 =?UTF-8?q?=C4=9B=C5=A1n=C3=A9=20zm=C4=9Bn=C4=9B=20e-mailu=20a=20resetu=20?=
 =?UTF-8?q?hesla?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Tehdy uživatel právě prošel odkazem z mailu.
---
 mo/web/acct.py | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/mo/web/acct.py b/mo/web/acct.py
index 53d14a07..a6646d22 100644
--- a/mo/web/acct.py
+++ b/mo/web/acct.py
@@ -550,6 +550,7 @@ class Reg2:
             },
         )
 
+        mo.users.reset_user_dsn(user)
         self.spend_request_and_commit()
         return True
 
@@ -560,6 +561,7 @@ class Reg2:
         app.logger.info(f'Reg2: Uživatel #{user.user_id} si resetoval heslo')
         mo.users.set_password(user, new_passwd, reset=True)
         mo.users.login(user)
+        mo.users.reset_user_dsn(user)
         self.spend_request_and_commit()
 
     def spend_request_and_commit(self):
-- 
GitLab


From a19fe6618eb9d02c1586668b380ce876c745aa82 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 25 Jan 2025 00:03:14 +0100
Subject: [PATCH 16/35] =?UTF-8?q?Registrace:=20Workflow=20pro=20ov=C4=9B?=
 =?UTF-8?q?=C5=99en=C3=AD=20e-mailu?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/email.py                                   | 11 +++
 mo/web/acct.py                                | 93 ++++++++++++++++++-
 ...il.html => acct_confirm_email_change.html} |  0
 .../acct_confirm_email_validity.html          |  8 ++
 mo/web/templates/acct_validate_email.html     | 32 +++++++
 5 files changed, 140 insertions(+), 4 deletions(-)
 rename mo/web/templates/{acct_confirm_email.html => acct_confirm_email_change.html} (100%)
 create mode 100644 mo/web/templates/acct_confirm_email_validity.html
 create mode 100644 mo/web/templates/acct_validate_email.html

diff --git a/mo/email.py b/mo/email.py
index 7e816724..1b03ae09 100644
--- a/mo/email.py
+++ b/mo/email.py
@@ -264,6 +264,17 @@ def send_confirm_change_email(user: db.User, rr: db.RegRequest) -> bool:
     '''.format(confirm_url('e', rr.email_token))), override_email=rr.email, rr=rr)
 
 
+def send_confirm_validate_email(user: db.User, rr: db.RegRequest) -> bool:
+    return send_user_email(user, 'Potvrzení e-mailové adresy', textwrap.dedent('''\
+        Pro potvrzení e-mailové adresy k účtu v Odevzdávacím systému Matematické
+        olympiády následujte tento odkaz:
+
+                {}
+
+        Váš OSMO
+    '''.format(confirm_url('v', rr.email_token))), rr=rr)
+
+
 def send_join_notify_email(dest: db.User, who: db.User, contest: db.Contest) -> bool:
     round = contest.round
     place = contest.place
diff --git a/mo/web/acct.py b/mo/web/acct.py
index a6646d22..deefa8cc 100644
--- a/mo/web/acct.py
+++ b/mo/web/acct.py
@@ -452,6 +452,11 @@ class Reg2:
             RegStatus.expired: 'Vypršela platnost kódu pro obnovení hesla, požádejte prosím o obnovu znovu.',
             RegStatus.already_spent: 'Tento odkaz na obnovení hesla byl již využit.',
         },
+        db.RegReqType.validate_email: {
+            RegStatus.new: 'Chybný potvrzovací kód. Zkontrolujte, že jste odkaz z e-mailu zkopírovali správně.',
+            RegStatus.expired: 'Vypršela platnost potvrzovacího kódu, požádejte prosím o potvrzení e-mailu znovu.',
+            RegStatus.already_spent: 'Tento odkaz na potvrzení funkčnosti e-mailu byl již využit.',
+        },
     }
 
     def __init__(self, token: Optional[str], expected_type: db.RegReqType):
@@ -554,6 +559,12 @@ class Reg2:
         self.spend_request_and_commit()
         return True
 
+    def validate_email(self) -> None:
+        user = self.rr.user
+        app.logger.info(f'Reg2: Uživatel #{user.user_id} potvrzuje email <{user.email}>')
+        mo.users.reset_user_dsn(user)
+        self.spend_request_and_commit()
+
     def change_passwd(self, new_passwd: str):
         sess = db.get_session()
         user = self.rr.user
@@ -608,7 +619,7 @@ def confirm_reg():
     return render_template('acct_reg2.html', form=form)
 
 
-class ConfirmEmailForm(FlaskForm):
+class ConfirmEmailChangeForm(FlaskForm):
     orig_email = wtforms.StringField('Původní e-mail', render_kw={"disabled": "disabled"})
     new_email = wtforms.StringField('Nový e-mail', render_kw={"disabled": "disabled"})
     submit = wtforms.SubmitField('Potvrdit změnu')
@@ -617,13 +628,13 @@ class ConfirmEmailForm(FlaskForm):
 
 # URL je explicitně uvedeno v mo.email.confirm_url
 @app.route('/acct/confirm/e', methods=('GET', 'POST'))
-def confirm_email():
+def confirm_email_change():
     reg2 = Reg2(request.args.get('token'), db.RegReqType.change_email)
     if reg2.status != RegStatus.ok:
         reg2.flash_message()
         return redirect(url_for('user_settings'))
 
-    form = ConfirmEmailForm()
+    form = ConfirmEmailChangeForm()
     if form.validate_on_submit():
         if form.submit.data:
             if reg2.change_email():
@@ -638,7 +649,38 @@ def confirm_email():
     form.orig_email.data = reg2.rr.user.email
     form.new_email.data = reg2.rr.email
 
-    return render_template('acct_confirm_email.html', form=form)
+    return render_template('acct_confirm_email_change.html', form=form)
+
+
+class ConfirmEmailValidityForm(FlaskForm):
+    email = wtforms.StringField('E-mail', render_kw={"disabled": "disabled"})
+    submit = wtforms.SubmitField('Potvrdit')
+    cancel = wtforms.SubmitField('Zrušit požadavek')
+
+
+# URL je explicitně uvedeno v mo.email.confirm_url
+@app.route('/acct/confirm/v', methods=('GET', 'POST'))
+def confirm_email_validity():
+    reg2 = Reg2(request.args.get('token'), db.RegReqType.validate_email)
+    if reg2.status != RegStatus.ok:
+        reg2.flash_message()
+        return redirect(url_for('user_settings'))
+
+    form = ConfirmEmailValidityForm()
+    if form.validate_on_submit():
+        if form.submit.data:
+            reg2.validate_email()
+            flash('E-mail potvrzen.', 'success')
+            return redirect(url_for('index'))
+        elif form.cancel.data:
+            reg2.spend_request_and_commit()
+            flash('Požadavek na potvrzení e-mailu zrušen.', 'success')
+            return redirect(url_for('user_settings'))
+
+    reg2.flash_message()
+    form.email.data = reg2.rr.user.email
+
+    return render_template('acct_confirm_email_validity.html', form=form)
 
 
 class CancelResetForm(FlaskForm):
@@ -687,6 +729,49 @@ def user_dsn():
     return render_template('acct_dsn.html', user=user, dsns=dsns)
 
 
+class ValidateEmailForm(FlaskForm):
+    submit = wtforms.SubmitField('Ověřit e-mail')
+
+
+@app.route('/acct/settings/validate-email', methods=('GET', 'POST'))
+@app.route('/acct/settings/validate-email/wait', endpoint='user_validate_email_wait')
+def user_validate_email():
+    sess = db.get_session()
+    user = g.user
+    if not user:
+        raise NeedLoginError()
+
+    if mo.users.email_is_fake(user.email):
+        flash('Tento účet nemá platnou e-mailovou adresu.', 'danger')
+        return redirect(url_for('user_settings'))
+
+    if user.dsn is None:
+        flash('Tento účet už má ověřenou e-mailovou adresu.', 'success')
+        return redirect(url_for('user_settings'))
+
+    if request.endpoint != 'user_validate_email_wait':
+        form = ValidateEmailForm()
+        if form.validate_on_submit():
+            ok = True
+            rr = mo.users.new_reg_request(db.RegReqType.validate_email, request.remote_addr)
+            if rr:
+                rr.user_id = user.user_id
+                sess.add(rr)
+                sess.commit()
+                app.logger.info(f'Validace e-mailu: Požadavek pro uživatele #{user.user_id}')
+                mo.email.send_confirm_validate_email(user, rr)
+            else:
+                app.logger.info('Validace e-mailu: Rate limit')
+                flash('Příliš mnoho požadavků na validaci e-mailu. Počkejte prosím chvíli a zkuste to znovu.', 'danger')
+                ok = False
+            if ok:
+                return redirect(url_for('user_validate_email_wait'))
+    else:
+        form = None
+
+    return render_template('acct_validate_email.html', form=form)
+
+
 if getattr(config, 'INSECURE_TEST_LOGIN', False):
     @app.route('/test-login/<email>')
     def test_login(email: str):
diff --git a/mo/web/templates/acct_confirm_email.html b/mo/web/templates/acct_confirm_email_change.html
similarity index 100%
rename from mo/web/templates/acct_confirm_email.html
rename to mo/web/templates/acct_confirm_email_change.html
diff --git a/mo/web/templates/acct_confirm_email_validity.html b/mo/web/templates/acct_confirm_email_validity.html
new file mode 100644
index 00000000..3316e1b0
--- /dev/null
+++ b/mo/web/templates/acct_confirm_email_validity.html
@@ -0,0 +1,8 @@
+{% extends "base.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
+{% block title %}Potvrzení platnosti e-mailu{% endblock %}
+{% block body %}
+
+{{ wtf.quick_form(form, form_type='horizontal', button_map={'submit': 'primary'}) }}
+
+{% endblock %}
diff --git a/mo/web/templates/acct_validate_email.html b/mo/web/templates/acct_validate_email.html
new file mode 100644
index 00000000..a801de71
--- /dev/null
+++ b/mo/web/templates/acct_validate_email.html
@@ -0,0 +1,32 @@
+{% extends "base.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
+{% block title %}Ověření e-mailové adresy{% endblock %}
+{% block body %}
+
+{% if form %}
+
+<p>
+	Abychom ověřili platnosti vaši e-mailové adresy, pošleme na ni e-mail.
+	Až přijde, klikněte na odkaz v něm obsažený.
+</p>
+
+{{ wtf.quick_form(form, form_type='simple', button_map={'submit': 'primary'}) }}
+
+{% else %}
+
+<p>
+	Vyčkejte prosím, až vám přijde e-mail.
+</p>
+
+<p>
+	Pakliže nepřichází:
+</p>
+
+<div class="btn-group">
+	<a class='btn btn-default' href='{{ url_for('user_settings_personal') }}'>Změnit e-mail</a>
+	<a class='btn btn-default' href="{{ url_for('user_settings') }}">Zpět na nastavení účtu</a>
+</div>
+
+{% endif %}
+
+{% endblock %}
-- 
GitLab


From a0f76898d062cb1733d956db06a38435283e7b6a Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 25 Jan 2025 00:05:23 +0100
Subject: [PATCH 17/35] =?UTF-8?q?Kosmetick=C3=A1=20=C3=BAprava=20str=C3=A1?=
 =?UTF-8?q?nky=20na=20editaci=20=C3=BA=C4=8Dtu?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/templates/org_user_edit.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/mo/web/templates/org_user_edit.html b/mo/web/templates/org_user_edit.html
index 529f49da..f2f7e5aa 100644
--- a/mo/web/templates/org_user_edit.html
+++ b/mo/web/templates/org_user_edit.html
@@ -16,7 +16,7 @@
 <tr><td>Poznámka:</td><td style="white-space: pre-line;">{{ user.note }}</td></tr>
 </table>
 
-<a href='{% if is_org %}{{ url_for('org_org', id=user.user_id) }}{% else %}{{ url_for('org_user', id=user.user_id) }}{% endif %}'>Zpět na detail</a>
+<a class='btn btn-default' href='{{ user|user_url }}'>Zpět na detail</a>
 
 <hr>
 
-- 
GitLab


From 333dcf2b8da16a64c20712dccde9683f30605ef8 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 25 Jan 2025 00:28:20 +0100
Subject: [PATCH 18/35] =?UTF-8?q?DSN:=20V=C3=BDklad=20b=C4=9B=C5=BEn=C3=BD?=
 =?UTF-8?q?ch=20status=20k=C3=B3d=C5=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/util_dsn.py                  | 28 ++++++++++++++++++++++++++++
 mo/web/jinja.py                 |  2 ++
 mo/web/templates/parts/dsn.html |  2 +-
 3 files changed, 31 insertions(+), 1 deletion(-)
 create mode 100644 mo/util_dsn.py

diff --git a/mo/util_dsn.py b/mo/util_dsn.py
new file mode 100644
index 00000000..b442e256
--- /dev/null
+++ b/mo/util_dsn.py
@@ -0,0 +1,28 @@
+# Utility na práci s mailovými nedoručenkami
+
+from typing import Optional
+
+
+dsn_explanation = {
+    '4.2.2': 'schránka je plná',
+    '4.4.1': 'server nepřijímá spojení',
+    '5.1.1': 'adresa neexistuje',
+    '5.1.10': 'doména nepřijímá poštu',         # Null MX
+    '5.2.1': 'schránka je zablokována',
+    '5.2.2': 'schránka je plná',
+    '5.4.1': 'server nepřijímá spojení',
+    '5.4.6': 'cyklické přeposílání pošty',
+    '5.4.14': 'cyklické přeposílání pošty',     # nestandardní status od MS Exchange
+    '5.7.1': 'příjem pošty zakázán pravidly cílové sítě',
+}
+
+
+def format_dsn_status(status: Optional[str]) -> str:
+    if not status:
+        return '–'
+    else:
+        explain = dsn_explanation.get(status)
+        if explain:
+            return f'{status} – {explain}'
+        else:
+            return status
diff --git a/mo/web/jinja.py b/mo/web/jinja.py
index 2a890c51..6df9522b 100644
--- a/mo/web/jinja.py
+++ b/mo/web/jinja.py
@@ -14,6 +14,7 @@ import mo.place_level
 import mo.points
 from mo.rights import Right
 from mo.util import assert_not_none
+from mo.util_dsn import format_dsn_status
 import mo.util_format as util_format
 from mo.web import app
 from mo.web.org_place import place_breadcrumbs
@@ -69,6 +70,7 @@ jg.update(Right=Right)
 jg.update(place_breadcrumbs=place_breadcrumbs)
 # Funkce asset_url se přidává v mo.ext.assets
 
+jf.update(dsn_status=format_dsn_status)
 jf.update(user_url=user_url)
 
 
diff --git a/mo/web/templates/parts/dsn.html b/mo/web/templates/parts/dsn.html
index 5feed019..f7260c86 100644
--- a/mo/web/templates/parts/dsn.html
+++ b/mo/web/templates/parts/dsn.html
@@ -8,7 +8,7 @@
 		<td>{{ dsn.arrived_at|time_and_timedelta }}
 	<tr>
 		<th>Kód chyby
-		<td>{{ dsn.status }}
+		<td>{{ dsn.status|dsn_status }}
 	<tr>
 		<th>Chybová zpráva
 		<td>{{ dsn.diag_code|or_dash }}
-- 
GitLab


From 8609c9216828500b06396d0454d26fde99caa5ba Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 25 Jan 2025 00:31:56 +0100
Subject: [PATCH 19/35] =?UTF-8?q?PagerForm=20nepot=C5=99ebuje=20submit?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Všichni jeho potomci si submit definují vlastní.
---
 mo/web/util.py | 1 -
 1 file changed, 1 deletion(-)

diff --git a/mo/web/util.py b/mo/web/util.py
index d7987db5..148665f1 100644
--- a/mo/web/util.py
+++ b/mo/web/util.py
@@ -21,7 +21,6 @@ class PagerForm(FlaskForm):
     offset = wtforms.IntegerField()
     next = wtforms.SubmitField("Další")
     previous = wtforms.SubmitField("Předchozí")
-    submit = wtforms.SubmitField("Filtrovat")
 
     def apply_limits(self, query: Query, pagesize: int = 50) -> Tuple[int, Query]:
         count = db.get_count(query)
-- 
GitLab


From bc4d3218a5a3ad8a19ec125df62b9bf9f534fde5 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 25 Jan 2025 00:52:23 +0100
Subject: [PATCH 20/35] =?UTF-8?q?Nov=C3=A1=20sekce=20webu=20s=20n=C3=A1str?=
 =?UTF-8?q?oji=20pro=20spr=C3=A1vce?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/__init__.py | 6 ++++++
 mo/web/admin.py    | 1 +
 2 files changed, 7 insertions(+)
 create mode 100644 mo/web/admin.py

diff --git a/mo/web/__init__.py b/mo/web/__init__.py
index e8dbd227..743333c7 100644
--- a/mo/web/__init__.py
+++ b/mo/web/__init__.py
@@ -191,6 +191,11 @@ def init_request():
             raise NeedLoginError()
         if user.is_org or user.is_admin:
             return redirect(url_for('org_index'))
+    elif path.startswith('/admin/'):
+        if not user:
+            raise NeedLoginError()
+        if not user.is_admin:
+            raise werkzeug.exceptions.Forbidden()
     elif path.startswith('/doc/'):
         if user and (user.is_org or user.is_admin):
             g.gatekeeper = mo.rights.Gatekeeper(user)
@@ -260,6 +265,7 @@ except ImportError:
 
 
 # Většina webu je v samostatných modulech
+import mo.web.admin
 import mo.web.api
 import mo.web.api_dsn
 import mo.web.acct
diff --git a/mo/web/admin.py b/mo/web/admin.py
new file mode 100644
index 00000000..93e759f7
--- /dev/null
+++ b/mo/web/admin.py
@@ -0,0 +1 @@
+# Web: Nástroje pro správce
-- 
GitLab


From cd5f51530504b2006c6d0184b2aa94ea6475f633 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 25 Jan 2025 00:53:26 +0100
Subject: [PATCH 21/35] =?UTF-8?q?PagerForm=20um=C3=AD=20po=C4=8D=C3=ADtat?=
 =?UTF-8?q?=20prvn=C3=AD=20a=20posledn=C3=AD=20polo=C5=BEku?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/templates/org_generic_list.html |  3 +--
 mo/web/templates/org_orgs.html         |  3 +--
 mo/web/templates/org_users.html        |  3 +--
 mo/web/util.py                         | 14 ++++++++++++++
 4 files changed, 17 insertions(+), 6 deletions(-)

diff --git a/mo/web/templates/org_generic_list.html b/mo/web/templates/org_generic_list.html
index 106a0342..e6c2e900 100644
--- a/mo/web/templates/org_generic_list.html
+++ b/mo/web/templates/org_generic_list.html
@@ -60,9 +60,8 @@
 			<input type="hidden" name="offset" value="{{filter.offset.data}}">
 			<input type="hidden" name="limit" value="{{filter.limit.data}}">
 		</div>
-		{% set max = filter.offset.data + filter.limit.data if filter.offset.data + filter.limit.data < count else count %}
 		{% if count > 0 %}
-			Zobrazuji záznamy <b>{{filter.offset.data + 1}}</b> až <b>{{ max }}</b> z <b>{{count}} nalezených účastníků</b>.
+			Zobrazuji záznamy <b>{{filter.get_min()}}</b> až <b>{{filter.get_max(count)}}</b> z <b>{{count}} nalezených účastníků</b>.
 		{% else %}
 			<b>Nebyly nalezeny žádné záznamy účastníků.</b>
 		{% endif %}
diff --git a/mo/web/templates/org_orgs.html b/mo/web/templates/org_orgs.html
index 6bbe924a..5ee560b4 100644
--- a/mo/web/templates/org_orgs.html
+++ b/mo/web/templates/org_orgs.html
@@ -61,9 +61,8 @@
 				<button class="btn" disabled>Další</button>
 			{% endif %}
 	</div>
-	{% set max = filter.offset.data + filter.limit.data if filter.offset.data + filter.limit.data < count else count %}
 	{% if count > 0 %}
-		Zobrazuji záznamy <b>{{filter.offset.data + 1}}</b> až <b>{{ max }}</b> z <b>{{count}} nalezených organizátorů</b>.
+		Zobrazuji záznamy <b>{{filter.get_min()}}</b> až <b>{{filter.get_max(count)}}</b> z <b>{{count}} nalezených organizátorů</b>.
 	{% else %}
 		<b>Nebyly nalezeny žádné záznamy organizátorů.</b>
 	{% endif %}
diff --git a/mo/web/templates/org_users.html b/mo/web/templates/org_users.html
index 82aaf3a7..198e5b65 100644
--- a/mo/web/templates/org_users.html
+++ b/mo/web/templates/org_users.html
@@ -54,9 +54,8 @@
 				<button class="btn" disabled>Další</button>
 			{% endif %}
 	</div>
-	{% set max = filter.offset.data + filter.limit.data if filter.offset.data + filter.limit.data < count else count %}
 	{% if count > 0 %}
-		Zobrazuji záznamy <b>{{filter.offset.data + 1}}</b> až <b>{{ max }}</b> z <b>{{count}} nalezených soutěžících</b>.
+		Zobrazuji záznamy <b>{{filter.get_min()}}</b> až <b>{{filter.get_max(count)}}</b> z <b>{{count}} nalezených soutěžících</b>.
 	{% else %}
 		<b>Nebyly nalezeny žádné záznamy soutěžících.</b>
 	{% endif %}
diff --git a/mo/web/util.py b/mo/web/util.py
index 148665f1..245438eb 100644
--- a/mo/web/util.py
+++ b/mo/web/util.py
@@ -40,6 +40,20 @@ class PagerForm(FlaskForm):
 
         return (count, query)
 
+    def get_min(self) -> int:
+        if self.offset.data is not None:
+            return self.offset.data + 1
+        else:
+            return 0
+
+    def get_max(self, count) -> int:
+        if self.offset.data is None or self.limit.data is None:
+            return 0
+        if self.offset.data + self.limit.data < count:
+            return self.offset.data + self.limit.data
+        else:
+            return count
+
 
 def task_statement_exists(round: db.Round) -> bool:
     if round.tasks_file is None:
-- 
GitLab


From 2854c4728c7899b409d39cac330226f78419a860 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 25 Jan 2025 00:54:13 +0100
Subject: [PATCH 22/35] =?UTF-8?q?DSN:=20P=C5=99ehled=20v=C5=A1ech=20DSN=20?=
 =?UTF-8?q?pro=20spr=C3=A1vce?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/admin.py                 | 20 ++++++++++++++++++++
 mo/web/templates/admin_dsn.html | 27 +++++++++++++++++++++++++++
 2 files changed, 47 insertions(+)
 create mode 100644 mo/web/templates/admin_dsn.html

diff --git a/mo/web/admin.py b/mo/web/admin.py
index 93e759f7..71a9e83a 100644
--- a/mo/web/admin.py
+++ b/mo/web/admin.py
@@ -1 +1,21 @@
 # Web: Nástroje pro správce
+
+from flask import render_template, request
+
+import mo.db as db
+from mo.web import app
+from mo.web.util import PagerForm
+
+
+@app.route('/admin/dsn')
+def admin_all_dsn():
+    pager = PagerForm(formdata=request.args)
+    if request.args:
+        pager.validate()
+
+    sess = db.get_session()
+    query = sess.query(db.EmailDSN).order_by(db.EmailDSN.arrived_at.desc())
+    (count, query) = pager.apply_limits(query, pagesize=20)
+    dsns = query.all()
+
+    return render_template('admin_dsn.html', dsns=dsns, pager=pager, count=count)
diff --git a/mo/web/templates/admin_dsn.html b/mo/web/templates/admin_dsn.html
new file mode 100644
index 00000000..2a08bef6
--- /dev/null
+++ b/mo/web/templates/admin_dsn.html
@@ -0,0 +1,27 @@
+{% extends "base.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
+{% block title %}Všechny nedoručenky{% endblock %}
+{% block body %}
+
+<div class="btn-group">
+	{% if pager.offset.data > 0 %}
+		{{ wtf.form_field(pager.previous) }}
+	{% else %}
+		<button class="btn" disabled>Předchozí</button>
+	{% endif %}
+	{% if count > pager.offset.data + pager.limit.data %}
+		{{ wtf.form_field(pager.next) }}
+	{% else %}
+		<button class="btn" disabled>Další</button>
+	{% endif %}
+</div>
+
+{% if count > 0 %}
+	Zobrazuji nedoručenky <b>{{pager.get_min()}}</b> až <b>{{pager.get_max(count)}}</b> z <b>{{count}} nalezených</b>.
+{% else %}
+	<b>Nebyly nalezeny žádné nedoručenky.</b>
+{% endif %}
+
+{% include "parts/dsn.html" %}
+
+{% endblock %}
-- 
GitLab


From 111787ed8c6f61a4a3f684de55bb417ff5ff8b61 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 25 Jan 2025 00:58:21 +0100
Subject: [PATCH 23/35] =?UTF-8?q?Admin:=20Odkaz=20na=20seznam=20DSN=20z=20?=
 =?UTF-8?q?hlavn=C3=AD=20str=C3=A1nky?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/templates/org_index.html | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/mo/web/templates/org_index.html b/mo/web/templates/org_index.html
index a0229652..aa72dcd3 100644
--- a/mo/web/templates/org_index.html
+++ b/mo/web/templates/org_index.html
@@ -84,13 +84,17 @@
 
 {% if g.user.is_admin %}
 
-<h3>Univerzální hledátko</h3>
+<h3>Nástroje pro správce</h3>
 
 <form method=GET action="" class='form form-inline' role=form>
 	<input class='form-control' name=search size=32 placeholder='cID pID rID uID x@y.z' autofocus></input>
 	<input class='btn btn-primary' type="submit" value='Vyhledat'>
 </form>
 
+<div class='button-group space-top'>
+	<a class='btn btn-default' href='{{ url_for('admin_all_dsn') }}'>Nedoručenky</a>
+</div>
+
 {% endif %}
 
 {% endblock %}
-- 
GitLab


From 03abff5ad80571f1c69a0606c6c7e083a2218fc5 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 25 Jan 2025 01:20:55 +0100
Subject: [PATCH 24/35] =?UTF-8?q?DSN:=20Expirace=20nedoru=C4=8Denek?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Nemažeme nedoručenky, na které se odkazuje nějaký účet.
Ale o registraci se nestaráme, ta velmi pravděpodobně expiruje dřív.
---
 etc/config.py.example |  3 +++
 mo/util_dsn.py        | 22 ++++++++++++++++++++++
 mo/web/__init__.py    |  3 +++
 3 files changed, 28 insertions(+)

diff --git a/etc/config.py.example b/etc/config.py.example
index 9c02e622..15d62304 100644
--- a/etc/config.py.example
+++ b/etc/config.py.example
@@ -80,6 +80,9 @@ REG_MAX_PER_MINUTE = 10
 # Jak dlouho vydrží tokeny používané při registraci a změnách e-mailu [min]
 REG_TOKEN_VALIDITY = 10
 
+# Jak dlouho si pamatujeme e-mailovou nedoručenku [min]
+DSN_EXPIRATION = 30 * 1440
+
 # Aktuální ročník MO
 CURRENT_YEAR = 71
 
diff --git a/mo/util_dsn.py b/mo/util_dsn.py
index b442e256..64fe8cd7 100644
--- a/mo/util_dsn.py
+++ b/mo/util_dsn.py
@@ -1,7 +1,13 @@
 # Utility na práci s mailovými nedoručenkami
 
+from datetime import timedelta
+from sqlalchemy import select
 from typing import Optional
 
+import mo
+import mo.config as config
+import mo.db as db
+
 
 dsn_explanation = {
     '4.2.2': 'schránka je plná',
@@ -26,3 +32,19 @@ def format_dsn_status(status: Optional[str]) -> str:
             return f'{status} – {explain}'
         else:
             return status
+
+
+def expire_dsns() -> None:
+    expiration = getattr(config, 'DSN_EXPIRATION', None)
+    if expiration is None:
+        return
+
+    sess = db.get_session()
+    conn = sess.connection()
+    user_table = db.User.__table__
+    dsn_table = db.EmailDSN.__table__
+    conn.execute(dsn_table.delete()
+                 .where(dsn_table.c.arrived_at < mo.now - timedelta(days=expiration))
+                 .where(dsn_table.c.dsn_id.not_in(select(user_table.c.dsn_id))))
+
+    sess.commit()
diff --git a/mo/web/__init__.py b/mo/web/__init__.py
index 743333c7..f8ccc040 100644
--- a/mo/web/__init__.py
+++ b/mo/web/__init__.py
@@ -23,6 +23,7 @@ import mo.jobs.submit
 import mo.rights
 import mo.users
 import mo.util
+import mo.util_dsn
 
 
 class MOFlask(Flask):
@@ -210,11 +211,13 @@ app.before_request(init_request)
 #    - projdeme joby pro případ, že by se ztratil signál
 #    - expirujeme zastaralé joby
 #    - expirujeme zastaralé registrační tokeny
+#    - expirujeme zastaralé nedoručenky
 def collect_garbage() -> None:
     mo.now = mo.util.get_now()
     mo.jobs.submit.check_broken_submits()
     mo.jobs.process_jobs()
     mo.users.expire_reg_requests()
+    mo.util_dsn.expire_dsns()
 
 
 @app.cli.command('gc')
-- 
GitLab


From 48145ba153c36037b5e53ad7c39a51cd08c60303 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 25 Jan 2025 01:24:39 +0100
Subject: [PATCH 25/35] =?UTF-8?q?DSN:=20reset=5Fuser=5Fdsn()=20p=C5=99esou?=
 =?UTF-8?q?v=C3=A1me=20do=20mo.util=5Fdsn?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/users.py         |  9 ---------
 mo/util_dsn.py      | 10 ++++++++++
 mo/web/acct.py      |  6 +++---
 mo/web/org_users.py |  3 ++-
 4 files changed, 15 insertions(+), 13 deletions(-)

diff --git a/mo/users.py b/mo/users.py
index 0754725c..ef89b1ae 100644
--- a/mo/users.py
+++ b/mo/users.py
@@ -467,12 +467,3 @@ def request_reset_password(user: db.User, client: Optional[str]) -> Optional[db.
             details={'action': 'ask-reset'},
         )
     return rr
-
-
-def reset_user_dsn(user: db.User) -> None:
-    if user.dsn_id:
-        logger.info(f'Uživatel #{user.user_id}: Reset DSN')
-        user.dsn_id = None
-        conn = db.get_session().connection()
-        dsn_table = db.EmailDSN.__table__
-        conn.execute(dsn_table.delete().where(dsn_table.c.user_id == user.user_id))
diff --git a/mo/util_dsn.py b/mo/util_dsn.py
index 64fe8cd7..165530f0 100644
--- a/mo/util_dsn.py
+++ b/mo/util_dsn.py
@@ -7,6 +7,7 @@ from typing import Optional
 import mo
 import mo.config as config
 import mo.db as db
+from mo.util import logger
 
 
 dsn_explanation = {
@@ -48,3 +49,12 @@ def expire_dsns() -> None:
                  .where(dsn_table.c.dsn_id.not_in(select(user_table.c.dsn_id))))
 
     sess.commit()
+
+
+def reset_user_dsn(user: db.User) -> None:
+    if user.dsn_id:
+        logger.info(f'Uživatel #{user.user_id}: Reset DSN')
+        user.dsn_id = None
+        conn = db.get_session().connection()
+        dsn_table = db.EmailDSN.__table__
+        conn.execute(dsn_table.delete().where(dsn_table.c.user_id == user.user_id))
diff --git a/mo/web/acct.py b/mo/web/acct.py
index deefa8cc..a9072301 100644
--- a/mo/web/acct.py
+++ b/mo/web/acct.py
@@ -555,14 +555,14 @@ class Reg2:
             },
         )
 
-        mo.users.reset_user_dsn(user)
+        mo.util_dsn.reset_user_dsn(user)
         self.spend_request_and_commit()
         return True
 
     def validate_email(self) -> None:
         user = self.rr.user
         app.logger.info(f'Reg2: Uživatel #{user.user_id} potvrzuje email <{user.email}>')
-        mo.users.reset_user_dsn(user)
+        mo.util_dsn.reset_user_dsn(user)
         self.spend_request_and_commit()
 
     def change_passwd(self, new_passwd: str):
@@ -572,7 +572,7 @@ class Reg2:
         app.logger.info(f'Reg2: Uživatel #{user.user_id} si resetoval heslo')
         mo.users.set_password(user, new_passwd, reset=True)
         mo.users.login(user)
-        mo.users.reset_user_dsn(user)
+        mo.util_dsn.reset_user_dsn(user)
         self.spend_request_and_commit()
 
     def spend_request_and_commit(self):
diff --git a/mo/web/org_users.py b/mo/web/org_users.py
index 43f104b0..be60c783 100644
--- a/mo/web/org_users.py
+++ b/mo/web/org_users.py
@@ -21,6 +21,7 @@ from mo.imports import GlobalOrgsImport
 import mo.rights
 from mo.rights import Right, Rights
 import mo.util
+import mo.util_dsn
 import mo.users
 from mo.web import app
 import mo.web.fields as mo_fields
@@ -525,7 +526,7 @@ def org_user_edit(id: int):
             if sess.is_modified(user):
                 changes = db.get_object_changes(user)
                 if 'email' in changes:
-                    mo.users.reset_user_dsn(user)
+                    mo.util_dsn.reset_user_dsn(user)
                 app.logger.info(f"Uživatel #{id} změněn: {changes}")
                 mo.util.log(
                     type=db.LogType.user,
-- 
GitLab


From 636af5efb22d6c16745f9f146d5a3ef1941422a5 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 25 Jan 2025 01:29:55 +0100
Subject: [PATCH 26/35] =?UTF-8?q?DSN:=20Adminsk=C3=BD=20p=C5=99ehled=20uka?=
 =?UTF-8?q?zuje,=20koho=20se=20nedoru=C4=8Denka=20t=C3=BDk=C3=A1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/admin.py                 |  7 +++++--
 mo/web/templates/parts/dsn.html | 12 ++++++++++++
 2 files changed, 17 insertions(+), 2 deletions(-)

diff --git a/mo/web/admin.py b/mo/web/admin.py
index 71a9e83a..c31dc9d0 100644
--- a/mo/web/admin.py
+++ b/mo/web/admin.py
@@ -1,6 +1,7 @@
 # Web: Nástroje pro správce
 
 from flask import render_template, request
+from sqlalchemy.orm import joinedload
 
 import mo.db as db
 from mo.web import app
@@ -14,8 +15,10 @@ def admin_all_dsn():
         pager.validate()
 
     sess = db.get_session()
-    query = sess.query(db.EmailDSN).order_by(db.EmailDSN.arrived_at.desc())
+    query = (sess.query(db.EmailDSN)
+             .options(joinedload(db.EmailDSN.user), joinedload(db.EmailDSN.reg))
+             .order_by(db.EmailDSN.arrived_at.desc()))
     (count, query) = pager.apply_limits(query, pagesize=20)
     dsns = query.all()
 
-    return render_template('admin_dsn.html', dsns=dsns, pager=pager, count=count)
+    return render_template('admin_dsn.html', dsns=dsns, pager=pager, count=count, admin_list=True)
diff --git a/mo/web/templates/parts/dsn.html b/mo/web/templates/parts/dsn.html
index f7260c86..06d0da26 100644
--- a/mo/web/templates/parts/dsn.html
+++ b/mo/web/templates/parts/dsn.html
@@ -6,6 +6,18 @@
 	<tr>
 		<th>Čas
 		<td>{{ dsn.arrived_at|time_and_timedelta }}
+	{% if admin_list %}
+	<tr>
+		<th>Pro koho
+		{% if dsn.user is not none %}
+			<td>Uživatel {{ dsn.user|user_link }}{{ dsn.user|user_flags }}
+			{% if dsn.user.dsn == dsn %} (primární DSN){% endif %}
+		{% elif dsn.reg_id is not none %}
+		<td>Registrace #{{dsn.reg_id}} ({{dsn.reg.type.name}})
+		{% else %}
+			<td>???
+		{% endif %}
+	{% endif %}
 	<tr>
 		<th>Kód chyby
 		<td>{{ dsn.status|dsn_status }}
-- 
GitLab


From 7cfa1b874462b6dc87205ee490164826d572242c Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 25 Jan 2025 11:24:05 +0100
Subject: [PATCH 27/35] =?UTF-8?q?DSN:=20Na=20=C3=BA=C4=8Dty=20s=20nedosa?=
 =?UTF-8?q?=C5=BEitelnou=20adresou=20nepos=C3=ADl=C3=A1me=20notifikace?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/db.py          | 4 ++++
 mo/jobs/notify.py | 2 +-
 mo/web/user.py    | 4 ++--
 3 files changed, 7 insertions(+), 3 deletions(-)

diff --git a/mo/db.py b/mo/db.py
index 185da8e1..6be1ff0d 100644
--- a/mo/db.py
+++ b/mo/db.py
@@ -467,6 +467,10 @@ class User(Base):
     def is_system(self) -> bool:
         return self.user_id == 0
 
+    @property
+    def wants_notify(self) -> bool:
+        return self.email_notify and self.dsn_id is None
+
 
 def get_system_user() -> User:
     """Uživatel s user_id=0 je systémový (viz db.ddl)"""
diff --git a/mo/jobs/notify.py b/mo/jobs/notify.py
index ea522b7d..50097dea 100644
--- a/mo/jobs/notify.py
+++ b/mo/jobs/notify.py
@@ -70,7 +70,7 @@ def handle_send_grading_info(the_job: TheJob):
 
     for user, sent in todo:
         num_total += 1
-        if not user.email_notify:
+        if not user.wants_notify:
             num_dont_want += 1
         elif sent:
             num_before += 1
diff --git a/mo/web/user.py b/mo/web/user.py
index 17364b2b..d39a61df 100644
--- a/mo/web/user.py
+++ b/mo/web/user.py
@@ -266,8 +266,8 @@ def join_notify(c: db.Contest) -> None:
         notify = {ur.user for ur in uroles if ur.applies_to(at=place, year=r.year, cat=r.category, seq=r.seq)}
         if notify:
             for org in notify:
-                logger.info(f'Join: {"Notifikuji" if org.email_notify else "Nenotifikuji"} orga <{org.email}> pro místo {place.get_code()}')
-                if org.email_notify:
+                logger.info(f'Join: {"Notifikuji" if org.wants_notify else "Nenotifikuji"} orga <{org.email}> pro místo {place.get_code()}')
+                if org.wants_notify:
                     mo.email.send_join_notify_email(org, g.user, c)
             return
         place = place.parent_place
-- 
GitLab


From 2f76c127cd348f2367f4e763a36ea215d120f65c Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 25 Jan 2025 11:26:05 +0100
Subject: [PATCH 28/35] =?UTF-8?q?V=C3=BDpis=20rol=C3=AD=20k=20m=C3=ADstu?=
 =?UTF-8?q?=20ukazuje=20user=5Fflags?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/templates/org_place_roles.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/mo/web/templates/org_place_roles.html b/mo/web/templates/org_place_roles.html
index dfaaa739..7f9fa673 100644
--- a/mo/web/templates/org_place_roles.html
+++ b/mo/web/templates/org_place_roles.html
@@ -23,7 +23,7 @@
 		{% for role in roles %}
 		<tr>
 			<td>{{ roles_by_type[role.role].name }}
-			<td>{{ role.user|user_link }}
+			<td>{{ role.user|user_link }}{{ role.user|user_flags }}
 			<td>{{ role.year or '–' }}
 			<td>{{ role.category or '–' }}
 			<td>{{ role.seq or '–' }}
-- 
GitLab


From 9c4d16d4fa693af66580b5b3ec3bd7cde933a7c4 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 25 Jan 2025 11:35:57 +0100
Subject: [PATCH 29/35] =?UTF-8?q?Zm=C4=9Bna=20osobn=C3=ADch=20=C3=BAdaj?=
 =?UTF-8?q?=C5=AF=20zapom=C3=ADnala=20kontrolovat=20aktu=C3=A1ln=C3=AD=20h?=
 =?UTF-8?q?eslo?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

*blush*
---
 mo/web/acct.py | 22 +++++++++++++++-------
 1 file changed, 15 insertions(+), 7 deletions(-)

diff --git a/mo/web/acct.py b/mo/web/acct.py
index a9072301..b15a62be 100644
--- a/mo/web/acct.py
+++ b/mo/web/acct.py
@@ -23,6 +23,7 @@ import mo.rights
 import mo.tokens
 import mo.users
 import mo.util
+from mo.util import assert_not_none
 from mo.web import app, NeedLoginError
 import mo.web.fields as mo_fields
 
@@ -183,8 +184,11 @@ def user_settings_personal():
     if not form.submit.data:
         form.email.data = user.email
 
-    if form.validate_on_submit():
-        ok = True
+    def process_submit() -> bool:
+        if not mo.users.check_password(user, assert_not_none(form.current_passwd.data)):
+            flash('Nesouhlasí aktuální heslo.', 'danger')
+            return False
+
         if form.new_passwd.data:
             app.logger.info(f'Settings: Změněno heslo uživatele #{user.user_id}')
             mo.users.set_password(user, form.new_passwd.data)
@@ -195,10 +199,12 @@ def user_settings_personal():
             )
             sess.commit()
             flash('Heslo změněno.', 'success')
+
         if form.email.data != user.email:
-            if mo.users.user_by_email(form.email.data) is not None:
+            if mo.users.user_by_email(assert_not_none(form.email.data)) is not None:
+                # Tady prosakuje informace o existenci účtu, ale tu prozrazuje i login.
                 flash('Tuto e-mailovou adresu už používá jiný uživatel.', 'danger')
-                ok = False
+                return False
             else:
                 rr = mo.users.new_reg_request(db.RegReqType.change_email, request.remote_addr)
                 if rr:
@@ -212,9 +218,11 @@ def user_settings_personal():
                 else:
                     app.logger.info('Settings: Rate limit')
                     flash('Příliš mnoho požadavků na změny e-mailu. Počkejte prosím chvíli a zkuste to znovu.', 'danger')
-                    ok = False
-        if ok:
-            return redirect(url_for('user_settings'))
+                    return False
+        return True
+
+    if form.validate_on_submit() and process_submit():
+        return redirect(url_for('user_settings'))
 
     return render_template('settings_change.html', form=form)
 
-- 
GitLab


From 565a0cb4a19cbe48c5a7b2cf924f8f3ae8eb0371 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 25 Jan 2025 17:11:56 +0100
Subject: [PATCH 30/35] =?UTF-8?q?DSN:=20send=5Fpassword=5Freset=5Femail=20?=
 =?UTF-8?q?dost=C3=A1v=C3=A1=20cel=C3=BD=20RegRequest?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

... a jeho DSN token je také vázaný na RegRequest, nejen na uživatele.
---
 mo/email.py    | 4 ++--
 mo/web/acct.py | 2 +-
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/mo/email.py b/mo/email.py
index 1b03ae09..78684545 100644
--- a/mo/email.py
+++ b/mo/email.py
@@ -225,7 +225,7 @@ def send_new_account_email(user: db.User, token: str) -> bool:
     '''.format(activate_url(token))))
 
 
-def send_password_reset_email(user: db.User, token: str) -> bool:
+def send_password_reset_email(user: db.User, rr: db.RegRequest) -> bool:
     return send_user_email(user, 'Obnova hesla', textwrap.dedent('''\
         Někdo požádal o obnovení hesla k Vašemu účtu v Odevzdávacím systému
         Matematické olympiády.
@@ -235,7 +235,7 @@ def send_password_reset_email(user: db.User, token: str) -> bool:
                 {}
 
         Váš OSMO
-    '''.format(confirm_url('p', token))))
+    '''.format(confirm_url('p', rr.email_token))), rr=rr)
 
 
 def send_confirm_create_email(user: db.User, rr: db.RegRequest) -> bool:
diff --git a/mo/web/acct.py b/mo/web/acct.py
index b15a62be..e4b8d5f0 100644
--- a/mo/web/acct.py
+++ b/mo/web/acct.py
@@ -73,7 +73,7 @@ def login():
             rr = mo.users.request_reset_password(user, request.remote_addr)
             if rr:
                 db.get_session().commit()
-                mo.email.send_password_reset_email(user, rr.email_token)
+                mo.email.send_password_reset_email(user, rr)
                 flash('Na uvedenou adresu byl odeslán e-mail s odkazem na obnovu hesla.', 'success')
             else:
                 flash('Příliš časté požadavky na obnovu hesla.', 'danger')
-- 
GitLab


From 11a89b4c4cb1689258f0888df7f5e228f4cbe38d Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 25 Jan 2025 17:17:18 +0100
Subject: [PATCH 31/35] =?UTF-8?q?DSN:=20Obecn=C3=A9=20UI=20pro=20=C4=8Dek?=
 =?UTF-8?q?=C3=A1n=C3=AD=20na=20mail=20s=20periodickou=20kontrolou=20nedor?=
 =?UTF-8?q?u=C4=8Denek?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/acct.py                             | 24 ++++++++++
 mo/web/templates/acct_dsn_check.html       | 22 +++++++++
 mo/web/templates/parts/wait_for_email.html | 56 ++++++++++++++++++++++
 static/mo.css                              | 15 ++++++
 4 files changed, 117 insertions(+)
 create mode 100644 mo/web/templates/acct_dsn_check.html
 create mode 100644 mo/web/templates/parts/wait_for_email.html

diff --git a/mo/web/acct.py b/mo/web/acct.py
index e4b8d5f0..1051ecc7 100644
--- a/mo/web/acct.py
+++ b/mo/web/acct.py
@@ -780,6 +780,30 @@ def user_validate_email():
     return render_template('acct_validate_email.html', form=form)
 
 
+def make_dsn_check_token(rr: db.RegRequest) -> str:
+    return mo.tokens.sign_token([str(rr.reg_id)], 'dsn-check')
+
+
+@app.route('/acct/dsn-check/<token>')
+def dsn_check(token):
+    # Jelikož nevyžadujeme login, nechceme předávat v URL přímo IDčka do databáze.
+    # Místo toho předáváme podepsaný token obsahující IDčko.
+    fields = mo.tokens.verify_token(token, 'dsn-check')
+    if not fields:
+        raise werkzeug.exceptions.NotFound()
+
+    reg_id = int(fields[0])
+    sess = db.get_session()
+    reg = sess.query(db.RegRequest).options(joinedload(db.RegRequest.dsn)).get(reg_id)
+    if not reg:
+        raise werkzeug.exceptions.NotFound()
+
+    if reg.dsn is not None:
+        return render_template('acct_dsn_check.html', dsn=reg.dsn)
+    else:
+        return ""
+
+
 if getattr(config, 'INSECURE_TEST_LOGIN', False):
     @app.route('/test-login/<email>')
     def test_login(email: str):
diff --git a/mo/web/templates/acct_dsn_check.html b/mo/web/templates/acct_dsn_check.html
new file mode 100644
index 00000000..5b14c354
--- /dev/null
+++ b/mo/web/templates/acct_dsn_check.html
@@ -0,0 +1,22 @@
+<div class='alert alert-danger'>
+E-mail se nepodařilo doručit.
+
+<table class='dsn-check-err'>
+	<tr>
+		<th>Kód chyby:
+		<td>{{ dsn.status|dsn_status }}
+	<tr>
+		<th>Chybová zpráva:
+		<td>{{ dsn.diag_code|or_dash }}
+	<tr>
+		<th>Vzdálený server:
+		<td>{{ dsn.remote_mta|or_dash }}
+</table>
+</div>
+
+{#
+
+{% set dsns = [dsn] %}
+{% include "parts/dsn.html" %}
+
+#}
diff --git a/mo/web/templates/parts/wait_for_email.html b/mo/web/templates/parts/wait_for_email.html
new file mode 100644
index 00000000..e281d298
--- /dev/null
+++ b/mo/web/templates/parts/wait_for_email.html
@@ -0,0 +1,56 @@
+<div id='dsn-report'>
+<p>
+	Počkejte prosím, až vám přijde e-mail, a klikněte na odkaz v něm uvedený.
+</p>
+</div>
+
+{% if dsn_check_token %}
+
+<script type="text/javascript">
+let dsn_check_counter = 0;
+let dsn_stop = false;
+
+function schedule_dsn_check()
+{
+	if (dsn_stop)
+		return;
+
+	dsn_check_counter++;
+	if (dsn_check_counter > 100)
+		return;
+
+	let timeout = 30_000;
+	if (dsn_check_counter < 10)
+		timeout = 5_000;
+
+	window.setTimeout(function() {
+		console.log('DSN: Checking');
+		fetch("{{ url_for('dsn_check', token=dsn_check_token) }}")
+		.then(response => {
+			if (response.status >= 400 && response.status <= 499)
+				dsn_stop = true;
+			if (response.ok)
+				return response.text();
+			else
+				throw Error("Failed with status code " + response.status)
+		})
+		.then((value) => {
+			if (value != "") {
+				console.log('DSN: Received report')
+				const report_div = document.getElementById("dsn-report");
+				report_div.innerHTML = value;
+				dsn_stop = true;
+			}
+			schedule_dsn_check();
+		})
+		.catch(err => {
+			console.log('DSN: ' + err)
+			schedule_dsn_check();
+		});
+	}, timeout);
+}
+
+schedule_dsn_check();
+</script>
+
+{% endif %}
diff --git a/static/mo.css b/static/mo.css
index 58864bb1..655fbfec 100644
--- a/static/mo.css
+++ b/static/mo.css
@@ -596,6 +596,21 @@ table.data input.form-control {
 	margin: 2ex 2ex;
 }
 
+/* Upozornění na nedoručený mail v registraci */
+
+table.dsn-check-err {
+	border-collapse: collapse;
+	margin-top: 2ex;
+}
+
+table.dsn-check-err tr td, table.dsn-check-err tr th {
+	padding-top: 0.1ex;
+}
+
+table.dsn-check-err tr th {
+	padding-right: 2ex;
+}
+
 /* Vzhled pro mobily a úzké displeje */
 
 @media only screen and (max-width: 600px) {
-- 
GitLab


From 85343b78a3a54f0338f24400298e910c09d35d5f Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 25 Jan 2025 17:18:44 +0100
Subject: [PATCH 32/35] =?UTF-8?q?Registrace:=20V=C5=A1echna=20workflows=20?=
 =?UTF-8?q?s=20=C4=8Dek=C3=A1n=C3=ADm=20na=20mail=20sjednocena?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 mo/web/acct.py                               | 47 +++++++++++++-------
 mo/web/templates/acct_reg2.html              |  2 +-
 mo/web/templates/acct_reset_passwd_wait.html |  7 +++
 mo/web/templates/acct_validate_email.html    | 12 ++---
 mo/web/templates/settings_change.html        | 12 +++++
 5 files changed, 53 insertions(+), 27 deletions(-)
 create mode 100644 mo/web/templates/acct_reset_passwd_wait.html

diff --git a/mo/web/acct.py b/mo/web/acct.py
index 1051ecc7..c76eff4b 100644
--- a/mo/web/acct.py
+++ b/mo/web/acct.py
@@ -60,7 +60,7 @@ def login():
     if not form.validate_on_submit():
         return render_template('login.html', form=form, error=None)
 
-    email = form.email.data
+    email = assert_not_none(form.email.data)
     user = mo.users.user_by_email(email)
 
     if not user:
@@ -75,6 +75,7 @@ def login():
                 db.get_session().commit()
                 mo.email.send_password_reset_email(user, rr)
                 flash('Na uvedenou adresu byl odeslán e-mail s odkazem na obnovu hesla.', 'success')
+                return redirect(url_for('reset_passwd_wait', dsn_check_token=make_dsn_check_token(rr)))
             else:
                 flash('Příliš časté požadavky na obnovu hesla.', 'danger')
     elif not form.passwd.data or not mo.users.check_password(user, form.passwd.data):
@@ -97,6 +98,11 @@ def login():
     return render_template('login.html', form=form)
 
 
+@app.route('/acct/reset-passwd/<dsn_check_token>')
+def reset_passwd_wait(dsn_check_token: str):
+    return render_template('acct_reset_passwd_wait.html', dsn_check_token=dsn_check_token)
+
+
 @app.route('/acct/logout', methods=('POST',))
 def logout():
     session.clear()
@@ -174,20 +180,24 @@ class PersonalSettingsForm(FlaskForm):
 
 
 @app.route('/acct/settings/personal', methods=('GET', 'POST'))
-def user_settings_personal():
+@app.route('/acct/settings/personal/<dsn_check_token>', methods=('GET', 'POST'))
+def user_settings_personal(dsn_check_token: Optional[str] = None):
     sess = db.get_session()
     user = g.user
     if not user:
         raise NeedLoginError()
 
+    if dsn_check_token:
+        return render_template('settings_change.html', form=None, dsn_check_token=dsn_check_token)
+
     form = PersonalSettingsForm()
     if not form.submit.data:
         form.email.data = user.email
 
-    def process_submit() -> bool:
+    def process_submit() -> Optional[str]:
         if not mo.users.check_password(user, assert_not_none(form.current_passwd.data)):
             flash('Nesouhlasí aktuální heslo.', 'danger')
-            return False
+            return None
 
         if form.new_passwd.data:
             app.logger.info(f'Settings: Změněno heslo uživatele #{user.user_id}')
@@ -204,7 +214,7 @@ def user_settings_personal():
             if mo.users.user_by_email(assert_not_none(form.email.data)) is not None:
                 # Tady prosakuje informace o existenci účtu, ale tu prozrazuje i login.
                 flash('Tuto e-mailovou adresu už používá jiný uživatel.', 'danger')
-                return False
+                return None
             else:
                 rr = mo.users.new_reg_request(db.RegReqType.change_email, request.remote_addr)
                 if rr:
@@ -215,14 +225,15 @@ def user_settings_personal():
                     app.logger.info(f'Settings: Požadavek na změnu e-mailu uživatele #{user.user_id}')
                     flash('Odeslán e-mail s odkazem na potvrzení nové adresy.', 'success')
                     mo.email.send_confirm_change_email(user, rr)
+                    return url_for('user_settings_personal', dsn_check_token=make_dsn_check_token(rr))
                 else:
                     app.logger.info('Settings: Rate limit')
                     flash('Příliš mnoho požadavků na změny e-mailu. Počkejte prosím chvíli a zkuste to znovu.', 'danger')
-                    return False
-        return True
+                    return None
+        return url_for('user_settings')
 
-    if form.validate_on_submit() and process_submit():
-        return redirect(url_for('user_settings'))
+    if form.validate_on_submit() and (redir_to := process_submit()):
+        return redirect(redir_to)
 
     return render_template('settings_change.html', form=form)
 
@@ -417,7 +428,7 @@ def create_acct():
         flash('Odeslán e-mail s odkazem na založení účtu.', 'success')
         user = db.User(email=form.email.data, first_name='Nový', last_name='Uživatel')
         mo.email.send_confirm_create_email(user, reg1.rr)
-        return redirect(url_for('confirm_reg'))
+        return redirect(url_for('confirm_reg', dsn_check_token=make_dsn_check_token(rr=reg1.rr)))
 
     form.captcha.description = reg1.captcha_task()
     if reg1.status != RegStatus.ok:
@@ -604,10 +615,11 @@ class Reg2Form(FlaskForm):
 
 # URL je explicitně uvedeno v mo.email.confirm_url
 @app.route('/acct/confirm/r', methods=('GET', 'POST'))
-def confirm_reg():
+@app.route('/acct/confirm/r/<dsn_check_token>', methods=('GET', 'POST'))
+def confirm_reg(dsn_check_token: Optional[str] = None):
     token = request.args.get('token')
     if token is None:
-        return render_template('acct_reg2.html', form=None)
+        return render_template('acct_reg2.html', form=None, dsn_check_token=dsn_check_token)
 
     reg2 = Reg2(token, db.RegReqType.register)
     if reg2.status != RegStatus.ok:
@@ -742,8 +754,8 @@ class ValidateEmailForm(FlaskForm):
 
 
 @app.route('/acct/settings/validate-email', methods=('GET', 'POST'))
-@app.route('/acct/settings/validate-email/wait', endpoint='user_validate_email_wait')
-def user_validate_email():
+@app.route('/acct/settings/validate-email/<dsn_check_token>')
+def user_validate_email(dsn_check_token: Optional[str] = None):
     sess = db.get_session()
     user = g.user
     if not user:
@@ -757,7 +769,7 @@ def user_validate_email():
         flash('Tento účet už má ověřenou e-mailovou adresu.', 'success')
         return redirect(url_for('user_settings'))
 
-    if request.endpoint != 'user_validate_email_wait':
+    if not dsn_check_token:
         form = ValidateEmailForm()
         if form.validate_on_submit():
             ok = True
@@ -768,16 +780,17 @@ def user_validate_email():
                 sess.commit()
                 app.logger.info(f'Validace e-mailu: Požadavek pro uživatele #{user.user_id}')
                 mo.email.send_confirm_validate_email(user, rr)
+                dsn_check_token = make_dsn_check_token(rr=rr)
             else:
                 app.logger.info('Validace e-mailu: Rate limit')
                 flash('Příliš mnoho požadavků na validaci e-mailu. Počkejte prosím chvíli a zkuste to znovu.', 'danger')
                 ok = False
             if ok:
-                return redirect(url_for('user_validate_email_wait'))
+                return redirect(url_for('user_validate_email', dsn_check_token=dsn_check_token, _method='GET'))
     else:
         form = None
 
-    return render_template('acct_validate_email.html', form=form)
+    return render_template('acct_validate_email.html', form=form, dsn_check_token=dsn_check_token)
 
 
 def make_dsn_check_token(rr: db.RegRequest) -> str:
diff --git a/mo/web/templates/acct_reg2.html b/mo/web/templates/acct_reg2.html
index a0f6eeaa..3b7ddc0b 100644
--- a/mo/web/templates/acct_reg2.html
+++ b/mo/web/templates/acct_reg2.html
@@ -18,7 +18,7 @@ kategorie a napište svému nadřazenému garantovi, ať vám přidělí práva.
 
 {% else %}
 
-<p>Počkejte prosím, až vám přijde e-mail a klikněte na odkaz v něm uvedený.
+{% include "parts/wait_for_email.html" %}
 
 {% endif %}
 
diff --git a/mo/web/templates/acct_reset_passwd_wait.html b/mo/web/templates/acct_reset_passwd_wait.html
new file mode 100644
index 00000000..59b155b3
--- /dev/null
+++ b/mo/web/templates/acct_reset_passwd_wait.html
@@ -0,0 +1,7 @@
+{% extends "base.html" %}
+{% block title %}Obnova hesla{% endblock %}
+{% block body %}
+
+{% include "parts/wait_for_email.html" %}
+
+{% endblock %}
diff --git a/mo/web/templates/acct_validate_email.html b/mo/web/templates/acct_validate_email.html
index a801de71..1ead2626 100644
--- a/mo/web/templates/acct_validate_email.html
+++ b/mo/web/templates/acct_validate_email.html
@@ -14,19 +14,13 @@
 
 {% else %}
 
-<p>
-	Vyčkejte prosím, až vám přijde e-mail.
-</p>
+{% include "parts/wait_for_email.html" %}
 
-<p>
-	Pakliže nepřichází:
-</p>
+{% endif %}
 
-<div class="btn-group">
+<div class="btn-group space-top">
 	<a class='btn btn-default' href='{{ url_for('user_settings_personal') }}'>Změnit e-mail</a>
 	<a class='btn btn-default' href="{{ url_for('user_settings') }}">Zpět na nastavení účtu</a>
 </div>
 
-{% endif %}
-
 {% endblock %}
diff --git a/mo/web/templates/settings_change.html b/mo/web/templates/settings_change.html
index 568ca0fa..c8e3a6dc 100644
--- a/mo/web/templates/settings_change.html
+++ b/mo/web/templates/settings_change.html
@@ -3,6 +3,18 @@
 {% block title %}Změna osobních údajů{% endblock %}
 {% block body %}
 
+{% if form %}
+
 {{ wtf.quick_form(form, form_type='horizontal', button_map={'submit': 'primary'}) }}
 
+{% elif dsn_check_token %}
+
+{% include "parts/wait_for_email.html" %}
+
+<div class="btn-group space-top">
+	<a class='btn btn-default' href="{{ url_for('user_settings') }}">Zpět na nastavení účtu</a>
+</div>
+
+{% endif %}
+
 {% endblock %}
-- 
GitLab


From aa1914bfadfd04ac95b6650778f48cda4b47cb7a Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 25 Jan 2025 17:53:41 +0100
Subject: [PATCH 33/35] DSN: Autorizace API

---
 bin/send-dsn          | 25 ++++++++++++++++++++++---
 etc/config.py.example |  4 ++++
 mo/web/api_dsn.py     | 13 +++++++++++++
 3 files changed, 39 insertions(+), 3 deletions(-)

diff --git a/bin/send-dsn b/bin/send-dsn
index e3cb49c2..6cc6785c 100755
--- a/bin/send-dsn
+++ b/bin/send-dsn
@@ -2,21 +2,40 @@
 # Tento skript se volá při doručování pošty (například pomocí "execute" v Sieve)
 # a předá mail webové části OSMO přes /api/email-dsn.
 
+import os
+from pathlib import Path
 import requests
 from requests.exceptions import RequestException
 import sys
 
 if len(sys.argv) != 2:
-    print('Arguments: <URL of OSMO root>/', file=sys.stderr)
+    print('Arguments: <URL of OSMO root>', file=sys.stderr)
     sys.exit(1)
 
 osmo_url = sys.argv[1]
 mail = sys.stdin.buffer.read()
 
+key_path = Path.home() / '.config/osmo/dsn-api-key'
 try:
-    reply = requests.post(f'{osmo_url}api/email-dsn', data=mail, timeout=30)
-except RequestException:
+    with key_path.open() as f:
+        key = f.readline().strip()
+        if key == "":
+            print(f'Cannot read key from {key_path}', file=sys.stderr)
+            sys.exit(1)
+except OSError as e:
+    print(f'Cannot read {key_path}: {e}', file=sys.stderr)
+    sys.exit(1)
+
+try:
+    reply = requests.post(
+        os.path.join(osmo_url, 'api/email-dsn'),
+        data=mail,
+        headers={'Authorization': f'Bearer {key}'},
+        timeout=30)
+except RequestException as e:
+    print(f'Error sending DSN: {e}')
     sys.exit(1)
 
 if reply.status_code != 200:
+    print(f'Error sending DSN: HTTP status {reply.status_code}')
     sys.exit(1)
diff --git a/etc/config.py.example b/etc/config.py.example
index 15d62304..a714932b 100644
--- a/etc/config.py.example
+++ b/etc/config.py.example
@@ -111,3 +111,7 @@ MAILING_LIST_EXCLUDE = {
 # Maximální počet e-mailových adres, které jsme ochotni ve webovém rozhraní zobrazit
 # uživatelům bez práva unrestricted_email.
 EMAILS_SHOW_MAX = 500
+
+# Klíč k API na zpracování mailových nedoručenek. Měl by být také uložen
+# v ~/.config/osmo/dsn-api-key účtu, který volá bin/send-dsn. Nesmí obsahovat mezery.
+# DSN_API_KEY = "..."
diff --git a/mo/web/api_dsn.py b/mo/web/api_dsn.py
index 99194148..87bb85e0 100644
--- a/mo/web/api_dsn.py
+++ b/mo/web/api_dsn.py
@@ -112,8 +112,21 @@ def process_dsn_reg(dsn: db.EmailDSN) -> None:
     dsn.reg.dsn_id = dsn.dsn_id
 
 
+def authorize_email_dsn() -> bool:
+    dsn_api_token = getattr(config, 'DSN_API_KEY', None)
+    auth_header = request.headers.get('Authorization')
+    if dsn_api_token is None or auth_header is None:
+        return False
+
+    fields = auth_header.split()
+    return len(fields) == 2 and fields[1] == dsn_api_token
+
+
 @app.route('/api/email-dsn', methods=('POST',))
 def api_email_dsn() -> Response:
+    if not authorize_email_dsn():
+        raise werkzeug.exceptions.Forbidden()
+
     body = request.get_data(cache=False)
 
     try:
-- 
GitLab


From 79b3b6fce4a05a66c89d4357e55e86ec816d8d18 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 25 Jan 2025 21:29:57 +0100
Subject: [PATCH 34/35] =?UTF-8?q?README:=20Trik=20pro=20kernel=20<5.4=20u?=
 =?UTF-8?q?=C5=BE=20nen=C3=AD=20pot=C5=99eba?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 README.md | 3 ---
 1 file changed, 3 deletions(-)

diff --git a/README.md b/README.md
index 71fea33e..2ffcab1a 100644
--- a/README.md
+++ b/README.md
@@ -34,9 +34,6 @@ License verze 3 nebo novější (viz soubor LICENSE).
 
 ## Instalace na produkční server
 
-	# Pro systém s jádrem < 5.4 zvýšit net.core.somaxconn (jako root)
-	[ "`cat /proc/sys/net/core/somaxconn`" -lt 4096 ] && echo net.core.somaxconn=4096 >> /etc/sysctl.conf && sysctl -p
-
 	# Založit účet mo-web (jako root)
 	adduser --system mo-web --shell /bin/bash
 	loginctl enable-linger mo-web
-- 
GitLab


From 8dc000d472ac51725354bb30c874ebc7a396ec0d Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Sat, 25 Jan 2025 22:02:42 +0100
Subject: [PATCH 35/35] DSN: Dokumentace

---
 README.md  |  3 +++
 doc/dsn.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 54 insertions(+)
 create mode 100644 doc/dsn.md

diff --git a/README.md b/README.md
index 2ffcab1a..e7e5f833 100644
--- a/README.md
+++ b/README.md
@@ -84,6 +84,9 @@ License verze 3 nebo novější (viz soubor LICENSE).
 
 	# Na instalaci nové verze pak stačí spustit bin/deploy
 
+	# Chcete-li automaticky zpracovávat nedoručenky od mailů odeslaných OSMO,
+	# postupujte podle doc/dsn.md.
+
 ## Mražení závislostí
 
 	pip freeze | grep -v '^osmo=' >constraints.txt
diff --git a/doc/dsn.md b/doc/dsn.md
new file mode 100644
index 00000000..39abe106
--- /dev/null
+++ b/doc/dsn.md
@@ -0,0 +1,51 @@
+# Zpracování mailových nedoručenek (DSN)
+
+## Princip
+
+Kdykoliv OSMO odesílá mail, umí do obálkového FROM přidat token
+(oddělený od jména uživatele pluskem), do kterého je zakódováno,
+kterého uživatele nebo pokusu o registraci se mail týká.
+
+Pokud přijde nedoručenka, OSMO ji zpracuje a z tokenu v adrese
+jejího adresáta určí, ke komu patří. K uživatelům a probíhajícím
+registracím si pak poznamená informaci o nefunkčnosti e-mailové
+adresy a umí ji uživateli zobrazit.
+
+## Implementace
+
+Nedoručenky se posílají na osmo-auto+token@mo.mff.cuni.cz, což se
+přepíše na mo-web+dsn@gimli.ms.mff.cuni.cz.
+
+Účet mo-web má nakonfigurovaný následující filtr pro Sieve:
+
+    require ["envelope", "subaddress", "include", "mailbox", "vnd.dovecot.execute"];
+
+    # Základní systémová pravidla
+    include :global "default";
+
+    if envelope :detail :matches "To" ["dsn", "dsn+*"] {
+        if execute :pipe "osmo-send-dsn" ["https://osmo.matematickaolympiada.cz/"] {
+            redirect "spravce+osmo-dsn-ok@example.org";
+        } else {
+            redirect "spravce+osmo-dsn-error@example.org";
+        }
+    } else {
+        redirect "spravce+osmo-unknown@example.org";
+    }
+
+To spustí program bin/send-dsn, nainstalovaný jako
+/usr/local/lib/dovecot/sieve-execute/osmo-send-dsn,
+a ten předá nedoručenku webové části OSMO. Pokud zrovna webová část
+neběží nebo neumí nedoručenku zpracovat, osmo-send-dsn selže, takže
+Sieve nedoručenku přepošle správci.
+
+Webová část si vyparsuje token z hlavičkového To, takže interní
+přeposílání během doručování ji nezmate.
+
+Skript bin/send-dsn je minimalistický, stačí mu Python se standardní
+knihovnou Pythonu a balíčkem requests. Skript může běžet pod jiným
+uživatelem, nebo dokonce na jiném stroji než zbytek OSMO.
+
+Komunikace mezi bin/send-dsn a zbytkem OSMO je autorizována tajným
+klíčem. Web OSMO si ho přečte z DSN_API_KEY z configu, send-dsn
+ho hledá jako jediný řádek souboru ~/.config/osmo/dsn-api-key.
-- 
GitLab