├── common ├── __init__.py ├── mail_receiver_utils.py ├── config.py └── wait-for-it.sh ├── app ├── src │ ├── __init__.py │ ├── logging.py │ ├── tags.py │ ├── templates.py │ ├── resolver.py │ ├── db.py │ ├── check_results.py │ ├── worker.py │ ├── app_utils.py │ ├── app.py │ └── template_utils.py ├── translations │ ├── requirements.txt │ ├── messages.pot │ ├── en_US │ │ └── LC_MESSAGES │ │ │ └── messages.po │ ├── lt_LT │ │ └── LC_MESSAGES │ │ │ └── messages.po │ └── pl_PL │ │ └── LC_MESSAGES │ │ └── messages.po ├── static │ ├── fonts │ │ ├── bootstrap-icons.woff │ │ └── bootstrap-icons.woff2 │ ├── index.css │ └── images │ │ └── spinner.svg ├── templates │ ├── custom_failed_check_results_hints.html │ ├── custom_layout.html │ ├── 404.html │ ├── check_running.html │ ├── custom_root_layout.html │ ├── check_domain.html │ ├── base.html │ ├── root.html │ ├── check_email.html │ └── check_results.html ├── requirements.txt └── docker │ └── Dockerfile ├── scan ├── libmailgoose │ ├── __init__.py │ ├── languages.txt │ ├── language.py │ ├── logging.py │ └── lax_record_query.py ├── requirements.txt └── setup.py ├── docs ├── .gitignore ├── user-guide │ ├── .gitignore │ ├── configuration.rst │ └── translation.rst ├── requirements.txt ├── index.rst ├── Makefile ├── features.rst ├── generate_config_docs.py ├── conf.py └── quick-start.rst ├── test ├── requirements.txt ├── config.py ├── Dockerfile ├── test_common.py ├── base.py ├── test_api.py ├── test_spf.py └── test_dmarc.py ├── .gitignore ├── env.example ├── .github ├── images │ ├── logo.png │ └── logo_dark.png ├── screenshots │ └── check_results.png ├── workflows │ ├── test.yml │ ├── lint.yml │ ├── check_no_translations_to_update.yml │ └── liccheck.yml └── dependabot.yml ├── mail_receiver ├── requirements.txt ├── Dockerfile └── server.py ├── babel.cfg ├── docker-bind9 ├── Dockerfile └── named.conf ├── setup.cfg ├── .readthedocs.yml ├── scripts ├── test └── update_translation_files ├── .mypy.ini ├── pyproject.toml ├── .pre-commit-config.yaml ├── docker-compose.test.yml ├── LICENSE ├── docker-compose.yml └── README.md /common/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scan/libmailgoose/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | _build 2 | venv/ 3 | -------------------------------------------------------------------------------- /test/requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.32.5 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | logs/ 3 | .env 4 | -------------------------------------------------------------------------------- /docs/user-guide/.gitignore: -------------------------------------------------------------------------------- 1 | config-docs.inc 2 | -------------------------------------------------------------------------------- /env.example: -------------------------------------------------------------------------------- 1 | APP_DOMAIN=change-this.example.com 2 | -------------------------------------------------------------------------------- /scan/libmailgoose/languages.txt: -------------------------------------------------------------------------------- 1 | pl_PL 2 | en_US 3 | lt_LT 4 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | python-decouple==3.8 2 | Sphinx==9.0.4 3 | sphinx-rtd-theme==3.0.2 4 | -------------------------------------------------------------------------------- /test/config.py: -------------------------------------------------------------------------------- 1 | TEST_DOMAIN = "test.mailgoose.cert.pl" 2 | APP_URL = "http://app:8000" 3 | -------------------------------------------------------------------------------- /.github/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CERT-Polska/mailgoose/HEAD/.github/images/logo.png -------------------------------------------------------------------------------- /mail_receiver/requirements.txt: -------------------------------------------------------------------------------- 1 | aiosmtpd==1.4.6 2 | python-decouple==3.8 3 | redis==5.0.1 4 | /scan 5 | -------------------------------------------------------------------------------- /.github/images/logo_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CERT-Polska/mailgoose/HEAD/.github/images/logo_dark.png -------------------------------------------------------------------------------- /babel.cfg: -------------------------------------------------------------------------------- 1 | [jinja2: **/**.html] 2 | encoding = utf-8 3 | extensions = src.tags.BuildIDTag,src.tags.LanguageTag 4 | -------------------------------------------------------------------------------- /app/translations/requirements.txt: -------------------------------------------------------------------------------- 1 | Babel==2.17.0 2 | Jinja2==3.1.6 3 | jinja2-simple-tags==0.6.1 4 | python-decouple==3.8 5 | -------------------------------------------------------------------------------- /.github/screenshots/check_results.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CERT-Polska/mailgoose/HEAD/.github/screenshots/check_results.png -------------------------------------------------------------------------------- /app/static/fonts/bootstrap-icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CERT-Polska/mailgoose/HEAD/app/static/fonts/bootstrap-icons.woff -------------------------------------------------------------------------------- /app/static/fonts/bootstrap-icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CERT-Polska/mailgoose/HEAD/app/static/fonts/bootstrap-icons.woff2 -------------------------------------------------------------------------------- /docker-bind9/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM internetsystemsconsortium/bind9:9.18 2 | 3 | ENV TZ=UTC 4 | 5 | COPY docker-bind9/named.conf /etc/bind/named.conf 6 | -------------------------------------------------------------------------------- /app/templates/custom_failed_check_results_hints.html: -------------------------------------------------------------------------------- 1 | {# If you have any additional text to display if the checks have failed, please put it here. #} 2 | -------------------------------------------------------------------------------- /scan/requirements.txt: -------------------------------------------------------------------------------- 1 | checkdmarc==5.10.12 2 | dkimpy==1.1.8 3 | PyNaCl==1.6.1 4 | pyspf==2.0.14 5 | python-multipart==0.0.21 6 | validators==0.35.0 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # whitespace before :, too long line, line break before binary operator 3 | ignore = E203,E501,W503 4 | exclude = .venv.translations,docs/venv 5 | -------------------------------------------------------------------------------- /scan/libmailgoose/language.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from pathlib import Path 3 | 4 | with open(Path(__file__).parent / "languages.txt") as f: 5 | Language = Enum("Language", {line.strip(): line.strip() for line in f}) # type: ignore 6 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.12" 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | python: 12 | install: 13 | - requirements: docs/requirements.txt 14 | -------------------------------------------------------------------------------- /app/templates/custom_layout.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {# If you want to change the layout in order to provide e.g. custom additional scripts #} 4 | {# or a navbar with logo, mount a different file to this path using Docker volume mount. #} 5 | -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker compose -f docker-compose.test.yml down --remove-orphans 4 | docker compose -f docker-compose.test.yml up -d --build 5 | 6 | docker compose -f docker-compose.test.yml build test 7 | docker compose -f docker-compose.test.yml run test 8 | -------------------------------------------------------------------------------- /docs/user-guide/configuration.rst: -------------------------------------------------------------------------------- 1 | Configuration options 2 | ===================== 3 | 4 | Mailgoose can be configured by setting the following variables in the ``.env`` file (in the form of ``VARIABLE_NAME=VARIABLE_VALUE`` 5 | directives, e.g. ``APP_DOMAIN=example.com``): 6 | 7 | .. include:: config-docs.inc 8 | -------------------------------------------------------------------------------- /test/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:latest 2 | 3 | COPY test/requirements.txt /requirements.txt 4 | RUN pip install -r /requirements.txt 5 | 6 | COPY common/wait-for-it.sh /wait-for-it.sh 7 | 8 | RUN mkdir /test 9 | COPY test/*.py /test 10 | 11 | WORKDIR /test/ 12 | 13 | CMD python -m unittest discover 14 | -------------------------------------------------------------------------------- /app/src/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | def build_logger(name: str) -> logging.Logger: 5 | logger = logging.getLogger(name) 6 | logger.setLevel(logging.INFO) 7 | handler = logging.StreamHandler() 8 | handler.setLevel(logging.INFO) 9 | logger.addHandler(handler) 10 | return logger 11 | -------------------------------------------------------------------------------- /app/requirements.txt: -------------------------------------------------------------------------------- 1 | /scan/ 2 | dacite==1.8.1 3 | email-validator==2.1.0.post1 4 | fastapi==0.109.1 5 | Jinja2==3.1.6 6 | jinja2-simple-tags==0.6.1 7 | psycopg2-binary==2.9.9 8 | python-decouple==3.8 9 | redis==5.0.1 10 | rq==1.15.1 11 | SQLAlchemy==2.0.25 12 | uvicorn==0.26.0 13 | -r translations/requirements.txt 14 | -------------------------------------------------------------------------------- /common/mail_receiver_utils.py: -------------------------------------------------------------------------------- 1 | import string 2 | 3 | 4 | def get_key_from_username(email_username: str) -> bytes: 5 | prefix = "message-" 6 | allowed_characters = string.ascii_letters + string.digits + ".-" 7 | return (prefix + "".join(char for char in email_username if char in allowed_characters)).encode("ascii") 8 | -------------------------------------------------------------------------------- /scan/libmailgoose/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | def build_logger(name: str) -> logging.Logger: 5 | logger = logging.getLogger(name) 6 | logger.setLevel(logging.INFO) 7 | handler = logging.StreamHandler() 8 | handler.setLevel(logging.INFO) 9 | logger.addHandler(handler) 10 | return logger 11 | -------------------------------------------------------------------------------- /docker-bind9/named.conf: -------------------------------------------------------------------------------- 1 | acl anyone { 2 | 0.0.0.0/0; 3 | }; 4 | 5 | options { 6 | directory "/var/cache/bind"; 7 | 8 | dnssec-validation auto; 9 | 10 | recursion yes; 11 | 12 | max-cache-ttl 1; 13 | max-ncache-ttl 1; 14 | 15 | allow-recursion { anyone; }; 16 | 17 | listen-on-v6 { any; }; 18 | }; 19 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: "tests" 2 | on: 3 | pull_request: 4 | branches: [ '**' ] 5 | 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | timeout-minutes: 20 10 | steps: 11 | - name: Check out repository 12 | uses: actions/checkout@v2 13 | - name: run tests 14 | run: ./scripts/test 15 | -------------------------------------------------------------------------------- /mail_receiver/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-alpine3.18 2 | 3 | RUN apk add tzdata git 4 | 5 | ENV TZ=Europe/Warsaw 6 | RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone 7 | 8 | COPY scan /scan 9 | COPY mail_receiver/requirements.txt /requirements.txt 10 | RUN pip install -r /requirements.txt 11 | 12 | COPY common /opt/common/ 13 | COPY mail_receiver/server.py /opt/server.py 14 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to mailgoose documentation! 2 | ==================================== 3 | 4 | Mailgoose is a web application that allows the users to check whether their SPF, DMARC and 5 | DKIM configuration is set up correctly. 6 | 7 | .. toctree:: 8 | :maxdepth: 2 9 | :caption: Contents: 10 | 11 | quick-start 12 | features 13 | user-guide/configuration 14 | user-guide/translation 15 | -------------------------------------------------------------------------------- /.mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy-checkdmarc.*] 2 | ignore_missing_imports = True 3 | 4 | [mypy-decouple.*] 5 | ignore_missing_imports = True 6 | 7 | [mypy-dkim.*] 8 | ignore_missing_imports = True 9 | 10 | [mypy-libmailgoose.*] 11 | ignore_missing_imports = True 12 | 13 | [mypy-sphinx_rtd_theme.*] 14 | ignore_missing_imports = True 15 | 16 | [mypy-jinja2_simple_tags.*] 17 | ignore_missing_imports = True 18 | 19 | [mypy-publicsuffixlist.*] 20 | ignore_missing_imports = True 21 | 22 | [mypy-validators.*] 23 | ignore_missing_imports = True 24 | 25 | [mypy-spf.*] 26 | ignore_missing_imports = True 27 | -------------------------------------------------------------------------------- /app/templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends "custom_layout.html" %} 2 | 3 | {% block body %} 4 |
5 |
6 |
7 |
{{ _("Error 404") }}
8 |
9 | {{ _("Resource not found. Make sure the address is correct or ") }} 10 | {{ _("go back to homepage.") }} 11 |
12 |
13 |
14 |
15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /app/docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11.2-alpine 2 | 3 | RUN apk add bash openssl git tzdata opendkim opendkim-utils 4 | 5 | ENV TZ=Europe/Warsaw 6 | RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone 7 | 8 | WORKDIR /app/ 9 | COPY scan /scan 10 | COPY app/requirements.txt /requirements.txt 11 | COPY app/translations/requirements.txt /translations/requirements.txt 12 | 13 | RUN pip install -r /requirements.txt 14 | 15 | COPY common/wait-for-it.sh /wait-for-it.sh 16 | COPY common/ /app/common/ 17 | 18 | COPY app/ /app/ 19 | RUN openssl rand -hex 10 > /app/build_id 20 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: "lint" 2 | on: 3 | pull_request: 4 | branches: [ '**' ] 5 | 6 | jobs: 7 | lint: 8 | runs-on: ubuntu-latest 9 | timeout-minutes: 5 10 | steps: 11 | - name: Check out repository 12 | uses: actions/checkout@v2 13 | - name: Set up Python 3.11 14 | uses: actions/setup-python@v2 15 | with: 16 | python-version: "3.11" 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install pre-commit==3.5.0 21 | - name: Run pre-commit 22 | run: pre-commit run --all-files 23 | -------------------------------------------------------------------------------- /.github/workflows/check_no_translations_to_update.yml: -------------------------------------------------------------------------------- 1 | name: "check that we don't need to update translations" 2 | on: 3 | pull_request: 4 | branches: [ '**' ] 5 | 6 | jobs: 7 | check_no_translations_to_update: 8 | runs-on: ubuntu-latest 9 | timeout-minutes: 3 10 | steps: 11 | - name: Check out repository 12 | uses: actions/checkout@v2 13 | - name: Set up Python 3.11 14 | uses: actions/setup-python@v2 15 | with: 16 | python-version: "3.11" 17 | - name: Update the translations 18 | run: ./scripts/update_translation_files 19 | - name: Check that the files didn't change 20 | run: git diff --exit-code 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/app/translations/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "pip" 8 | directory: "/app/" 9 | schedule: 10 | interval: "weekly" 11 | - package-ecosystem: "pip" 12 | directory: "/docs/" 13 | schedule: 14 | interval: "weekly" 15 | - package-ecosystem: "pip" 16 | directory: "/mail_receiver/" 17 | schedule: 18 | interval: "weekly" 19 | - package-ecosystem: "pip" 20 | directory: "/scan/" 21 | schedule: 22 | interval: "weekly" 23 | - package-ecosystem: "pip" 24 | directory: "/test/" 25 | schedule: 26 | interval: "weekly" 27 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /test/test_common.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | import os 3 | import re 4 | 5 | from base import BaseTestCase 6 | 7 | CONFIG_WITH_WARNINGS_REGEX = r"DMARC:\s*configuration warnings" 8 | INCORRECT_CONFIG_REGEX = r"DMARC:\s*incorrect configuration" 9 | CORRECT_CONFIG_REGEX = r"DMARC:\s*correct configuration" 10 | WARNING_REGEX = "bi bi-exclamation-triangle" 11 | 12 | 13 | class NonexistentDomainTestCase(BaseTestCase): 14 | def test_nonexistent_domain(self) -> None: 15 | result = self.check_domain(binascii.hexlify(os.urandom(16)).decode("ascii") + ".com") 16 | assert not re.search(CORRECT_CONFIG_REGEX, result) 17 | assert not re.search(INCORRECT_CONFIG_REGEX, result) 18 | assert "Domain does not exist" in result 19 | -------------------------------------------------------------------------------- /app/src/tags.py: -------------------------------------------------------------------------------- 1 | from functools import cache 2 | 3 | from jinja2_simple_tags import StandaloneTag 4 | 5 | try: 6 | from common.config import Config 7 | 8 | LANGUAGE = Config.UI.LANGUAGE 9 | except ImportError: 10 | # This may happen e.g. when pybabel is processing the templates to find messages to be translated 11 | LANGUAGE = "" 12 | 13 | 14 | class BuildIDTag(StandaloneTag): # type: ignore 15 | tags = {"build_id"} 16 | 17 | @cache 18 | def render(self) -> str: 19 | with open("/app/build_id") as f: 20 | return f.read().strip() 21 | 22 | 23 | class LanguageTag(StandaloneTag): # type: ignore 24 | tags = {"language"} 25 | language = LANGUAGE.replace("_", "-") 26 | 27 | @cache 28 | def render(self) -> str: 29 | return self.language 30 | -------------------------------------------------------------------------------- /app/templates/check_running.html: -------------------------------------------------------------------------------- 1 | {% extends "custom_layout.html" %} 2 | 3 | {% block header_additional %} 4 | 5 | {% endblock %} 6 | 7 | {% block body %} 8 |
9 |
10 |

{% trans %}Configuration analysis is running{% endtrans %}

11 |
12 | {% trans %}Waiting{% endtrans %} 13 |
14 |
{% trans %}Waiting for the analysis to finish{% endtrans %}
15 |
{% trans %}This page will refresh automatically.{% endtrans %}
16 |
17 |
18 |
19 |
20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /app/templates/custom_root_layout.html: -------------------------------------------------------------------------------- 1 | {% extends "custom_layout.html" %} 2 | 3 | {# To customize the root (/) page of the system provide a different file (replacing this one) #} 4 | {# using Docker Compose volume mount. With this, you may e.g. describe on the root page what are #} 5 | {# the benefits of using SPF/DKIM/DMARC or link to your tutorials that describe setting up these #} 6 | {# mechanisms. #} 7 | 8 | {% block body %} 9 |
10 |
11 |
12 | {% block check_by_sending_email %} 13 | {% endblock %} 14 |
15 |
16 | {% block check_domain %} 17 | {% endblock %} 18 |
19 |
20 |
21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /scan/setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | from distutils.core import setup 5 | 6 | with open(os.path.join(os.path.dirname(__file__), "requirements.txt")) as f: 7 | requires = f.read().splitlines() 8 | 9 | 10 | setup( 11 | name="libmailgoose", 12 | version="1.3.15", 13 | description="libmailgoose - check the settings needed to protect against e-mail spoofing", 14 | author="CERT Polska", 15 | author_email="info@cert.pl", 16 | license="BSD", 17 | url="https://github.com/CERT-Polska/mailgoose", 18 | packages=["libmailgoose"], 19 | package_data={"": ["languages.txt"]}, 20 | include_package_data=True, 21 | scripts=[], 22 | classifiers=[ 23 | "Programming Language :: Python :: 3", 24 | "License :: OSI Approved :: BSD License", 25 | ], 26 | install_requires=requires, 27 | ) 28 | -------------------------------------------------------------------------------- /docs/features.rst: -------------------------------------------------------------------------------- 1 | Features 2 | ======== 3 | 4 | - checking **SPF** and **DMARC** configuration by providing a domain, 5 | - checking **SPF**, **DMARC** and **DKIM** configuration by sending a test e-mail, 6 | - SSL support for incoming e-mails (please refer to ``SSL_CERTIFICATE_PATH`` and 7 | ``SSL_PRIVATE_KEY_PATH`` settings documentation in :doc:`user-guide/configuration` to learn how to set it up), 8 | - easy translation to a different language, 9 | - easy layout customization, 10 | - REST API. 11 | 12 | REST API 13 | -------- 14 | REST API documentation is auto-generated by the FastAPI framework in the form of 15 | Swagger and is available at your mailgoose instance under ``/docs`` URL. To check 16 | whether a domain has SPF and DMARC set up correctly, use: 17 | 18 | .. code-block:: console 19 | 20 | curl -X POST http://127.0.0.1:8000/api/v1/check-domain?domain=example.com 21 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.isort] 2 | profile = "black" 3 | 4 | [tool.black] 5 | line-length = 120 6 | 7 | [tool.liccheck] 8 | authorized_licenses = [ 9 | "bsd", 10 | "BSD-3-Clause", 11 | "apache 2.0", 12 | "Apache License 2.0", 13 | "apache software", 14 | "isc", 15 | "isc license (iscl)", 16 | "gnu lesser general public license v2 or later (lgplv2+)", 17 | "gnu library or lesser general public license (lgpl)", 18 | "mozilla public license 2.0 (mpl 2.0)", 19 | "mit", 20 | "PSF-2.0", 21 | "MIT AND Python-2.0", 22 | "python software foundation", 23 | "PSF-2.0", 24 | "the unlicense (unlicense)", 25 | "cc0 1.0 universal (cc0 1.0) public domain dedication", 26 | ] 27 | 28 | [tool.liccheck.authorized_packages] 29 | # The license name in the package is too generic ("DFSG approved") to be whitelisted in `authorized_licenses` 30 | dkimpy = "1.1.5" 31 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.5.0 4 | hooks: 5 | - id: end-of-file-fixer 6 | - id: trailing-whitespace 7 | - repo: https://github.com/psf/black 8 | rev: 24.1.1 9 | hooks: 10 | - id: black 11 | - repo: https://github.com/pycqa/isort 12 | rev: 5.13.2 13 | hooks: 14 | - id: isort 15 | name: isort (python) 16 | - repo: https://github.com/pre-commit/mirrors-mypy 17 | rev: v1.8.0 18 | hooks: 19 | - id: mypy 20 | args: [--strict] 21 | additional_dependencies: 22 | - aiosmtpd==1.4.4.post2 23 | - dacite==1.8.1 24 | - dnspython==2.4.2 25 | - email-validator==2.1.0.post1 26 | - fastapi==0.104.1 27 | - Jinja2==3.1.2 28 | - rq==1.15.1 29 | - sqlalchemy-stubs==0.4 30 | - types-redis==4.6.0.11 31 | - types-requests==2.31.0.10 32 | - repo: https://github.com/PyCQA/flake8 33 | rev: 7.0.0 34 | hooks: 35 | - id: flake8 36 | args: [.] 37 | -------------------------------------------------------------------------------- /scripts/update_translation_files: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd $(dirname $0)/.. 4 | 5 | LOCALES=`cat scan/libmailgoose/languages.txt` 6 | 7 | if [ ! -d .venv.translations ] 8 | then 9 | python3 -m venv .venv.translations 10 | fi 11 | 12 | . .venv.translations/bin/activate 13 | 14 | pip install -r app/translations/requirements.txt 15 | 16 | PYTHONPATH=app pybabel extract \ 17 | --omit-header \ 18 | --strip-comments \ 19 | -F babel.cfg \ 20 | -o app/translations/messages.pot \ 21 | app 22 | 23 | for locale in $LOCALES 24 | do 25 | mkdir -p app/translations/$locale/LC_MESSAGES 26 | pybabel update \ 27 | --omit-header \ 28 | --init-missing \ 29 | -l $locale \ 30 | -i app/translations/messages.pot \ 31 | -d app/translations/ 32 | 33 | # Remove the last newline so that we don't conflict with linters 34 | sed -i -z '$ s/\n$//' app/translations/$locale/LC_MESSAGES/messages.po 35 | done 36 | 37 | sed -i -z '$ s/\n$//' app/translations/messages.pot 38 | -------------------------------------------------------------------------------- /app/src/templates.py: -------------------------------------------------------------------------------- 1 | import gettext 2 | import subprocess 3 | 4 | from fastapi.templating import Jinja2Templates 5 | 6 | from .tags import BuildIDTag, LanguageTag 7 | from .template_utils import mailgoose_urlize 8 | 9 | 10 | def setup_templates(language: str) -> Jinja2Templates: 11 | templates = Jinja2Templates(directory="templates", extensions=[BuildIDTag, LanguageTag, "jinja2.ext.i18n"]) 12 | templates.env.filters["mailgoose_urlize"] = mailgoose_urlize 13 | subprocess.call( 14 | [ 15 | "pybabel", 16 | "compile", 17 | "-f", 18 | "--input", 19 | f"/app/translations/{language}/LC_MESSAGES/messages.po", 20 | "--output", 21 | f"/app/translations/{language}/LC_MESSAGES/messages.mo", 22 | ], 23 | stderr=subprocess.DEVNULL, # suppress a misleading message where compiled translations will be saved 24 | ) 25 | 26 | templates.env.install_gettext_translations( # type: ignore 27 | gettext.translation(domain="messages", localedir="/app/translations", languages=[language]), newstyle=True 28 | ) 29 | 30 | return templates 31 | -------------------------------------------------------------------------------- /test/base.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | from typing import Any, Dict 3 | from unittest import TestCase 4 | 5 | import requests 6 | from config import APP_URL 7 | 8 | 9 | class BaseTestCase(TestCase): 10 | def check_domain(self, domain: str) -> str: 11 | submission_response = requests.post(APP_URL + "/check-domain/scan", {"domain": domain}, allow_redirects=False) 12 | submission_response.raise_for_status() 13 | 14 | if not submission_response.next or not submission_response.next.url: 15 | raise RuntimeError("Did not receive a redirect after submitting domain for scanning") 16 | 17 | results_url = submission_response.next.url 18 | 19 | results_response = requests.get(results_url) 20 | results_response.raise_for_status() 21 | 22 | while "Configuration analysis is running" in results_response.text: 23 | results_response = requests.get(results_url) 24 | results_response.raise_for_status() 25 | 26 | sleep(5) 27 | 28 | return results_response.text.replace("\n", " ") 29 | 30 | def check_domain_api_v1(self, domain: str) -> Dict[str, Any]: 31 | response = requests.post(APP_URL + "/api/v1/check-domain?domain=" + domain) 32 | return response.json() # type: ignore 33 | -------------------------------------------------------------------------------- /app/templates/check_domain.html: -------------------------------------------------------------------------------- 1 | {% extends "custom_layout.html" %} 2 | 3 | {% block body %} 4 |
5 |
6 |
7 |
8 |
9 |
{% trans %}Check domain{% endtrans %}
10 |
11 |
12 | 13 | 14 | 15 | {% trans %}Don't put the entire e-mail address here, only the part after "@"{% endtrans %} 16 | 17 |
18 | 19 |
20 |
21 |
22 |
23 |
24 |
25 | {% endblock %} 26 | -------------------------------------------------------------------------------- /app/static/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: #f5f5f5; 3 | } 4 | 5 | body .container-fluid:first-child { 6 | padding-top: 20px; 7 | } 8 | 9 | a { 10 | color: #155C25; 11 | } 12 | 13 | .bg-primary, .btn-primary { 14 | background-color: #155C25 !important; 15 | border-color: #155C25 !important; 16 | } 17 | 18 | @media(min-width: 600px) { 19 | td.label { 20 | width: 200px; 21 | } 22 | } 23 | 24 | @media(max-width: 599px) { 25 | td.label { 26 | width: 125px; 27 | } 28 | } 29 | 30 | .navbar { 31 | margin-bottom: 20px; 32 | } 33 | 34 | .navbar-brand img { 35 | height: 2.5em; 36 | margin-left: 1em; 37 | } 38 | 39 | code { 40 | overflow-wrap: anywhere; 41 | color: #555 !important; 42 | } 43 | 44 | .text-warning-dark { 45 | color: #856404; 46 | } 47 | 48 | td ul { 49 | list-style-type: "- "; 50 | margin: 5px 0 5px 5px; 51 | padding-left: 10px; 52 | } 53 | 54 | .input-group.with-margin { 55 | margin: 10px 0; 56 | } 57 | 58 | .card-highlight { 59 | border-width: 3px; 60 | } 61 | 62 | @media(max-width: 450px) { 63 | .navbar-brand { 64 | font-size: 16px; 65 | } 66 | .navbar-brand img { 67 | height: 2em; 68 | } 69 | } 70 | 71 | .spinner { 72 | display: block; 73 | float: right; 74 | } 75 | 76 | .mailto-link-with-button { 77 | margin: 10px 0 20px; 78 | } 79 | 80 | .mailto-link-with-button .copy-button-wrapper { 81 | display: inline-block; 82 | margin-left: 10px; 83 | } 84 | -------------------------------------------------------------------------------- /.github/workflows/liccheck.yml: -------------------------------------------------------------------------------- 1 | name: "license-check" 2 | on: 3 | pull_request: 4 | branches: [ '**' ] 5 | 6 | jobs: 7 | license-check: 8 | runs-on: ubuntu-latest 9 | timeout-minutes: 10 10 | steps: 11 | - name: Check out repository 12 | uses: actions/checkout@v2 13 | - name: Set up Python 3.11 14 | uses: actions/setup-python@v2 15 | with: 16 | python-version: "3.11" 17 | - name: Remove checkdmarc installed from CERT PL fork from requirements as it's not supported by liccheck, remove /scan as it's a local package 18 | run: cp app/requirements.txt app/requirements.txt.orig; cat app/requirements.txt.orig | grep -v ^git+.*checkdmarc | grep -v ^/scan > app/requirements.txt 19 | - name: Remove /scan as it's a local package 20 | run: cp mail_receiver/requirements.txt mail_receiver/requirements.txt.orig; cat mail_receiver/requirements.txt.orig | grep -v ^/scan > mail_receiver/requirements.txt 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install -r app/requirements.txt -r mail_receiver/requirements.txt -r test/requirements.txt liccheck==0.9.2 25 | - name: Run liccheck on app/requirements.txt 26 | run: liccheck -r app/requirements.txt 27 | - name: Run liccheck on mail_receiver/requirements.txt 28 | run: liccheck -r mail_receiver/requirements.txt 29 | - name: Run liccheck on test/requirements.txt 30 | run: liccheck -r test/requirements.txt 31 | -------------------------------------------------------------------------------- /docker-compose.test.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | app: 5 | build: 6 | context: . 7 | dockerfile: app/docker/Dockerfile 8 | environment: 9 | APP_DOMAIN: "app" 10 | DB_URL: postgresql+psycopg2://postgres:postgres@db-test:5432/mailgoose 11 | FORWARDED_ALLOW_IPS: "*" 12 | LANGUAGE: en_US 13 | REDIS_MESSAGE_DATA_EXPIRY_SECONDS: 864000 14 | REDIS_URL: redis://redis-test:6379/0 15 | command: bash -c "/wait-for-it.sh db-test:5432 -- uvicorn src.app:app --host 0.0.0.0 --port 8000 --proxy-headers" 16 | worker: 17 | build: 18 | context: . 19 | dockerfile: app/docker/Dockerfile 20 | environment: 21 | APP_DOMAIN: "app" 22 | DB_URL: postgresql+psycopg2://postgres:postgres@db-test:5432/mailgoose 23 | LANGUAGE: en_US 24 | REDIS_MESSAGE_DATA_EXPIRY_SECONDS: 864000 25 | REDIS_URL: redis://redis-test:6379/0 26 | command: bash -c "/wait-for-it.sh db:5432 -- rq worker" 27 | db-test: 28 | image: postgres:15.2-alpine 29 | environment: 30 | - POSTGRES_DB=mailgoose 31 | - POSTGRES_USER=postgres 32 | - POSTGRES_PASSWORD=postgres 33 | redis-test: 34 | image: redis 35 | bind9: 36 | build: 37 | context: . 38 | dockerfile: docker-bind9/Dockerfile 39 | test: 40 | profiles: ["test"] # This will prevent the service from starting by default 41 | build: 42 | context: . 43 | dockerfile: test/Dockerfile 44 | command: bash -c "/wait-for-it.sh app:8000 -- python -m unittest discover" 45 | -------------------------------------------------------------------------------- /docs/user-guide/translation.rst: -------------------------------------------------------------------------------- 1 | Translation 2 | =========== 3 | The UI translations reside in ``./app/translations``. If the original messages changed, e.g. because 4 | you changed the UI, update the ``.po`` files by running: 5 | 6 | ``./scripts/update_translation_files`` 7 | 8 | and then put the translations in the respective ``.po`` files. The compilation will happen 9 | automatically when starting the system. 10 | 11 | The error message translations for DMARC/SPF/DKIM problems reside in the ``TRANSLATIONS`` dict in ``app/src/translate.py``. 12 | The following syntax: 13 | 14 | .. code-block:: console 15 | 16 | ( 17 | f"{PLACEHOLDER} is not a valid DMARC report URI", 18 | f"{PLACEHOLDER} ... translation for your language...", 19 | ), 20 | 21 | 22 | means, that the ``{PLACEHOLDER}`` part will be copied verbatim into the translation - this is to 23 | support situations, where the error message contains e.g. a domain, a URI or other part dependent on the configuration. 24 | 25 | Adding a new language 26 | --------------------- 27 | If you want to support a new language: 28 | 29 | - add it in ``scan/libmailgoose/languages.txt``, 30 | - run ``./scripts/update_translation_files`` and fill ``.po`` files for the UI messages for your language in ``./app/translations`` 31 | (**you may skip that part if you want only the library error messages to be translated**), 32 | - add the error message translations for your language in ``scan/libmailgoose/translate.py``. 33 | 34 | **You don't have to translate everything - pull requests with partial translations are also welcome!** 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023, CERT Polska 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, 6 | are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, 9 | this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | * Neither the name of mailgoose nor the names of its contributors 14 | may be used to endorse or promote products derived from this software 15 | without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR 21 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 22 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 23 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 24 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 25 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 26 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /app/src/resolver.py: -------------------------------------------------------------------------------- 1 | import dns.resolver 2 | 3 | from common.config import Config 4 | 5 | from .logging import build_logger 6 | 7 | 8 | class WrappedResolver(dns.resolver.Resolver): 9 | logger = build_logger(__name__) 10 | num_retries = 3 11 | 12 | def resolve(self, *args, **kwargs): # type: ignore 13 | result = None 14 | last_exception = None 15 | num_exceptions = 0 16 | 17 | for i in range(self.num_retries): 18 | try: 19 | if i < self.num_retries - 1: 20 | self.nameservers = Config.Network.NAMESERVERS 21 | else: 22 | self.nameservers = Config.Network.FALLBACK_NAMESERVERS 23 | result = super().resolve(*args, **kwargs) 24 | break 25 | except Exception as e: 26 | num_exceptions += 1 27 | self.logger.exception("problem when resolving: %s, %s", args, kwargs) 28 | last_exception = e 29 | 30 | self.logger.info( 31 | "%s DNS query: %s, %s -> %s", 32 | "flaky" if num_exceptions > 0 and num_exceptions < self.num_retries else "non-flaky", 33 | args, 34 | kwargs, 35 | [str(item) for item in result] if result else last_exception, 36 | ) 37 | 38 | if last_exception and not result: 39 | raise last_exception 40 | 41 | return result 42 | 43 | 44 | def setup_resolver() -> None: 45 | if dns.resolver.Resolver != WrappedResolver: 46 | dns.resolver.Resolver = WrappedResolver # type: ignore 47 | -------------------------------------------------------------------------------- /scan/libmailgoose/lax_record_query.py: -------------------------------------------------------------------------------- 1 | # This is a module to query record candidates to be displayed in the UI. 2 | # 3 | # If something is not a correct record, but looks like one, and checkdmarc 4 | # tells there is no record, the candidate will be displayed. 5 | 6 | from typing import List 7 | 8 | import dns.resolver 9 | from checkdmarc import get_base_domain 10 | from checkdmarc.utils import query_dns 11 | 12 | SIGNATURE_LOOKUP_LENGTH = 20 13 | 14 | 15 | def lax_query_spf_record(domain: str) -> List[str]: 16 | try: 17 | records = query_dns(domain, "SPF") 18 | if records: 19 | return records # type: ignore 20 | except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): 21 | pass 22 | 23 | records = [] 24 | answers = query_dns(domain, "TXT") 25 | for answer in answers: 26 | if "spf" in answer[:SIGNATURE_LOOKUP_LENGTH].lower(): 27 | records.append(answer) 28 | return records # type: ignore 29 | 30 | 31 | def lax_query_single_dmarc_record(domain: str) -> List[str]: 32 | target = "_dmarc.{0}".format(domain.lower()) 33 | 34 | try: 35 | record_candidates = query_dns(target, "TXT") 36 | records = [] 37 | 38 | for record in record_candidates: 39 | if "dmarc" in record[:SIGNATURE_LOOKUP_LENGTH].lower(): 40 | records.append(record) 41 | return records 42 | 43 | except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): 44 | return [] 45 | 46 | 47 | def lax_query_dmarc_record(domain: str) -> List[str]: 48 | records = lax_query_single_dmarc_record(domain) 49 | if records: 50 | return records 51 | base_domain = get_base_domain(domain) 52 | if base_domain and domain != base_domain: 53 | return lax_query_single_dmarc_record(base_domain) 54 | return [] 55 | -------------------------------------------------------------------------------- /app/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% macro copy_button(data) %} 5 | 7 | {# Source: MIT-licensed https://icon-sets.iconify.design/bi/clipboard/ #} 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | {% endmacro %} 16 | 17 | 18 | 19 | 20 | 21 | 22 | {% block title %}mailgoose{% endblock %} 23 | 24 | 25 | 26 | 27 | 28 | {% block header_additional %} 29 | {% endblock %} 30 | 31 | 32 | 33 | {% block navbar %} 34 | {% endblock %} 35 | 36 |
37 | {% block before_main_content %} 38 | {% endblock %} 39 | 40 | {% block body %} 41 | {% endblock %} 42 |
43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | x-common-configuration: 2 | &common-configuration 3 | restart: always 4 | 5 | services: 6 | app: 7 | build: 8 | context: . 9 | dockerfile: app/docker/Dockerfile 10 | environment: 11 | DB_URL: postgresql+psycopg2://postgres:postgres@db:5432/mailgoose 12 | FORWARDED_ALLOW_IPS: "*" 13 | REDIS_URL: redis://redis:6379/0 14 | env_file: 15 | - .env 16 | command: bash -c "/wait-for-it.sh db:5432 -- uvicorn src.app:app --host 0.0.0.0 --port 8000 --proxy-headers --workers 8" 17 | ports: 18 | - 8000:8000 19 | <<: *common-configuration 20 | 21 | worker: 22 | build: 23 | context: . 24 | dockerfile: app/docker/Dockerfile 25 | environment: 26 | DB_URL: postgresql+psycopg2://postgres:postgres@db:5432/mailgoose 27 | REDIS_URL: redis://redis:6379/0 28 | env_file: 29 | - .env 30 | command: bash -c "/wait-for-it.sh db:5432 -- rq worker" 31 | <<: *common-configuration 32 | 33 | mail_receiver: 34 | build: 35 | context: . 36 | dockerfile: mail_receiver/Dockerfile 37 | command: python3 /opt/server.py 38 | environment: 39 | REDIS_URL: redis://redis:6379/0 40 | env_file: 41 | - .env 42 | ports: 43 | - 25:25 44 | - 587:587 45 | <<: *common-configuration 46 | 47 | db: 48 | image: postgres:15.2-alpine 49 | environment: 50 | - POSTGRES_DB=mailgoose 51 | - POSTGRES_USER=postgres 52 | - POSTGRES_PASSWORD=postgres 53 | volumes: 54 | - data-db:/var/lib/postgresql/data 55 | <<: *common-configuration 56 | 57 | redis: 58 | image: redis 59 | volumes: 60 | - data-redis:/data 61 | <<: *common-configuration 62 | 63 | bind9: 64 | build: 65 | context: . 66 | dockerfile: docker-bind9/Dockerfile 67 | <<: *common-configuration 68 | 69 | volumes: 70 | data-db: 71 | data-redis: 72 | -------------------------------------------------------------------------------- /app/templates/root.html: -------------------------------------------------------------------------------- 1 | {% extends "custom_root_layout.html" %} 2 | 3 | {% block check_by_sending_email %} 4 |
5 |
6 |
{% trans %}Check configuration by sending an e-mail{% endtrans %}
7 |
8 |

9 | {% trans trimmed %} 10 | Verify your DKIM, DMARC, and SPF settings by sending a test e-mail. 11 | {% endtrans %} 12 |

13 |

14 | 15 | {% trans trimmed %} 16 | This is the recommended path. Sending a test e-mail allows us to perform more accurate analysis. 17 | {% endtrans %} 18 | 19 |

20 |
21 | {% trans %}Send an e-mail{% endtrans %} 22 |
23 |
24 | {% endblock %} 25 | 26 | {% block check_domain %} 27 |
28 |
29 |
{% trans %}Check domain{% endtrans %}
30 |
31 |

32 | {% trans trimmed %} 33 | Verify your SPF and DMARC settings by providing a domain. Note: only the SPF 34 | and DMARC mechanisms will be checked. To check DKIM, you need to send a test e-mail. 35 | {% endtrans %} 36 |

37 |
38 | {% trans %}Check domain{% endtrans %} 39 |
40 |
41 | {% endblock %} 42 | -------------------------------------------------------------------------------- /test/test_api.py: -------------------------------------------------------------------------------- 1 | from base import BaseTestCase 2 | from config import TEST_DOMAIN 3 | 4 | 5 | class APITestCase(BaseTestCase): 6 | def test_dmarc_starts_with_whitespace(self) -> None: 7 | result = self.check_domain_api_v1("starts-with-whitespace.dmarc." + TEST_DOMAIN) 8 | del result["result"]["timestamp"] 9 | self.assertEqual( 10 | result, 11 | { 12 | "result": { 13 | "domain": { 14 | "domain_does_not_exist": False, 15 | "spf": { 16 | "valid": False, 17 | "errors": [ 18 | "Valid SPF record not found. We recommend using all three mechanisms: SPF, DKIM and DMARC " 19 | "to decrease the possibility of successful e-mail message spoofing.", 20 | ], 21 | "warnings": [], 22 | "record_not_found": True, 23 | "record_could_not_be_fully_validated": False, 24 | }, 25 | "dmarc": { 26 | "record_candidates": [" v=DMARC1; p=none"], 27 | "valid": False, 28 | "tags": {}, 29 | "errors": [ 30 | "Found a DMARC record that starts with whitespace. Please remove the whitespace, as some " 31 | "implementations may not process it correctly.", 32 | ], 33 | "warnings": [], 34 | "record_not_found": False, 35 | }, 36 | "spf_not_required_because_of_correct_dmarc": False, 37 | "domain": "starts-with-whitespace.dmarc.test.mailgoose.cert.pl", 38 | "base_domain": "cert.pl", 39 | "warnings": [], 40 | }, 41 | }, 42 | }, 43 | ) 44 | -------------------------------------------------------------------------------- /docs/generate_config_docs.py: -------------------------------------------------------------------------------- 1 | import os 2 | import textwrap 3 | from pathlib import Path 4 | from typing import IO, Any, get_type_hints 5 | 6 | # By default, these variables are required by config. As we are importing the config 7 | # only to get the docs, let's mock them. 8 | os.environ["APP_DOMAIN"] = "" 9 | os.environ["DB_URL"] = "" 10 | os.environ["DEFAULT_NAMESERVER_NAME_OVERRIDE"] = "dns.google" 11 | os.environ["REDIS_URL"] = "" 12 | from config import DEFAULTS, Config # type: ignore # noqa 13 | from sphinx.application import Sphinx # type: ignore # noqa 14 | 15 | 16 | def setup(app: Sphinx) -> None: 17 | app.connect("config-inited", on_config_inited) 18 | 19 | 20 | def on_config_inited(_1: Any, _2: Any) -> None: 21 | output = Path(__file__).parents[0] / "user-guide" / "config-docs.inc" 22 | 23 | with open(output, "w") as f: 24 | print_docs_for_class(Config, output_file=f) 25 | 26 | 27 | def print_docs_for_class(cls: type, output_file: IO[str], depth: int = 0) -> None: 28 | if depth > 0: 29 | output_file.write(cls.__name__ + "\n") 30 | 31 | header_characters = '-^"' 32 | output_file.write(header_characters[depth - 1] * len(cls.__name__) + "\n\n") 33 | 34 | hints = get_type_hints(cls, include_extras=True) 35 | for variable_name in dir(cls): 36 | if variable_name.startswith("__"): 37 | continue 38 | 39 | member = getattr(cls, variable_name) 40 | if isinstance(member, type): 41 | print_docs_for_class(member, output_file, depth + 1) 42 | continue 43 | elif member == Config.verify_each_variable_is_annotated: 44 | continue 45 | 46 | (hint,) = hints[variable_name].__metadata__ 47 | indent = 4 * " " 48 | 49 | doc = "" 50 | for line in hint.strip().split("\n"): 51 | doc += indent + line + "\n" 52 | 53 | if variable_name in DEFAULTS: 54 | default_str = f"{indent}Default: ``{DEFAULTS[variable_name]}``\n\n" 55 | else: 56 | default_str = "" 57 | 58 | output_file.write( 59 | textwrap.dedent( 60 | f""" 61 | {variable_name}\n{default_str}{doc} 62 | """.strip() 63 | ) 64 | + "\n\n" 65 | ) 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | logo 5 | 6 |

7 | 8 | Mailgoose is a web application that allows the users to check whether their SPF, DMARC and 9 | DKIM configuration is set up correctly. CERT PL uses it to run 10 | bezpiecznapoczta.cert.pl, an online service 11 | that helps Polish institutions to configure their domains to decrease the probability of successful 12 | e-mail spoofing. 13 | 14 | Under the hood, Mailgoose uses checkdmarc 15 | and dkimpy, among others. 16 | 17 | ## [Quick Start 🔨](https://mailgoose.readthedocs.io/en/latest/quick-start.html) | [Docs 📚](https://mailgoose.readthedocs.io/en/latest/) 18 | 19 | ## Features 20 | For an up-to-date list of features, please refer to [the documentation](https://mailgoose.readthedocs.io/en/latest/features.html). 21 | 22 | ## Screenshots 23 | ![Check results](.github/screenshots/check_results.png) 24 | 25 | ## Development 26 | 27 | ### Tests 28 | To run the tests, use: 29 | 30 | ``` 31 | ./scripts/test 32 | ``` 33 | 34 | ### Code formatting 35 | Mailgoose uses `pre-commit` to run linters and format the code. 36 | `pre-commit` is executed on CI to verify that the code is formatted properly. 37 | 38 | To run it locally, use: 39 | 40 | ``` 41 | pre-commit run --all-files 42 | ``` 43 | 44 | To setup `pre-commit` so that it runs before each commit, use: 45 | 46 | ``` 47 | pre-commit install 48 | ``` 49 | 50 | ### Building the docs 51 | 52 | To build the documentation, use: 53 | 54 | ``` 55 | cd docs 56 | python3 -m venv venv 57 | . venv/bin/activate 58 | pip install -r requirements.txt 59 | make html 60 | ``` 61 | 62 | ## Contributing 63 | Contributions are welcome! We will appreciate both ideas for improvements (added as 64 | [GitHub issues](https://github.com/CERT-Polska/mailgoose/issues)) as well as pull requests 65 | with new features or code improvements. 66 | 67 | However obvious it may seem we kindly remind you that by contributing to mailgoose 68 | you agree that the BSD 3-Clause License shall apply to your input automatically, 69 | without the need for any additional declarations to be made. 70 | -------------------------------------------------------------------------------- /test/test_spf.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from base import BaseTestCase 4 | from config import TEST_DOMAIN 5 | 6 | RECORD_COULD_NOT_BE_FULLY_VERIFIED_REGEX = r"SPF:\s*record couldn't be fully verified" 7 | INCORRECT_CONFIG_REGEX = r"SPF:\s*incorrect configuration" 8 | CORRECT_CONFIG_REGEX = r"SPF:\s*correct configuration" 9 | 10 | 11 | class SPFTestCase(BaseTestCase): 12 | def test_correct(self) -> None: 13 | result = self.check_domain("correct.spf." + TEST_DOMAIN) 14 | assert re.search(CORRECT_CONFIG_REGEX, result) 15 | assert not re.search(INCORRECT_CONFIG_REGEX, result) 16 | 17 | def test_service_domains(self) -> None: 18 | """Check whether domain names containing underscore are allowed.""" 19 | result = self.check_domain("_spf.cert.pl") 20 | assert re.search(CORRECT_CONFIG_REGEX, result) 21 | assert not re.search(INCORRECT_CONFIG_REGEX, result) 22 | 23 | def test_macros(self) -> None: 24 | result = self.check_domain("macros.spf." + TEST_DOMAIN) 25 | assert re.search(RECORD_COULD_NOT_BE_FULLY_VERIFIED_REGEX, result) 26 | assert not re.search(CORRECT_CONFIG_REGEX, result) 27 | assert not re.search(INCORRECT_CONFIG_REGEX, result) 28 | assert "SPF records containing macros aren't supported by the system yet." in result 29 | 30 | def test_syntax_error(self) -> None: 31 | result = self.check_domain("syntax-error.spf." + TEST_DOMAIN) 32 | assert re.search(INCORRECT_CONFIG_REGEX, result) 33 | assert not re.search(CORRECT_CONFIG_REGEX, result) 34 | assert ( 35 | "syntax-error.spf.test.mailgoose.cert.pl: Expected end_of_statement or " 36 | "mechanism at position 7 (marked with ➞) in: v=spf1 ➞=bcdefgh" 37 | ) in result or ( 38 | "syntax-error.spf.test.mailgoose.cert.pl: Expected mechanism or " 39 | "end_of_statement at position 7 (marked with ➞) in: v=spf1 ➞=bcdefgh" 40 | ) in result 41 | 42 | def test_problematic_include(self) -> None: 43 | result = self.check_domain("includes-other-domain.spf." + TEST_DOMAIN) 44 | assert re.search(INCORRECT_CONFIG_REGEX, result) 45 | assert not re.search(CORRECT_CONFIG_REGEX, result) 46 | assert ( 47 | "The SPF record's include chain has a reference to the includes-yet-another-domain.spf.test.mailgoose.cert.pl " 48 | "domain that doesn't have an SPF record. When using directives such as 'include' " 49 | "or 'redirect' remember that the destination domain must have a correct SPF record." 50 | ) in result 51 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | import datetime 10 | import os 11 | import sys 12 | from typing import List 13 | 14 | # If extensions (or modules to document with autodoc) are in another directory, 15 | # add these directories to sys.path here. If the directory is relative to the 16 | # documentation root, use os.path.abspath to make it absolute, like shown here. 17 | # 18 | # import os 19 | # import sys 20 | # sys.path.insert(0, os.path.abspath('.')) 21 | import sphinx_rtd_theme # noqa 22 | 23 | sys.path.insert(0, os.path.abspath("../common")) 24 | sys.path.insert(0, os.path.abspath("../scan")) 25 | sys.path.insert(0, os.path.abspath("..")) 26 | sys.path.insert(0, os.path.abspath(".")) 27 | 28 | 29 | # -- Project information ----------------------------------------------------- 30 | 31 | project = "mailgoose" 32 | copyright = f"{datetime.datetime.now().year}, CERT Polska" 33 | author = "CERT Polska" 34 | 35 | # The full version, including alpha/beta/rc tags 36 | release = "1.3.15" 37 | 38 | latex_engine = "xelatex" 39 | 40 | # -- General configuration --------------------------------------------------- 41 | 42 | # Add any Sphinx extension module names here, as strings. They can be 43 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 44 | # ones. 45 | extensions = [ 46 | "sphinx_rtd_theme", 47 | "sphinx.ext.graphviz", 48 | "sphinx.ext.autodoc", 49 | "sphinx.ext.autosummary", 50 | "generate_config_docs", 51 | ] 52 | 53 | graphviz_output_format = "svg" 54 | 55 | # Add any paths that contain templates here, relative to this directory. 56 | templates_path = ["_templates"] 57 | 58 | # List of patterns, relative to source directory, that match files and 59 | # directories to ignore when looking for source files. 60 | # This pattern also affects html_static_path and html_extra_path. 61 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "venv"] 62 | 63 | 64 | # -- Options for HTML output ------------------------------------------------- 65 | 66 | # The theme to use for HTML and HTML Help pages. See the documentation for 67 | # a list of builtin themes. 68 | # 69 | html_theme = "sphinx_rtd_theme" 70 | 71 | # Add any paths that contain custom static files (such as style sheets) here, 72 | # relative to this directory. They are copied after the builtin static files, 73 | # so a file named "default.css" will overwrite the builtin "default.css". 74 | html_static_path: List[str] = [] 75 | 76 | 77 | html_css_files = [ 78 | "style.css", 79 | ] 80 | -------------------------------------------------------------------------------- /app/src/db.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from enum import Enum 3 | 4 | from sqlalchemy import JSON, Boolean, Column, DateTime, Integer, String, create_engine 5 | from sqlalchemy.orm import declarative_base, sessionmaker # type: ignore 6 | 7 | from common.config import Config 8 | 9 | Base = declarative_base() 10 | engine = create_engine(Config.Data.DB_URL) 11 | Session = sessionmaker(bind=engine) 12 | 13 | 14 | class ScanLogEntrySource(str, Enum): 15 | GUI = "gui" 16 | API = "api" 17 | 18 | 19 | class NonexistentTranslationLogEntry(Base): # type: ignore 20 | __tablename__ = "nonexistent_translation_logs" 21 | id = Column(Integer, primary_key=True) 22 | created_at = Column(DateTime, default=datetime.datetime.utcnow) 23 | message = Column(String) 24 | 25 | 26 | class ServerErrorLogEntry(Base): # type: ignore 27 | __tablename__ = "server_error_logs" 28 | 29 | id = Column(Integer, primary_key=True) 30 | created_at = Column(DateTime, default=datetime.datetime.utcnow) 31 | url = Column(String) 32 | error = Column(String) 33 | 34 | def __repr__(self) -> str: 35 | return ("") % ( 36 | self.created_at, 37 | self.url, 38 | ) 39 | 40 | 41 | class DKIMImplementationMismatchLogEntry(Base): # type: ignore 42 | __tablename__ = "dkim_implementation_mismatch_logs" 43 | 44 | id = Column(Integer, primary_key=True) 45 | created_at = Column(DateTime, default=datetime.datetime.utcnow) 46 | message = Column(String) 47 | dkimpy_valid = Column(Boolean) 48 | opendkim_valid = Column(Boolean) 49 | 50 | def __repr__(self) -> str: 51 | return ("") % ( 52 | self.created_at, 53 | self.dkimpy_valid, 54 | self.opendkim_valid, 55 | ) 56 | 57 | 58 | class ScanLogEntry(Base): # type: ignore 59 | __tablename__ = "scan_logs" 60 | 61 | id = Column(Integer, primary_key=True) 62 | created_at = Column(DateTime, default=datetime.datetime.utcnow) 63 | envelope_domain = Column(String) 64 | from_domain = Column(String) 65 | dkim_domain = Column(String) 66 | message = Column(String) 67 | source = Column(String) 68 | client_ip = Column(String) 69 | client_user_agent = Column(String) 70 | check_options = Column(JSON) 71 | result = Column(JSON) 72 | error = Column(String) 73 | 74 | def __repr__(self) -> str: 75 | return ("") % ( 76 | self.domain, 77 | self.source, 78 | self.client_ip, 79 | self.client_user_agent, 80 | ) 81 | 82 | 83 | Base.metadata.create_all(bind=engine) 84 | -------------------------------------------------------------------------------- /app/src/check_results.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import datetime 3 | import json 4 | from typing import Any, Dict, Optional 5 | 6 | import dacite 7 | from libmailgoose.scan import ScanResult 8 | from redis import Redis 9 | 10 | from common.config import Config 11 | 12 | from .logging import build_logger 13 | 14 | REDIS = Redis.from_url(Config.Data.REDIS_URL) 15 | 16 | LOGGER = build_logger(__name__) 17 | 18 | 19 | class JSONEncoderAdditionalTypes(json.JSONEncoder): 20 | def default(self, o: Any) -> Any: 21 | if dataclasses.is_dataclass(o): 22 | return dataclasses.asdict(o) 23 | if isinstance(o, datetime.datetime): 24 | return o.isoformat() 25 | return super().default(o) 26 | 27 | 28 | def save_check_results( 29 | envelope_domain: str, 30 | from_domain: str, 31 | dkim_domain: Optional[str], 32 | result: Optional[ScanResult], 33 | error: Optional[str], 34 | rescan_url: str, 35 | message_recipient_username: Optional[str], 36 | token: str, 37 | ) -> None: 38 | # We don't use HSET or HMSET, as result is a recursive dict, and values that can be stored 39 | # using HSET/HMSET are bytes, string, int or float, so we still wouldn't avoid serialization. 40 | REDIS.set( 41 | f"check-results-{token}", 42 | json.dumps( 43 | { 44 | "created_at": datetime.datetime.now(), 45 | "envelope_domain": envelope_domain, 46 | "from_domain": from_domain, 47 | "dkim_domain": dkim_domain, 48 | "result": result, 49 | "error": error, 50 | "rescan_url": rescan_url, 51 | "message_recipient_username": message_recipient_username, 52 | }, 53 | indent=4, 54 | cls=JSONEncoderAdditionalTypes, 55 | ), 56 | ) 57 | 58 | 59 | def load_check_results(token: str) -> Optional[Dict[str, Any]]: 60 | data = REDIS.get(f"check-results-{token}") 61 | 62 | if not data: 63 | return None 64 | 65 | result: Dict[str, Any] = json.loads(data) 66 | 67 | result["created_at"] = datetime.datetime.fromisoformat(result["created_at"]) 68 | if result["result"]: 69 | result["result"]["timestamp"] = datetime.datetime.fromisoformat(result["result"]["timestamp"]) 70 | if result["result"]["message_timestamp"]: 71 | result["result"]["message_timestamp"] = datetime.datetime.fromisoformat( 72 | result["result"]["message_timestamp"] 73 | ) 74 | if not result["result"]["domain"].get("domain_does_not_exist"): 75 | result["result"]["domain"]["domain_does_not_exist"] = False 76 | 77 | try: 78 | dacite.from_dict( 79 | data_class=ScanResult, 80 | data=result["result"], 81 | ) 82 | except dacite.WrongTypeError: 83 | LOGGER.exception("Wrong type detected when deserializing") 84 | 85 | # As we stored what we got from the check module, we allow bad types here, logging 86 | # instead of raising 87 | result["result"] = dacite.from_dict( 88 | data_class=ScanResult, 89 | data=result["result"], 90 | config=dacite.Config(check_types=False), 91 | ) 92 | 93 | result["age_threshold_minutes"] = Config.UI.OLD_CHECK_RESULTS_AGE_MINUTES 94 | result["is_old"] = ( 95 | datetime.datetime.now() - result["created_at"] 96 | ).total_seconds() > 60 * Config.UI.OLD_CHECK_RESULTS_AGE_MINUTES 97 | return result 98 | -------------------------------------------------------------------------------- /docs/quick-start.rst: -------------------------------------------------------------------------------- 1 | Quick Start 2 | =========== 3 | 4 | Running mailgoose locally 5 | ------------------------- 6 | To run the service locally, use: 7 | 8 | .. code-block:: console 9 | 10 | git clone https://github.com/CERT-Polska/mailgoose 11 | 12 | cd mailgoose 13 | 14 | cp env.example .env # After doing that, customize the settings in .env if needed 15 | 16 | docker compose up --build 17 | 18 | The application will listen on http://127.0.0.1:8000 . 19 | 20 | To start the application, you need to configure only the variables present in 21 | ``env.example`` - all others are optional. To learn what settings are available, 22 | please refer to :doc:`user-guide/configuration`. 23 | 24 | To send a test e-mail to a local instance of Mailgoose from the terminal, you may use Python 25 | ``smtplib`` library: 26 | 27 | .. code-block:: python 28 | 29 | import smtplib 30 | 31 | server = smtplib.SMTP('localhost') 32 | server.sendmail( 33 | "example@example.com", 34 | recipient_address, # put here the proper recipient address 35 | "From: example@example.com\r\nhello" 36 | ) 37 | server.quit() 38 | 39 | Production deployment 40 | --------------------- 41 | Before deploying the system to production, remember: 42 | 43 | - that the ``mail_receiver`` container is responsible for saving incoming mails to 44 | Redis - make sure ports 25 and 587 are exposed publicly so that mailgoose will be able 45 | to receive a test e-mail. Make sure the domain configured in the ``APP_DOMAIN`` setting has ``MX`` DNS 46 | records pointing to the server ``mail_receiver`` is running on, 47 | - that the domain your system is listening on should not use CNAME records - this is not allowed by the RFCs and it causes multiple random issues, e.g. related to DKIM validation, 48 | - that SMTP SSL is supported - please refer to ``SSL_CERTIFICATE_PATH`` and ``SSL_PRIVATE_KEY_PATH`` 49 | settings documentation in :doc:`user-guide/configuration` to learn how to set it up, 50 | - to change the database password to a more secure one and to use Redis password (or make sure 51 | the database and Redis are isolated on the network), 52 | - to decide whether you want to launch a database/Redis instance inside a container or 53 | e.g. attaching to your own PostgreSQL/Redis cluster, 54 | - to check whether you want to use Google nameservers or other ones. 55 | 56 | Instead of copying ``docker-compose.yml``, you may override the configuration using the 57 | ``docker compose -f docker-compose.yml -f docker-compose.override.yml`` syntax. 58 | 59 | Changing the layout 60 | ------------------- 61 | If you want to change the main layout template (e.g. to provide additional scripts or your own 62 | custom navbar with logo), mount a different file using Docker to ``/app/templates/custom_layout.html``. 63 | Refer to ``app/templates/base.html`` to learn what block you can fill. 64 | 65 | You can also customize the root page (/) of the system by providing your own file that will 66 | replace ``/app/templates/custom_root_layout.html``. 67 | 68 | By replacing ``/app/templates/custom_failed_check_result_hints.html`` you may provide your own 69 | text that will be displayed if the e-mail sender verification mechanisms checks fail (for example 70 | to provide links to tutorials). 71 | 72 | At CERT PL we use a separate ``docker-compose.yml`` file with additional configuration 73 | specific to our instance (https://bezpiecznapoczta.cert.pl/). Instead of copying 74 | ``docker-compose.yml``, we override the configuration using the 75 | ``docker compose -f docker-compose.yml -f docker-compose.override.yml`` syntax. 76 | -------------------------------------------------------------------------------- /app/static/images/spinner.svg: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /app/templates/check_email.html: -------------------------------------------------------------------------------- 1 | {% extends "custom_layout.html" %} 2 | 3 | {% block header_additional %} 4 | 36 | {% endblock %} 37 | 38 | {% block body %} 39 |
40 |
41 |
42 |
43 |
44 |
{% trans %}Check configuration by sending an e-mail{% endtrans %}
45 |
46 | 49 | 55 | 56 | {% trans %}Waiting{% endtrans %} 57 |
{% trans %}Waiting for the message to be received{% endtrans %}
58 | 59 |

60 | {% trans %}As soon as the message is received, the page will automatically refresh - you will then see the check results.{% endtrans %} 61 |

62 |

63 | {% trans %}If after a while you still don't see the results, that means that we didn't receive your message. In that case:{% endtrans %} 64 |

65 |
    66 |
  • {% trans %}make sure you sent the message to the correct e-mail address{% endtrans %},
  • 67 |
  • {% trans %}if you manage your own e-mail server, make sure that the server sent the message correctly{% endtrans %}{% if site_contact_email %},{% else %}.{% endif %}
  • 68 | {% if site_contact_email %} 69 | {# This wording is on purpose - we want the e-mail to be followed by a space, not a dot/comma to facillitate copying #} 70 |
  • {{ pgettext("verb imperative", "contact") }} {{ site_contact_email }} {% trans %}if the above didn't solve the problem.{% endtrans %}
  • 71 | {% endif %} 72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 | {% endblock %} 80 | -------------------------------------------------------------------------------- /app/src/worker.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import traceback 3 | from typing import Optional 4 | 5 | from libmailgoose.language import Language 6 | from libmailgoose.scan import DomainValidationException, ScanningException 7 | from libmailgoose.translate import translate 8 | from redis import Redis 9 | 10 | from common.config import Config 11 | 12 | from .app_utils import get_from_and_dkim_domain, scan_and_log 13 | from .check_results import save_check_results 14 | from .db import ScanLogEntrySource, ServerErrorLogEntry, Session 15 | from .logging import build_logger 16 | from .resolver import setup_resolver 17 | 18 | LOGGER = build_logger(__name__) 19 | REDIS = Redis.from_url(Config.Data.REDIS_URL) 20 | 21 | setup_resolver() 22 | 23 | 24 | def scan_domain_job( 25 | client_ip: Optional[str], 26 | client_user_agent: Optional[str], 27 | domain: str, 28 | token: str, 29 | ) -> None: 30 | try: 31 | result = scan_and_log( 32 | source=ScanLogEntrySource.GUI, 33 | envelope_domain=domain, 34 | from_domain=domain, 35 | dkim_domain=None, 36 | message=None, 37 | message_sender_ip=None, 38 | message_timestamp=None, 39 | nameservers=Config.Network.NAMESERVERS, 40 | language=Language(Config.UI.LANGUAGE), 41 | client_ip=client_ip, 42 | client_user_agent=client_user_agent, 43 | ) 44 | error = None 45 | except (DomainValidationException, ScanningException) as e: 46 | result = None 47 | error = translate(e.message, Language(Config.UI.LANGUAGE)) 48 | except Exception: 49 | session = Session() 50 | server_error_log_entry = ServerErrorLogEntry(url="worker", error=traceback.format_exc()) 51 | session.add(server_error_log_entry) 52 | session.commit() 53 | 54 | result = None 55 | LOGGER.exception("Error during configuration validation") 56 | error = translate("An unknown error has occured during configuration validation.", Language(Config.UI.LANGUAGE)) 57 | 58 | save_check_results( 59 | envelope_domain=domain, 60 | from_domain=domain, 61 | dkim_domain=None, 62 | result=result, 63 | error=error, 64 | rescan_url="/check-domain/", 65 | message_recipient_username=None, 66 | token=token, 67 | ) 68 | 69 | 70 | def scan_message_and_domain_job( 71 | client_ip: Optional[str], 72 | client_user_agent: Optional[str], 73 | envelope_domain: str, 74 | token: str, 75 | message_key: bytes, 76 | recipient_username: str, 77 | ) -> None: 78 | message_data = REDIS.get(message_key) 79 | message_sender_ip = REDIS.get(message_key + b"-sender_ip") 80 | message_timestamp_raw = REDIS.get(message_key + b"-timestamp") 81 | 82 | if not message_data or not message_sender_ip or not message_timestamp_raw: 83 | raise RuntimeError("Worker coudn't access message data") 84 | 85 | message_timestamp = datetime.datetime.fromisoformat(message_timestamp_raw.decode("ascii")) 86 | 87 | from_domain, dkim_domain = get_from_and_dkim_domain(message_data) 88 | if not from_domain: 89 | result = None 90 | error = translate("Invalid or no e-mail domain in the message From header", Language(Config.UI.LANGUAGE)) 91 | else: 92 | try: 93 | result = scan_and_log( 94 | source=ScanLogEntrySource.GUI, 95 | envelope_domain=envelope_domain, 96 | from_domain=from_domain, 97 | dkim_domain=dkim_domain, 98 | message=message_data, 99 | message_sender_ip=message_sender_ip, 100 | message_timestamp=message_timestamp, 101 | nameservers=Config.Network.NAMESERVERS, 102 | language=Language(Config.UI.LANGUAGE), 103 | client_ip=client_ip, 104 | client_user_agent=client_user_agent, 105 | ) 106 | error = None 107 | except (DomainValidationException, ScanningException) as e: 108 | result = None 109 | error = translate(e.message, Language(Config.UI.LANGUAGE)) 110 | except Exception: 111 | session = Session() 112 | server_error_log_entry = ServerErrorLogEntry(url="worker", error=traceback.format_exc()) 113 | session.add(server_error_log_entry) 114 | session.commit() 115 | 116 | result = None 117 | error = translate( 118 | "An unknown error has occured during configuration validation.", Language(Config.UI.LANGUAGE) 119 | ) 120 | 121 | save_check_results( 122 | envelope_domain=envelope_domain, 123 | from_domain=from_domain or envelope_domain, 124 | dkim_domain=dkim_domain, 125 | result=result, 126 | error=error, 127 | rescan_url="/check-email/", 128 | message_recipient_username=recipient_username, 129 | token=token, 130 | ) 131 | -------------------------------------------------------------------------------- /test/test_dmarc.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from base import BaseTestCase 4 | from config import TEST_DOMAIN 5 | 6 | CONFIG_WITH_WARNINGS_REGEX = r"DMARC:\s*configuration warnings" 7 | INCORRECT_CONFIG_REGEX = r"DMARC:\s*incorrect configuration" 8 | CORRECT_CONFIG_REGEX = r"DMARC:\s*correct configuration" 9 | WARNING_REGEX = "bi bi-exclamation-triangle" 10 | 11 | 12 | class DMARCTestCase(BaseTestCase): 13 | def test_correct(self) -> None: 14 | result = self.check_domain("correct.dmarc." + TEST_DOMAIN) 15 | assert re.search(CORRECT_CONFIG_REGEX, result) 16 | assert not re.search(INCORRECT_CONFIG_REGEX, result) 17 | 18 | def test_starts_with_whitespace(self) -> None: 19 | result = self.check_domain("starts-with-whitespace.dmarc." + TEST_DOMAIN) 20 | assert not re.search(CORRECT_CONFIG_REGEX, result) 21 | assert re.search(INCORRECT_CONFIG_REGEX, result) 22 | assert ( 23 | "Found a DMARC record that starts with whitespace. Please remove the whitespace, as some " 24 | "implementations may not process it correctly." 25 | ) in result 26 | 27 | def test_none_policy(self) -> None: 28 | result = self.check_domain("none-policy.dmarc." + TEST_DOMAIN) 29 | assert re.search(CONFIG_WITH_WARNINGS_REGEX, result) 30 | assert not re.search(CORRECT_CONFIG_REGEX, result) 31 | assert not re.search(INCORRECT_CONFIG_REGEX, result) 32 | assert "A p tag value of none has no effect on email sent as" in result 33 | 34 | def test_unrelated_records(self) -> None: 35 | result = self.check_domain("contains-unrelated-records.dmarc." + TEST_DOMAIN) 36 | assert re.search(CONFIG_WITH_WARNINGS_REGEX, result) 37 | assert not re.search(CORRECT_CONFIG_REGEX, result) 38 | assert not re.search(INCORRECT_CONFIG_REGEX, result) 39 | assert ( 40 | "Unrelated TXT record found in the '_dmarc' subdomain. We recommend removing it, as such unrelated " 41 | "records may cause problems with some DMARC implementations." 42 | ) in result 43 | 44 | def test_public_suffix(self) -> None: 45 | result = self.check_domain("gov.pl") 46 | assert ( 47 | "Requested to scan a domain that is a public suffix, i.e. a domain such as .com where anybody could " 48 | "register their subdomain. Such domain don't have to have properly configured e-mail sender verification " 49 | "mechanisms. Please make sure you really wanted to check such domain and not its subdomain." 50 | ) in result 51 | 52 | def test_syntax_error(self) -> None: 53 | result = self.check_domain("syntax-error.dmarc." + TEST_DOMAIN) 54 | assert re.search(INCORRECT_CONFIG_REGEX, result) 55 | assert not re.search(CORRECT_CONFIG_REGEX, result) 56 | assert ( 57 | "Error: Expected end_of_statement or tag_value at position 10 (marked with ➞) in: v=DMARC1; ➞=none" 58 | ) in result or ( 59 | "Error: Expected tag_value or end_of_statement at position 10 (marked with ➞) in: v=DMARC1; ➞=none" 60 | ) in result 61 | 62 | def test_syntax_error_policy_location(self) -> None: 63 | result = self.check_domain("syntax-error-policy-location.dmarc." + TEST_DOMAIN) 64 | assert re.search(INCORRECT_CONFIG_REGEX, result) 65 | assert not re.search(CORRECT_CONFIG_REGEX, result) 66 | assert ("the p tag must immediately follow the v tag") in result 67 | 68 | def test_rua_no_mailto(self) -> None: 69 | result = self.check_domain("rua-no-mailto.dmarc." + TEST_DOMAIN) 70 | assert re.search(INCORRECT_CONFIG_REGEX, result) 71 | assert not re.search(CORRECT_CONFIG_REGEX, result) 72 | assert ( 73 | "dmarc@mailgoose.cert.pl is not a valid DMARC report URI - please make sure that the URI begins " 74 | "with a schema such as mailto:" 75 | ) in result 76 | 77 | def test_rua_double_mailto(self) -> None: 78 | result = self.check_domain("rua-double-mailto.dmarc." + TEST_DOMAIN) 79 | assert re.search(INCORRECT_CONFIG_REGEX, result) 80 | assert not re.search(CORRECT_CONFIG_REGEX, result) 81 | assert "mailto:mailto:dmarc@mailgoose.cert.pl is not a valid DMARC report URI" in result 82 | assert "please make sure that the URI begins with a schema:" not in result 83 | 84 | def test_no_redundant_fo_message(self) -> None: 85 | result = self.check_domain("redundant-fo.dmarc." + TEST_DOMAIN) 86 | assert not re.search(INCORRECT_CONFIG_REGEX, result) 87 | assert re.search(CORRECT_CONFIG_REGEX, result) 88 | assert " fo " not in result 89 | 90 | def test_no_rua_policy_none(self) -> None: 91 | result = self.check_domain("no-rua-none.dmarc." + TEST_DOMAIN) 92 | assert re.search(INCORRECT_CONFIG_REGEX, result) 93 | assert not re.search(CORRECT_CONFIG_REGEX, result) 94 | assert "A p tag value of none has no effect on email sent as" in result 95 | 96 | def test_no_rua_policy_reject(self) -> None: 97 | result = self.check_domain("no-rua-reject.dmarc." + TEST_DOMAIN) 98 | assert not re.search(INCORRECT_CONFIG_REGEX, result) 99 | assert not re.search(WARNING_REGEX, result) 100 | assert "rua tag" not in result 101 | assert re.search(CORRECT_CONFIG_REGEX, result) 102 | -------------------------------------------------------------------------------- /mail_receiver/server.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | import datetime 3 | import logging 4 | import os 5 | import ssl 6 | import time 7 | from email.message import Message as EmailMessage 8 | from typing import Any, Dict, Optional, Sequence, Union 9 | 10 | from aiosmtpd.controller import Controller 11 | from aiosmtpd.handlers import Message as BaseMessageHandler 12 | from aiosmtpd.smtp import SMTP, Envelope, Session 13 | from redis import Redis 14 | 15 | from common.config import Config 16 | from common.mail_receiver_utils import get_key_from_username 17 | 18 | logging.basicConfig( 19 | format="%(asctime)s %(levelname)-8s %(message)s", 20 | level=logging.INFO, 21 | datefmt="%Y-%m-%d %H:%M:%S", 22 | ) 23 | 24 | LOGGER = logging.getLogger(__name__) 25 | REDIS = Redis.from_url(Config.Data.REDIS_URL) 26 | 27 | if Config.Network.SSL_PRIVATE_KEY_PATH and Config.Network.SSL_CERTIFICATE_PATH: 28 | assert os.path.exists(Config.Network.SSL_PRIVATE_KEY_PATH) 29 | assert os.path.exists(Config.Network.SSL_CERTIFICATE_PATH) 30 | LOGGER.info("SSL key and certificate exist, creating context") 31 | SSL_CONTEXT: Optional[ssl.SSLContext] = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) 32 | assert SSL_CONTEXT 33 | SSL_CONTEXT.load_cert_chain(Config.Network.SSL_CERTIFICATE_PATH, Config.Network.SSL_PRIVATE_KEY_PATH) 34 | else: 35 | LOGGER.info("SSL key and certificate don't exist, not creating context") 36 | SSL_CONTEXT = None 37 | 38 | 39 | class EmailProcessingException(Exception): 40 | pass 41 | 42 | 43 | class RedisHandler(BaseMessageHandler): 44 | def handle_DATA(self, server: SMTP, session: Session, envelope: Envelope) -> Any: 45 | mail_from = envelope.mail_from 46 | assert mail_from 47 | 48 | # We use the raw content, not the parsed message from handle_message, so that changes (e.g. headers reordering) 49 | # don't break DKIM body hash. 50 | content = envelope.original_content or b"" 51 | 52 | LOGGER.info("SSL: %s", session.ssl) 53 | LOGGER.info( 54 | "Raw message body bytes (hexlified): %s", 55 | binascii.hexlify(content), 56 | ) 57 | 58 | for rcpt_to in envelope.rcpt_tos: 59 | try: 60 | # We ignore the domain on purpose. Some e-mail providers (e.g. Wirtualna Polska, wp.pl), when 61 | # tasked with sending an e-mail to a domain, if this domain contains a CNAME record, will 62 | # actually send the e-mail to the CNAME destination. 63 | rcpt_to_username, _ = rcpt_to.split("@", 1) 64 | 65 | key = get_key_from_username(rcpt_to_username) 66 | 67 | LOGGER.info( 68 | "Saving message: %s -> %s (%s bytes) to Redis under key %s", 69 | mail_from, 70 | rcpt_to, 71 | len(content), 72 | key, 73 | ) 74 | REDIS.setex(key, Config.Data.REDIS_MESSAGE_DATA_EXPIRY_SECONDS, content) 75 | REDIS.setex( 76 | key + b"-sender_ip", 77 | Config.Data.REDIS_MESSAGE_DATA_EXPIRY_SECONDS, 78 | session.peer[0], 79 | ) 80 | REDIS.setex( 81 | key + b"-timestamp", 82 | Config.Data.REDIS_MESSAGE_DATA_EXPIRY_SECONDS, 83 | datetime.datetime.now().isoformat(), 84 | ) 85 | REDIS.setex( 86 | key + b"-sender", 87 | Config.Data.REDIS_MESSAGE_DATA_EXPIRY_SECONDS, 88 | mail_from, 89 | ) 90 | LOGGER.info("Saved") 91 | except Exception: 92 | LOGGER.exception( 93 | "Exception while processing message: %s -> %s", 94 | mail_from, 95 | rcpt_to, 96 | ) 97 | 98 | # The exception details are passed to the recipient server - therefore we raise 99 | # a generic exception so that we don't leak implementation details. 100 | raise EmailProcessingException("Internal server error when processing message") 101 | 102 | result = super().handle_DATA(server, session, envelope) 103 | return result 104 | 105 | def handle_message(self, message: EmailMessage) -> None: 106 | pass 107 | 108 | 109 | class MailgooseSMTP(SMTP): 110 | # This is a hack to support some misconfigured SMTP servers that send AUTH parameter in MAIL FROM even if 111 | # we don't advertise such capability in EHLO (https://www.rfc-editor.org/rfc/rfc4954.html). 112 | def _getparams(self, params: Sequence[str]) -> Optional[Dict[str, Union[str, bool]]]: 113 | result = super()._getparams(params) 114 | if result and "AUTH" in result: 115 | del result["AUTH"] 116 | return result 117 | 118 | 119 | class ControllerOptionalTLS(Controller): 120 | def factory(self) -> SMTP: 121 | if SSL_CONTEXT: 122 | return MailgooseSMTP(self.handler, tls_context=SSL_CONTEXT) 123 | else: 124 | return MailgooseSMTP(self.handler) 125 | 126 | 127 | # We change the line length as some broken servers don't follow RFC 128 | SMTP.line_length_limit = 102400 129 | 130 | controller_25 = ControllerOptionalTLS(RedisHandler(), hostname="0.0.0.0", port=25) 131 | controller_25.start() # type: ignore 132 | controller_587 = ControllerOptionalTLS(RedisHandler(), hostname="0.0.0.0", port=587) 133 | controller_587.start() # type: ignore 134 | 135 | while True: 136 | time.sleep(3600) 137 | -------------------------------------------------------------------------------- /app/src/app_utils.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | import dataclasses 3 | import datetime 4 | import io 5 | import traceback 6 | from email import message_from_file 7 | from email.utils import parseaddr 8 | from typing import List, Optional, Tuple 9 | 10 | import dkim.util 11 | from email_validator import EmailNotValidError, validate_email 12 | from libmailgoose.language import Language 13 | from libmailgoose.scan import ScanResult, scan 14 | from libmailgoose.translate import translate_scan_result 15 | 16 | from common.config import Config 17 | 18 | from .db import ( 19 | DKIMImplementationMismatchLogEntry, 20 | NonexistentTranslationLogEntry, 21 | ScanLogEntry, 22 | ScanLogEntrySource, 23 | Session, 24 | ) 25 | from .logging import build_logger 26 | 27 | LOGGER = build_logger(__name__) 28 | 29 | 30 | def get_from_and_dkim_domain(message: bytes) -> Tuple[Optional[str], Optional[str]]: 31 | stream = io.StringIO(message.decode("utf-8", errors="ignore")) 32 | message_parsed = message_from_file(stream) 33 | 34 | if "from" in message_parsed: 35 | from_address_with_optional_name = message_parsed["from"] 36 | _, from_address = parseaddr(from_address_with_optional_name) 37 | 38 | try: 39 | validate_email(from_address, check_deliverability=False) 40 | _, from_domain = from_address.split("@", 1) 41 | except EmailNotValidError as e: 42 | LOGGER.info("E-mail %s is not valid: %s", from_address, e) 43 | from_domain = None 44 | else: 45 | from_domain = None 46 | 47 | dkim_domain = None 48 | if "dkim-signature" in message_parsed: 49 | try: 50 | sig = dkim.util.parse_tag_value(message_parsed["dkim-signature"].encode("ascii")) 51 | dkim_domain_raw = sig.get(b"d", None) 52 | if dkim_domain_raw: 53 | dkim_domain = dkim_domain_raw.decode("ascii") 54 | except dkim.util.InvalidTagValueList: 55 | pass 56 | 57 | return from_domain, dkim_domain 58 | 59 | 60 | def dkim_implementation_mismatch_callback(message: bytes, dkimpy_valid: bool, opendkim_valid: bool) -> None: 61 | session = Session() 62 | log_entry = DKIMImplementationMismatchLogEntry( 63 | message=binascii.hexlify(message), 64 | dkimpy_valid=dkimpy_valid, 65 | opendkim_valid=opendkim_valid, 66 | ) 67 | session.add(log_entry) 68 | session.commit() 69 | 70 | 71 | def scan_and_log( 72 | source: ScanLogEntrySource, 73 | envelope_domain: str, 74 | from_domain: str, 75 | dkim_domain: Optional[str], 76 | message: Optional[bytes], 77 | message_sender_ip: Optional[bytes], 78 | message_timestamp: Optional[datetime.datetime], 79 | nameservers: List[str], 80 | language: Language, 81 | client_ip: Optional[str], 82 | client_user_agent: Optional[str], 83 | ) -> ScanResult: 84 | scan_log_entry = ScanLogEntry( 85 | envelope_domain=envelope_domain, 86 | from_domain=from_domain, 87 | dkim_domain=dkim_domain, 88 | message=message, 89 | source=source.value, 90 | client_ip=client_ip, 91 | client_user_agent=client_user_agent, 92 | check_options={ 93 | "nameservers": nameservers, 94 | }, 95 | ) 96 | result = ScanResult( 97 | domain=None, 98 | dkim=None, 99 | timestamp=datetime.datetime.now(), 100 | message_timestamp=message_timestamp, 101 | ) 102 | 103 | try: 104 | result = translate_scan_result( 105 | scan( 106 | envelope_domain=envelope_domain, 107 | from_domain=from_domain, 108 | dkim_domain=dkim_domain, 109 | message=message, 110 | message_sender_ip=message_sender_ip, 111 | message_timestamp=message_timestamp, 112 | nameservers=nameservers, 113 | dkim_implementation_mismatch_callback=dkim_implementation_mismatch_callback, 114 | ), 115 | language=language, 116 | nonexistent_translation_handler=_nonexistent_translation_handler, 117 | ) 118 | return result 119 | except Exception: 120 | scan_log_entry.error = traceback.format_exc() 121 | raise 122 | finally: 123 | session = Session() 124 | scan_log_entry.result = dataclasses.asdict(result) 125 | scan_log_entry.result["timestamp"] = scan_log_entry.result["timestamp"].isoformat() 126 | if scan_log_entry.result["message_timestamp"]: 127 | scan_log_entry.result["message_timestamp"] = scan_log_entry.result["message_timestamp"].isoformat() 128 | session.add(scan_log_entry) 129 | session.commit() 130 | 131 | 132 | def recipient_username_to_address(username: str) -> str: 133 | # We do not use the request hostname as due to proxy configuration we sometimes got the 134 | # Host header wrong, thus breaking the check-by-email feature. 135 | return f"{username}@{Config.Network.APP_DOMAIN}" 136 | 137 | 138 | def _nonexistent_translation_handler(message: str) -> str: 139 | """ 140 | By default, translate_scan_result() raises an exception when a translation doesn't exist. 141 | When users verify their mail configuration from an app, we want to degrade gracefully - instead 142 | of raising an exception, we log the information about missing translation and display 143 | English message. 144 | """ 145 | nonexistent_translation_log_entry = NonexistentTranslationLogEntry(message=message) 146 | 147 | session = Session() 148 | session.add(nonexistent_translation_log_entry) 149 | session.commit() 150 | 151 | return message 152 | -------------------------------------------------------------------------------- /common/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from socket import gethostbyname 3 | from typing import Annotated, Any, List, get_type_hints 4 | 5 | import decouple 6 | from libmailgoose.language import Language 7 | 8 | DEFAULTS = {} 9 | 10 | 11 | def get_config(name: str, **kwargs) -> Any: # type: ignore 12 | if "default" in kwargs: 13 | DEFAULTS[name] = kwargs["default"] 14 | return decouple.config(name, **kwargs) 15 | 16 | 17 | class Config: 18 | class Data: 19 | DB_URL: Annotated[ 20 | str, 21 | "The URL used to connect to the database (as documented on " 22 | "https://docs.sqlalchemy.org/en/20/core/engines.html#database-urls). The CERT PL " 23 | "production instance uses PostgreSQL - this is the database the system has been most " 24 | "thoroughly tested on.\n\n" 25 | "If you start the system using the default ``docker-compose.yml`` file in the Github repository, this " 26 | "variable (and a database) will be set up for you.", 27 | ] = get_config("DB_URL", default=None) 28 | REDIS_MESSAGE_DATA_EXPIRY_SECONDS: Annotated[ 29 | int, 30 | "The messages sent to the system and stored in Redis expire in order to decrease the chance that " 31 | "Redis takes too much memory (the full scan logs are stored in a Postgres database). This variable " 32 | "controls how long they will be stored before they expire.", 33 | ] = get_config("REDIS_MESSAGE_DATA_EXPIRY_SECONDS", cast=int, default=10 * 24 * 60 * 60) 34 | REDIS_URL: Annotated[ 35 | str, 36 | "The URL used to connect to Redis, eg. `redis://redis:6379/0 `_ (the format is " 37 | "documented on `https://redis-py.readthedocs.io/en/stable/connections.html#redis.Redis.from_url " 38 | "`_).\n\n" 39 | "If you start the system using the default ``docker-compose.yml`` file in the Github repository, this " 40 | "variable (and a Redis instance) will be set up for you.", 41 | ] = get_config("REDIS_URL") 42 | 43 | class Network: 44 | APP_DOMAIN: Annotated[str, "The domain the site is running on."] = get_config("APP_DOMAIN") 45 | NAMESERVERS: Annotated[ 46 | List[str], 47 | "A comma-separated list of nameservers that will be used to resolve domains.", 48 | ] = get_config( 49 | "NAMESERVERS", 50 | default=gethostbyname(os.environ.get("DEFAULT_NAMESERVER_NAME_OVERRIDE", "bind9")), 51 | cast=decouple.Csv(str), 52 | ) 53 | FALLBACK_NAMESERVERS: Annotated[ 54 | List[str], 55 | "A comma-separated list of nameservers that will be used to resolve domains if NAMESERVERS fail. This can " 56 | "be used e.g. to use recursive nameservers as NAMESERVERS and nameservers such as e.g. 8.8.8.8 as FALLBACK_NAMESERVERS.", 57 | ] = get_config("FALLBACK_NAMESERVERS", default="8.8.8.8", cast=decouple.Csv(str)) 58 | SSL_PRIVATE_KEY_PATH: Annotated[ 59 | str, 60 | "SSL private key path. Please refer to ``SSL_CERTIFICATE_PATH`` variable documentation to " 61 | "learn potential caveats.", 62 | ] = decouple.config("SSL_PRIVATE_KEY_PATH", default=None) 63 | SSL_CERTIFICATE_PATH: Annotated[ 64 | str, 65 | "SSL certificate path. Remember:\n\n" 66 | "1. to mount it into your Docker container,\n" 67 | "2. to restart the containers if a new one is generated,\n" 68 | "3. that generated certificates may be symbolic links - their destination must also be mounted.\n\n", 69 | ] = decouple.config("SSL_CERTIFICATE_PATH", default=None) 70 | 71 | class UI: 72 | LANGUAGE: Annotated[ 73 | str, 74 | "The language the site will use (in the form of ``language_COUNTRY``, e.g. ``en_US``). " 75 | f"Supported options are: {', '.join(sorted('``' + language.value + '``' for language in Language))}.", 76 | ] = get_config("LANGUAGE", default="en_US") 77 | OLD_CHECK_RESULTS_AGE_MINUTES: Annotated[ 78 | int, 79 | "If the user is viewing old check results, they will see a message that the check result " 80 | "may not describe the current configuration. This is the threshold (in minutes) how old " 81 | "the check results need to be for that message to be displayed.", 82 | ] = get_config("OLD_CHECK_RESULTS_AGE_MINUTES", default=60, cast=int) 83 | SITE_CONTACT_EMAIL: Annotated[ 84 | str, 85 | "The contact e-mail that will be displayed in the UI (currently in the message that " 86 | "describes what to do if e-mails to the system aren't received).", 87 | ] = get_config("SITE_CONTACT_EMAIL", default=None) 88 | 89 | @staticmethod 90 | def verify_each_variable_is_annotated() -> None: 91 | def verify_class(cls: type) -> None: 92 | hints = get_type_hints(cls) 93 | 94 | for variable_name in dir(cls): 95 | if variable_name.startswith("__"): 96 | continue 97 | member = getattr(cls, variable_name) 98 | 99 | if isinstance(member, type): 100 | verify_class(member) 101 | elif member == Config.verify_each_variable_is_annotated: 102 | pass 103 | else: 104 | assert variable_name in hints, f"{variable_name} in {cls} has no type hint" 105 | 106 | verify_class(Config) 107 | 108 | 109 | Config.verify_each_variable_is_annotated() 110 | -------------------------------------------------------------------------------- /common/wait-for-it.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # From https://github.com/vishnubob/wait-for-it/blob/master/wait-for-it.sh (MIT-licensed) 3 | # 4 | # The MIT License (MIT) 5 | # Copyright (c) 2016 Giles Hall 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy of 8 | # this software and associated documentation files (the "Software"), to deal in 9 | # the Software without restriction, including without limitation the rights to 10 | # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 11 | # of the Software, and to permit persons to whom the Software is furnished to do 12 | # so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in all 15 | # copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | # SOFTWARE. 24 | # 25 | # Use this script to test if a given TCP host/port are available 26 | 27 | WAITFORIT_cmdname=${0##*/} 28 | 29 | echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } 30 | 31 | usage() 32 | { 33 | cat << USAGE >&2 34 | Usage: 35 | $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] 36 | -h HOST | --host=HOST Host or IP under test 37 | -p PORT | --port=PORT TCP port under test 38 | Alternatively, you specify the host and port as host:port 39 | -s | --strict Only execute subcommand if the test succeeds 40 | -q | --quiet Don't output any status messages 41 | -t TIMEOUT | --timeout=TIMEOUT 42 | Timeout in seconds, zero for no timeout 43 | -- COMMAND ARGS Execute command with args after the test finishes 44 | USAGE 45 | exit 1 46 | } 47 | 48 | wait_for() 49 | { 50 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 51 | echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 52 | else 53 | echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" 54 | fi 55 | WAITFORIT_start_ts=$(date +%s) 56 | while : 57 | do 58 | if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then 59 | nc -z $WAITFORIT_HOST $WAITFORIT_PORT 60 | WAITFORIT_result=$? 61 | else 62 | (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 63 | WAITFORIT_result=$? 64 | fi 65 | if [[ $WAITFORIT_result -eq 0 ]]; then 66 | WAITFORIT_end_ts=$(date +%s) 67 | echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" 68 | break 69 | fi 70 | sleep 1 71 | done 72 | return $WAITFORIT_result 73 | } 74 | 75 | wait_for_wrapper() 76 | { 77 | # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 78 | if [[ $WAITFORIT_QUIET -eq 1 ]]; then 79 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 80 | else 81 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 82 | fi 83 | WAITFORIT_PID=$! 84 | trap "kill -INT -$WAITFORIT_PID" INT 85 | wait $WAITFORIT_PID 86 | WAITFORIT_RESULT=$? 87 | if [[ $WAITFORIT_RESULT -ne 0 ]]; then 88 | echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 89 | fi 90 | return $WAITFORIT_RESULT 91 | } 92 | 93 | # process arguments 94 | while [[ $# -gt 0 ]] 95 | do 96 | case "$1" in 97 | *:* ) 98 | WAITFORIT_hostport=(${1//:/ }) 99 | WAITFORIT_HOST=${WAITFORIT_hostport[0]} 100 | WAITFORIT_PORT=${WAITFORIT_hostport[1]} 101 | shift 1 102 | ;; 103 | --child) 104 | WAITFORIT_CHILD=1 105 | shift 1 106 | ;; 107 | -q | --quiet) 108 | WAITFORIT_QUIET=1 109 | shift 1 110 | ;; 111 | -s | --strict) 112 | WAITFORIT_STRICT=1 113 | shift 1 114 | ;; 115 | -h) 116 | WAITFORIT_HOST="$2" 117 | if [[ $WAITFORIT_HOST == "" ]]; then break; fi 118 | shift 2 119 | ;; 120 | --host=*) 121 | WAITFORIT_HOST="${1#*=}" 122 | shift 1 123 | ;; 124 | -p) 125 | WAITFORIT_PORT="$2" 126 | if [[ $WAITFORIT_PORT == "" ]]; then break; fi 127 | shift 2 128 | ;; 129 | --port=*) 130 | WAITFORIT_PORT="${1#*=}" 131 | shift 1 132 | ;; 133 | -t) 134 | WAITFORIT_TIMEOUT="$2" 135 | if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi 136 | shift 2 137 | ;; 138 | --timeout=*) 139 | WAITFORIT_TIMEOUT="${1#*=}" 140 | shift 1 141 | ;; 142 | --) 143 | shift 144 | WAITFORIT_CLI=("$@") 145 | break 146 | ;; 147 | --help) 148 | usage 149 | ;; 150 | *) 151 | echoerr "Unknown argument: $1" 152 | usage 153 | ;; 154 | esac 155 | done 156 | 157 | if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then 158 | echoerr "Error: you need to provide a host and port to test." 159 | usage 160 | fi 161 | 162 | WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} 163 | WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} 164 | WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} 165 | WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} 166 | 167 | # Check to see if timeout is from busybox? 168 | WAITFORIT_TIMEOUT_PATH=$(type -p timeout) 169 | WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) 170 | 171 | WAITFORIT_BUSYTIMEFLAG="" 172 | if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then 173 | WAITFORIT_ISBUSY=1 174 | # Check if busybox timeout uses -t flag 175 | # (recent Alpine versions don't support -t anymore) 176 | if timeout &>/dev/stdout | grep -q -e '-t '; then 177 | WAITFORIT_BUSYTIMEFLAG="-t" 178 | fi 179 | else 180 | WAITFORIT_ISBUSY=0 181 | fi 182 | 183 | if [[ $WAITFORIT_CHILD -gt 0 ]]; then 184 | wait_for 185 | WAITFORIT_RESULT=$? 186 | exit $WAITFORIT_RESULT 187 | else 188 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 189 | wait_for_wrapper 190 | WAITFORIT_RESULT=$? 191 | else 192 | wait_for 193 | WAITFORIT_RESULT=$? 194 | fi 195 | fi 196 | 197 | if [[ $WAITFORIT_CLI != "" ]]; then 198 | if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then 199 | echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" 200 | exit $WAITFORIT_RESULT 201 | fi 202 | exec "${WAITFORIT_CLI[@]}" 203 | else 204 | exit $WAITFORIT_RESULT 205 | fi 206 | -------------------------------------------------------------------------------- /app/translations/messages.pot: -------------------------------------------------------------------------------- 1 | #: app/templates/404.html:7 2 | msgid "Error 404" 3 | msgstr "" 4 | 5 | #: app/templates/404.html:9 6 | msgid "Resource not found. Make sure the address is correct or " 7 | msgstr "" 8 | 9 | #: app/templates/404.html:10 10 | msgid "go back to homepage." 11 | msgstr "" 12 | 13 | #: app/templates/base.html:6 14 | msgid "Copied" 15 | msgstr "" 16 | 17 | #: app/templates/check_domain.html:9 app/templates/root.html:29 18 | #: app/templates/root.html:38 19 | msgid "Check domain" 20 | msgstr "" 21 | 22 | #: app/templates/check_domain.html:12 app/templates/check_results.html:74 23 | #: app/templates/check_results.html:76 app/templates/check_results.html:78 24 | #: app/templates/check_results.html:199 app/templates/check_results.html:239 25 | #: app/templates/check_results.html:280 26 | msgid "Domain" 27 | msgstr "" 28 | 29 | #: app/templates/check_domain.html:15 30 | msgid "Don't put the entire e-mail address here, only the part after \"@\"" 31 | msgstr "" 32 | 33 | #: app/templates/check_domain.html:18 34 | msgid "Check" 35 | msgstr "" 36 | 37 | #: app/templates/check_email.html:44 app/templates/root.html:6 38 | msgid "Check configuration by sending an e-mail" 39 | msgstr "" 40 | 41 | #: app/templates/check_email.html:47 42 | msgid "" 43 | "To verify e-mail configuration, send any e-mail message to the address " 44 | "shown below:" 45 | msgstr "" 46 | 47 | #: app/templates/check_email.html:56 app/templates/check_running.html:12 48 | msgid "Waiting" 49 | msgstr "" 50 | 51 | #: app/templates/check_email.html:57 52 | msgid "Waiting for the message to be received" 53 | msgstr "" 54 | 55 | #: app/templates/check_email.html:60 56 | msgid "" 57 | "As soon as the message is received, the page will automatically refresh -" 58 | " you will then see the check results." 59 | msgstr "" 60 | 61 | #: app/templates/check_email.html:63 62 | msgid "" 63 | "If after a while you still don't see the results, that means that we " 64 | "didn't receive your message. In that case:" 65 | msgstr "" 66 | 67 | #: app/templates/check_email.html:66 68 | msgid "make sure you sent the message to the correct e-mail address" 69 | msgstr "" 70 | 71 | #: app/templates/check_email.html:67 72 | msgid "" 73 | "if you manage your own e-mail server, make sure that the server sent the " 74 | "message correctly" 75 | msgstr "" 76 | 77 | #: app/templates/check_email.html:70 78 | msgctxt "verb imperative" 79 | msgid "contact" 80 | msgstr "" 81 | 82 | #: app/templates/check_email.html:70 83 | msgid "if the above didn't solve the problem." 84 | msgstr "" 85 | 86 | #: app/templates/check_results.html:49 87 | msgid "incorrect configuration" 88 | msgstr "" 89 | 90 | #: app/templates/check_results.html:51 91 | msgid "record couldn't be fully verified" 92 | msgstr "" 93 | 94 | #: app/templates/check_results.html:53 95 | msgid "configuration warnings" 96 | msgstr "" 97 | 98 | #: app/templates/check_results.html:55 99 | msgid "correct configuration" 100 | msgstr "" 101 | 102 | #: app/templates/check_results.html:81 103 | msgid "e-mail sender verification mechanisms check results:" 104 | msgstr "" 105 | 106 | #: app/templates/check_results.html:83 107 | msgid "E-mail sender verification mechanisms check results:" 108 | msgstr "" 109 | 110 | #: app/templates/check_results.html:86 111 | msgid "SPF and DMARC" 112 | msgstr "" 113 | 114 | #: app/templates/check_results.html:87 115 | msgid "DKIM" 116 | msgstr "" 117 | 118 | #: app/templates/check_results.html:88 119 | msgid "SPF, DMARC and DKIM" 120 | msgstr "" 121 | 122 | #: app/templates/check_results.html:95 123 | #, python-format 124 | msgid "" 125 | "The results you are viewing are older than %(age_threshold_minutes)s " 126 | "minutes." 127 | msgstr "" 128 | 129 | #: app/templates/check_results.html:100 130 | #, python-format 131 | msgid "" 132 | "If you want to view up-to-date results, please run a new check." 134 | msgstr "" 135 | 136 | #: app/templates/check_results.html:109 137 | msgid "Test date: " 138 | msgstr "" 139 | 140 | #: app/templates/check_results.html:110 141 | #, python-format 142 | msgid "(e-mail message from %(date_str)s)" 143 | msgstr "" 144 | 145 | #: app/templates/check_results.html:113 146 | msgid "To share check results, copy the following link:" 147 | msgstr "" 148 | 149 | #: app/templates/check_results.html:124 150 | msgid "Domain does not exist." 151 | msgstr "" 152 | 153 | #: app/templates/check_results.html:137 154 | msgid "Check summary" 155 | msgstr "" 156 | 157 | #: app/templates/check_results.html:141 158 | msgctxt "zero" 159 | msgid "mechanisms" 160 | msgstr "" 161 | 162 | #: app/templates/check_results.html:143 163 | msgid "mechanism" 164 | msgstr "" 165 | 166 | #: app/templates/check_results.html:145 167 | msgid "mechanisms" 168 | msgstr "" 169 | 170 | #: app/templates/check_results.html:147 171 | msgid "out of" 172 | msgstr "" 173 | 174 | #: app/templates/check_results.html:150 175 | msgctxt "zero" 176 | msgid "configured" 177 | msgstr "" 178 | 179 | #: app/templates/check_results.html:152 180 | msgctxt "singular" 181 | msgid "configured" 182 | msgstr "" 183 | 184 | #: app/templates/check_results.html:154 185 | msgctxt "plural" 186 | msgid "configured" 187 | msgstr "" 188 | 189 | #: app/templates/check_results.html:156 190 | msgid "without issues." 191 | msgstr "" 192 | 193 | #: app/templates/check_results.html:173 194 | msgid "SPF: the record is optional" 195 | msgstr "" 196 | 197 | #: app/templates/check_results.html:177 198 | msgid "" 199 | "Because the DMARC record is configured correctly, the SPF record is not " 200 | "required. Sending e-mail messages from this domain without using the SPF " 201 | "mechanism is still possible - in that case, the messages need to have " 202 | "correct DKIM signatures." 203 | msgstr "" 204 | 205 | #: app/templates/check_results.html:184 206 | msgid "" 207 | "However, we recommend configuring an SPF record if possible (even if the " 208 | "domain is not used to send e-mails), because older mail servers may not " 209 | "support DMARC and use SPF for verification. The combination of all " 210 | "protection mechanisms - SPF, DKIM and DMARC allows all servers to " 211 | "properly verify e-mail message authenticity." 212 | msgstr "" 213 | 214 | #: app/templates/check_results.html:205 app/templates/check_results.html:210 215 | #: app/templates/check_results.html:245 app/templates/check_results.html:250 216 | msgid "Record" 217 | msgstr "" 218 | 219 | #: app/templates/check_results.html:210 app/templates/check_results.html:250 220 | msgid "Records" 221 | msgstr "" 222 | 223 | #: app/templates/check_results.html:219 app/templates/check_results.html:259 224 | #: app/templates/check_results.html:285 225 | msgid "Warnings" 226 | msgstr "" 227 | 228 | #: app/templates/check_results.html:220 app/templates/check_results.html:224 229 | #: app/templates/check_results.html:260 app/templates/check_results.html:264 230 | #: app/templates/check_results.html:286 app/templates/check_results.html:290 231 | msgid "none" 232 | msgstr "" 233 | 234 | #: app/templates/check_results.html:223 app/templates/check_results.html:263 235 | #: app/templates/check_results.html:289 236 | msgid "Errors" 237 | msgstr "" 238 | 239 | #: app/templates/check_results.html:302 240 | msgid "" 241 | "To increase the chance that your configuration is interpreted by all " 242 | "e-mail servers correctly, we recommend fixing all errors and warnings." 243 | msgstr "" 244 | 245 | #: app/templates/check_results.html:310 246 | msgid "" 247 | "After fixing the issues, please rerun the scan - some problems can be " 248 | "detected only if earlier checks complete successfully." 249 | msgstr "" 250 | 251 | #: app/templates/check_running.html:10 252 | msgid "Configuration analysis is running" 253 | msgstr "" 254 | 255 | #: app/templates/check_running.html:14 256 | msgid "Waiting for the analysis to finish" 257 | msgstr "" 258 | 259 | #: app/templates/check_running.html:15 260 | msgid "This page will refresh automatically." 261 | msgstr "" 262 | 263 | #: app/templates/root.html:9 264 | msgid "" 265 | "Verify your DKIM, DMARC, and SPF settings by sending" 266 | " a test e-mail." 267 | msgstr "" 268 | 269 | #: app/templates/root.html:15 270 | msgid "" 271 | "This is the recommended path. Sending a test e-mail allows us to perform " 272 | "more accurate analysis." 273 | msgstr "" 274 | 275 | #: app/templates/root.html:21 276 | msgid "Send an e-mail" 277 | msgstr "" 278 | 279 | #: app/templates/root.html:32 280 | msgid "" 281 | "Verify your SPF and DMARC settings by providing a domain. Note: only the " 282 | "SPF and DMARC mechanisms will be checked. To check DKIM, " 283 | "you need to send a test e-mail." 284 | msgstr "" 285 | -------------------------------------------------------------------------------- /app/translations/en_US/LC_MESSAGES/messages.po: -------------------------------------------------------------------------------- 1 | #: app/templates/404.html:7 2 | msgid "Error 404" 3 | msgstr "" 4 | 5 | #: app/templates/404.html:9 6 | msgid "Resource not found. Make sure the address is correct or " 7 | msgstr "" 8 | 9 | #: app/templates/404.html:10 10 | msgid "go back to homepage." 11 | msgstr "" 12 | 13 | #: app/templates/base.html:6 14 | msgid "Copied" 15 | msgstr "" 16 | 17 | #: app/templates/check_domain.html:9 app/templates/root.html:29 18 | #: app/templates/root.html:38 19 | msgid "Check domain" 20 | msgstr "" 21 | 22 | #: app/templates/check_domain.html:12 app/templates/check_results.html:74 23 | #: app/templates/check_results.html:76 app/templates/check_results.html:78 24 | #: app/templates/check_results.html:199 app/templates/check_results.html:239 25 | #: app/templates/check_results.html:280 26 | msgid "Domain" 27 | msgstr "" 28 | 29 | #: app/templates/check_domain.html:15 30 | msgid "Don't put the entire e-mail address here, only the part after \"@\"" 31 | msgstr "" 32 | 33 | #: app/templates/check_domain.html:18 34 | msgid "Check" 35 | msgstr "" 36 | 37 | #: app/templates/check_email.html:44 app/templates/root.html:6 38 | msgid "Check configuration by sending an e-mail" 39 | msgstr "" 40 | 41 | #: app/templates/check_email.html:47 42 | msgid "" 43 | "To verify e-mail configuration, send any e-mail message to the address " 44 | "shown below:" 45 | msgstr "" 46 | 47 | #: app/templates/check_email.html:56 app/templates/check_running.html:12 48 | msgid "Waiting" 49 | msgstr "" 50 | 51 | #: app/templates/check_email.html:57 52 | msgid "Waiting for the message to be received" 53 | msgstr "" 54 | 55 | #: app/templates/check_email.html:60 56 | msgid "" 57 | "As soon as the message is received, the page will automatically refresh -" 58 | " you will then see the check results." 59 | msgstr "" 60 | 61 | #: app/templates/check_email.html:63 62 | msgid "" 63 | "If after a while you still don't see the results, that means that we " 64 | "didn't receive your message. In that case:" 65 | msgstr "" 66 | 67 | #: app/templates/check_email.html:66 68 | msgid "make sure you sent the message to the correct e-mail address" 69 | msgstr "" 70 | 71 | #: app/templates/check_email.html:67 72 | msgid "" 73 | "if you manage your own e-mail server, make sure that the server sent the " 74 | "message correctly" 75 | msgstr "" 76 | 77 | #: app/templates/check_email.html:70 78 | msgctxt "verb imperative" 79 | msgid "contact" 80 | msgstr "" 81 | 82 | #: app/templates/check_email.html:70 83 | msgid "if the above didn't solve the problem." 84 | msgstr "" 85 | 86 | #: app/templates/check_results.html:49 87 | msgid "incorrect configuration" 88 | msgstr "" 89 | 90 | #: app/templates/check_results.html:51 91 | msgid "record couldn't be fully verified" 92 | msgstr "" 93 | 94 | #: app/templates/check_results.html:53 95 | msgid "configuration warnings" 96 | msgstr "" 97 | 98 | #: app/templates/check_results.html:55 99 | msgid "correct configuration" 100 | msgstr "" 101 | 102 | #: app/templates/check_results.html:81 103 | msgid "e-mail sender verification mechanisms check results:" 104 | msgstr "" 105 | 106 | #: app/templates/check_results.html:83 107 | msgid "E-mail sender verification mechanisms check results:" 108 | msgstr "" 109 | 110 | #: app/templates/check_results.html:86 111 | msgid "SPF and DMARC" 112 | msgstr "" 113 | 114 | #: app/templates/check_results.html:87 115 | msgid "DKIM" 116 | msgstr "" 117 | 118 | #: app/templates/check_results.html:88 119 | msgid "SPF, DMARC and DKIM" 120 | msgstr "" 121 | 122 | #: app/templates/check_results.html:95 123 | #, python-format 124 | msgid "" 125 | "The results you are viewing are older than %(age_threshold_minutes)s " 126 | "minutes." 127 | msgstr "" 128 | 129 | #: app/templates/check_results.html:100 130 | #, python-format 131 | msgid "" 132 | "If you want to view up-to-date results, please run a new check." 134 | msgstr "" 135 | 136 | #: app/templates/check_results.html:109 137 | msgid "Test date: " 138 | msgstr "" 139 | 140 | #: app/templates/check_results.html:110 141 | #, python-format 142 | msgid "(e-mail message from %(date_str)s)" 143 | msgstr "" 144 | 145 | #: app/templates/check_results.html:113 146 | msgid "To share check results, copy the following link:" 147 | msgstr "" 148 | 149 | #: app/templates/check_results.html:124 150 | msgid "Domain does not exist." 151 | msgstr "" 152 | 153 | #: app/templates/check_results.html:137 154 | msgid "Check summary" 155 | msgstr "" 156 | 157 | #: app/templates/check_results.html:141 158 | msgctxt "zero" 159 | msgid "mechanisms" 160 | msgstr "" 161 | 162 | #: app/templates/check_results.html:143 163 | msgid "mechanism" 164 | msgstr "" 165 | 166 | #: app/templates/check_results.html:145 167 | msgid "mechanisms" 168 | msgstr "" 169 | 170 | #: app/templates/check_results.html:147 171 | msgid "out of" 172 | msgstr "" 173 | 174 | #: app/templates/check_results.html:150 175 | msgctxt "zero" 176 | msgid "configured" 177 | msgstr "" 178 | 179 | #: app/templates/check_results.html:152 180 | msgctxt "singular" 181 | msgid "configured" 182 | msgstr "" 183 | 184 | #: app/templates/check_results.html:154 185 | msgctxt "plural" 186 | msgid "configured" 187 | msgstr "" 188 | 189 | #: app/templates/check_results.html:156 190 | msgid "without issues." 191 | msgstr "" 192 | 193 | #: app/templates/check_results.html:173 194 | msgid "SPF: the record is optional" 195 | msgstr "" 196 | 197 | #: app/templates/check_results.html:177 198 | msgid "" 199 | "Because the DMARC record is configured correctly, the SPF record is not " 200 | "required. Sending e-mail messages from this domain without using the SPF " 201 | "mechanism is still possible - in that case, the messages need to have " 202 | "correct DKIM signatures." 203 | msgstr "" 204 | 205 | #: app/templates/check_results.html:184 206 | msgid "" 207 | "However, we recommend configuring an SPF record if possible (even if the " 208 | "domain is not used to send e-mails), because older mail servers may not " 209 | "support DMARC and use SPF for verification. The combination of all " 210 | "protection mechanisms - SPF, DKIM and DMARC allows all servers to " 211 | "properly verify e-mail message authenticity." 212 | msgstr "" 213 | 214 | #: app/templates/check_results.html:205 app/templates/check_results.html:210 215 | #: app/templates/check_results.html:245 app/templates/check_results.html:250 216 | msgid "Record" 217 | msgstr "" 218 | 219 | #: app/templates/check_results.html:210 app/templates/check_results.html:250 220 | msgid "Records" 221 | msgstr "" 222 | 223 | #: app/templates/check_results.html:219 app/templates/check_results.html:259 224 | #: app/templates/check_results.html:285 225 | msgid "Warnings" 226 | msgstr "" 227 | 228 | #: app/templates/check_results.html:220 app/templates/check_results.html:224 229 | #: app/templates/check_results.html:260 app/templates/check_results.html:264 230 | #: app/templates/check_results.html:286 app/templates/check_results.html:290 231 | msgid "none" 232 | msgstr "" 233 | 234 | #: app/templates/check_results.html:223 app/templates/check_results.html:263 235 | #: app/templates/check_results.html:289 236 | msgid "Errors" 237 | msgstr "" 238 | 239 | #: app/templates/check_results.html:302 240 | msgid "" 241 | "To increase the chance that your configuration is interpreted by all " 242 | "e-mail servers correctly, we recommend fixing all errors and warnings." 243 | msgstr "" 244 | 245 | #: app/templates/check_results.html:310 246 | msgid "" 247 | "After fixing the issues, please rerun the scan - some problems can be " 248 | "detected only if earlier checks complete successfully." 249 | msgstr "" 250 | 251 | #: app/templates/check_running.html:10 252 | msgid "Configuration analysis is running" 253 | msgstr "" 254 | 255 | #: app/templates/check_running.html:14 256 | msgid "Waiting for the analysis to finish" 257 | msgstr "" 258 | 259 | #: app/templates/check_running.html:15 260 | msgid "This page will refresh automatically." 261 | msgstr "" 262 | 263 | #: app/templates/root.html:9 264 | msgid "" 265 | "Verify your DKIM, DMARC, and SPF settings by sending" 266 | " a test e-mail." 267 | msgstr "" 268 | 269 | #: app/templates/root.html:15 270 | msgid "" 271 | "This is the recommended path. Sending a test e-mail allows us to perform " 272 | "more accurate analysis." 273 | msgstr "" 274 | 275 | #: app/templates/root.html:21 276 | msgid "Send an e-mail" 277 | msgstr "" 278 | 279 | #: app/templates/root.html:32 280 | msgid "" 281 | "Verify your SPF and DMARC settings by providing a domain. Note: only the " 282 | "SPF and DMARC mechanisms will be checked. To check DKIM, " 283 | "you need to send a test e-mail." 284 | msgstr "" 285 | -------------------------------------------------------------------------------- /app/src/app.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | import dataclasses 3 | import os 4 | import time 5 | import traceback 6 | from email.utils import parseaddr 7 | from typing import Any, Callable, Optional 8 | 9 | from fastapi import FastAPI, Form, HTTPException, Request 10 | from fastapi.responses import HTMLResponse, RedirectResponse 11 | from fastapi.staticfiles import StaticFiles 12 | from libmailgoose.language import Language 13 | from libmailgoose.scan import DomainValidationException, ScanningException, ScanResult 14 | from redis import Redis 15 | from rq import Queue 16 | from starlette.responses import Response 17 | 18 | from common.config import Config 19 | from common.mail_receiver_utils import get_key_from_username 20 | 21 | from .app_utils import recipient_username_to_address, scan_and_log 22 | from .check_results import load_check_results 23 | from .db import ScanLogEntrySource, ServerErrorLogEntry, Session 24 | from .logging import build_logger 25 | from .resolver import setup_resolver 26 | from .templates import setup_templates 27 | from .worker import scan_domain_job, scan_message_and_domain_job 28 | 29 | app = FastAPI() 30 | LOGGER = build_logger(__name__) 31 | REDIS = Redis.from_url(Config.Data.REDIS_URL) 32 | job_queue = Queue(connection=REDIS) 33 | 34 | app.mount("/static", StaticFiles(directory="static"), name="static") 35 | 36 | setup_resolver() 37 | 38 | templates = setup_templates(Config.UI.LANGUAGE) 39 | 40 | 41 | @dataclasses.dataclass 42 | class ScanAPICallResult: 43 | system_error: Optional[bool] = None 44 | system_error_message: Optional[str] = None 45 | result: Optional[ScanResult] = None 46 | 47 | 48 | @app.exception_handler(404) 49 | async def custom_404_handler(request: Request, exception: HTTPException) -> Response: 50 | return templates.TemplateResponse("404.html", {"request": request}) 51 | 52 | 53 | @app.middleware("http") 54 | async def catch_exceptions_log_time_middleware(request: Request, call_next: Callable[[Request], Any]) -> Any: 55 | try: 56 | time_start = time.time() 57 | result = await call_next(request) 58 | LOGGER.info( 59 | "%s %s took %s seconds", 60 | request.method, 61 | request.url.path, 62 | time.time() - time_start, 63 | ) 64 | return result 65 | except Exception: 66 | LOGGER.exception("An error occured when handling request") 67 | 68 | session = Session() 69 | server_error_log_entry = ServerErrorLogEntry(url=str(request.url), error=traceback.format_exc()) 70 | session.add(server_error_log_entry) 71 | session.commit() 72 | 73 | return HTMLResponse(status_code=500, content="Internal Server Error") 74 | 75 | 76 | @app.get("/", response_class=HTMLResponse, include_in_schema=False) 77 | async def root(request: Request) -> Response: 78 | return templates.TemplateResponse("root.html", {"request": request}) 79 | 80 | 81 | @app.get("/check-email", response_class=HTMLResponse, include_in_schema=False) 82 | async def check_email_form(request: Request) -> Response: 83 | recipient_username = f"{binascii.hexlify(os.urandom(16)).decode('ascii')}" 84 | key = get_key_from_username(recipient_username) 85 | REDIS.setex(b"requested-" + key, Config.Data.REDIS_MESSAGE_DATA_EXPIRY_SECONDS, 1) 86 | 87 | return RedirectResponse("/check-email/" + recipient_username) 88 | 89 | 90 | @app.get( 91 | "/check-email/{recipient_username}", 92 | response_class=HTMLResponse, 93 | include_in_schema=False, 94 | ) 95 | async def check_email_results(request: Request, recipient_username: str) -> Response: 96 | recipient_address = recipient_username_to_address(recipient_username) 97 | key = get_key_from_username(recipient_username) 98 | 99 | if not REDIS.get(b"requested-" + key): 100 | # This is to prevent users providing their own, non-random (e.g. offensive) 101 | # keys. 102 | return RedirectResponse("/check-email") 103 | 104 | message_data = REDIS.get(key) 105 | message_sender_ip = REDIS.get(key + b"-sender_ip") 106 | message_timestamp_raw = REDIS.get(key + b"-timestamp") 107 | mail_from_raw = REDIS.get(key + b"-sender") 108 | 109 | if not message_data or not message_sender_ip or not message_timestamp_raw or not mail_from_raw: 110 | return templates.TemplateResponse( 111 | "check_email.html", 112 | { 113 | "request": request, 114 | "recipient_username": recipient_username, 115 | "recipient_address": recipient_address, 116 | "site_contact_email": Config.UI.SITE_CONTACT_EMAIL, 117 | }, 118 | ) 119 | 120 | _, mail_from = parseaddr(mail_from_raw.decode("ascii")) 121 | _, envelope_domain = tuple(mail_from.split("@", 1)) 122 | 123 | client_ip = request.client.host if request.client else None 124 | client_user_agent = request.headers.get("user-agent", None) 125 | 126 | token = binascii.hexlify(os.urandom(32)).decode("ascii") 127 | job_queue.enqueue( 128 | scan_message_and_domain_job, 129 | client_ip, 130 | client_user_agent, 131 | envelope_domain, 132 | token, 133 | key, 134 | recipient_username, 135 | job_id=token, 136 | ) 137 | 138 | return RedirectResponse(f"/check-results/{token}", status_code=302) 139 | 140 | 141 | @app.get("/check-domain", response_class=HTMLResponse, include_in_schema=False) 142 | async def check_domain_form(request: Request) -> Response: 143 | return templates.TemplateResponse("check_domain.html", {"request": request}) 144 | 145 | 146 | @app.get("/check-domain/scan", response_class=HTMLResponse, include_in_schema=False) 147 | async def check_domain_scan_get(request: Request) -> Response: 148 | return RedirectResponse("/check-domain") 149 | 150 | 151 | @app.post("/check-domain/scan", response_class=HTMLResponse, include_in_schema=False) 152 | async def check_domain_scan_post(request: Request, domain: str = Form()) -> Response: 153 | client_ip = request.client.host if request.client else None 154 | client_user_agent = request.headers.get("user-agent", None) 155 | 156 | token = binascii.hexlify(os.urandom(32)).decode("ascii") 157 | job_queue.enqueue(scan_domain_job, client_ip, client_user_agent, domain, token, job_id=token) 158 | 159 | return RedirectResponse(f"/check-results/{token}", status_code=302) 160 | 161 | 162 | @app.get("/check-results/{token}", response_class=HTMLResponse, include_in_schema=False) 163 | async def check_results(request: Request, token: str) -> Response: 164 | if job := job_queue.fetch_job(token): 165 | if job.get_status(refresh=False) not in ["finished", "canceled", "failed"]: 166 | return templates.TemplateResponse( 167 | "check_running.html", 168 | {"request": request}, 169 | ) 170 | 171 | check_results = load_check_results(token) 172 | 173 | if not check_results: 174 | raise HTTPException(status_code=404) 175 | 176 | return templates.TemplateResponse( 177 | "check_results.html", 178 | {"request": request, "url": request.url, **check_results}, 179 | ) 180 | 181 | 182 | @app.get("/api/v1/email-received", include_in_schema=False) 183 | async def email_received(request: Request, recipient_username: str) -> bool: 184 | key = get_key_from_username(recipient_username) 185 | message_data = REDIS.get(key) 186 | return message_data is not None 187 | 188 | 189 | async def check_domain_api(request: Request, domain: str) -> ScanAPICallResult: 190 | """ 191 | An API to check e-mail sender verification mechanisms of a domain. 192 | 193 | Returns a ScanAPICallResult object, containing information whether the request 194 | was successful and a ScanResult object. The DKIM field of the ScanResult 195 | object will be empty, as DKIM can't be checked when given only a domain. 196 | """ 197 | try: 198 | client_ip = request.client.host if request.client else None 199 | client_user_agent = request.headers.get("user-agent", None) 200 | 201 | result = scan_and_log( 202 | source=ScanLogEntrySource.API, 203 | envelope_domain=domain, 204 | from_domain=domain, 205 | dkim_domain=None, 206 | message=None, 207 | message_sender_ip=None, 208 | message_timestamp=None, 209 | nameservers=Config.Network.NAMESERVERS, 210 | language=Language(Config.UI.LANGUAGE), 211 | client_ip=client_ip, 212 | client_user_agent=client_user_agent, 213 | ) 214 | return ScanAPICallResult(result=result) 215 | except (DomainValidationException, ScanningException) as e: 216 | LOGGER.exception("An error occured during check of %s", domain) 217 | return ScanAPICallResult(system_error=True, system_error_message=e.message) 218 | 219 | 220 | app.get("/api/v1/check-domain", response_model_exclude_none=True)(check_domain_api) 221 | app.post("/api/v1/check-domain", response_model_exclude_none=True)(check_domain_api) 222 | -------------------------------------------------------------------------------- /app/translations/lt_LT/LC_MESSAGES/messages.po: -------------------------------------------------------------------------------- 1 | #: app/templates/404.html:7 2 | msgid "Error 404" 3 | msgstr "Klaida 404" 4 | 5 | #: app/templates/404.html:9 6 | msgid "Resource not found. Make sure the address is correct or " 7 | msgstr "Puslapis nerastas. Įsitikinkite ar adresas yra teisingas arba " 8 | 9 | #: app/templates/404.html:10 10 | msgid "go back to homepage." 11 | msgstr "grįžkite atgal į pradinį puslapį." 12 | 13 | #: app/templates/base.html:6 14 | msgid "Copied" 15 | msgstr "Nukopijuota" 16 | 17 | #: app/templates/check_domain.html:9 app/templates/root.html:29 18 | #: app/templates/root.html:38 19 | msgid "Check domain" 20 | msgstr "Tikrinti domeną" 21 | 22 | #: app/templates/check_domain.html:12 app/templates/check_results.html:74 23 | #: app/templates/check_results.html:76 app/templates/check_results.html:78 24 | #: app/templates/check_results.html:199 app/templates/check_results.html:239 25 | #: app/templates/check_results.html:280 26 | msgid "Domain" 27 | msgstr "Domenas" 28 | 29 | #: app/templates/check_domain.html:15 30 | msgid "Don't put the entire e-mail address here, only the part after \"@\"" 31 | msgstr "Neveskite viso el pašto adreso, tik tą dalį po \"@\"" 32 | 33 | #: app/templates/check_domain.html:18 34 | msgid "Check" 35 | msgstr "Tikrinti" 36 | 37 | #: app/templates/check_email.html:44 app/templates/root.html:6 38 | msgid "Check configuration by sending an e-mail" 39 | msgstr "Tikrinti konfiguraciją siunčiant el. laišką" 40 | 41 | #: app/templates/check_email.html:47 42 | msgid "" 43 | "To verify e-mail configuration, send any e-mail message to the address " 44 | "shown below:" 45 | msgstr "" 46 | "Kad patikrinti el-pašto konfiguraciją, reikia išsiųsti bet kokį laišką " 47 | "adresatui nurodytam žemiau:" 48 | 49 | #: app/templates/check_email.html:56 app/templates/check_running.html:12 50 | msgid "Waiting" 51 | msgstr "Luktelkite" 52 | 53 | #: app/templates/check_email.html:57 54 | msgid "Waiting for the message to be received" 55 | msgstr "Laukiama kada el-laiškas bus gautas" 56 | 57 | #: app/templates/check_email.html:60 58 | msgid "" 59 | "As soon as the message is received, the page will automatically refresh -" 60 | " you will then see the check results." 61 | msgstr "" 62 | "Iš karto kai tik el-laiškas bus gautas, puslapis persikraus automatiškai " 63 | "- ir tada bus galima patikrinti rezultatus." 64 | 65 | #: app/templates/check_email.html:63 66 | msgid "" 67 | "If after a while you still don't see the results, that means that we " 68 | "didn't receive your message. In that case:" 69 | msgstr "" 70 | "Jeigu po kurio laiko vistiek nematote rezultų, tai reiškia, kad mes " 71 | "negavome jūsų laiško. Tokiu atveju:" 72 | 73 | #: app/templates/check_email.html:66 74 | msgid "make sure you sent the message to the correct e-mail address" 75 | msgstr "įsitikinkite, kad siunčiate el. laišką teisingu adresu" 76 | 77 | #: app/templates/check_email.html:67 78 | msgid "" 79 | "if you manage your own e-mail server, make sure that the server sent the " 80 | "message correctly" 81 | msgstr "" 82 | "jeigu administruojate pašto serverį, įsitikinkite, kad serveris išsiunčia" 83 | " žinutę teisingai." 84 | 85 | #: app/templates/check_email.html:70 86 | msgctxt "verb imperative" 87 | msgid "contact" 88 | msgstr "kontaktas" 89 | 90 | #: app/templates/check_email.html:70 91 | msgid "if the above didn't solve the problem." 92 | msgstr "jeigu tai neišsprendė problemos." 93 | 94 | #: app/templates/check_results.html:49 95 | msgid "incorrect configuration" 96 | msgstr "neteisinga konfiguracija" 97 | 98 | #: app/templates/check_results.html:51 99 | msgid "record couldn't be fully verified" 100 | msgstr "įrašas negali būti pilnai patvirtintas" 101 | 102 | #: app/templates/check_results.html:53 103 | msgid "configuration warnings" 104 | msgstr "konfiguracijos klaidos" 105 | 106 | #: app/templates/check_results.html:55 107 | msgid "correct configuration" 108 | msgstr "teisinga konfiguracija" 109 | 110 | #: app/templates/check_results.html:81 111 | msgid "e-mail sender verification mechanisms check results:" 112 | msgstr "pašto saugos testo rezultatai" 113 | 114 | #: app/templates/check_results.html:83 115 | msgid "E-mail sender verification mechanisms check results:" 116 | msgstr "pašto saugos testo rezultatai" 117 | 118 | #: app/templates/check_results.html:86 119 | msgid "SPF and DMARC" 120 | msgstr "SPF ir DMARC" 121 | 122 | #: app/templates/check_results.html:87 123 | msgid "DKIM" 124 | msgstr "DKIM" 125 | 126 | #: app/templates/check_results.html:88 127 | msgid "SPF, DMARC and DKIM" 128 | msgstr "SPF, DMARC ir DKIM" 129 | 130 | #: app/templates/check_results.html:95 131 | #, python-format 132 | msgid "" 133 | "The results you are viewing are older than %(age_threshold_minutes)s " 134 | "minutes." 135 | msgstr "Rezultatai kuriuos žiūrite yra senesni nei %(age_threshold_minutes)s min." 136 | 137 | #: app/templates/check_results.html:100 138 | #, python-format 139 | msgid "" 140 | "If you want to view up-to-date results, please run a new check." 142 | msgstr "" 143 | "Jeigu norite pamatyti naujausius rezultatus, prašome atlikti naują patikrinimą." 145 | 146 | #: app/templates/check_results.html:109 147 | msgid "Test date: " 148 | msgstr "Testo data: " 149 | 150 | #: app/templates/check_results.html:110 151 | #, python-format 152 | msgid "(e-mail message from %(date_str)s)" 153 | msgstr "(el-laiškas žinutė iš %(date_str)s)" 154 | 155 | #: app/templates/check_results.html:113 156 | msgid "To share check results, copy the following link:" 157 | msgstr "Jei norite pasidalinti patikrinimo rezultatais, nukopijuokite šią nuorodą:" 158 | 159 | #: app/templates/check_results.html:124 160 | msgid "Domain does not exist." 161 | msgstr "" 162 | 163 | # "link:" 164 | #: app/templates/check_results.html:137 165 | msgid "Check summary" 166 | msgstr "Patikrinimo rezultatai" 167 | 168 | #: app/templates/check_results.html:141 169 | msgctxt "zero" 170 | msgid "mechanisms" 171 | msgstr "mechanizmai" 172 | 173 | #: app/templates/check_results.html:143 174 | msgid "mechanism" 175 | msgstr "mechanizmas" 176 | 177 | #: app/templates/check_results.html:145 178 | msgid "mechanisms" 179 | msgstr "mechanizmai" 180 | 181 | #: app/templates/check_results.html:147 182 | msgid "out of" 183 | msgstr "iš" 184 | 185 | #: app/templates/check_results.html:150 186 | msgctxt "zero" 187 | msgid "configured" 188 | msgstr "sukonfigūruotas" 189 | 190 | #: app/templates/check_results.html:152 191 | msgctxt "singular" 192 | msgid "configured" 193 | msgstr "sukonfigūruotas" 194 | 195 | #: app/templates/check_results.html:154 196 | msgctxt "plural" 197 | msgid "configured" 198 | msgstr "sukonfigūruoti" 199 | 200 | #: app/templates/check_results.html:156 201 | msgid "without issues." 202 | msgstr "be klaidų." 203 | 204 | #: app/templates/check_results.html:173 205 | msgid "SPF: the record is optional" 206 | msgstr "SPF: įrašas neprivalomas" 207 | 208 | #: app/templates/check_results.html:177 209 | msgid "" 210 | "Because the DMARC record is configured correctly, the SPF record is not " 211 | "required. Sending e-mail messages from this domain without using the SPF " 212 | "mechanism is still possible - in that case, the messages need to have " 213 | "correct DKIM signatures." 214 | msgstr "" 215 | "Kadangi DMARC įrašas sukonfigūruotas teisingai, SPF įrašas nėra " 216 | "privalomas. Siunčiami pranešimai iš šio domeno nenaudojant SPF mechanizmą" 217 | " yra įmanomi - tokiu atveju žinutė turi turėti teisingą DKIM parašą." 218 | 219 | #: app/templates/check_results.html:184 220 | msgid "" 221 | "However, we recommend configuring an SPF record if possible (even if the " 222 | "domain is not used to send e-mails), because older mail servers may not " 223 | "support DMARC and use SPF for verification. The combination of all " 224 | "protection mechanisms - SPF, DKIM and DMARC allows all servers to " 225 | "properly verify e-mail message authenticity." 226 | msgstr "" 227 | "Tačiau mes rekomenduojame susikonfiguruoti SPF įrašą jeigu įmanoma (net " 228 | "jei domenas nėra naudojamas siųsti el-laiškams), nes senesni pašto " 229 | "serveriai gali nepalaikyti DMARC ir naudoti SPF tikrinimui. Visų apsaugos" 230 | " mechanizmų SPF, DKIM ir DMARC - kombinacija leidžia visiems serveriams " 231 | "tinkamai patikrinti el. pašto žinučių autentiškumą." 232 | 233 | #: app/templates/check_results.html:205 app/templates/check_results.html:210 234 | #: app/templates/check_results.html:245 app/templates/check_results.html:250 235 | msgid "Record" 236 | msgstr "Įrašas" 237 | 238 | #: app/templates/check_results.html:210 app/templates/check_results.html:250 239 | msgid "Records" 240 | msgstr "Įrašai" 241 | 242 | #: app/templates/check_results.html:219 app/templates/check_results.html:259 243 | #: app/templates/check_results.html:285 244 | msgid "Warnings" 245 | msgstr "Įspėjimai" 246 | 247 | #: app/templates/check_results.html:220 app/templates/check_results.html:224 248 | #: app/templates/check_results.html:260 app/templates/check_results.html:264 249 | #: app/templates/check_results.html:286 app/templates/check_results.html:290 250 | msgid "none" 251 | msgstr "nėra" 252 | 253 | #: app/templates/check_results.html:223 app/templates/check_results.html:263 254 | #: app/templates/check_results.html:289 255 | msgid "Errors" 256 | msgstr "Klaidos" 257 | 258 | #: app/templates/check_results.html:302 259 | msgid "" 260 | "To increase the chance that your configuration is interpreted by all " 261 | "e-mail servers correctly, we recommend fixing all errors and warnings." 262 | msgstr "" 263 | "Kad padidintumėte tikimybę, jog jūsų konfigūracija bus teisingai " 264 | "interpretuojama visų el. pašto serverių, mes rekomenduojame ištaisyti " 265 | "visas klaidas ir perspėjimus. " 266 | 267 | #: app/templates/check_results.html:310 268 | msgid "" 269 | "After fixing the issues, please rerun the scan - some problems can be " 270 | "detected only if earlier checks complete successfully." 271 | msgstr "" 272 | "Ištaisius problemas, prašome pakartotinai paleisti skenavimą - kai kurios" 273 | " problemos gali būti aptiktos tik tuomet, jei ankstesni patikrinimai bus" 274 | " sėkmingai baigti." 275 | 276 | #: app/templates/check_running.html:10 277 | msgid "Configuration analysis is running" 278 | msgstr "Konfigūracijos analizė yra vykdoma" 279 | 280 | #: app/templates/check_running.html:14 281 | msgid "Waiting for the analysis to finish" 282 | msgstr "Laukiama, kol analizė baigsis" 283 | 284 | #: app/templates/check_running.html:15 285 | msgid "This page will refresh automatically." 286 | msgstr "Šį puslapį atnaujins automatiškai." 287 | 288 | #: app/templates/root.html:9 289 | msgid "" 290 | "Verify your DKIM, DMARC, and SPF settings by sending" 291 | " a test e-mail." 292 | msgstr "" 293 | "Patikrinkite savo DKIM, DMARC ir SPF nustatymus, " 294 | "išsiųsdami el. laišką." 295 | 296 | #: app/templates/root.html:15 297 | msgid "" 298 | "This is the recommended path. Sending a test e-mail allows us to perform " 299 | "more accurate analysis." 300 | msgstr "" 301 | "Tai rekomenduojama procedūra. Laiško siuntimas leidžia mums atlikti " 302 | "tikslesnę analizę." 303 | 304 | #: app/templates/root.html:21 305 | msgid "Send an e-mail" 306 | msgstr "Išsiųsti el. laišką" 307 | 308 | #: app/templates/root.html:32 309 | msgid "" 310 | "Verify your SPF and DMARC settings by providing a domain. Note: only the " 311 | "SPF and DMARC mechanisms will be checked. To check DKIM, " 312 | "you need to send a test e-mail." 313 | msgstr "" 314 | "Patikrinkite savo SPF ir DMARC nustatymus pateikdami domeną. Pastaba: tik" 315 | " SPF ir DMARC mechanizmai bus patikrinti. Norėdami " 316 | "patikrinti DKIM, turite išsiųsti el. laišką." 317 | -------------------------------------------------------------------------------- /app/translations/pl_PL/LC_MESSAGES/messages.po: -------------------------------------------------------------------------------- 1 | #: app/templates/404.html:7 2 | msgid "Error 404" 3 | msgstr "Błąd 404" 4 | 5 | #: app/templates/404.html:9 6 | msgid "Resource not found. Make sure the address is correct or " 7 | msgstr "Nie odnaleziono zasobu. Upewnij się, czy adres jest poprawny lub " 8 | 9 | #: app/templates/404.html:10 10 | msgid "go back to homepage." 11 | msgstr "przejdź do strony głównej." 12 | 13 | #: app/templates/base.html:6 14 | msgid "Copied" 15 | msgstr "Skopiowano" 16 | 17 | #: app/templates/check_domain.html:9 app/templates/root.html:29 18 | #: app/templates/root.html:38 19 | msgid "Check domain" 20 | msgstr "Sprawdź domenę" 21 | 22 | #: app/templates/check_domain.html:12 app/templates/check_results.html:74 23 | #: app/templates/check_results.html:76 app/templates/check_results.html:78 24 | #: app/templates/check_results.html:199 app/templates/check_results.html:239 25 | #: app/templates/check_results.html:280 26 | msgid "Domain" 27 | msgstr "Domena" 28 | 29 | #: app/templates/check_domain.html:15 30 | msgid "Don't put the entire e-mail address here, only the part after \"@\"" 31 | msgstr "Nie podawaj całego adresu email, jedynie część po znaku \"@\"" 32 | 33 | #: app/templates/check_domain.html:18 34 | msgid "Check" 35 | msgstr "Sprawdź" 36 | 37 | #: app/templates/check_email.html:44 app/templates/root.html:6 38 | msgid "Check configuration by sending an e-mail" 39 | msgstr "Sprawdź wysyłając e-mail" 40 | 41 | #: app/templates/check_email.html:47 42 | msgid "" 43 | "To verify e-mail configuration, send any e-mail message to the address " 44 | "shown below:" 45 | msgstr "" 46 | "Aby zweryfikować konfigurację poczty, wyślij dowolną wiadomość e-mail na " 47 | "adres podany poniżej:" 48 | 49 | #: app/templates/check_email.html:56 app/templates/check_running.html:12 50 | msgid "Waiting" 51 | msgstr "Oczekiwanie" 52 | 53 | #: app/templates/check_email.html:57 54 | msgid "Waiting for the message to be received" 55 | msgstr "Oczekiwanie na odbiór wiadomości" 56 | 57 | #: app/templates/check_email.html:60 58 | msgid "" 59 | "As soon as the message is received, the page will automatically refresh -" 60 | " you will then see the check results." 61 | msgstr "" 62 | "Jeśli wiadomość zostanie odebrana, strona odświeży się automatycznie - " 63 | "zobaczysz wtedy wyniki sprawdzenia mechanizmów zabezpieczeń." 64 | 65 | #: app/templates/check_email.html:63 66 | msgid "" 67 | "If after a while you still don't see the results, that means that we " 68 | "didn't receive your message. In that case:" 69 | msgstr "" 70 | "Jeśli po dłuższym czasie nie widzisz wyników, to znaczy, że nie " 71 | "otrzymaliśmy wiadomości. W takiej sytuacji:" 72 | 73 | #: app/templates/check_email.html:66 74 | msgid "make sure you sent the message to the correct e-mail address" 75 | msgstr "upewnij się, że wysyłasz wiadomość na poprawny adres" 76 | 77 | #: app/templates/check_email.html:67 78 | msgid "" 79 | "if you manage your own e-mail server, make sure that the server sent the " 80 | "message correctly" 81 | msgstr "" 82 | "jeśli administrujesz serwerem pocztowym, sprawdź czy Twój serwer pocztowy" 83 | " poprawnie wysłał wiadomość" 84 | 85 | #: app/templates/check_email.html:70 86 | msgctxt "verb imperative" 87 | msgid "contact" 88 | msgstr "skontaktuj się z" 89 | 90 | #: app/templates/check_email.html:70 91 | msgid "if the above didn't solve the problem." 92 | msgstr "w przypadku, jeśli powyższe nie rozwiązało problemu." 93 | 94 | #: app/templates/check_results.html:49 95 | msgid "incorrect configuration" 96 | msgstr "konfiguracja nieprawidłowa" 97 | 98 | #: app/templates/check_results.html:51 99 | msgid "record couldn't be fully verified" 100 | msgstr "rekord nie mógł być w pełni sprawdzony" 101 | 102 | #: app/templates/check_results.html:53 103 | msgid "configuration warnings" 104 | msgstr "uwagi dotyczące konfiguracji" 105 | 106 | #: app/templates/check_results.html:55 107 | msgid "correct configuration" 108 | msgstr "konfiguracja prawidłowa" 109 | 110 | #: app/templates/check_results.html:81 111 | msgid "e-mail sender verification mechanisms check results:" 112 | msgstr "wyniki testów mechanizmów zabezpieczeń poczty e-mail:" 113 | 114 | #: app/templates/check_results.html:83 115 | msgid "E-mail sender verification mechanisms check results:" 116 | msgstr "Wyniki testów mechanizmów zabezpieczeń poczty e-mail:" 117 | 118 | #: app/templates/check_results.html:86 119 | msgid "SPF and DMARC" 120 | msgstr "SPF i DMARC" 121 | 122 | #: app/templates/check_results.html:87 123 | msgid "DKIM" 124 | msgstr "DKIM" 125 | 126 | #: app/templates/check_results.html:88 127 | msgid "SPF, DMARC and DKIM" 128 | msgstr "SPF, DMARC i DKIM" 129 | 130 | #: app/templates/check_results.html:95 131 | #, python-format 132 | msgid "" 133 | "The results you are viewing are older than %(age_threshold_minutes)s " 134 | "minutes." 135 | msgstr "Oglądają Państwo wyniki starsze niż %(age_threshold_minutes)s minut." 136 | 137 | #: app/templates/check_results.html:100 138 | #, python-format 139 | msgid "" 140 | "If you want to view up-to-date results, please run a new check." 142 | msgstr "" 143 | "Jeśli chcą Państwo uzyskać najnowsze wyniki, prosimy wykonać ponowne sprawdzenie." 145 | 146 | #: app/templates/check_results.html:109 147 | msgid "Test date: " 148 | msgstr "Data sprawdzenia: " 149 | 150 | #: app/templates/check_results.html:110 151 | #, python-format 152 | msgid "(e-mail message from %(date_str)s)" 153 | msgstr "(wiadomość e-mail z %(date_str)s)" 154 | 155 | #: app/templates/check_results.html:113 156 | msgid "To share check results, copy the following link:" 157 | msgstr "" 158 | "Jeśli chcą Państwo udostępnić wyniki sprawdzenia, prosimy skopiować ten " 159 | "link:" 160 | 161 | #: app/templates/check_results.html:124 162 | msgid "Domain does not exist." 163 | msgstr "Domena nie istnieje." 164 | 165 | #: app/templates/check_results.html:137 166 | msgid "Check summary" 167 | msgstr "Podsumowanie sprawdzenia" 168 | 169 | #: app/templates/check_results.html:141 170 | msgctxt "zero" 171 | msgid "mechanisms" 172 | msgstr "mechanizmów" 173 | 174 | #: app/templates/check_results.html:143 175 | msgid "mechanism" 176 | msgstr "mechanizm" 177 | 178 | #: app/templates/check_results.html:145 179 | msgid "mechanisms" 180 | msgstr "mechanizmy" 181 | 182 | #: app/templates/check_results.html:147 183 | msgid "out of" 184 | msgstr "z" 185 | 186 | #: app/templates/check_results.html:150 187 | msgctxt "zero" 188 | msgid "configured" 189 | msgstr "skonfigurowanych" 190 | 191 | #: app/templates/check_results.html:152 192 | msgctxt "singular" 193 | msgid "configured" 194 | msgstr "skonfigurowany" 195 | 196 | #: app/templates/check_results.html:154 197 | msgctxt "plural" 198 | msgid "configured" 199 | msgstr "skonfigurowane" 200 | 201 | #: app/templates/check_results.html:156 202 | msgid "without issues." 203 | msgstr "bez zastrzeżeń." 204 | 205 | #: app/templates/check_results.html:173 206 | msgid "SPF: the record is optional" 207 | msgstr "SPF: rekord opcjonalny" 208 | 209 | #: app/templates/check_results.html:177 210 | msgid "" 211 | "Because the DMARC record is configured correctly, the SPF record is not " 212 | "required. Sending e-mail messages from this domain without using the SPF " 213 | "mechanism is still possible - in that case, the messages need to have " 214 | "correct DKIM signatures." 215 | msgstr "" 216 | "Ponieważ rekord DMARC jest skonfigurowany poprawnie, rekord SPF nie jest " 217 | "konieczny. Wysyłanie wiadomości z tej domeny bez wykorzystywania " 218 | "mechanizmu SPF nadal jest możliwe - w takiej sytuacji wiadomości muszą " 219 | "posiadać poprawny podpis DKIM." 220 | 221 | #: app/templates/check_results.html:184 222 | msgid "" 223 | "However, we recommend configuring an SPF record if possible (even if the " 224 | "domain is not used to send e-mails), because older mail servers may not " 225 | "support DMARC and use SPF for verification. The combination of all " 226 | "protection mechanisms - SPF, DKIM and DMARC allows all servers to " 227 | "properly verify e-mail message authenticity." 228 | msgstr "" 229 | "Rekomendujemy jednak ustawienie rekordu SPF, jeśli mają Państwo taką " 230 | "możliwość (nawet, jeśli domena nie jest przeznaczona do wysyłki poczty), " 231 | "ponieważ starsze serwery pocztowe mogą nie wspierać mechanizmu DMARC i " 232 | "używają SPF. Połączenie wszystkich mechanizmów ochrony - SPF, DKIM oraz " 233 | "DMARC umożliwi innym serwerom na poprawne zweryfikowanie wiadomości we " 234 | "wszystkich przypadkach." 235 | 236 | #: app/templates/check_results.html:205 app/templates/check_results.html:210 237 | #: app/templates/check_results.html:245 app/templates/check_results.html:250 238 | msgid "Record" 239 | msgstr "Rekord" 240 | 241 | #: app/templates/check_results.html:210 app/templates/check_results.html:250 242 | msgid "Records" 243 | msgstr "Rekordy" 244 | 245 | #: app/templates/check_results.html:219 app/templates/check_results.html:259 246 | #: app/templates/check_results.html:285 247 | msgid "Warnings" 248 | msgstr "Ostrzeżenia" 249 | 250 | #: app/templates/check_results.html:220 app/templates/check_results.html:224 251 | #: app/templates/check_results.html:260 app/templates/check_results.html:264 252 | #: app/templates/check_results.html:286 app/templates/check_results.html:290 253 | msgid "none" 254 | msgstr "brak" 255 | 256 | #: app/templates/check_results.html:223 app/templates/check_results.html:263 257 | #: app/templates/check_results.html:289 258 | msgid "Errors" 259 | msgstr "Błędy" 260 | 261 | #: app/templates/check_results.html:302 262 | msgid "" 263 | "To increase the chance that your configuration is interpreted by all " 264 | "e-mail servers correctly, we recommend fixing all errors and warnings." 265 | msgstr "" 266 | "Aby zwiększyć szansę, że Państwa konfiguracja zostanie poprawnie " 267 | "zinterpretowana przez wszystkie serwery, rekomendujemy poprawę zarówno " 268 | "błędów, jak i ostrzeżeń." 269 | 270 | #: app/templates/check_results.html:310 271 | msgid "" 272 | "After fixing the issues, please rerun the scan - some problems can be " 273 | "detected only if earlier checks complete successfully." 274 | msgstr "" 275 | "Po poprawie błędów prosimy ponowić skanowanie - niektóre błędy mogą " 276 | "zostać znalezione dopiero po udanym wykonaniu wcześniejszych testów." 277 | 278 | #: app/templates/check_running.html:10 279 | msgid "Configuration analysis is running" 280 | msgstr "Trwa analiza konfiguracji" 281 | 282 | #: app/templates/check_running.html:14 283 | msgid "Waiting for the analysis to finish" 284 | msgstr "Oczekiwanie na zakończenie analizy" 285 | 286 | #: app/templates/check_running.html:15 287 | msgid "This page will refresh automatically." 288 | msgstr "Strona odświeży się automatycznie." 289 | 290 | #: app/templates/root.html:9 291 | msgid "" 292 | "Verify your DKIM, DMARC, and SPF settings by sending" 293 | " a test e-mail." 294 | msgstr "" 295 | "Gdy wyślesz testową wiadomość e-mail na specjalny adres, system " 296 | "zweryfikuje poprawność konfiguracji mechanizmów SPF, DKIM " 297 | "i DMARC." 298 | 299 | #: app/templates/root.html:15 300 | msgid "" 301 | "This is the recommended path. Sending a test e-mail allows us to perform " 302 | "more accurate analysis." 303 | msgstr "" 304 | "Ta ścieżka jest przez nas rekomendowana – dzięki niej będziemy " 305 | "w stanie wykonać dokładniejsze sprawdzenie, niż korzystając " 306 | "z domeny." 307 | 308 | #: app/templates/root.html:21 309 | msgid "Send an e-mail" 310 | msgstr "Wyślij e-mail" 311 | 312 | #: app/templates/root.html:32 313 | msgid "" 314 | "Verify your SPF and DMARC settings by providing a domain. Note: only the " 315 | "SPF and DMARC mechanisms will be checked. To check DKIM, " 316 | "you need to send a test e-mail." 317 | msgstr "" 318 | "Możesz skorzystać także z opcji weryfikacji konfiguracji podając " 319 | "domenę. W tym wypadku zostaną sprawdzone tylko mechanizmy SPF " 320 | "i DMARC - dla sprawdzenia DKIM konieczne jest wysłanie " 321 | "testowego e-maila." 322 | -------------------------------------------------------------------------------- /app/templates/check_results.html: -------------------------------------------------------------------------------- 1 | {% extends "custom_layout.html" %} 2 | 3 | {% macro render_problem(problem) -%} 4 | {% set lines = problem.split('\n') %} 5 | {% for line in lines %} 6 | {{ line|mailgoose_urlize(target="_blank") }} 7 | {% if not loop.last %} 8 |
9 | {% endif %} 10 | {% endfor %} 11 | {%- endmacro %} 12 | 13 | {% macro render_problems(problems) -%} 14 | {% if problems|length > 1 %} 15 |
    16 | {% for problem in problems|sort %} 17 |
  • {{ render_problem(problem) }}
  • 18 | {% endfor %} 19 |
20 | {% else %} 21 | {# 1 or 0 #} 22 | {% for problem in problems %}{{ render_problem(problem) }}{% endfor %} 23 | {% endif %} 24 | {%- endmacro %} 25 | 26 | {% macro card_header(title, data) -%} 27 |
37 | 38 | {% if not data.valid %} 39 | 40 | {% elif data.record_could_not_be_fully_validated %} 41 | 42 | {% elif data.warnings %} 43 | 44 | {% else %} 45 | 46 | {% endif %} 47 | {{ title }}: 48 | {% if not data.valid %} 49 | {% trans %}incorrect configuration{% endtrans %} 50 | {% elif data.record_could_not_be_fully_validated %} 51 | {% trans %}record couldn't be fully verified{% endtrans %} 52 | {% elif data.warnings %} 53 | {% trans %}configuration warnings{% endtrans %} 54 | {% else %} 55 | {% trans %}correct configuration{% endtrans %} 56 | {% endif %} 57 |
58 | {%- endmacro %} 59 | 60 | {% block body %} 61 |
62 |
63 |
64 | {% if error %} 65 |
66 |
67 | {{ error }} 68 |
69 |
70 | {% elif result.domain or result.dkim %} 71 |

72 | {% if not envelope_domain or not from_domain or from_domain == envelope_domain %} 73 | {% if not envelope_domain %} 74 | {% trans %}Domain{% endtrans %}: {{ from_domain }} 75 | {% elif not from_domain %} 76 | {% trans %}Domain{% endtrans %}: {{ envelope_domain }} 77 | {% elif from_domain == envelope_domain %} 78 | {% trans %}Domain{% endtrans %}: {{ envelope_domain }} 79 | {% endif %} 80 | - 81 | {% trans %}e-mail sender verification mechanisms check results:{% endtrans %} 82 | {% else %} 83 | {% trans %}E-mail sender verification mechanisms check results:{% endtrans %} 84 | {% endif %} 85 | 86 | {% if result.domain and not result.dkim %}{% trans %}SPF and DMARC{% endtrans %} 87 | {% elif not result.domain and result.dkim %}{% trans %}DKIM{% endtrans %} 88 | {% else %}{% trans %}SPF, DMARC and DKIM{% endtrans %}{% endif %} 89 |

90 | 91 |
92 |
93 | {% if is_old %} 94 | 95 | {{ gettext( 96 | "The results you are viewing are older than %(age_threshold_minutes)s minutes.", 97 | age_threshold_minutes=age_threshold_minutes 98 | ) 99 | }} 100 | {{ gettext( 101 | "If you want to view up-to-date results, please run a new check.", 102 | rescan_url=rescan_url 103 | ) 104 | }} 105 | 106 | {% endif %} 107 | 108 | 109 | {% trans %}Test date: {% endtrans %}{{ result.timestamp.strftime("%Y-%m-%d %H:%M:%S") }}{% if result.message_timestamp %} 110 | {{ gettext("(e-mail message from %(date_str)s)", date_str=result.message_timestamp.strftime("%Y-%m-%d %H:%M:%S")) }}{% endif %}.

111 |
112 | 113 | {% trans %}To share check results, copy the following link:{% endtrans %} 114 |
115 | 116 | {{ copy_button(url) }} 117 |
118 |
119 |
120 | 121 | {% if result.domain.domain_does_not_exist %} 122 |
123 |
124 | {% trans %}Domain does not exist.{% endtrans %} 125 |
126 |
127 | {% else %} 128 |
129 |
130 | {% if result.num_correct_mechanisms == result.num_checked_mechanisms %} 131 | 132 | {% elif result.has_not_valid_mechanisms %} 133 | 134 | {% else %} 135 | 136 | {% endif %} 137 | {% trans %}Check summary{% endtrans %}: 138 | 139 | {{ result.num_correct_mechanisms }} 140 | {% if result.num_correct_mechanisms == 0 %} 141 | {{ pgettext("zero", "mechanisms") }} 142 | {% elif result.num_correct_mechanisms == 1 %} 143 | {% trans %}mechanism{% endtrans %} 144 | {% else %} 145 | {% trans %}mechanisms{% endtrans %} 146 | {% endif %} 147 | {% trans %}out of{% endtrans %} 148 | {{ result.num_checked_mechanisms }} 149 | {% if result.num_correct_mechanisms == 0 %} 150 | {{ pgettext("zero", "configured") }} 151 | {% elif result.num_correct_mechanisms == 1 %} 152 | {{ pgettext("singular", "configured") }} 153 | {% else %} 154 | {{ pgettext("plural", "configured") }} 155 | {% endif %} 156 | {% trans %}without issues.{% endtrans %} 157 | 158 |
159 |
160 | 161 | {% if result.domain %} 162 | {% for warning in result.domain.warnings %} 163 |
164 |
165 | ⚠️ {{ warning }} 166 |
167 |
168 | {% endfor %} 169 | 170 |
171 | {% if result.domain.spf_not_required_because_of_correct_dmarc %} 172 |
173 | {% trans %}SPF: the record is optional{% endtrans %} 174 |
175 |
176 |

177 | {% trans trimmed %} 178 | Because the DMARC record is configured correctly, the SPF record is not required. Sending e-mail 179 | messages from this domain without using the SPF mechanism is still possible - in that case, the messages 180 | need to have correct DKIM signatures. 181 | {% endtrans %} 182 |

183 |

184 | {% trans trimmed %} 185 | However, we recommend configuring an SPF record if possible (even if the domain is not used 186 | to send e-mails), because older mail servers may not support DMARC and use SPF for verification. 187 | The combination of all protection mechanisms - SPF, DKIM and DMARC allows all servers to properly 188 | verify e-mail message authenticity. 189 | {% endtrans %} 190 |

191 |
192 | {% else %} 193 | {{ card_header("SPF", result.domain.spf) }} 194 |
195 | 196 | 197 | {% if envelope_domain %} 198 | 199 | 200 | 201 | 202 | {% endif %} 203 | {% if result.domain.spf.record %} 204 | 205 | 206 | 207 | 208 | {% elif result.domain.spf.record_candidates %} 209 | 210 | 211 | 216 | 217 | {% endif %} 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 |
{% trans %}Domain{% endtrans %}{{ envelope_domain }}
{% trans %}Record{% endtrans %}{{ result.domain.spf.record }}
{% if result.domain.spf.record_candidates|length > 1 %}{% trans %}Records{% endtrans %}{% else %}{% trans %}Record{% endtrans %}{% endif %} 212 | {% for record in result.domain.spf.record_candidates %} 213 | {{ record }}
214 | {% endfor %} 215 |
{% trans %}Warnings{% endtrans %}{% if result.domain.spf.warnings %}{{ render_problems(result.domain.spf.warnings) }}{% else %}{% trans %}none{% endtrans %}{% endif %}
{% trans %}Errors{% endtrans %}{% if result.domain.spf.errors %}{{ render_problems(result.domain.spf.errors) }}{% else %}{% trans %}none{% endtrans %}{% endif %}
228 |
229 | {% endif %} 230 |
231 | 232 |
233 | {{ card_header("DMARC", result.domain.dmarc) }} 234 |
235 | 236 | 237 | {% if from_domain %} 238 | 239 | 240 | 241 | 242 | {% endif %} 243 | {% if result.domain.dmarc.record %} 244 | 245 | 246 | 247 | 248 | {% elif result.domain.dmarc.record_candidates %} 249 | 250 | 251 | 256 | 257 | {% endif %} 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 |
{% trans %}Domain{% endtrans %}{{ from_domain }}
{% trans %}Record{% endtrans %}{{ result.domain.dmarc.record }}
{% if result.domain.dmarc.record_candidates|length > 1 %}{% trans %}Records{% endtrans %}{% else %}{% trans %}Record{% endtrans %}{% endif %} 252 | {% for record in result.domain.dmarc.record_candidates %} 253 | {{ record }}
254 | {% endfor %} 255 |
{% trans %}Warnings{% endtrans %}{% if result.domain.dmarc.warnings %}{{ render_problems(result.domain.dmarc.warnings) }}{% else %}{% trans %}none{% endtrans %}{% endif %}
{% trans %}Errors{% endtrans %}{% if result.domain.dmarc.errors %}{{ render_problems(result.domain.dmarc.errors) }}{% else %}{% trans %}none{% endtrans %}{% endif %}
268 |
269 |
270 | {% endif %} 271 | 272 | {% if result.dkim %} 273 |
274 | {{ card_header("DKIM", result.dkim) }} 275 |
276 | 277 | 278 | {% if dkim_domain %} 279 | 280 | 281 | 282 | 283 | {% endif %} 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 |
{% trans %}Domain{% endtrans %}{{ dkim_domain }}
{% trans %}Warnings{% endtrans %}{% if result.dkim.warnings %}{{ render_problems(result.dkim.warnings) }}{% else %}{% trans %}none{% endtrans %}{% endif %}
{% trans %}Errors{% endtrans %}{% if result.dkim.errors %}{{ render_problems(result.dkim.errors) }}{% else %}{% trans %}none{% endtrans %}{% endif %}
293 |
294 |
295 | {% endif %} 296 | 297 | {% if result.domain.spf.warnings or result.domain.spf.errors or result.domain.dmarc.warnings or result.domain.dmarc.errors or result.dkim.warnings or result.dkim.errors %} 298 |
299 |
300 | {% if result.domain.spf.warnings or result.domain.dmarc.warnings or result.dkim.warnings %} 301 |

302 | {% trans trimmed %} 303 | To increase the chance that your configuration is interpreted by all e-mail servers correctly, 304 | we recommend fixing all errors and warnings. 305 | {% endtrans %} 306 |

307 | {% endif %} 308 | 309 |

310 | {% trans %}After fixing the issues, please rerun the scan - some problems can be detected only if earlier checks complete successfully.{% endtrans %} 311 |

312 | {% include "custom_failed_check_results_hints.html" %} 313 |
314 |
315 | {% endif %} 316 | {% endif %} 317 | {% endif %} 318 |
319 |
320 |
321 | {% endblock %} 322 | -------------------------------------------------------------------------------- /app/src/template_utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | import typing as t 3 | 4 | import markupsafe 5 | 6 | IANA_TOP_LEVEL_DOMAINS = ( 7 | "(?:" 8 | "(?:aaa|aarp|abarth|abb|abbott|abbvie|abc|able|abogado|abudhabi|academy|accenture" 9 | "|accountant|accountants|aco|actor|adac|ads|adult|aeg|aero|aetna|afl|africa|agakhan|agency" 10 | "|aig|airbus|airforce|airtel|akdn|alfaromeo|alibaba|alipay|allfinanz|allstate|ally|alsace" 11 | "|alstom|amazon|americanexpress|americanfamily|amex|amfam|amica|amsterdam|analytics|android" 12 | "|anquan|anz|aol|apartments|app|apple|aquarelle|arab|aramco|archi|army|arpa|art|arte" 13 | "|asda|asia|associates|athleta|attorney|auction|audi|audible|audio|auspost|author|auto" 14 | "|autos|avianca|aws|axa|azure|a[cdefgilmoqrstuwxz])" 15 | "|(?:baby|baidu|banamex|bananarepublic|band|bank|bar|barcelona|barclaycard|barclays" 16 | "|barefoot|bargains|baseball|basketball|bauhaus|bayern|bbc|bbt|bbva|bcg|bcn|beats|beauty" 17 | "|beer|bentley|berlin|best|bestbuy|bet|bharti|bible|bid|bike|bing|bingo|bio|biz|black" 18 | "|blackfriday|blockbuster|blog|bloomberg|blue|bms|bmw|bnpparibas|boats|boehringer|bofa" 19 | "|bom|bond|boo|book|booking|bosch|bostik|boston|bot|boutique|box|bradesco|bridgestone" 20 | "|broadway|broker|brother|brussels|bugatti|build|builders|business|buy|buzz|bzh|b[abdefghijmnorstvwyz])" 21 | "|(?:cab|cafe|cal|call|calvinklein|cam|camera|camp|cancerresearch|canon|capetown|capital" 22 | "|capitalone|car|caravan|cards|care|career|careers|cars|casa|case|cash|casino|cat|catering" 23 | "|catholic|cba|cbn|cbre|cbs|center|ceo|cern|cfa|cfd|chanel|channel|charity|chase|chat" 24 | "|cheap|chintai|christmas|chrome|church|cipriani|circle|cisco|citadel|citi|citic|city" 25 | "|cityeats|claims|cleaning|click|clinic|clinique|clothing|cloud|club|clubmed|coach|codes" 26 | "|coffee|college|cologne|com|comcast|commbank|community|company|compare|computer|comsec" 27 | "|condos|construction|consulting|contact|contractors|cooking|cookingchannel|cool|coop" 28 | "|corsica|country|coupon|coupons|courses|cpa|credit|creditcard|creditunion|cricket|crown" 29 | "|crs|cruise|cruises|cuisinella|cymru|cyou|c[acdfghiklmnoruvwxyz])" 30 | "|(?:dabur|dad|dance|data|date|dating|datsun|day|dclk|dds|deal|dealer|deals|degree" 31 | "|delivery|dell|deloitte|delta|democrat|dental|dentist|desi|design|dev|dhl|diamonds|diet" 32 | "|digital|direct|directory|discount|discover|dish|diy|dnp|docs|doctor|dog|domains|dot" 33 | "|download|drive|dtv|dubai|dunlop|dupont|durban|dvag|dvr|d[ejkmoz])" 34 | "|(?:earth|eat|eco|edeka|edu|education|email|emerck|energy|engineer|engineering|enterprises" 35 | "|epson|equipment|ericsson|erni|esq|estate|etisalat|eurovision|eus|events|exchange|expert" 36 | "|exposed|express|extraspace|e[cegrstu])" 37 | "|(?:fage|fail|fairwinds|faith|family|fan|fans|farm|farmers|fashion|fast|fedex|feedback" 38 | "|ferrari|ferrero|fiat|fidelity|fido|film|final|finance|financial|fire|firestone|firmdale" 39 | "|fish|fishing|fit|fitness|flickr|flights|flir|florist|flowers|fly|foo|food|foodnetwork" 40 | "|football|ford|forex|forsale|forum|foundation|fox|free|fresenius|frl|frogans|frontdoor" 41 | "|frontier|ftr|fujitsu|fun|fund|furniture|futbol|fyi|f[ijkmor])" 42 | "|(?:gal|gallery|gallo|gallup|game|games|gap|garden|gay|gbiz|gdn|gea|gent|genting" 43 | "|george|ggee|gift|gifts|gives|giving|glass|gle|global|globo|gmail|gmbh|gmo|gmx|godaddy" 44 | "|gold|goldpoint|golf|goo|goodyear|goog|google|gop|got|gov|grainger|graphics|gratis|green" 45 | "|gripe|grocery|group|guardian|gucci|guge|guide|guitars|guru|g[abdefghilmnpqrstuwy])" 46 | "|(?:hair|hamburg|hangout|haus|hbo|hdfc|hdfcbank|health|healthcare|help|helsinki|here" 47 | "|hermes|hgtv|hiphop|hisamitsu|hitachi|hiv|hkt|hockey|holdings|holiday|homedepot|homegoods" 48 | "|homes|homesense|honda|horse|hospital|host|hosting|hot|hoteles|hotels|hotmail|house" 49 | "|how|hsbc|hughes|hyatt|hyundai|h[kmnrtu])" 50 | "|(?:ibm|icbc|ice|icu|ieee|ifm|ikano|imamat|imdb|immo|immobilien|inc|industries|infiniti" 51 | "|info|ing|ink|institute|insurance|insure|int|international|intuit|investments|ipiranga" 52 | "|irish|ismaili|ist|istanbul|itau|itv|i[delmnoqrst])" 53 | "|(?:jaguar|java|jcb|jeep|jetzt|jewelry|jio|jll|jmp|jnj|jobs|joburg|jot|joy|jpmorgan" 54 | "|jprs|juegos|juniper|j[emop])" 55 | "|(?:kaufen|kddi|kerryhotels|kerrylogistics|kerryproperties|kfh|kia|kids|kim|kinder" 56 | "|kindle|kitchen|kiwi|koeln|komatsu|kosher|kpmg|kpn|krd|kred|kuokgroup|kyoto|k[eghimnprwyz])" 57 | "|(?:lacaixa|lamborghini|lamer|lancaster|lancia|land|landrover|lanxess|lasalle|lat" 58 | "|latino|latrobe|law|lawyer|lds|lease|leclerc|lefrak|legal|lego|lexus|lgbt|lidl|life" 59 | "|lifeinsurance|lifestyle|lighting|like|lilly|limited|limo|lincoln|linde|link|lipsy|live" 60 | "|living|llc|llp|loan|loans|locker|locus|loft|lol|london|lotte|lotto|love|lpl|lplfinancial" 61 | "|ltd|ltda|lundbeck|luxe|luxury|l[abcikrstuvy])" 62 | "|(?:macys|madrid|maif|maison|makeup|man|management|mango|map|market|marketing|markets" 63 | "|marriott|marshalls|maserati|mattel|mba|mckinsey|med|media|meet|melbourne|meme|memorial" 64 | "|men|menu|merckmsd|miami|microsoft|mil|mini|mint|mit|mitsubishi|mlb|mls|mma|mobi|mobile" 65 | "|moda|moe|moi|mom|monash|money|monster|mormon|mortgage|moscow|moto|motorcycles|mov|movie" 66 | "|msd|mtn|mtr|museum|music|mutual|m[acdeghklmnopqrstuvwxyz])" 67 | "|(?:nab|nagoya|name|natura|navy|nba|nec|net|netbank|netflix|network|neustar|new|news" 68 | "|next|nextdirect|nexus|nfl|ngo|nhk|nico|nike|nikon|ninja|nissan|nissay|nokia|northwesternmutual" 69 | "|norton|now|nowruz|nowtv|nra|nrw|ntt|nyc|n[acefgilopruz])" 70 | "|(?:obi|observer|office|okinawa|olayan|olayangroup|oldnavy|ollo|omega|one|ong|onl" 71 | "|online|ooo|open|oracle|orange|org|organic|origins|osaka|otsuka|ott|ovh|om)" 72 | "|(?:page|panasonic|paris|pars|partners|parts|party|passagens|pay|pccw|pet|pfizer" 73 | "|pharmacy|phd|philips|phone|photo|photography|photos|physio|pics|pictet|pictures|pid" 74 | "|pin|ping|pink|pioneer|pizza|place|play|playstation|plumbing|plus|pnc|pohl|poker|politie" 75 | "|porn|post|pramerica|praxi|press|prime|pro|prod|productions|prof|progressive|promo|properties" 76 | "|property|protection|pru|prudential|pub|pwc|p[aefghklmnrstwy])" 77 | "|(?:qpon|quebec|quest|qa)" 78 | "|(?:racing|radio|read|realestate|realtor|realty|recipes|red|redstone|redumbrella" 79 | "|rehab|reise|reisen|reit|reliance|ren|rent|rentals|repair|report|republican|rest|restaurant" 80 | "|review|reviews|rexroth|rich|richardli|ricoh|ril|rio|rip|rocher|rocks|rodeo|rogers|room" 81 | "|rsvp|rugby|ruhr|run|rwe|ryukyu|r[eosuw])" 82 | "|(?:saarland|safe|safety|sakura|sale|salon|samsclub|samsung|sandvik|sandvikcoromant" 83 | "|sanofi|sap|sarl|sas|save|saxo|sbi|sbs|sca|scb|schaeffler|schmidt|scholarships|school" 84 | "|schule|schwarz|science|scot|search|seat|secure|security|seek|select|sener|services" 85 | "|ses|seven|sew|sex|sexy|sfr|shangrila|sharp|shaw|shell|shia|shiksha|shoes|shop|shopping" 86 | "|shouji|show|showtime|silk|sina|singles|site|ski|skin|sky|skype|sling|smart|smile|sncf" 87 | "|soccer|social|softbank|software|sohu|solar|solutions|song|sony|soy|spa|space|sport" 88 | "|spot|srl|stada|staples|star|statebank|statefarm|stc|stcgroup|stockholm|storage|store" 89 | "|stream|studio|study|style|sucks|supplies|supply|support|surf|surgery|suzuki|swatch" 90 | "|swiss|sydney|systems|s[abcdeghijklmnorstuvxyz])" 91 | "|(?:tab|taipei|talk|taobao|target|tatamotors|tatar|tattoo|tax|taxi|tci|tdk|team|tech" 92 | "|technology|tel|temasek|tennis|teva|thd|theater|theatre|tiaa|tickets|tienda|tiffany" 93 | "|tips|tires|tirol|tjmaxx|tjx|tkmaxx|tmall|today|tokyo|tools|top|toray|toshiba|total" 94 | "|tours|town|toyota|toys|trade|trading|training|travel|travelchannel|travelers|travelersinsurance" 95 | "|trust|trv|tube|tui|tunes|tushu|tvs|t[cdfghjklmnortvwz])" 96 | "|(?:ubank|ubs|unicom|university|uno|uol|ups|u[agksyz])" 97 | "|(?:vacations|vana|vanguard|vegas|ventures|verisign|versicherung|vet|viajes|video" 98 | "|vig|viking|villas|vin|vip|virgin|visa|vision|viva|vivo|vlaanderen|vodka|volkswagen" 99 | "|volvo|vote|voting|voto|voyage|vuelos|v[aceginu])" 100 | "|(?:wales|walmart|walter|wang|wanggou|watch|watches|weather|weatherchannel|webcam" 101 | "|weber|website|wed|wedding|weibo|weir|whoswho|wien|wiki|williamhill|win|windows|wine" 102 | "|winners|wme|wolterskluwer|woodside|work|works|world|wow|wtc|wtf|w[fs])" 103 | "|(?:\u03b5\u03bb|\u03b5\u03c5|\u0431\u0433|\u0431\u0435\u043b|\u0434\u0435\u0442\u0438" 104 | "|\u0435\u044e|\u043a\u0430\u0442\u043e\u043b\u0438\u043a|\u043a\u043e\u043c|\u043c\u043a\u0434" 105 | "|\u043c\u043e\u043d|\u043c\u043e\u0441\u043a\u0432\u0430|\u043e\u043d\u043b\u0430\u0439\u043d" 106 | "|\u043e\u0440\u0433|\u0440\u0443\u0441|\u0440\u0444|\u0441\u0430\u0439\u0442|\u0441\u0440\u0431" 107 | "|\u0443\u043a\u0440|\u049b\u0430\u0437|\u0570\u0561\u0575|\u05d9\u05e9\u05e8\u05d0\u05dc" 108 | "|\u05e7\u05d5\u05dd|\u0627\u0628\u0648\u0638\u0628\u064a|\u0627\u062a\u0635\u0627\u0644\u0627\u062a" 109 | "|\u0627\u0631\u0627\u0645\u0643\u0648|\u0627\u0644\u0627\u0631\u062f\u0646|\u0627\u0644\u0628\u062d\u0631\u064a\u0646" 110 | "|\u0627\u0644\u062c\u0632\u0627\u0626\u0631|\u0627\u0644\u0633\u0639\u0648\u062f\u064a\u0629" 111 | "|\u0627\u0644\u0639\u0644\u064a\u0627\u0646|\u0627\u0644\u0645\u063a\u0631\u0628|\u0627\u0645\u0627\u0631\u0627\u062a" 112 | "|\u0627\u06cc\u0631\u0627\u0646|\u0628\u0627\u0631\u062a|\u0628\u0627\u0632\u0627\u0631" 113 | "|\u0628\u064a\u062a\u0643|\u0628\u06be\u0627\u0631\u062a|\u062a\u0648\u0646\u0633|\u0633\u0648\u062f\u0627\u0646" 114 | "|\u0633\u0648\u0631\u064a\u0629|\u0634\u0628\u0643\u0629|\u0639\u0631\u0627\u0642|\u0639\u0631\u0628" 115 | "|\u0639\u0645\u0627\u0646|\u0641\u0644\u0633\u0637\u064a\u0646|\u0642\u0637\u0631|\u0643\u0627\u062b\u0648\u0644\u064a\u0643" 116 | "|\u0643\u0648\u0645|\u0645\u0635\u0631|\u0645\u0644\u064a\u0633\u064a\u0627|\u0645\u0648\u0631\u064a\u062a\u0627\u0646\u064a\u0627" 117 | "|\u0645\u0648\u0642\u0639|\u0647\u0645\u0631\u0627\u0647|\u067e\u0627\u06a9\u0633\u062a\u0627\u0646" 118 | "|\u0680\u0627\u0631\u062a|\u0915\u0949\u092e|\u0928\u0947\u091f|\u092d\u093e\u0930\u0924" 119 | "|\u092d\u093e\u0930\u0924\u092e\u094d|\u092d\u093e\u0930\u094b\u0924|\u0938\u0902\u0917\u0920\u0928" 120 | "|\u09ac\u09be\u0982\u09b2\u09be|\u09ad\u09be\u09b0\u09a4|\u09ad\u09be\u09f0\u09a4|\u0a2d\u0a3e\u0a30\u0a24" 121 | "|\u0aad\u0abe\u0ab0\u0aa4|\u0b2d\u0b3e\u0b30\u0b24|\u0b87\u0ba8\u0bcd\u0ba4\u0bbf\u0baf\u0bbe" 122 | "|\u0b87\u0bb2\u0b99\u0bcd\u0b95\u0bc8|\u0b9a\u0bbf\u0b99\u0bcd\u0b95\u0baa\u0bcd\u0baa\u0bc2\u0bb0\u0bcd" 123 | "|\u0c2d\u0c3e\u0c30\u0c24\u0c4d|\u0cad\u0cbe\u0cb0\u0ca4|\u0d2d\u0d3e\u0d30\u0d24\u0d02" 124 | "|\u0dbd\u0d82\u0d9a\u0dcf|\u0e04\u0e2d\u0e21|\u0e44\u0e17\u0e22|\u0ea5\u0eb2\u0ea7|\u10d2\u10d4" 125 | "|\u307f\u3093\u306a|\u30a2\u30de\u30be\u30f3|\u30af\u30e9\u30a6\u30c9|\u30b0\u30fc\u30b0\u30eb" 126 | "|\u30b3\u30e0|\u30b9\u30c8\u30a2|\u30bb\u30fc\u30eb|\u30d5\u30a1\u30c3\u30b7\u30e7\u30f3" 127 | "|\u30dd\u30a4\u30f3\u30c8|\u4e16\u754c|\u4e2d\u4fe1|\u4e2d\u56fd|\u4e2d\u570b|\u4e2d\u6587\u7f51" 128 | "|\u4e9a\u9a6c\u900a|\u4f01\u4e1a|\u4f5b\u5c71|\u4fe1\u606f|\u5065\u5eb7|\u516b\u5366" 129 | "|\u516c\u53f8|\u516c\u76ca|\u53f0\u6e7e|\u53f0\u7063|\u5546\u57ce|\u5546\u5e97|\u5546\u6807" 130 | "|\u5609\u91cc|\u5609\u91cc\u5927\u9152\u5e97|\u5728\u7ebf|\u5927\u62ff|\u5929\u4e3b\u6559" 131 | "|\u5a31\u4e50|\u5bb6\u96fb|\u5e7f\u4e1c|\u5fae\u535a|\u6148\u5584|\u6211\u7231\u4f60" 132 | "|\u624b\u673a|\u62db\u8058|\u653f\u52a1|\u653f\u5e9c|\u65b0\u52a0\u5761|\u65b0\u95fb" 133 | "|\u65f6\u5c1a|\u66f8\u7c4d|\u673a\u6784|\u6de1\u9a6c\u9521|\u6e38\u620f|\u6fb3\u9580" 134 | "|\u70b9\u770b|\u79fb\u52a8|\u7ec4\u7ec7\u673a\u6784|\u7f51\u5740|\u7f51\u5e97|\u7f51\u7ad9" 135 | "|\u7f51\u7edc|\u8054\u901a|\u8bfa\u57fa\u4e9a|\u8c37\u6b4c|\u8d2d\u7269|\u901a\u8ca9" 136 | "|\u96c6\u56e2|\u96fb\u8a0a\u76c8\u79d1|\u98de\u5229\u6d66|\u98df\u54c1|\u9910\u5385" 137 | "|\u9999\u683c\u91cc\u62c9|\u9999\u6e2f|\ub2f7\ub137|\ub2f7\ucef4|\uc0bc\uc131|\ud55c\uad6d" 138 | "|verm\xf6gensberater|verm\xf6gensberatung|xbox|xerox|xfinity|xihuan|xin|xn\\-\\-11b4c3d" 139 | "|xn\\-\\-1ck2e1b|xn\\-\\-1qqw23a|xn\\-\\-2scrj9c|xn\\-\\-30rr7y|xn\\-\\-3bst00m|xn\\-\\-3ds443g" 140 | "|xn\\-\\-3e0b707e|xn\\-\\-3hcrj9c|xn\\-\\-3pxu8k|xn\\-\\-42c2d9a|xn\\-\\-45br5cyl|xn\\-\\-45brj9c" 141 | "|xn\\-\\-45q11c|xn\\-\\-4dbrk0ce|xn\\-\\-4gbrim|xn\\-\\-54b7fta0cc|xn\\-\\-55qw42g|xn\\-\\-55qx5d" 142 | "|xn\\-\\-5su34j936bgsg|xn\\-\\-5tzm5g|xn\\-\\-6frz82g|xn\\-\\-6qq986b3xl|xn\\-\\-80adxhks" 143 | "|xn\\-\\-80ao21a|xn\\-\\-80aqecdr1a|xn\\-\\-80asehdb|xn\\-\\-80aswg|xn\\-\\-8y0a063a" 144 | "|xn\\-\\-90a3ac|xn\\-\\-90ae|xn\\-\\-90ais|xn\\-\\-9dbq2a|xn\\-\\-9et52u|xn\\-\\-9krt00a" 145 | "|xn\\-\\-b4w605ferd|xn\\-\\-bck1b9a5dre4c|xn\\-\\-c1avg|xn\\-\\-c2br7g|xn\\-\\-cck2b3b" 146 | "|xn\\-\\-cckwcxetd|xn\\-\\-cg4bki|xn\\-\\-clchc0ea0b2g2a9gcd|xn\\-\\-czr694b|xn\\-\\-czrs0t" 147 | "|xn\\-\\-czru2d|xn\\-\\-d1acj3b|xn\\-\\-d1alf|xn\\-\\-e1a4c|xn\\-\\-eckvdtc9d|xn\\-\\-efvy88h" 148 | "|xn\\-\\-fct429k|xn\\-\\-fhbei|xn\\-\\-fiq228c5hs|xn\\-\\-fiq64b|xn\\-\\-fiqs8s|xn\\-\\-fiqz9s" 149 | "|xn\\-\\-fjq720a|xn\\-\\-flw351e|xn\\-\\-fpcrj9c3d|xn\\-\\-fzc2c9e2c|xn\\-\\-fzys8d69uvgm" 150 | "|xn\\-\\-g2xx48c|xn\\-\\-gckr3f0f|xn\\-\\-gecrj9c|xn\\-\\-gk3at1e|xn\\-\\-h2breg3eve" 151 | "|xn\\-\\-h2brj9c|xn\\-\\-h2brj9c8c|xn\\-\\-hxt814e|xn\\-\\-i1b6b1a6a2e|xn\\-\\-imr513n" 152 | "|xn\\-\\-io0a7i|xn\\-\\-j1aef|xn\\-\\-j1amh|xn\\-\\-j6w193g|xn\\-\\-jlq480n2rg|xn\\-\\-jlq61u9w7b" 153 | "|xn\\-\\-jvr189m|xn\\-\\-kcrx77d1x4a|xn\\-\\-kprw13d|xn\\-\\-kpry57d|xn\\-\\-kput3i" 154 | "|xn\\-\\-l1acc|xn\\-\\-lgbbat1ad8j|xn\\-\\-mgb9awbf|xn\\-\\-mgba3a3ejt|xn\\-\\-mgba3a4f16a" 155 | "|xn\\-\\-mgba7c0bbn0a|xn\\-\\-mgbaakc7dvf|xn\\-\\-mgbaam7a8h|xn\\-\\-mgbab2bd|xn\\-\\-mgbah1a3hjkrd" 156 | "|xn\\-\\-mgbai9azgqp6j|xn\\-\\-mgbayh7gpa|xn\\-\\-mgbbh1a|xn\\-\\-mgbbh1a71e|xn\\-\\-mgbc0a9azcg" 157 | "|xn\\-\\-mgbca7dzdo|xn\\-\\-mgbcpq6gpa1a|xn\\-\\-mgberp4a5d4ar|xn\\-\\-mgbgu82a|xn\\-\\-mgbi4ecexp" 158 | "|xn\\-\\-mgbpl2fh|xn\\-\\-mgbt3dhd|xn\\-\\-mgbtx2b|xn\\-\\-mgbx4cd0ab|xn\\-\\-mix891f" 159 | "|xn\\-\\-mk1bu44c|xn\\-\\-mxtq1m|xn\\-\\-ngbc5azd|xn\\-\\-ngbe9e0a|xn\\-\\-ngbrx|xn\\-\\-node" 160 | "|xn\\-\\-nqv7f|xn\\-\\-nqv7fs00ema|xn\\-\\-nyqy26a|xn\\-\\-o3cw4h|xn\\-\\-ogbpf8fl|xn\\-\\-otu796d" 161 | "|xn\\-\\-p1acf|xn\\-\\-p1ai|xn\\-\\-pgbs0dh|xn\\-\\-pssy2u|xn\\-\\-q7ce6a|xn\\-\\-q9jyb4c" 162 | "|xn\\-\\-qcka1pmc|xn\\-\\-qxa6a|xn\\-\\-qxam|xn\\-\\-rhqv96g|xn\\-\\-rovu88b|xn\\-\\-rvc1e0am3e" 163 | "|xn\\-\\-s9brj9c|xn\\-\\-ses554g|xn\\-\\-t60b56a|xn\\-\\-tckwe|xn\\-\\-tiq49xqyj|xn\\-\\-unup4y" 164 | "|xn\\-\\-vermgensberater\\-ctb|xn\\-\\-vermgensberatung\\-pwb|xn\\-\\-vhquv|xn\\-\\-vuq861b" 165 | "|xn\\-\\-w4r85el8fhu5dnra|xn\\-\\-w4rs40l|xn\\-\\-wgbh1c|xn\\-\\-wgbl6a|xn\\-\\-xhq521b" 166 | "|xn\\-\\-xkc2al3hye2a|xn\\-\\-xkc2dl3a5ee0h|xn\\-\\-y9a3aq|xn\\-\\-yfro4i67o|xn\\-\\-ygbi2ammx" 167 | "|xn\\-\\-zfr164b|xxx|xyz)" 168 | "|(?:yachts|yahoo|yamaxun|yandex|yodobashi|yoga|yokohama|you|youtube|yun|y[et])" 169 | "|(?:zappos|zara|zero|zip|zone|zuerich|z[amw]))" 170 | ) 171 | 172 | PUNYCODE_TLD = "xn\\-\\-[\\w\\-]{0,58}\\w" 173 | 174 | UCS_CHAR = ( 175 | "\u00A1-\u1FFF" 176 | "\u200C-\u2027" 177 | "\u202A-\u202E" 178 | "\u2030-\u2FFF" 179 | "\u3001-\uD7FF" 180 | "\uF900-\uFDCF" 181 | "\uFDF0-\uFFEF" 182 | "\U00010000-\U0001FFFD" 183 | "\U00020000-\U0002FFFD" 184 | "\U00030000-\U0003FFFD" 185 | "\U00040000-\U0004FFFD" 186 | "\U00050000-\U0005FFFD" 187 | "\U00060000-\U0006FFFD" 188 | "\U00070000-\U0007FFFD" 189 | "\U00080000-\U0008FFFD" 190 | "\U00090000-\U0009FFFD" 191 | "\U000A0000-\U000AFFFD" 192 | "\U000B0000-\U000BFFFD" 193 | "\U000C0000-\U000CFFFD" 194 | "\U000D0000-\U000DFFFD" 195 | "\U000E1000-\U000EFFFD" 196 | ) 197 | 198 | LABEL_CHAR = f"a-zA-Z0-9{UCS_CHAR}" 199 | IRI_LABEL = f"[{LABEL_CHAR}](?:[{LABEL_CHAR}_\\-]" "{0,61}" f"[{LABEL_CHAR}])" "{0,1}" 200 | 201 | PROTOCOL = "(?i:http|https|rtsp|ftp)://" 202 | WORD_BOUNDARY = "(?:\\b|$|^)" 203 | USER_INFO = ( 204 | "(?:[a-zA-Z0-9\\$\\-\\_\\.\\+\\!\\*\\'\\(\\)" 205 | "\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,64}(?:\\:(?:[a-zA-Z0-9\\$\\-\\_" 206 | "\\.\\+\\!\\*\\'\\(\\)\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,25})?\\@" 207 | ) 208 | PORT_NUMBER = "\\:\\d{1,5}" 209 | PATH_AND_QUERY = f"[/\\?](?:(?:[{LABEL_CHAR};/\\?:@&=#~" "\\-\\.\\+!\\*'\\(\\),_\\$])|(?:%[a-fA-F0-9]{2}))*" 210 | 211 | STRICT_TLD = f"(?:{IANA_TOP_LEVEL_DOMAINS}|{PUNYCODE_TLD})" 212 | 213 | IP_ADDRESS_STRING = ( 214 | "((25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9])\\.(25[0-5]|2[0-4]" 215 | "[0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1]" 216 | "[0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}" 217 | "|[1-9][0-9]|[0-9]))" 218 | ) 219 | 220 | STRICT_HOST_NAME = f"(?:(?:{IRI_LABEL}\\.)+{STRICT_TLD})" 221 | 222 | STRICT_DOMAIN_NAME = f"(?:{STRICT_HOST_NAME}|{IP_ADDRESS_STRING})" 223 | 224 | RELAXED_DOMAIN_NAME = f"(?:(?:{IRI_LABEL}(?:\\.(?=\\S))?)+|{IP_ADDRESS_STRING})" 225 | 226 | STRICT_DOMAIN_NAME_RE = re.compile(STRICT_DOMAIN_NAME) 227 | 228 | WEB_URL_WITH_PROTOCOL_RE = re.compile( 229 | "(" 230 | f"{WORD_BOUNDARY}(?:" 231 | f"(?:{PROTOCOL}(?:{USER_INFO})?)" 232 | f"(?:{RELAXED_DOMAIN_NAME})?" 233 | f"(?:{PORT_NUMBER})?" 234 | ")" 235 | f"(?:{PATH_AND_QUERY})?{WORD_BOUNDARY}" 236 | ")" 237 | ) 238 | 239 | 240 | def mailgoose_urlize( 241 | text: str, 242 | rel: t.Optional[str] = None, 243 | target: t.Optional[str] = None, 244 | ) -> str: 245 | """Copied and adapted from jinja2 urlize - changed so that domains are monospace, not links.""" 246 | words = re.split(r"(\s+)", str(markupsafe.escape(text))) 247 | rel_attr = f' rel="{markupsafe.escape(rel)}"' if rel else "" 248 | target_attr = f' target="{markupsafe.escape(target)}"' if target else "" 249 | 250 | for i, word in enumerate(words): 251 | head, middle, tail = "", word, "" 252 | match = re.match(r"^([(<]|<)+", middle) 253 | 254 | if match: 255 | head = match.group() 256 | middle = middle[match.end() :] 257 | 258 | # Unlike lead, which is anchored to the start of the string, 259 | # need to check that the string ends with any of the characters 260 | # before trying to match all of them, to avoid backtracking. 261 | if middle.endswith((")", ">", ".", ",", "\n", ">")): 262 | match = re.search(r"([)>.,\n]|>)+$", middle) 263 | 264 | if match: 265 | tail = match.group() 266 | middle = middle[: match.start()] 267 | 268 | # Prefer balancing parentheses in URLs instead of ignoring a 269 | # trailing character. 270 | for start_char, end_char in ("(", ")"), ("<", ">"), ("<", ">"): 271 | start_count = middle.count(start_char) 272 | 273 | if start_count <= middle.count(end_char): 274 | # Balanced, or lighter on the left 275 | continue 276 | 277 | # Move as many as possible from the tail to balance 278 | for _ in range(min(start_count, tail.count(end_char))): 279 | end_index = tail.index(end_char) + len(end_char) 280 | # Move anything in the tail before the end char too 281 | middle += tail[:end_index] 282 | tail = tail[end_index:] 283 | 284 | if WEB_URL_WITH_PROTOCOL_RE.match(middle): 285 | middle = f'{middle}' 286 | elif STRICT_DOMAIN_NAME_RE.match(middle): 287 | middle = f"{middle}" 288 | 289 | words[i] = f"{head}{middle}{tail}" 290 | 291 | return markupsafe.Markup("".join(words)) 292 | --------------------------------------------------------------------------------