diff --git a/README.md b/README.md
index 71fea33eb5b0a5fe672110fa8559fa7b779f2fc6..e7e5f833bd7f1cf00cfa61abd3bc92621c708b56 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
@@ -87,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/bin/send-dsn b/bin/send-dsn
index e3cb49c2e3ec6788bc284f3439ff9f01c56c47d8..6cc6785c493fbf78dfabcad906adbaeb3883d1f6 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/db/db.ddl b/db/db.ddl
index 8fa36b32660939a5c85ca30d3f5e52b9ab457385..7d2775a8df480fe816865dba3712c9a1e34ff9e4 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)
@@ -402,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 (
@@ -415,6 +418,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 +510,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 0000000000000000000000000000000000000000..9ca7e5862e6ffa9df4e92863c5311d241666f237
--- /dev/null
+++ b/db/upgrade-20250123.sql
@@ -0,0 +1,23 @@
+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;
+
+ALTER TYPE reg_req_type ADD VALUE 'validate_email';
diff --git a/doc/dsn.md b/doc/dsn.md
new file mode 100644
index 0000000000000000000000000000000000000000..39abe1062391196c302d9f0c67bfbfbf92cbcb42
--- /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.
diff --git a/etc/config.py.example b/etc/config.py.example
index 9c02e6220eba28e46892afe3c92af960ed968e39..a714932b78127839cda2fd3615ccb0808caba96c 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
 
@@ -108,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/db.py b/mo/db.py
index 4e2dfe2a2368aaa5cf6824c9dd5887c72f5cc4e5..6be1ff0d57f14e75564071a3716cc312e6065f80 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
@@ -465,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)"""
@@ -918,6 +924,7 @@ class RegReqType(MOEnum):
     register = auto()
     change_email = auto()
     reset_passwd = auto()
+    validate_email = auto()
 
 
 class RegRequest(Base):
@@ -932,9 +939,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 +1054,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
diff --git a/mo/email.py b/mo/email.py
index 7e8167242c1ac1d3f24db39a731ac55f9fb7a1c0..78684545d2c23be6a41700618eb8c658e2c125a9 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:
@@ -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/jobs/notify.py b/mo/jobs/notify.py
index ea522b7d34a38c44abe1059ddf1f70012167f4c7..50097deaa70e6f14f6662bdd7f558480faf5bcbc 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/users.py b/mo/users.py
index 2b702c70f19d63a12eb28d50bba7b6246f2331e6..ef89b1ae49b0224fbfc7360b1ac8e782f0438c58 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)
diff --git a/mo/util_dsn.py b/mo/util_dsn.py
new file mode 100644
index 0000000000000000000000000000000000000000..165530f055f47fee780bcfe5bc2f9dc5bbcf8e33
--- /dev/null
+++ b/mo/util_dsn.py
@@ -0,0 +1,60 @@
+# 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
+from mo.util import logger
+
+
+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
+
+
+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()
+
+
+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/__init__.py b/mo/web/__init__.py
index e8dbd2277d57709f546f74935c428234a1e86e6a..f8ccc040f8ed3cb984b97e144e9190f76faef56d 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):
@@ -191,6 +192,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)
@@ -205,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')
@@ -260,6 +268,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/acct.py b/mo/web/acct.py
index 02c829287ee7423dce89ea871897e88fa7bc5d62..c76eff4bbee543f4615af38f157832d8dde76653 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
 
@@ -59,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:
@@ -72,8 +73,9 @@ 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')
+                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):
@@ -96,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()
@@ -173,18 +180,25 @@ 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
 
-    if form.validate_on_submit():
-        ok = True
+    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 None
+
         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 +209,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 None
             else:
                 rr = mo.users.new_reg_request(db.RegReqType.change_email, request.remote_addr)
                 if rr:
@@ -209,12 +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')
-                    ok = False
-        if ok:
-            return redirect(url_for('user_settings'))
+                    return None
+        return 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)
 
@@ -409,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:
@@ -452,12 +471,18 @@ 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: 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:
@@ -523,9 +548,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,10 +574,16 @@ class Reg2:
             },
         )
 
-        self.rr.used_at = mo.now
-        sess.commit()
+        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.util_dsn.reset_user_dsn(user)
+        self.spend_request_and_commit()
+
     def change_passwd(self, new_passwd: str):
         sess = db.get_session()
         user = self.rr.user
@@ -562,11 +591,10 @@ 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.util_dsn.reset_user_dsn(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,12 +613,13 @@ 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():
+@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:
@@ -610,29 +639,29 @@ 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')
     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():
+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():
                 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'))
 
@@ -640,14 +669,45 @@ 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):
     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 +722,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'))
 
@@ -674,6 +734,89 @@ 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)
+
+
+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/<dsn_check_token>')
+def user_validate_email(dsn_check_token: Optional[str] = None):
+    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 not dsn_check_token:
+        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)
+                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', dsn_check_token=dsn_check_token, _method='GET'))
+    else:
+        form = None
+
+    return render_template('acct_validate_email.html', form=form, dsn_check_token=dsn_check_token)
+
+
+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/admin.py b/mo/web/admin.py
new file mode 100644
index 0000000000000000000000000000000000000000..c31dc9d0009569bfe6c9c155912d6dceca07a68f
--- /dev/null
+++ b/mo/web/admin.py
@@ -0,0 +1,24 @@
+# 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
+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)
+             .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, admin_list=True)
diff --git a/mo/web/api_dsn.py b/mo/web/api_dsn.py
index 64de762c82662260dbc34b36ff9b9f8eb34d8d41..87bb85e061bb9a55849a1c04e23376db1f96a964 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,77 @@ 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
+
+
+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:
-    # FIXME: Authorization?
-    # FIXME: Add in Flask 3.1: request.max_content_length = 1048576
+    if not authorize_email_dsn():
+        raise werkzeug.exceptions.Forbidden()
+
     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({})
diff --git a/mo/web/jinja.py b/mo/web/jinja.py
index 96515a7c1e0ba75b85b03f806a2a3eb68351637f..6df9522b145eeed32face2372f84428444e8bdcd 100644
--- a/mo/web/jinja.py
+++ b/mo/web/jinja.py
@@ -14,10 +14,11 @@ 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
-from mo.web.util import user_html_flags
+from mo.web.util import user_html_flags, user_url
 
 # Konfigurace Jinjy
 
@@ -69,20 +70,15 @@ 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)
+
 
 @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/org_users.py b/mo/web/org_users.py
index 11e0240d810743fa9cae21869a7917f25e75dc4f..be60c783a6446dca3effc800f4cd187d2d354be0 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
@@ -524,8 +525,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.util_dsn.reset_user_dsn(user)
+                app.logger.info(f"Uživatel #{id} změněn: {changes}")
                 mo.util.log(
                     type=db.LogType.user,
                     what=id,
@@ -822,3 +824,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/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 0000000000000000000000000000000000000000..3316e1b0022bb5b99c367d4c690c005b1ed6f94b
--- /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_dsn.html b/mo/web/templates/acct_dsn.html
new file mode 100644
index 0000000000000000000000000000000000000000..ba0d4a38fccaecede7541ed82323ed12816a16a9
--- /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 %}
diff --git a/mo/web/templates/acct_dsn_check.html b/mo/web/templates/acct_dsn_check.html
new file mode 100644
index 0000000000000000000000000000000000000000..5b14c3549c2429c01239a7e824e53dba3a705503
--- /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/acct_reg2.html b/mo/web/templates/acct_reg2.html
index a0f6eeaa2d05203bca0844499ba4e53c9bb56557..3b7ddc0be175cb75d04c00eac1e040696008cf9b 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 0000000000000000000000000000000000000000..59b155b39248d875a546f0315fc62e6c9bcb9ee0
--- /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
new file mode 100644
index 0000000000000000000000000000000000000000..1ead262662664a734838565f927b2c6384182ab1
--- /dev/null
+++ b/mo/web/templates/acct_validate_email.html
@@ -0,0 +1,26 @@
+{% 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 %}
+
+{% include "parts/wait_for_email.html" %}
+
+{% endif %}
+
+<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>
+
+{% endblock %}
diff --git a/mo/web/templates/admin_dsn.html b/mo/web/templates/admin_dsn.html
new file mode 100644
index 0000000000000000000000000000000000000000..2a08bef6520055a60b6a075227d7d98dd99ba159
--- /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 %}
diff --git a/mo/web/templates/org_generic_list.html b/mo/web/templates/org_generic_list.html
index 106a0342ffa17cb7e7822f0cafa0593cf62419a4..e6c2e900dbd7e7b2a8acc9f7a1781d54a6fe1a69 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_index.html b/mo/web/templates/org_index.html
index d14d84b70f4359936e83bca38535b1deafc223a8..aa72dcd35b1cd79720ea01623375542fae35ad54 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>
@@ -82,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 %}
diff --git a/mo/web/templates/org_org.html b/mo/web/templates/org_org.html
index beb75788f1b1210fda4e4ba11d88e69b50a1dbe2..b2b96baadcc056bfb08a3eaae2386671006aaa1d 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_orgs.html b/mo/web/templates/org_orgs.html
index 6bbe924a00e17eb8e086485134f0652ccd93942e..5ee560b45412322f5fea132943a6ccf777209a5c 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_place_roles.html b/mo/web/templates/org_place_roles.html
index dfaaa739eebd66b05b367598e5a7c035fbdbeb64..7f9fa673067024b61c3c7f4d74f07c95775328eb 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 '–' }}
diff --git a/mo/web/templates/org_user.html b/mo/web/templates/org_user.html
index 313a25696dc925a0cf221c45cb3fd8fd9f022fcf..301211481b1c5b610b4f1f4c2b71baebc9b2f564 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_dsn.html b/mo/web/templates/org_user_dsn.html
new file mode 100644
index 0000000000000000000000000000000000000000..f766a824b374ec25dc6a640c6f1b0c9c4209df7d
--- /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/org_user_edit.html b/mo/web/templates/org_user_edit.html
index 4d78b0772c87e6ebd98eb1d03a95a96e8c3409ec..f2f7e5aa0111f74e0896cc35eed1d45ffb86228b 100644
--- a/mo/web/templates/org_user_edit.html
+++ b/mo/web/templates/org_user_edit.html
@@ -6,13 +6,17 @@
 <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>
 </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>
 
diff --git a/mo/web/templates/org_users.html b/mo/web/templates/org_users.html
index 82aaf3a79e83758fc24f93ce5b17d8c505f8a286..198e5b6531df46faef0a16b5d55ea3f5b9746348 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/templates/parts/check_dsn.html b/mo/web/templates/parts/check_dsn.html
new file mode 100644
index 0000000000000000000000000000000000000000..d32bb5d24ba8b1dbc65a35854b11748414b66be5
--- /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/parts/dsn.html b/mo/web/templates/parts/dsn.html
new file mode 100644
index 0000000000000000000000000000000000000000..06d0da2659b19cdadb04f5c85d81009028063dd4
--- /dev/null
+++ b/mo/web/templates/parts/dsn.html
@@ -0,0 +1,40 @@
+{# Výpis mailových nedoručenek #}
+
+{% for dsn in dsns %}
+
+<table class=data>
+	<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 }}
+	<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 %}
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 0000000000000000000000000000000000000000..e281d29887cc83412582ee6b6fd3a8d684d18d8f
--- /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/mo/web/templates/settings.html b/mo/web/templates/settings.html
index 50335f12b36d2aaae770f8dedb7ac131278de930..14350ca398072c29d16743f6f53f1232a0347510 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/settings_change.html b/mo/web/templates/settings_change.html
index 568ca0fa6402ee006d649222486e398baaf91e74..c8e3a6dcd00f2a2f60ecc452bef9c06d13c03c00 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 %}
diff --git a/mo/web/templates/user_index.html b/mo/web/templates/user_index.html
index ad3bf1375e9b092f1a31baacbaff7adb5a4456db..46d89241a41efe3881d7271960a0f6d0e7cd3109 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 d6af6b7349d54d82f436fc3ba7394966da6e3e06..c45c8e621f1040b0c9b7032680c84f3234f73ed3 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.
 
diff --git a/mo/web/user.py b/mo/web/user.py
index 17364b2b8e36317c5c83ac9158a98422f87d3995..d39a61df97febe827d3a52c7f333058c865cd30c 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
diff --git a/mo/web/util.py b/mo/web/util.py
index 82a43235a5cfb93f777a933794c1acf52e59f127..245438eb496320a6746353bb7d428665ee033bad 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)
@@ -41,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:
@@ -147,8 +160,17 @@ 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:
         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)
diff --git a/static/mo.css b/static/mo.css
index ce1867abd85d1c0e26f60272d45741040ff9f1a8..655fbfecbd27b6eb226224ddfb72299156cd504e 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 */
@@ -591,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) {