diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..737a878 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/app/translations/" + schedule: + interval: "weekly" + - package-ecosystem: "pip" + directory: "/app/" + schedule: + interval: "weekly" + - package-ecosystem: "pip" + directory: "/mail_receiver/" + schedule: + interval: "weekly" + - package-ecosystem: "pip" + directory: "/test/" + schedule: + interval: "weekly" diff --git a/.github/workflows/check_no_translations_to_update.yml b/.github/workflows/check_no_translations_to_update.yml new file mode 100644 index 0000000..617e4e4 --- /dev/null +++ b/.github/workflows/check_no_translations_to_update.yml @@ -0,0 +1,20 @@ +name: "check that we don't need to update translations" +on: + push: + branches: [ '**' ] + +jobs: + check_no_translations_to_update: + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - name: Check out repository + uses: actions/checkout@v2 + - name: Set up Python 3.11 + uses: actions/setup-python@v2 + with: + python-version: "3.11" + - name: Update the translations + run: ./scripts/update_translation_files + - name: Check that the files didn't change + run: git diff --exit-code diff --git a/.github/workflows/liccheck.yml b/.github/workflows/liccheck.yml new file mode 100644 index 0000000..2cdc6bc --- /dev/null +++ b/.github/workflows/liccheck.yml @@ -0,0 +1,28 @@ +name: "license-check" +on: + push: + branches: [ '**' ] + +jobs: + license-check: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@v2 + - name: Set up Python 3.11 + uses: actions/setup-python@v2 + with: + python-version: "3.11" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r app/requirements.txt -r mail_receiver/requirements.txt -r test/requirements.txt liccheck==0.9.2 + - name: Remove checkdmarc installed from CERT PL fork from requirements as it's not supported by liccheck + run: cp app/requirements.txt app/requirements.txt.orig; cat app/requirements.txt.orig | grep -v ^git+.*checkdmarc > app/requirements.txt + - name: Run liccheck on app/requirements.txt + run: liccheck -r app/requirements.txt + - name: Run liccheck on mail_receiver/requirements.txt + run: liccheck -r mail_receiver/requirements.txt + - name: Run liccheck on test/requirements.txt + run: liccheck -r test/requirements.txt diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..5fe5d80 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,22 @@ +name: "lint" +on: + push: + branches: [ '**' ] + +jobs: + lint: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out repository + uses: actions/checkout@v2 + - name: Set up Python 3.11 + uses: actions/setup-python@v2 + with: + python-version: "3.11" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pre-commit==3.5.0 + - name: Run pre-commit + run: pre-commit run --all-files diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..7d0f01a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,14 @@ +name: "tests" +on: + push: + branches: [ '**' ] + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Check out repository + uses: actions/checkout@v2 + - name: run tests + run: ./scripts/test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ced2950 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +logs/ diff --git a/.mypy.ini b/.mypy.ini new file mode 100644 index 0000000..c2d0fb0 --- /dev/null +++ b/.mypy.ini @@ -0,0 +1,14 @@ +[mypy-decouple.*] +ignore_missing_imports = True + +[mypy-dkim.*] +ignore_missing_imports = True + +[mypy-jinja2_simple_tags.*] +ignore_missing_imports = True + +[mypy-publicsuffixlist.*] +ignore_missing_imports = True + +[mypy-validators.*] +ignore_missing_imports = True diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..31c8be7 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,35 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace +- repo: https://github.com/psf/black + rev: 23.11.0 + hooks: + - id: black +- repo: https://github.com/pycqa/isort + rev: 5.12.0 + hooks: + - id: isort + name: isort (python) +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.7.1 + hooks: + - id: mypy + args: [--strict] + additional_dependencies: + - aiosmtpd==1.4.4.post2 + - dacite==1.8.1 + - dnspython==2.4.2 + - email-validator==2.1.0.post1 + - fastapi==0.104.1 + - Jinja2==3.1.2 + - sqlalchemy-stubs==0.4 + - types-redis==4.6.0.11 + - types-requests==2.31.0.10 +- repo: https://github.com/PyCQA/flake8 + rev: 6.1.0 + hooks: + - id: flake8 + args: [.] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6f74287 --- /dev/null +++ b/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2023, CERT Polska + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + * Neither the name of mailgoose nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ca736c5 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# mailgoose + +## How to run locally +To run the service locally, use: + +``` +cp env.example .env +# Please review the file and set the configuration variables as needed, +# especially APP_DOMAIN - the domain you will serve the application on. +# +# You may provide additional settings in this file, e.g. SSL certificate +# settings: SSL_PRIVATE_KEY_PATH and SSL_CERTIFICATE_PATH. + +docker compose up --build +``` + +The application will listen on http://127.0.0.1:8000 + +## How to deploy to production +Before deploying the system using the configuration in `docker-compose.yml` remember to: + +- change the database password to a more secure one, +- use Redis password, +- consider whether you want to launch a database/Redis instance inside a container (instead + of e.g. attaching to your own PostgreSQL cluster), +- check whether you want to use Google nameservers. + +Instead of copying `docker-compose.yml`, you may override the configuration using the +`docker compose -f docker-compose.yml -f docker-compose.override.yml` syntax. + +## How to change the layout +If you want to change the main layout template (e.g. to provide additional scripts or your own +custom navbar with logo), mount a different file using Docker to `/app/templates/custom_layout.html`. +Refer to `app/templates/base.html` to learn what block you can fill. + +You can also customize the root page (/) of the system by providing your own file that will +replace `/app/templates/custom_root_layout.html`. + +By replacing `/app/templates/custom_failed_check_result_hints.html` you may provide your own +text that will be displayed if the e-mail sender verification mechanisms checks fail. + +At CERT PL we use a separate `docker-compose.yml` file with additional configuration +specific to our instance (https://bezpiecznapoczta.cert.pl/). Instead of copying +`docker-compose.yml`, we override the configuration using the +`docker compose -f docker-compose.yml -f docker-compose.override.yml` syntax. + +## How to use the HTTP API + +To check a domain using a HTTP API, use: + +``` +curl -X POST http://127.0.0.1:8000/api/v1/check-domain?domain=example.com +``` + +## How to run the tests +To run the tests, use: + +``` +./script/test +``` diff --git a/app/docker/Dockerfile b/app/docker/Dockerfile new file mode 100644 index 0000000..3d21bec --- /dev/null +++ b/app/docker/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.11.2-alpine + +RUN apk add bash openssl git tzdata opendkim opendkim-utils + +ENV TZ=Europe/Warsaw +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +WORKDIR /app/ +COPY app/requirements.txt /requirements.txt +COPY app/translations/requirements.txt /translations/requirements.txt + +RUN pip install -r /requirements.txt + +COPY common/wait-for-it.sh /wait-for-it.sh +COPY common/mail_receiver_utils.py /app/mail_receiver_utils.py + +COPY app/ /app/ +RUN openssl rand -hex 10 > /app/build_id diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..db4872f --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,15 @@ +git+https://github.com/CERT-Polska/checkdmarc@allowing-whitespace +dacite==1.8.1 +dkimpy==1.1.5 +email-validator==2.1.0.post1 +fastapi==0.104.1 +Jinja2==3.1.2 +jinja2-simple-tags==0.5.0 +psycopg2-binary==2.9.9 +python-decouple==3.8 +python-multipart==0.0.6 +redis==5.0.1 +SQLAlchemy==2.0.23 +uvicorn==0.24.0.post1 +validators==0.22.0 +-r translations/requirements.txt diff --git a/app/src/__init__.py b/app/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/src/app.py b/app/src/app.py new file mode 100644 index 0000000..7891448 --- /dev/null +++ b/app/src/app.py @@ -0,0 +1,249 @@ +import binascii +import dataclasses +import datetime +import os +import time +import traceback +from email.utils import parseaddr +from typing import Any, Callable, Optional + +import decouple +from fastapi import FastAPI, Form, HTTPException, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from mail_receiver_utils import get_key_from_username +from redis import Redis +from starlette.responses import Response + +from .app_utils import ( + get_from_and_dkim_domain, + recipient_username_to_address, + scan_and_log, +) +from .check_results import load_check_results, save_check_results +from .db import ScanLogEntrySource, ServerErrorLogEntry, Session +from .logging import build_logger +from .resolver import setup_resolver +from .scan import DomainValidationException, ScanningException, ScanResult +from .templates import setup_templates +from .translate import Language, translate + +app = FastAPI() +LOGGER = build_logger(__name__) + +app.mount("/static", StaticFiles(directory="static"), name="static") + +setup_resolver() + +APP_DOMAIN = decouple.config("APP_DOMAIN") +LANGUAGE = decouple.config("LANGUAGE") +REDIS_MESSAGE_DATA_EXPIRY_SECONDS = decouple.config("REDIS_MESSAGE_DATA_EXPIRY_SECONDS") +NAMESERVERS = decouple.config("NAMESERVERS", default="8.8.8.8", cast=decouple.Csv(str)) +REDIS = Redis.from_url(decouple.config("REDIS_CONNECTION_STRING")) +SITE_CONTACT_EMAIL = decouple.config("SITE_CONTACT_EMAIL", default=None) + +templates = setup_templates(LANGUAGE) + + +@dataclasses.dataclass +class ScanAPICallResult: + system_error: Optional[bool] = None + result: Optional[ScanResult] = None + + +@app.exception_handler(404) +async def custom_404_handler(request: Request, exception: HTTPException) -> Response: + return templates.TemplateResponse("404.html", {"request": request}) + + +@app.middleware("http") +async def catch_exceptions_log_time_middleware(request: Request, call_next: Callable[[Request], Any]) -> Any: + try: + time_start = time.time() + result = await call_next(request) + LOGGER.info( + "%s %s took %s seconds", + request.method, + request.url.path, + time.time() - time_start, + ) + return result + except Exception: + LOGGER.exception("An error occured when handling request") + + session = Session() + server_error_log_entry = ServerErrorLogEntry(url=str(request.url), error=traceback.format_exc()) + session.add(server_error_log_entry) + session.commit() + + return HTMLResponse(status_code=500, content="Internal Server Error") + + +@app.get("/", response_class=HTMLResponse, include_in_schema=False) +async def root(request: Request) -> Response: + return templates.TemplateResponse("root.html", {"request": request}) + + +@app.get("/check-email", response_class=HTMLResponse, include_in_schema=False) +async def check_email_form(request: Request) -> Response: + recipient_username = f"{binascii.hexlify(os.urandom(16)).decode('ascii')}" + key = get_key_from_username(recipient_username) + REDIS.setex(b"requested-" + key, REDIS_MESSAGE_DATA_EXPIRY_SECONDS, 1) + + return RedirectResponse("/check-email/" + recipient_username) + + +@app.get( + "/check-email/{recipient_username}", + response_class=HTMLResponse, + include_in_schema=False, +) +async def check_email_results(request: Request, recipient_username: str) -> Response: + recipient_address = recipient_username_to_address(recipient_username) + key = get_key_from_username(recipient_username) + + if not REDIS.get(b"requested-" + key): + # This is to prevent users providing their own, non-random (e.g. offensive) + # keys. + return RedirectResponse("/check-email") + + message_data = REDIS.get(key) + message_timestamp_raw = REDIS.get(key + b"-timestamp") + mail_from_raw = REDIS.get(key + b"-sender") + + if not message_data or not message_timestamp_raw or not mail_from_raw: + return templates.TemplateResponse( + "check_email.html", + { + "request": request, + "recipient_username": recipient_username, + "recipient_address": recipient_address, + "site_contact_email": SITE_CONTACT_EMAIL, + }, + ) + + message_timestamp = datetime.datetime.fromisoformat(message_timestamp_raw.decode("ascii")) + + _, mail_from = parseaddr(mail_from_raw.decode("ascii")) + _, envelope_domain = tuple(mail_from.split("@", 1)) + + from_domain, dkim_domain = get_from_and_dkim_domain(message_data) + if not from_domain: + result = None + error = translate("Invalid or no e-mail domain in the message From header", Language.pl_PL) + else: + try: + result = scan_and_log( + request=request, + source=ScanLogEntrySource.GUI, + envelope_domain=envelope_domain, + from_domain=from_domain, + dkim_domain=dkim_domain, + message=message_data, + message_timestamp=message_timestamp, + nameservers=NAMESERVERS, + language=Language(LANGUAGE), + ) + error = None + except (DomainValidationException, ScanningException) as e: + result = None + error = translate(e.message, Language.pl_PL) + + token = save_check_results( + envelope_domain=envelope_domain, + from_domain=from_domain or envelope_domain, + dkim_domain=dkim_domain, + result=result, + error=error, + rescan_url="/check-email/", + message_recipient_username=recipient_username, + ) + return RedirectResponse(f"/check-results/{token}", status_code=302) + + +@app.get("/check-domain", response_class=HTMLResponse, include_in_schema=False) +async def check_domain_form(request: Request) -> Response: + return templates.TemplateResponse("check_domain.html", {"request": request}) + + +@app.get("/check-domain/scan", response_class=HTMLResponse, include_in_schema=False) +async def check_domain_scan_get(request: Request) -> Response: + return RedirectResponse("/check-domain") + + +@app.post("/check-domain/scan", response_class=HTMLResponse, include_in_schema=False) +async def check_domain_scan_post(request: Request, domain: str = Form()) -> Response: + try: + result = scan_and_log( + request=request, + source=ScanLogEntrySource.GUI, + envelope_domain=domain, + from_domain=domain, + dkim_domain=None, + message=None, + message_timestamp=None, + nameservers=NAMESERVERS, + language=Language(LANGUAGE), + ) + error = None + except (DomainValidationException, ScanningException) as e: + result = None + error = translate(e.message, Language.pl_PL) + + token = save_check_results( + envelope_domain=domain, + from_domain=domain, + dkim_domain=None, + result=result, + error=error, + rescan_url="/check-domain/", + message_recipient_username=None, + ) + return RedirectResponse(f"/check-results/{token}", status_code=302) + + +@app.get("/check-results/{token}", response_class=HTMLResponse, include_in_schema=False) +async def check_results(request: Request, token: str) -> Response: + check_results = load_check_results(token) + + if not check_results: + raise HTTPException(status_code=404) + + return templates.TemplateResponse( + "check_results.html", + {"request": request, "url": request.url, **check_results}, + ) + + +@app.get("/api/v1/email-received", include_in_schema=False) +async def email_received(request: Request, recipient_username: str) -> bool: + key = get_key_from_username(recipient_username) + message_data = REDIS.get(key) + return message_data is not None + + +@app.post("/api/v1/check-domain", response_model_exclude_none=True) +async def check_domain_api(request: Request, domain: str) -> ScanAPICallResult: + """ + An API to check e-mail sender verification mechanisms of a domain. + + Returns a ScanAPICallResult object, containing information whether the request + was successful and a ScanResult object. The DKIM field of the ScanResult + object will be empty, as DKIM can't be checked when given only a domain. + """ + try: + result = scan_and_log( + request=request, + source=ScanLogEntrySource.API, + envelope_domain=domain, + from_domain=domain, + dkim_domain=None, + message=None, + message_timestamp=None, + nameservers=NAMESERVERS, + language=Language(LANGUAGE), + ) + return ScanAPICallResult(result=result) + except (DomainValidationException, ScanningException): + LOGGER.exception("An error occured during check of %s", domain) + return ScanAPICallResult(system_error=True) diff --git a/app/src/app_utils.py b/app/src/app_utils.py new file mode 100644 index 0000000..52a6a34 --- /dev/null +++ b/app/src/app_utils.py @@ -0,0 +1,148 @@ +import binascii +import dataclasses +import datetime +import io +import traceback +from email import message_from_file +from email.utils import parseaddr +from typing import List, Optional, Tuple + +import decouple +import dkim.util +from email_validator import EmailNotValidError, validate_email +from fastapi import Request + +from .db import ( + DKIMImplementationMismatchLogEntry, + NonexistentTranslationLogEntry, + ScanLogEntry, + ScanLogEntrySource, + Session, +) +from .logging import build_logger +from .scan import ScanResult, scan +from .translate import Language, translate_scan_result + +APP_DOMAIN = decouple.config("APP_DOMAIN") +LOGGER = build_logger(__name__) + + +def get_from_and_dkim_domain(message: bytes) -> Tuple[Optional[str], Optional[str]]: + stream = io.StringIO(message.decode("utf-8", errors="ignore")) + message_parsed = message_from_file(stream) + + if "from" in message_parsed: + from_address_with_optional_name = message_parsed["from"] + _, from_address = parseaddr(from_address_with_optional_name) + + try: + validate_email(from_address, check_deliverability=False) + _, from_domain = from_address.split("@", 1) + except EmailNotValidError as e: + LOGGER.info("E-mail %s is not valid: %s", from_address, e) + from_domain = None + else: + from_domain = None + + dkim_domain = None + if "dkim-signature" in message_parsed: + try: + sig = dkim.util.parse_tag_value(message_parsed["dkim-signature"].encode("ascii")) + dkim_domain_raw = sig.get(b"d", None) + if dkim_domain_raw: + dkim_domain = dkim_domain_raw.decode("ascii") + except dkim.util.InvalidTagValueList: + pass + + return from_domain, dkim_domain + + +def dkim_implementation_mismatch_callback(message: bytes, dkimpy_valid: bool, opendkim_valid: bool) -> None: + session = Session() + log_entry = DKIMImplementationMismatchLogEntry( + message=binascii.hexlify(message), + dkimpy_valid=dkimpy_valid, + opendkim_valid=opendkim_valid, + ) + session.add(log_entry) + session.commit() + + +def scan_and_log( + request: Request, + source: ScanLogEntrySource, + envelope_domain: str, + from_domain: str, + dkim_domain: Optional[str], + message: Optional[bytes], + message_timestamp: Optional[datetime.datetime], + nameservers: List[str], + language: Language, +) -> ScanResult: + scan_log_entry = ScanLogEntry( + envelope_domain=envelope_domain, + from_domain=from_domain, + dkim_domain=dkim_domain, + message=message, + source=source.value, + client_ip=request.client.host if request.client else None, + client_user_agent=request.headers.get("user-agent", None), + check_options={ + "nameservers": nameservers, + }, + ) + result = ScanResult( + domain=None, + dkim=None, + timestamp=datetime.datetime.now(), + message_timestamp=message_timestamp, + ) + + try: + result = translate_scan_result( + scan( + envelope_domain=envelope_domain, + from_domain=from_domain, + dkim_domain=dkim_domain, + message=message, + message_timestamp=message_timestamp, + nameservers=nameservers, + dkim_implementation_mismatch_callback=dkim_implementation_mismatch_callback, + ), + language=language, + nonexistent_translation_handler=_nonexistent_translation_handler, + ) + return result + except Exception: + scan_log_entry.error = traceback.format_exc() + raise + finally: + session = Session() + scan_log_entry.result = dataclasses.asdict(result) + scan_log_entry.result["timestamp"] = scan_log_entry.result["timestamp"].isoformat() + if scan_log_entry.result["message_timestamp"]: + scan_log_entry.result["message_timestamp"] = scan_log_entry.result["message_timestamp"].isoformat() + session.add(scan_log_entry) + session.commit() + + +def recipient_username_to_address(username: str) -> str: + # We do not use the request hostname as due to proxy configuration we sometimes got the + # Host header wrong, thus breaking the check-by-email feature. + return f"{username}@{APP_DOMAIN}" + + +def _nonexistent_translation_handler(message: str) -> str: + """ + By default, translate_scan_result() raises an exception when a translation doesn't exist. + When users verify their mail configuration from an app, we want to degrade gracefully - instead + of raising an exception, we log the information about missing translation and display + English message. + """ + nonexistent_translation_log_entry = NonexistentTranslationLogEntry(message=message) + + session = Session() + session.add(nonexistent_translation_log_entry) + session.commit() + + return message diff --git a/app/src/check_results.py b/app/src/check_results.py new file mode 100644 index 0000000..2eb3df7 --- /dev/null +++ b/app/src/check_results.py @@ -0,0 +1,97 @@ +import binascii +import dataclasses +import datetime +import json +import os +from typing import Any, Dict, Optional + +import dacite +import decouple +from redis import Redis + +from .logging import build_logger +from .scan import ScanResult + +OLD_CHECK_RESULTS_AGE_MINUTES = decouple.config("OLD_CHECK_RESULTS_AGE_MINUTES", default=60, cast=int) +REDIS = Redis.from_url(decouple.config("REDIS_CONNECTION_STRING")) + +LOGGER = build_logger(__name__) + + +class JSONEncoderAdditionalTypes(json.JSONEncoder): + def default(self, o: Any) -> Any: + if dataclasses.is_dataclass(o): + return dataclasses.asdict(o) + if isinstance(o, datetime.datetime): + return o.isoformat() + return super().default(o) + + +def save_check_results( + envelope_domain: str, + from_domain: str, + dkim_domain: Optional[str], + result: Optional[ScanResult], + error: Optional[str], + rescan_url: str, + message_recipient_username: Optional[str], +) -> str: + token = binascii.hexlify(os.urandom(32)).decode("ascii") + # We don't use HSET or HMSET, as result is a recursive dict, and values that can be stored + # using HSET/HMSET are bytes, string, int or float, so we still wouldn't avoid serialization. + REDIS.set( + f"check-results-{token}", + json.dumps( + { + "created_at": datetime.datetime.now(), + "envelope_domain": envelope_domain, + "from_domain": from_domain, + "dkim_domain": dkim_domain, + "result": result, + "error": error, + "rescan_url": rescan_url, + "message_recipient_username": message_recipient_username, + }, + indent=4, + cls=JSONEncoderAdditionalTypes, + ), + ) + return token + + +def load_check_results(token: str) -> Optional[Dict[str, Any]]: + data = REDIS.get(f"check-results-{token}") + + if not data: + return None + + result: Dict[str, Any] = json.loads(data) + + result["created_at"] = datetime.datetime.fromisoformat(result["created_at"]) + if result["result"]: + result["result"]["timestamp"] = datetime.datetime.fromisoformat(result["result"]["timestamp"]) + if result["result"]["message_timestamp"]: + result["result"]["message_timestamp"] = datetime.datetime.fromisoformat( + result["result"]["message_timestamp"] + ) + try: + dacite.from_dict( + data_class=ScanResult, + data=result["result"], + ) + except dacite.WrongTypeError: + LOGGER.exception("Wrong type detected when deserializing") + + # As we stored what we got from the check module, we allow bad types here, logging + # instead of raising + result["result"] = dacite.from_dict( + data_class=ScanResult, + data=result["result"], + config=dacite.Config(check_types=False), + ) + + result["age_threshold_minutes"] = OLD_CHECK_RESULTS_AGE_MINUTES + result["is_old"] = ( + datetime.datetime.now() - result["created_at"] + ).total_seconds() > 60 * OLD_CHECK_RESULTS_AGE_MINUTES + return result diff --git a/app/src/db.py b/app/src/db.py new file mode 100644 index 0000000..dced133 --- /dev/null +++ b/app/src/db.py @@ -0,0 +1,82 @@ +import datetime +import os +from enum import Enum + +from sqlalchemy import JSON, Boolean, Column, DateTime, Integer, String, create_engine +from sqlalchemy.orm import declarative_base, sessionmaker # type: ignore + +Base = declarative_base() +engine = create_engine(os.environ["DB_URL"]) +Session = sessionmaker(bind=engine) + + +class ScanLogEntrySource(str, Enum): + GUI = "gui" + API = "api" + + +class NonexistentTranslationLogEntry(Base): # type: ignore + __tablename__ = "nonexistent_translation_logs" + id = Column(Integer, primary_key=True) + created_at = Column(DateTime, default=datetime.datetime.utcnow) + message = Column(String) + + +class ServerErrorLogEntry(Base): # type: ignore + __tablename__ = "server_error_logs" + + id = Column(Integer, primary_key=True) + created_at = Column(DateTime, default=datetime.datetime.utcnow) + url = Column(String) + error = Column(String) + + def __repr__(self) -> str: + return ("") % ( + self.created_at, + self.url, + ) + + +class DKIMImplementationMismatchLogEntry(Base): # type: ignore + __tablename__ = "dkim_implementation_mismatch_logs" + + id = Column(Integer, primary_key=True) + created_at = Column(DateTime, default=datetime.datetime.utcnow) + message = Column(String) + dkimpy_valid = Column(Boolean) + opendkim_valid = Column(Boolean) + + def __repr__(self) -> str: + return ("") % ( + self.created_at, + self.dkimpy_valid, + self.opendkim_valid, + ) + + +class ScanLogEntry(Base): # type: ignore + __tablename__ = "scan_logs" + + id = Column(Integer, primary_key=True) + created_at = Column(DateTime, default=datetime.datetime.utcnow) + envelope_domain = Column(String) + from_domain = Column(String) + dkim_domain = Column(String) + message = Column(String) + source = Column(String) + client_ip = Column(String) + client_user_agent = Column(String) + check_options = Column(JSON) + result = Column(JSON) + error = Column(String) + + def __repr__(self) -> str: + return ("") % ( + self.domain, + self.source, + self.client_ip, + self.client_user_agent, + ) + + +Base.metadata.create_all(bind=engine) diff --git a/app/src/lax_record_query.py b/app/src/lax_record_query.py new file mode 100644 index 0000000..01a6eb5 --- /dev/null +++ b/app/src/lax_record_query.py @@ -0,0 +1,57 @@ +# This is a module to query record candidates to be displayed in the UI. +# +# If something is not a correct record, but looks like one, and checkdmarc +# tells there is no record, the candidate will be displayed. + +from typing import List + +import dns.resolver + +# We import private _query_dns from checkdmarc to avoid code duplication, as we +# consider importing a private function to be a lesser evil than duplicating it. +from checkdmarc import _query_dns as query_dns # type: ignore +from checkdmarc import get_base_domain + +SIGNATURE_LOOKUP_LENGTH = 20 + + +def lax_query_spf_record(domain: str) -> List[str]: + try: + records = query_dns(domain, "SPF") + if records: + return records # type: ignore + except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): + pass + + records = [] + answers = query_dns(domain, "TXT") + for answer in answers: + if "spf" in answer[:SIGNATURE_LOOKUP_LENGTH].lower(): + records.append(answer) + return records # type: ignore + + +def lax_query_single_dmarc_record(domain: str) -> List[str]: + target = "_dmarc.{0}".format(domain.lower()) + + try: + record_candidates = query_dns(target, "TXT") + records = [] + + for record in record_candidates: + if "dmarc" in record[:SIGNATURE_LOOKUP_LENGTH].lower(): + records.append(record) + return records + + except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): + return [] + + +def lax_query_dmarc_record(domain: str) -> List[str]: + records = lax_query_single_dmarc_record(domain) + if records: + return records + base_domain = get_base_domain(domain) + if base_domain and domain != base_domain: + return lax_query_single_dmarc_record(base_domain) + return [] diff --git a/app/src/logging.py b/app/src/logging.py new file mode 100644 index 0000000..a7c6394 --- /dev/null +++ b/app/src/logging.py @@ -0,0 +1,10 @@ +import logging + + +def build_logger(name: str) -> logging.Logger: + logger = logging.getLogger(name) + logger.setLevel(logging.INFO) + handler = logging.StreamHandler() + handler.setLevel(logging.INFO) + logger.addHandler(handler) + return logger diff --git a/app/src/resolver.py b/app/src/resolver.py new file mode 100644 index 0000000..8413f01 --- /dev/null +++ b/app/src/resolver.py @@ -0,0 +1,40 @@ +import dns.resolver + +from .logging import build_logger + + +class WrappedResolver(dns.resolver.Resolver): + logger = build_logger(__name__) + num_retries = 3 + + def resolve(self, *args, **kwargs): # type: ignore + result = None + last_exception = None + num_exceptions = 0 + + for _ in range(self.num_retries): + try: + result = super().resolve(*args, **kwargs) + break + except Exception as e: + num_exceptions += 1 + self.logger.exception("problem when resolving: %s, %s", args, kwargs) + last_exception = e + + self.logger.info( + "%s DNS query: %s, %s -> %s", + "flaky" if num_exceptions > 0 and num_exceptions < self.num_retries else "non-flaky", + args, + kwargs, + [str(item) for item in result] if result else last_exception, + ) + + if last_exception and not result: + raise last_exception + + return result + + +def setup_resolver() -> None: + if dns.resolver.Resolver != WrappedResolver: + dns.resolver.Resolver = WrappedResolver # type: ignore diff --git a/app/src/scan.py b/app/src/scan.py new file mode 100644 index 0000000..fb230b9 --- /dev/null +++ b/app/src/scan.py @@ -0,0 +1,577 @@ +import datetime +import io +import string +import subprocess +from dataclasses import dataclass, field +from email import message_from_file +from email.message import Message as EmailMessage +from typing import Any, Callable, Dict, List, Optional + +import checkdmarc # type: ignore +import dkim +import dkim.util +import dns.exception +import dns.resolver +import publicsuffixlist +import validators + +from . import lax_record_query +from .logging import build_logger + +checkdmarc.DNS_CACHE.max_age = 1 +checkdmarc.TLS_CACHE.max_age = 1 +checkdmarc.STARTTLS_CACHE.max_age = 1 + +psl = publicsuffixlist.PublicSuffixList() + +LOGGER = build_logger(__name__) + + +@dataclass +class SPFScanResult: + record: Optional[str] + record_candidates: Optional[List[str]] + valid: bool + errors: List[str] + warnings: List[str] + # As these errors are interpreted in a special way by downstream tools, + # let's have flags (not only string messages) whether they happened. + record_not_found: bool + record_could_not_be_fully_validated: bool = False + + +@dataclass +class DMARCScanResult: + location: Optional[str] + record: Optional[str] + record_candidates: Optional[List[str]] + valid: bool + errors: List[str] + warnings: List[str] + # As this error is interpreted in a special way by downstream tools, + # let's have a flag (not only string message) whether it happened. + record_not_found: bool = False + tags: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class DomainScanResult: + spf: SPFScanResult + dmarc: DMARCScanResult + domain: str + base_domain: str + warnings: List[str] + spf_not_required_because_of_correct_dmarc: bool = False + + +@dataclass +class DKIMScanResult: + valid: bool + errors: List[str] + warnings: List[str] + + +@dataclass +class ScanResult: + domain: Optional[DomainScanResult] + dkim: Optional[DKIMScanResult] + timestamp: Optional[datetime.datetime] + message_timestamp: Optional[datetime.datetime] + + @property + def num_checked_mechanisms(self) -> int: + result = 0 + if self.domain: + result += 2 + if self.dkim: + result += 1 + return result + + @property + def num_correct_mechanisms(self) -> int: + result = 0 + for mechanism in self.mechanisms: + if mechanism.valid and not mechanism.warnings: + result += 1 + return result + + @property + def has_not_valid_mechanisms(self) -> int: + for mechanism in self.mechanisms: + if not mechanism.valid: + return True + return False + + @property + def mechanisms(self) -> List[Any]: + mechanisms: List[Any] = [] + if self.domain: + mechanisms.append(self.domain.spf) + mechanisms.append(self.domain.dmarc) + + if self.dkim: + mechanisms.append(self.dkim) + return mechanisms + + +class ScanningException(Exception): + def __init__(self, message: str) -> None: + self.message = message + + +class DomainValidationException(Exception): + def __init__(self, message: str): + self.message = message + + +def validate_and_sanitize_domain(domain: str) -> str: + domain = domain.lower().strip(" .") + + for space in string.whitespace: + if space in domain: + raise DomainValidationException("Whitespace in domain name detected. Please provide a correct domain name.") + for forbidden_character in set(string.punctuation) - {".", "-", "_"}: + if forbidden_character in domain: + raise DomainValidationException( + f"Unexpected character in domain detected: {forbidden_character}. Please provide a correct domain name." + ) + + result = validators.domain(domain, rfc_2782=True) + if isinstance(result, validators.ValidationError): + raise DomainValidationException("Please provide a correct domain name.") + + return domain + + +def check_alignment( + parsed_dmarc_record: Dict[str, Any], + tag_name: str, + other_domain: str, + from_domain: str, +) -> bool: + if tag_name not in parsed_dmarc_record["tags"]: + # The default value, if no aspf/adkim tag is provided, is for the alignment to be relaxed + relaxed = True + else: + tag_value = parsed_dmarc_record["tags"][tag_name]["value"] + + if tag_value not in ["r", "s"]: + raise checkdmarc.DMARCSyntaxError(f"Unknown {tag_name} value: {tag_value}.") + relaxed = tag_value == "r" + + if relaxed: + return psl.privatesuffix(from_domain) == psl.privatesuffix(other_domain) # type: ignore + else: + return from_domain == other_domain + + +def check_spf_alignment(parsed_dmarc_record: Dict[str, Any], spf_domain: str, from_domain: str) -> bool: + return check_alignment(parsed_dmarc_record, "aspf", spf_domain, from_domain) + + +def check_dkim_alignment(parsed_dmarc_record: Dict[str, Any], dkim_domain: str, from_domain: str) -> bool: + return check_alignment(parsed_dmarc_record, "adkim", dkim_domain, from_domain) + + +def contains_spf_all_fail(parsed: Dict[str, Any]) -> bool: + if parsed["all"] in ["softfail", "fail"]: + return True + if "redirect" in parsed and parsed["redirect"]: + return contains_spf_all_fail(parsed["redirect"]["parsed"]) + return False + + +def scan_domain( + envelope_domain: str, + from_domain: str, + dkim_domain: Optional[str], + parked: bool = False, + nameservers: Optional[List[str]] = None, + include_dmarc_tag_descriptions: bool = False, + timeout: float = 5.0, + ignore_void_dns_lookups: bool = False, +) -> DomainScanResult: + envelope_domain = validate_and_sanitize_domain(envelope_domain) + from_domain = validate_and_sanitize_domain(from_domain) + + if dkim_domain: + dkim_domain = validate_and_sanitize_domain(dkim_domain) + + warnings = [] + + domains_to_check = [envelope_domain, from_domain] + if dkim_domain: + domains_to_check.append(dkim_domain) + + for domain in domains_to_check: + # We glue example-subdomain. to the domain due to the behavior of the publicsuffixlist library - if + # accept_unknown is False, the publicsuffix() of a *known* TLD would be None. + if psl.publicsuffix("example-subdomain." + domain, accept_unknown=False) == domain: + warnings.append( + "Requested to scan a domain that is a public suffix, i.e. a domain such as .com where anybody could " + "register their subdomain. Such domain don't have to have properly configured e-mail sender verification " + "mechanisms. Please make sure you really wanted to check such domain and not its subdomain.", + ) + elif psl.publicsuffix("example-subdomain." + domain) == domain: + warnings.append( + "Requested to scan a top-level domain. Top-level domains don't have to have properly configured e-mail sender " + "verification mechanisms. Please make sure you really wanted to check such domain and not its subdomain." + "Besides, the domain is not known to the Public Suffix List (https://publicsuffix.org/) - please verify whether " + "it is correct.", + ) + + warnings = sorted(set(warnings)) + + domain_result = DomainScanResult( + spf=SPFScanResult( + record=None, + record_candidates=None, + valid=True, + record_not_found=False, + record_could_not_be_fully_validated=False, + errors=[], + warnings=[], + ), + dmarc=DMARCScanResult( + record=None, + record_not_found=False, + record_candidates=None, + valid=True, + location=None, + errors=[], + warnings=[], + ), + domain=domain, + base_domain=checkdmarc.get_base_domain(domain), + warnings=warnings, + ) + + try: + spf_query = checkdmarc.query_spf_record(envelope_domain, nameservers=nameservers, timeout=timeout) + + domain_result.spf.record = spf_query["record"] + if domain_result.spf.record and "%" in domain_result.spf.record: + domain_result.spf.warnings = ["SPF records containing macros aren't supported by the system yet."] + domain_result.spf.record_could_not_be_fully_validated = True + elif not domain_result.spf.record: + raise checkdmarc.SPFRecordNotFound(None) + else: + domain_result.spf.warnings = spf_query["warnings"] + + try: + parsed_spf = checkdmarc.parse_spf_record( + domain_result.spf.record, + domain_result.domain, + parked=parked, + nameservers=nameservers, + timeout=timeout, + ) + domain_result.spf.warnings = list(set(domain_result.spf.warnings) | set(parsed_spf["warnings"])) + + if not contains_spf_all_fail(parsed_spf["parsed"]): + domain_result.spf.errors = [ + "SPF '~all' or '-all' directive not found. We recommend adding it, as it describes " + "what should happen with messages that fail SPF verification. For example, " + "'-all' will tell the recipient server to drop such messages." + ] + except checkdmarc.SPFRecordNotFound as e: + # This is a different type from standard SPFRecordNotFound - it occurs + # during *parsing*, so it is not caused by lack of SPF record, but + # a malformed one (e.g. including a domain that doesn't have an SPF record). + domain_result.spf.errors = [ + f"The SPF record's include chain has a reference to the {e.domain} domain that doesn't " + "have an SPF record. When using directives such as 'include' or 'redirect' remember " + "that the destination domain must have a correct SPF record.", + ] + except checkdmarc.SPFRecordNotFound as e: + # https://github.com/domainaware/checkdmarc/issues/90 + if isinstance(e.args[0], dns.exception.DNSException): + raise ScanningException(e.args[0].msg if e.args[0].msg else repr(e.args[0])) + + if isinstance(e.args[0], checkdmarc.MultipleSPFRTXTRecords): + domain_result.spf.errors = [ + "Multiple SPF records found. We recommend leaving only one, as multiple SPF records " + "can cause problems with some SPF implementations.", + ] + else: + # Sometimes an entry pretending to be an SPF record exists (e.g. something + # beginning with [space]v=spf1) so to avoid communication problems + # with the user we tell them that we didn't find a valid one. + domain_result.spf.errors = [ + "Valid SPF record not found. We recommend using all three mechanisms: SPF, DKIM and DMARC " + "to decrease the possibility of successful e-mail message spoofing.", + ] + domain_result.spf.record_not_found = True + except checkdmarc.SPFTooManyVoidDNSLookups: + if not ignore_void_dns_lookups: + domain_result.spf.errors = [ + "SPF record causes too many void DNS lookups. Some implementations may require the number of " + "failed DNS lookups (e.g. ones that reference a nonexistent domain) to be low. The DNS lookups " + "are caused by directives such as 'mx' or 'include'.", + ] + except checkdmarc.SPFIncludeLoop: + domain_result.spf.errors = [ + "SPF record includes an endless loop. Please check whether 'include' or 'redirect' directives don't " + "create a loop where a domain redirects back to itself or earlier domain." + ] + except checkdmarc.SPFRedirectLoop: + domain_result.spf.errors = [ + "SPF record includes an endless loop. Please check whether 'include' or 'redirect' directives don't " + "create a loop where a domain redirects back to itself or earlier domain." + ] + except checkdmarc.SPFSyntaxError as e: + # We put here the original exception message from checkdmarc (e.g. "example.com: Expected mechanism + # at position 42 (marked with ➞) in: (...)") as it contains information that is helpful to debug the syntax error. + domain_result.spf.errors = [e.args[0]] + except checkdmarc.SPFTooManyDNSLookups: + domain_result.spf.errors = [ + "SPF record causes too many DNS lookups. The DNS lookups are caused by directives such as 'mx' or 'include'. " + "The specification requires the number of DNS lookups to be lower or equal to 10 to decrease load on DNS servers.", + ] + + domain_result.spf.valid = len(domain_result.spf.errors) == 0 + + # DMARC + try: + dmarc_warnings = [] + + try: + dmarc_query = checkdmarc.query_dmarc_record(from_domain, nameservers=nameservers, timeout=timeout) + except checkdmarc.DMARCRecordNotFound as e: + if isinstance(e.args[0], checkdmarc.UnrelatedTXTRecordFoundAtDMARC): + dmarc_warnings.append( + "Unrelated TXT record found in the '_dmarc' subdomain. We recommend removing it, as such unrelated " + "records may cause problems with some DMARC implementations.", + ) + dmarc_query = checkdmarc.query_dmarc_record( + domain, + nameservers=nameservers, + timeout=timeout, + ignore_unrelated_records=True, + ) + else: + raise e + domain_result.dmarc.record = dmarc_query["record"] + if not domain_result.dmarc.record: + raise checkdmarc.DMARCRecordNotFound(None) + + domain_result.dmarc.location = dmarc_query["location"] + parsed_dmarc_record = checkdmarc.parse_dmarc_record( + dmarc_query["record"], + dmarc_query["location"], + parked=parked, + include_tag_descriptions=include_dmarc_tag_descriptions, + nameservers=nameservers, + timeout=timeout, + ) + + if not check_spf_alignment(parsed_dmarc_record, envelope_domain, from_domain): + domain_result.dmarc.errors.append( + f"Domain checked by the SPF mechanism (from the RFC5321.MailFrom header: {envelope_domain}) is not " + f"aligned with the DMARC record domain (from the RFC5322.From header: {from_domain}). Read more " + "about various e-mail From headers on https://dmarc.org/2016/07/how-many-from-addresses-are-there/" + ) + if dkim_domain: + if not check_dkim_alignment(parsed_dmarc_record, dkim_domain, from_domain): + domain_result.dmarc.errors.append( + f"Domain from the DKIM signature ({dkim_domain}) is not aligned with the DMARC record domain " + f"(from the From header: {from_domain})." + ) + + if parsed_dmarc_record["tags"]["p"]["value"] == "none": + if "rua" not in parsed_dmarc_record["tags"]: + domain_result.dmarc.errors.append( + "DMARC policy is 'none' and 'rua' is not set, which means that the DMARC setting is not effective." + ) + else: + dmarc_warnings.append( + "DMARC policy is 'none', which means that besides reporting no action will be taken. The policy describes what " + "action the recipient server should take when noticing a message that doesn't pass the verification. 'quarantine' policy " + "suggests the recipient server to flag the message as spam and 'reject' policy suggests the recipient " + "server to reject the message. We recommend using the 'quarantine' or 'reject' policy.\n\n" + "When testing the DMARC mechanism, to minimize the risk of correct messages not being delivered, " + "the 'none' policy may be used. Such tests are recommended especially when the domain is used to " + "send a large number of e-mails using various tools and not delivering a correct message is " + "unacceptable. In such cases the reports should be closely monitored, and the target setting should " + "be 'quarantine' or 'reject'.", + ) + + domain_result.dmarc.tags = parsed_dmarc_record["tags"] + domain_result.dmarc.warnings = list( + set(dmarc_query["warnings"]) | set(parsed_dmarc_record["warnings"]) | set(dmarc_warnings) + ) + except checkdmarc.DMARCRecordNotFound as e: + # https://github.com/domainaware/checkdmarc/issues/90 + if isinstance(e.args[0], dns.exception.DNSException): + raise ScanningException(e.args[0].msg if e.args[0].msg else repr(e.args[0])) + + # Sometimes an entry pretending to be a DMARC record exists so to avoid + # communication problems with the user we tell them that we + # didn't find a valid one. + domain_result.dmarc.errors = [ + "Valid DMARC record not found. We recommend using all three mechanisms: SPF, DKIM and DMARC " + "to decrease the possibility of successful e-mail message spoofing.", + ] + domain_result.dmarc.record_not_found = True + except checkdmarc.DMARCRecordInWrongLocation as e: + # We put here the original exception message from checkdmarc ("The DMARC record must be located at {0}, + # not {1}") as it contains the domain names describing the expected and actual location. + domain_result.dmarc.errors = [e.args[0]] + except checkdmarc.MultipleDMARCRecords: + domain_result.dmarc.errors = [ + "There are multiple DMARC records. We recommend leaving only one, as multiple " + "DMARC records can cause problems with some DMARC implementations.", + ] + except checkdmarc.SPFRecordFoundWhereDMARCRecordShouldBe: + domain_result.dmarc.errors = [ + "There is an SPF record instead of DMARC one on the '_dmarc' subdomain.", + ] + except checkdmarc.DMARCRecordStartsWithWhitespace: + domain_result.dmarc.errors = [ + "Found a DMARC record that starts with whitespace. " + "Please remove the whitespace, as some implementations may not " + "process it correctly." + ] + except checkdmarc.DMARCSyntaxError as e: + # We put here the original exception message from checkdmarc (e.g. "the p tag must immediately follow + # the v tag") as it contains information that is helpful to debug the syntax error. + domain_result.dmarc.errors = [e.args[0]] + except checkdmarc.InvalidDMARCTag: + domain_result.dmarc.errors = [ + "DMARC record uses an invalid tag. Please refer to https://datatracker.ietf.org/doc/html/rfc7489#section-6.3 " + "for the list of available tags." + ] + except checkdmarc.InvalidDMARCReportURI: + domain_result.dmarc.errors = [ + "DMARC report URI is invalid. The report URI should be an e-mail address prefixed with mailto:.", + ] + except checkdmarc.UnverifiedDMARCURIDestination: + domain_result.dmarc.errors = [ + "The destination of a DMARC report URI does not indicate that it accepts reports for the domain." + ] + except checkdmarc.DMARCReportEmailAddressMissingMXRecords: + domain_result.dmarc.errors = [ + "The domain of the email address in a DMARC report URI is missing MX records. That means, that this domain " + "may not receive DMARC reports." + ] + + domain_result.dmarc.valid = len(domain_result.dmarc.errors) == 0 + + if not domain_result.spf.record: + try: + domain_result.spf.record_candidates = lax_record_query.lax_query_spf_record(envelope_domain) + # If we are unable to retrieve the candidates, let's keep them empty, as the check result is more important. + except Exception: + pass + + if not domain_result.dmarc.record: + try: + domain_result.dmarc.record_candidates = lax_record_query.lax_query_dmarc_record(from_domain) + # If we are unable to retrieve the candidates, let's keep them empty, as the check result is more important. + except Exception: + pass + + domain_result.spf_not_required_because_of_correct_dmarc = ( + domain_result.spf is not None + and domain_result.spf.record_not_found + and domain_result.dmarc is not None + and bool(domain_result.dmarc.record) + and domain_result.dmarc.valid + and len(domain_result.dmarc.warnings) == 0 + and domain_result.dmarc.tags is not None + and "p" in domain_result.dmarc.tags + and domain_result.dmarc.tags["p"]["value"] in ["quarantine", "reject"] + ) + if domain_result.spf_not_required_because_of_correct_dmarc: + domain_result.spf.valid = True + + return domain_result + + +def scan_dkim( + message: bytes, + message_parsed: EmailMessage, + dkim_implementation_mismatch_callback: Optional[Callable[[bytes, bool, bool], None]] = None, +) -> DKIMScanResult: + if "dkim-signature" not in message_parsed: + return DKIMScanResult( + valid=False, + errors=["No DKIM signature found"], + warnings=[], + ) + + opendkim_valid = subprocess.run(["opendkim-testmsg"], input=message).returncode == 0 + + try: + # We don't call dkim.verify() directly because it would catch dkim.DKIMException + # for us, thus not allowing to translate the message. + d = dkim.DKIM(message) + dkimpy_valid = d.verify() + + LOGGER.info( + "DKIM libraries opinion: dkimpy=%s, opendkim=%s", + dkimpy_valid, + opendkim_valid, + ) + if dkimpy_valid != opendkim_valid and dkim_implementation_mismatch_callback: + dkim_implementation_mismatch_callback(message, dkimpy_valid, opendkim_valid) + + if dkimpy_valid: + return DKIMScanResult( + valid=True, + errors=[], + warnings=[], + ) + else: + return DKIMScanResult( + valid=False, + errors=["Found an invalid DKIM signature"], + warnings=[], + ) + except (dkim.DKIMException, dns.exception.DNSException) as e: + LOGGER.info( + "DKIM libraries opinion: dkimpy=False, opendkim=%s", + opendkim_valid, + ) + if opendkim_valid and dkim_implementation_mismatch_callback: + dkim_implementation_mismatch_callback(message, False, opendkim_valid) + + return DKIMScanResult( + valid=False, + errors=[e.args[0]], + warnings=[], + ) + + +def scan( + envelope_domain: str, + from_domain: str, + dkim_domain: Optional[str], + message: Optional[bytes], + message_timestamp: Optional[datetime.datetime], + nameservers: Optional[List[str]] = None, + dkim_implementation_mismatch_callback: Optional[Callable[[bytes, bool, bool], None]] = None, +) -> ScanResult: + if message: + stream = io.StringIO(message.decode("utf-8", errors="ignore")) + message_parsed = message_from_file(stream) + else: + message_parsed = None + + return ScanResult( + domain=scan_domain( + envelope_domain=envelope_domain, + from_domain=from_domain, + dkim_domain=dkim_domain, + nameservers=nameservers, + ), + dkim=scan_dkim( + message=message, + message_parsed=message_parsed, + dkim_implementation_mismatch_callback=dkim_implementation_mismatch_callback, + ) + if message and message_parsed + else None, + timestamp=datetime.datetime.now(), + message_timestamp=message_timestamp, + ) diff --git a/app/src/tags.py b/app/src/tags.py new file mode 100644 index 0000000..2b4d203 --- /dev/null +++ b/app/src/tags.py @@ -0,0 +1,22 @@ +from functools import cache + +import decouple +from jinja2_simple_tags import StandaloneTag + + +class BuildIDTag(StandaloneTag): # type: ignore + tags = {"build_id"} + + @cache + def render(self) -> str: + with open("/app/build_id") as f: + return f.read().strip() + + +class LanguageTag(StandaloneTag): # type: ignore + tags = {"language"} + language = decouple.config("LANGUAGE", default="en_US").replace("_", "-") + + @cache + def render(self) -> str: + return self.language # type: ignore diff --git a/app/src/template_utils.py b/app/src/template_utils.py new file mode 100644 index 0000000..d93fe79 --- /dev/null +++ b/app/src/template_utils.py @@ -0,0 +1,291 @@ +import re +import typing as t + +import markupsafe + +IANA_TOP_LEVEL_DOMAINS = ( + "(?:" + "(?:aaa|aarp|abarth|abb|abbott|abbvie|abc|able|abogado|abudhabi|academy|accenture" + "|accountant|accountants|aco|actor|adac|ads|adult|aeg|aero|aetna|afl|africa|agakhan|agency" + "|aig|airbus|airforce|airtel|akdn|alfaromeo|alibaba|alipay|allfinanz|allstate|ally|alsace" + "|alstom|amazon|americanexpress|americanfamily|amex|amfam|amica|amsterdam|analytics|android" + "|anquan|anz|aol|apartments|app|apple|aquarelle|arab|aramco|archi|army|arpa|art|arte" + "|asda|asia|associates|athleta|attorney|auction|audi|audible|audio|auspost|author|auto" + "|autos|avianca|aws|axa|azure|a[cdefgilmoqrstuwxz])" + "|(?:baby|baidu|banamex|bananarepublic|band|bank|bar|barcelona|barclaycard|barclays" + "|barefoot|bargains|baseball|basketball|bauhaus|bayern|bbc|bbt|bbva|bcg|bcn|beats|beauty" + "|beer|bentley|berlin|best|bestbuy|bet|bharti|bible|bid|bike|bing|bingo|bio|biz|black" + "|blackfriday|blockbuster|blog|bloomberg|blue|bms|bmw|bnpparibas|boats|boehringer|bofa" + "|bom|bond|boo|book|booking|bosch|bostik|boston|bot|boutique|box|bradesco|bridgestone" + "|broadway|broker|brother|brussels|bugatti|build|builders|business|buy|buzz|bzh|b[abdefghijmnorstvwyz])" + "|(?:cab|cafe|cal|call|calvinklein|cam|camera|camp|cancerresearch|canon|capetown|capital" + "|capitalone|car|caravan|cards|care|career|careers|cars|casa|case|cash|casino|cat|catering" + "|catholic|cba|cbn|cbre|cbs|center|ceo|cern|cfa|cfd|chanel|channel|charity|chase|chat" + "|cheap|chintai|christmas|chrome|church|cipriani|circle|cisco|citadel|citi|citic|city" + "|cityeats|claims|cleaning|click|clinic|clinique|clothing|cloud|club|clubmed|coach|codes" + "|coffee|college|cologne|com|comcast|commbank|community|company|compare|computer|comsec" + "|condos|construction|consulting|contact|contractors|cooking|cookingchannel|cool|coop" + "|corsica|country|coupon|coupons|courses|cpa|credit|creditcard|creditunion|cricket|crown" + "|crs|cruise|cruises|cuisinella|cymru|cyou|c[acdfghiklmnoruvwxyz])" + "|(?:dabur|dad|dance|data|date|dating|datsun|day|dclk|dds|deal|dealer|deals|degree" + "|delivery|dell|deloitte|delta|democrat|dental|dentist|desi|design|dev|dhl|diamonds|diet" + "|digital|direct|directory|discount|discover|dish|diy|dnp|docs|doctor|dog|domains|dot" + "|download|drive|dtv|dubai|dunlop|dupont|durban|dvag|dvr|d[ejkmoz])" + "|(?:earth|eat|eco|edeka|edu|education|email|emerck|energy|engineer|engineering|enterprises" + "|epson|equipment|ericsson|erni|esq|estate|etisalat|eurovision|eus|events|exchange|expert" + "|exposed|express|extraspace|e[cegrstu])" + "|(?:fage|fail|fairwinds|faith|family|fan|fans|farm|farmers|fashion|fast|fedex|feedback" + "|ferrari|ferrero|fiat|fidelity|fido|film|final|finance|financial|fire|firestone|firmdale" + "|fish|fishing|fit|fitness|flickr|flights|flir|florist|flowers|fly|foo|food|foodnetwork" + "|football|ford|forex|forsale|forum|foundation|fox|free|fresenius|frl|frogans|frontdoor" + "|frontier|ftr|fujitsu|fun|fund|furniture|futbol|fyi|f[ijkmor])" + "|(?:gal|gallery|gallo|gallup|game|games|gap|garden|gay|gbiz|gdn|gea|gent|genting" + "|george|ggee|gift|gifts|gives|giving|glass|gle|global|globo|gmail|gmbh|gmo|gmx|godaddy" + "|gold|goldpoint|golf|goo|goodyear|goog|google|gop|got|gov|grainger|graphics|gratis|green" + "|gripe|grocery|group|guardian|gucci|guge|guide|guitars|guru|g[abdefghilmnpqrstuwy])" + "|(?:hair|hamburg|hangout|haus|hbo|hdfc|hdfcbank|health|healthcare|help|helsinki|here" + "|hermes|hgtv|hiphop|hisamitsu|hitachi|hiv|hkt|hockey|holdings|holiday|homedepot|homegoods" + "|homes|homesense|honda|horse|hospital|host|hosting|hot|hoteles|hotels|hotmail|house" + "|how|hsbc|hughes|hyatt|hyundai|h[kmnrtu])" + "|(?:ibm|icbc|ice|icu|ieee|ifm|ikano|imamat|imdb|immo|immobilien|inc|industries|infiniti" + "|info|ing|ink|institute|insurance|insure|int|international|intuit|investments|ipiranga" + "|irish|ismaili|ist|istanbul|itau|itv|i[delmnoqrst])" + "|(?:jaguar|java|jcb|jeep|jetzt|jewelry|jio|jll|jmp|jnj|jobs|joburg|jot|joy|jpmorgan" + "|jprs|juegos|juniper|j[emop])" + "|(?:kaufen|kddi|kerryhotels|kerrylogistics|kerryproperties|kfh|kia|kids|kim|kinder" + "|kindle|kitchen|kiwi|koeln|komatsu|kosher|kpmg|kpn|krd|kred|kuokgroup|kyoto|k[eghimnprwyz])" + "|(?:lacaixa|lamborghini|lamer|lancaster|lancia|land|landrover|lanxess|lasalle|lat" + "|latino|latrobe|law|lawyer|lds|lease|leclerc|lefrak|legal|lego|lexus|lgbt|lidl|life" + "|lifeinsurance|lifestyle|lighting|like|lilly|limited|limo|lincoln|linde|link|lipsy|live" + "|living|llc|llp|loan|loans|locker|locus|loft|lol|london|lotte|lotto|love|lpl|lplfinancial" + "|ltd|ltda|lundbeck|luxe|luxury|l[abcikrstuvy])" + "|(?:macys|madrid|maif|maison|makeup|man|management|mango|map|market|marketing|markets" + "|marriott|marshalls|maserati|mattel|mba|mckinsey|med|media|meet|melbourne|meme|memorial" + "|men|menu|merckmsd|miami|microsoft|mil|mini|mint|mit|mitsubishi|mlb|mls|mma|mobi|mobile" + "|moda|moe|moi|mom|monash|money|monster|mormon|mortgage|moscow|moto|motorcycles|mov|movie" + "|msd|mtn|mtr|museum|music|mutual|m[acdeghklmnopqrstuvwxyz])" + "|(?:nab|nagoya|name|natura|navy|nba|nec|net|netbank|netflix|network|neustar|new|news" + "|next|nextdirect|nexus|nfl|ngo|nhk|nico|nike|nikon|ninja|nissan|nissay|nokia|northwesternmutual" + "|norton|now|nowruz|nowtv|nra|nrw|ntt|nyc|n[acefgilopruz])" + "|(?:obi|observer|office|okinawa|olayan|olayangroup|oldnavy|ollo|omega|one|ong|onl" + "|online|ooo|open|oracle|orange|org|organic|origins|osaka|otsuka|ott|ovh|om)" + "|(?:page|panasonic|paris|pars|partners|parts|party|passagens|pay|pccw|pet|pfizer" + "|pharmacy|phd|philips|phone|photo|photography|photos|physio|pics|pictet|pictures|pid" + "|pin|ping|pink|pioneer|pizza|place|play|playstation|plumbing|plus|pnc|pohl|poker|politie" + "|porn|post|pramerica|praxi|press|prime|pro|prod|productions|prof|progressive|promo|properties" + "|property|protection|pru|prudential|pub|pwc|p[aefghklmnrstwy])" + "|(?:qpon|quebec|quest|qa)" + "|(?:racing|radio|read|realestate|realtor|realty|recipes|red|redstone|redumbrella" + "|rehab|reise|reisen|reit|reliance|ren|rent|rentals|repair|report|republican|rest|restaurant" + "|review|reviews|rexroth|rich|richardli|ricoh|ril|rio|rip|rocher|rocks|rodeo|rogers|room" + "|rsvp|rugby|ruhr|run|rwe|ryukyu|r[eosuw])" + "|(?:saarland|safe|safety|sakura|sale|salon|samsclub|samsung|sandvik|sandvikcoromant" + "|sanofi|sap|sarl|sas|save|saxo|sbi|sbs|sca|scb|schaeffler|schmidt|scholarships|school" + "|schule|schwarz|science|scot|search|seat|secure|security|seek|select|sener|services" + "|ses|seven|sew|sex|sexy|sfr|shangrila|sharp|shaw|shell|shia|shiksha|shoes|shop|shopping" + "|shouji|show|showtime|silk|sina|singles|site|ski|skin|sky|skype|sling|smart|smile|sncf" + "|soccer|social|softbank|software|sohu|solar|solutions|song|sony|soy|spa|space|sport" + "|spot|srl|stada|staples|star|statebank|statefarm|stc|stcgroup|stockholm|storage|store" + "|stream|studio|study|style|sucks|supplies|supply|support|surf|surgery|suzuki|swatch" + "|swiss|sydney|systems|s[abcdeghijklmnorstuvxyz])" + "|(?:tab|taipei|talk|taobao|target|tatamotors|tatar|tattoo|tax|taxi|tci|tdk|team|tech" + "|technology|tel|temasek|tennis|teva|thd|theater|theatre|tiaa|tickets|tienda|tiffany" + "|tips|tires|tirol|tjmaxx|tjx|tkmaxx|tmall|today|tokyo|tools|top|toray|toshiba|total" + "|tours|town|toyota|toys|trade|trading|training|travel|travelchannel|travelers|travelersinsurance" + "|trust|trv|tube|tui|tunes|tushu|tvs|t[cdfghjklmnortvwz])" + "|(?:ubank|ubs|unicom|university|uno|uol|ups|u[agksyz])" + "|(?:vacations|vana|vanguard|vegas|ventures|verisign|versicherung|vet|viajes|video" + "|vig|viking|villas|vin|vip|virgin|visa|vision|viva|vivo|vlaanderen|vodka|volkswagen" + "|volvo|vote|voting|voto|voyage|vuelos|v[aceginu])" + "|(?:wales|walmart|walter|wang|wanggou|watch|watches|weather|weatherchannel|webcam" + "|weber|website|wed|wedding|weibo|weir|whoswho|wien|wiki|williamhill|win|windows|wine" + "|winners|wme|wolterskluwer|woodside|work|works|world|wow|wtc|wtf|w[fs])" + "|(?:\u03b5\u03bb|\u03b5\u03c5|\u0431\u0433|\u0431\u0435\u043b|\u0434\u0435\u0442\u0438" + "|\u0435\u044e|\u043a\u0430\u0442\u043e\u043b\u0438\u043a|\u043a\u043e\u043c|\u043c\u043a\u0434" + "|\u043c\u043e\u043d|\u043c\u043e\u0441\u043a\u0432\u0430|\u043e\u043d\u043b\u0430\u0439\u043d" + "|\u043e\u0440\u0433|\u0440\u0443\u0441|\u0440\u0444|\u0441\u0430\u0439\u0442|\u0441\u0440\u0431" + "|\u0443\u043a\u0440|\u049b\u0430\u0437|\u0570\u0561\u0575|\u05d9\u05e9\u05e8\u05d0\u05dc" + "|\u05e7\u05d5\u05dd|\u0627\u0628\u0648\u0638\u0628\u064a|\u0627\u062a\u0635\u0627\u0644\u0627\u062a" + "|\u0627\u0631\u0627\u0645\u0643\u0648|\u0627\u0644\u0627\u0631\u062f\u0646|\u0627\u0644\u0628\u062d\u0631\u064a\u0646" + "|\u0627\u0644\u062c\u0632\u0627\u0626\u0631|\u0627\u0644\u0633\u0639\u0648\u062f\u064a\u0629" + "|\u0627\u0644\u0639\u0644\u064a\u0627\u0646|\u0627\u0644\u0645\u063a\u0631\u0628|\u0627\u0645\u0627\u0631\u0627\u062a" + "|\u0627\u06cc\u0631\u0627\u0646|\u0628\u0627\u0631\u062a|\u0628\u0627\u0632\u0627\u0631" + "|\u0628\u064a\u062a\u0643|\u0628\u06be\u0627\u0631\u062a|\u062a\u0648\u0646\u0633|\u0633\u0648\u062f\u0627\u0646" + "|\u0633\u0648\u0631\u064a\u0629|\u0634\u0628\u0643\u0629|\u0639\u0631\u0627\u0642|\u0639\u0631\u0628" + "|\u0639\u0645\u0627\u0646|\u0641\u0644\u0633\u0637\u064a\u0646|\u0642\u0637\u0631|\u0643\u0627\u062b\u0648\u0644\u064a\u0643" + "|\u0643\u0648\u0645|\u0645\u0635\u0631|\u0645\u0644\u064a\u0633\u064a\u0627|\u0645\u0648\u0631\u064a\u062a\u0627\u0646\u064a\u0627" + "|\u0645\u0648\u0642\u0639|\u0647\u0645\u0631\u0627\u0647|\u067e\u0627\u06a9\u0633\u062a\u0627\u0646" + "|\u0680\u0627\u0631\u062a|\u0915\u0949\u092e|\u0928\u0947\u091f|\u092d\u093e\u0930\u0924" + "|\u092d\u093e\u0930\u0924\u092e\u094d|\u092d\u093e\u0930\u094b\u0924|\u0938\u0902\u0917\u0920\u0928" + "|\u09ac\u09be\u0982\u09b2\u09be|\u09ad\u09be\u09b0\u09a4|\u09ad\u09be\u09f0\u09a4|\u0a2d\u0a3e\u0a30\u0a24" + "|\u0aad\u0abe\u0ab0\u0aa4|\u0b2d\u0b3e\u0b30\u0b24|\u0b87\u0ba8\u0bcd\u0ba4\u0bbf\u0baf\u0bbe" + "|\u0b87\u0bb2\u0b99\u0bcd\u0b95\u0bc8|\u0b9a\u0bbf\u0b99\u0bcd\u0b95\u0baa\u0bcd\u0baa\u0bc2\u0bb0\u0bcd" + "|\u0c2d\u0c3e\u0c30\u0c24\u0c4d|\u0cad\u0cbe\u0cb0\u0ca4|\u0d2d\u0d3e\u0d30\u0d24\u0d02" + "|\u0dbd\u0d82\u0d9a\u0dcf|\u0e04\u0e2d\u0e21|\u0e44\u0e17\u0e22|\u0ea5\u0eb2\u0ea7|\u10d2\u10d4" + "|\u307f\u3093\u306a|\u30a2\u30de\u30be\u30f3|\u30af\u30e9\u30a6\u30c9|\u30b0\u30fc\u30b0\u30eb" + "|\u30b3\u30e0|\u30b9\u30c8\u30a2|\u30bb\u30fc\u30eb|\u30d5\u30a1\u30c3\u30b7\u30e7\u30f3" + "|\u30dd\u30a4\u30f3\u30c8|\u4e16\u754c|\u4e2d\u4fe1|\u4e2d\u56fd|\u4e2d\u570b|\u4e2d\u6587\u7f51" + "|\u4e9a\u9a6c\u900a|\u4f01\u4e1a|\u4f5b\u5c71|\u4fe1\u606f|\u5065\u5eb7|\u516b\u5366" + "|\u516c\u53f8|\u516c\u76ca|\u53f0\u6e7e|\u53f0\u7063|\u5546\u57ce|\u5546\u5e97|\u5546\u6807" + "|\u5609\u91cc|\u5609\u91cc\u5927\u9152\u5e97|\u5728\u7ebf|\u5927\u62ff|\u5929\u4e3b\u6559" + "|\u5a31\u4e50|\u5bb6\u96fb|\u5e7f\u4e1c|\u5fae\u535a|\u6148\u5584|\u6211\u7231\u4f60" + "|\u624b\u673a|\u62db\u8058|\u653f\u52a1|\u653f\u5e9c|\u65b0\u52a0\u5761|\u65b0\u95fb" + "|\u65f6\u5c1a|\u66f8\u7c4d|\u673a\u6784|\u6de1\u9a6c\u9521|\u6e38\u620f|\u6fb3\u9580" + "|\u70b9\u770b|\u79fb\u52a8|\u7ec4\u7ec7\u673a\u6784|\u7f51\u5740|\u7f51\u5e97|\u7f51\u7ad9" + "|\u7f51\u7edc|\u8054\u901a|\u8bfa\u57fa\u4e9a|\u8c37\u6b4c|\u8d2d\u7269|\u901a\u8ca9" + "|\u96c6\u56e2|\u96fb\u8a0a\u76c8\u79d1|\u98de\u5229\u6d66|\u98df\u54c1|\u9910\u5385" + "|\u9999\u683c\u91cc\u62c9|\u9999\u6e2f|\ub2f7\ub137|\ub2f7\ucef4|\uc0bc\uc131|\ud55c\uad6d" + "|verm\xf6gensberater|verm\xf6gensberatung|xbox|xerox|xfinity|xihuan|xin|xn\\-\\-11b4c3d" + "|xn\\-\\-1ck2e1b|xn\\-\\-1qqw23a|xn\\-\\-2scrj9c|xn\\-\\-30rr7y|xn\\-\\-3bst00m|xn\\-\\-3ds443g" + "|xn\\-\\-3e0b707e|xn\\-\\-3hcrj9c|xn\\-\\-3pxu8k|xn\\-\\-42c2d9a|xn\\-\\-45br5cyl|xn\\-\\-45brj9c" + "|xn\\-\\-45q11c|xn\\-\\-4dbrk0ce|xn\\-\\-4gbrim|xn\\-\\-54b7fta0cc|xn\\-\\-55qw42g|xn\\-\\-55qx5d" + "|xn\\-\\-5su34j936bgsg|xn\\-\\-5tzm5g|xn\\-\\-6frz82g|xn\\-\\-6qq986b3xl|xn\\-\\-80adxhks" + "|xn\\-\\-80ao21a|xn\\-\\-80aqecdr1a|xn\\-\\-80asehdb|xn\\-\\-80aswg|xn\\-\\-8y0a063a" + "|xn\\-\\-90a3ac|xn\\-\\-90ae|xn\\-\\-90ais|xn\\-\\-9dbq2a|xn\\-\\-9et52u|xn\\-\\-9krt00a" + "|xn\\-\\-b4w605ferd|xn\\-\\-bck1b9a5dre4c|xn\\-\\-c1avg|xn\\-\\-c2br7g|xn\\-\\-cck2b3b" + "|xn\\-\\-cckwcxetd|xn\\-\\-cg4bki|xn\\-\\-clchc0ea0b2g2a9gcd|xn\\-\\-czr694b|xn\\-\\-czrs0t" + "|xn\\-\\-czru2d|xn\\-\\-d1acj3b|xn\\-\\-d1alf|xn\\-\\-e1a4c|xn\\-\\-eckvdtc9d|xn\\-\\-efvy88h" + "|xn\\-\\-fct429k|xn\\-\\-fhbei|xn\\-\\-fiq228c5hs|xn\\-\\-fiq64b|xn\\-\\-fiqs8s|xn\\-\\-fiqz9s" + "|xn\\-\\-fjq720a|xn\\-\\-flw351e|xn\\-\\-fpcrj9c3d|xn\\-\\-fzc2c9e2c|xn\\-\\-fzys8d69uvgm" + "|xn\\-\\-g2xx48c|xn\\-\\-gckr3f0f|xn\\-\\-gecrj9c|xn\\-\\-gk3at1e|xn\\-\\-h2breg3eve" + "|xn\\-\\-h2brj9c|xn\\-\\-h2brj9c8c|xn\\-\\-hxt814e|xn\\-\\-i1b6b1a6a2e|xn\\-\\-imr513n" + "|xn\\-\\-io0a7i|xn\\-\\-j1aef|xn\\-\\-j1amh|xn\\-\\-j6w193g|xn\\-\\-jlq480n2rg|xn\\-\\-jlq61u9w7b" + "|xn\\-\\-jvr189m|xn\\-\\-kcrx77d1x4a|xn\\-\\-kprw13d|xn\\-\\-kpry57d|xn\\-\\-kput3i" + "|xn\\-\\-l1acc|xn\\-\\-lgbbat1ad8j|xn\\-\\-mgb9awbf|xn\\-\\-mgba3a3ejt|xn\\-\\-mgba3a4f16a" + "|xn\\-\\-mgba7c0bbn0a|xn\\-\\-mgbaakc7dvf|xn\\-\\-mgbaam7a8h|xn\\-\\-mgbab2bd|xn\\-\\-mgbah1a3hjkrd" + "|xn\\-\\-mgbai9azgqp6j|xn\\-\\-mgbayh7gpa|xn\\-\\-mgbbh1a|xn\\-\\-mgbbh1a71e|xn\\-\\-mgbc0a9azcg" + "|xn\\-\\-mgbca7dzdo|xn\\-\\-mgbcpq6gpa1a|xn\\-\\-mgberp4a5d4ar|xn\\-\\-mgbgu82a|xn\\-\\-mgbi4ecexp" + "|xn\\-\\-mgbpl2fh|xn\\-\\-mgbt3dhd|xn\\-\\-mgbtx2b|xn\\-\\-mgbx4cd0ab|xn\\-\\-mix891f" + "|xn\\-\\-mk1bu44c|xn\\-\\-mxtq1m|xn\\-\\-ngbc5azd|xn\\-\\-ngbe9e0a|xn\\-\\-ngbrx|xn\\-\\-node" + "|xn\\-\\-nqv7f|xn\\-\\-nqv7fs00ema|xn\\-\\-nyqy26a|xn\\-\\-o3cw4h|xn\\-\\-ogbpf8fl|xn\\-\\-otu796d" + "|xn\\-\\-p1acf|xn\\-\\-p1ai|xn\\-\\-pgbs0dh|xn\\-\\-pssy2u|xn\\-\\-q7ce6a|xn\\-\\-q9jyb4c" + "|xn\\-\\-qcka1pmc|xn\\-\\-qxa6a|xn\\-\\-qxam|xn\\-\\-rhqv96g|xn\\-\\-rovu88b|xn\\-\\-rvc1e0am3e" + "|xn\\-\\-s9brj9c|xn\\-\\-ses554g|xn\\-\\-t60b56a|xn\\-\\-tckwe|xn\\-\\-tiq49xqyj|xn\\-\\-unup4y" + "|xn\\-\\-vermgensberater\\-ctb|xn\\-\\-vermgensberatung\\-pwb|xn\\-\\-vhquv|xn\\-\\-vuq861b" + "|xn\\-\\-w4r85el8fhu5dnra|xn\\-\\-w4rs40l|xn\\-\\-wgbh1c|xn\\-\\-wgbl6a|xn\\-\\-xhq521b" + "|xn\\-\\-xkc2al3hye2a|xn\\-\\-xkc2dl3a5ee0h|xn\\-\\-y9a3aq|xn\\-\\-yfro4i67o|xn\\-\\-ygbi2ammx" + "|xn\\-\\-zfr164b|xxx|xyz)" + "|(?:yachts|yahoo|yamaxun|yandex|yodobashi|yoga|yokohama|you|youtube|yun|y[et])" + "|(?:zappos|zara|zero|zip|zone|zuerich|z[amw]))" +) + +PUNYCODE_TLD = "xn\\-\\-[\\w\\-]{0,58}\\w" + +UCS_CHAR = ( + "\u00A1-\u1FFF" + "\u200C-\u2027" + "\u202A-\u202E" + "\u2030-\u2FFF" + "\u3001-\uD7FF" + "\uF900-\uFDCF" + "\uFDF0-\uFFEF" + "\U00010000-\U0001FFFD" + "\U00020000-\U0002FFFD" + "\U00030000-\U0003FFFD" + "\U00040000-\U0004FFFD" + "\U00050000-\U0005FFFD" + "\U00060000-\U0006FFFD" + "\U00070000-\U0007FFFD" + "\U00080000-\U0008FFFD" + "\U00090000-\U0009FFFD" + "\U000A0000-\U000AFFFD" + "\U000B0000-\U000BFFFD" + "\U000C0000-\U000CFFFD" + "\U000D0000-\U000DFFFD" + "\U000E1000-\U000EFFFD" +) + +LABEL_CHAR = f"a-zA-Z0-9{UCS_CHAR}" +IRI_LABEL = f"[{LABEL_CHAR}](?:[{LABEL_CHAR}_\\-]" "{0,61}" f"[{LABEL_CHAR}])" "{0,1}" + +PROTOCOL = "(?i:http|https|rtsp|ftp)://" +WORD_BOUNDARY = "(?:\\b|$|^)" +USER_INFO = ( + "(?:[a-zA-Z0-9\\$\\-\\_\\.\\+\\!\\*\\'\\(\\)" + "\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,64}(?:\\:(?:[a-zA-Z0-9\\$\\-\\_" + "\\.\\+\\!\\*\\'\\(\\)\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,25})?\\@" +) +PORT_NUMBER = "\\:\\d{1,5}" +PATH_AND_QUERY = f"[/\\?](?:(?:[{LABEL_CHAR};/\\?:@&=#~" "\\-\\.\\+!\\*'\\(\\),_\\$])|(?:%[a-fA-F0-9]{2}))*" + +STRICT_TLD = f"(?:{IANA_TOP_LEVEL_DOMAINS}|{PUNYCODE_TLD})" + +IP_ADDRESS_STRING = ( + "((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]" + "[0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1]" + "[0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}" + "|[1-9][0-9]|[0-9]))" +) + +STRICT_HOST_NAME = f"(?:(?:{IRI_LABEL}\\.)+{STRICT_TLD})" + +STRICT_DOMAIN_NAME = f"(?:{STRICT_HOST_NAME}|{IP_ADDRESS_STRING})" + +RELAXED_DOMAIN_NAME = f"(?:(?:{IRI_LABEL}(?:\\.(?=\\S))?)+|{IP_ADDRESS_STRING})" + +STRICT_DOMAIN_NAME_RE = re.compile(STRICT_DOMAIN_NAME) + +WEB_URL_WITH_PROTOCOL_RE = re.compile( + "(" + f"{WORD_BOUNDARY}(?:" + f"(?:{PROTOCOL}(?:{USER_INFO})?)" + f"(?:{RELAXED_DOMAIN_NAME})?" + f"(?:{PORT_NUMBER})?" + ")" + f"(?:{PATH_AND_QUERY})?{WORD_BOUNDARY}" + ")" +) + + +def mailgoose_urlize( + text: str, + rel: t.Optional[str] = None, + target: t.Optional[str] = None, +) -> str: + """Copied and adapted from jinja2 urlize - changed so that domains are monospace, not links.""" + words = re.split(r"(\s+)", str(markupsafe.escape(text))) + rel_attr = f' rel="{markupsafe.escape(rel)}"' if rel else "" + target_attr = f' target="{markupsafe.escape(target)}"' if target else "" + + for i, word in enumerate(words): + head, middle, tail = "", word, "" + match = re.match(r"^([(<]|<)+", middle) + + if match: + head = match.group() + middle = middle[match.end() :] + + # Unlike lead, which is anchored to the start of the string, + # need to check that the string ends with any of the characters + # before trying to match all of them, to avoid backtracking. + if middle.endswith((")", ">", ".", ",", "\n", ">")): + match = re.search(r"([)>.,\n]|>)+$", middle) + + if match: + tail = match.group() + middle = middle[: match.start()] + + # Prefer balancing parentheses in URLs instead of ignoring a + # trailing character. + for start_char, end_char in ("(", ")"), ("<", ">"), ("<", ">"): + start_count = middle.count(start_char) + + if start_count <= middle.count(end_char): + # Balanced, or lighter on the left + continue + + # Move as many as possible from the tail to balance + for _ in range(min(start_count, tail.count(end_char))): + end_index = tail.index(end_char) + len(end_char) + # Move anything in the tail before the end char too + middle += tail[:end_index] + tail = tail[end_index:] + + if WEB_URL_WITH_PROTOCOL_RE.match(middle): + middle = f'{middle}' + elif STRICT_DOMAIN_NAME_RE.match(middle): + middle = f"{middle}" + + words[i] = f"{head}{middle}{tail}" + + return markupsafe.Markup("".join(words)) diff --git a/app/src/templates.py b/app/src/templates.py new file mode 100644 index 0000000..b05e0e6 --- /dev/null +++ b/app/src/templates.py @@ -0,0 +1,30 @@ +import gettext +import subprocess + +from fastapi.templating import Jinja2Templates + +from .tags import BuildIDTag, LanguageTag +from .template_utils import mailgoose_urlize + + +def setup_templates(language: str) -> Jinja2Templates: + templates = Jinja2Templates(directory="templates", extensions=[BuildIDTag, LanguageTag, "jinja2.ext.i18n"]) + templates.env.filters["mailgoose_urlize"] = mailgoose_urlize + subprocess.call( + [ + "pybabel", + "compile", + "-f", + "--input", + f"/app/translations/{language}/LC_MESSAGES/messages.po", + "--output", + f"/app/translations/{language}/LC_MESSAGES/messages.mo", + ], + stderr=subprocess.DEVNULL, # suppress a misleading message where compiled translations will be saved + ) + + templates.env.install_gettext_translations( # type: ignore + gettext.translation(domain="messages", localedir="/app/translations", languages=[language]), newstyle=True + ) + + return templates diff --git a/app/src/translate.py b/app/src/translate.py new file mode 100644 index 0000000..c4cacaf --- /dev/null +++ b/app/src/translate.py @@ -0,0 +1,598 @@ +import copy +import re +from enum import Enum +from typing import Callable, List, Optional, Tuple + +from .scan import DKIMScanResult, DomainScanResult, ScanResult + + +class Language(Enum): + en_US = "en_US" + pl_PL = "pl_PL" + + +PLACEHOLDER = "__PLACEHOLDER__" +SKIP_PLACEHOLDER = "__SKIP_PLACEHOLDER__" + + +TRANSLATIONS = { + Language.pl_PL: [ + ( + "SPF '~all' or '-all' directive not found. We recommend adding it, as it describes " + "what should happen with messages that fail SPF verification. For example, " + "'-all' will tell the recipient server to drop such messages.", + "Nie znaleziono dyrektywy '~all' lub '-all' w rekordzie SPF. Rekomendujemy jej dodanie, ponieważ " + "opisuje ona, jak powinny zostać potraktowane wiadomości, które zostaną odrzucone " + "przez mechanizm SPF. Na przykład, dyrektywa '-all' wskazuje serwerowi odbiorcy, " + "że powinien odrzucać takie wiadomości.", + ), + ( + "Valid SPF record not found. We recommend using all three mechanisms: SPF, DKIM and DMARC " + "to decrease the possibility of successful e-mail message spoofing.", + "Nie znaleziono poprawnego rekordu SPF. Rekomendujemy używanie wszystkich trzech mechanizmów: " + "SPF, DKIM i DMARC, aby zmniejszyć szansę, że sfałszowana wiadomość zostanie zaakceptowana " + "przez serwer odbiorcy.", + ), + ( + "Multiple SPF records found. We recommend leaving only one, as multiple SPF records " + "can cause problems with some SPF implementations.", + "Wykryto więcej niż jeden rekord SPF. Rekomendujemy pozostawienie jednego z nich - " + "obecność wielu rekordów może powodować problemy w działaniu niektórych implementacji mechanizmu SPF.", + ), + ( + f"The SPF record's include chain has a reference to the {PLACEHOLDER} domain that doesn't " + "have an SPF record. When using directives such as 'include' or 'redirect' remember " + "that the destination domain must have a correct SPF record.", + f"Rekord SPF odwołuje się (być może pośrednio) do domeny {PLACEHOLDER}, która nie zawiera rekordu SPF. " + "W przypadku odwoływania się do innych domen za pomocą dyrektyw SPF takich jak 'include' lub 'redirect', " + "domena docelowa powinna również zawierać rekord SPF.", + ), + ( + "SPF record causes too many void DNS lookups. Some implementations may require the number of " + "failed DNS lookups (e.g. ones that reference a nonexistent domain) to be low. The DNS lookups " + "are caused by directives such as 'mx' or 'include'.", + "Rekord SPF powoduje zbyt wiele nieudanych zapytań DNS. Niektóre implementacje mechanizmu " + "SPF wymagają, aby liczba nieudanych zapytań DNS (np. odwołujących się do nieistniejących domen) była " + "niska. Takie zapytania DNS mogą być spowodowane np. przez dyrektywy SPF 'mx' czy 'include'.", + ), + ( + "SPF record includes an endless loop. Please check whether 'include' or 'redirect' directives don't " + "create a loop where a domain redirects back to itself or earlier domain.", + "Rekord SPF zawiera nieskończoną pętlę. Prosimy sprawdzić, czy dyrektywy SPF 'include' lub 'redirect' " + "nie odwołują się z powrotem do tej samej domeny lub do wcześniejszych domen.", + ), + ( + "SPF record causes too many DNS lookups. The DNS lookups are caused by directives such as 'mx' or 'include'. " + "The specification requires the number of DNS lookups to be lower or equal to 10 to decrease load on DNS servers.", + "Rekord SPF powoduje zbyt wiele zapytań DNS. Zapytania DNS są powodowane przez niektóre dyrektywy SPF, takie jak " + "'mx' czy 'include'. Spefycikacja wymaga, aby liczba zapytań DNS nie przekraczała 10, aby nie powodować nadmiernego " + "obciążenia serwerów DNS.", + ), + ( + "The ptr mechanism should not be used - https://tools.ietf.org/html/rfc7208#section-5.5", + "Zgodnie ze specyfikacją SPF, nie należy używać mechanizmu 'ptr'. Pod adresem " + "https://tools.ietf.org/html/rfc7208#section-5.5 można znaleźć uzasadnienie tej rekomendacji.", + ), + ( + "Valid DMARC record not found. We recommend using all three mechanisms: SPF, DKIM and DMARC " + "to decrease the possibility of successful e-mail message spoofing.", + "Nie znaleziono poprawnego rekordu DMARC. Rekomendujemy używanie wszystkich trzech mechanizmów: " + "SPF, DKIM i DMARC, aby zmniejszyć szansę, że sfałszowana wiadomość zostanie zaakceptowana " + "przez serwer odbiorcy.", + ), + ( + "DMARC policy is 'none' and 'rua' is not set, which means that the DMARC setting is not effective.", + "Polityka DMARC jest ustawiona na 'none' i nie ustawiono odbiorcy raportów w polu 'rua', co " + "oznacza, że ustawienie DMARC nie będzie skuteczne.", + ), + ( + f"The DMARC record must be located at {PLACEHOLDER}, not {PLACEHOLDER}", + f"Rekord DMARC powinien znajdować się w domenie {PLACEHOLDER}, nie {PLACEHOLDER}.", + ), + ( + "There are multiple DMARC records. We recommend leaving only one, as multiple " + "DMARC records can cause problems with some DMARC implementations.", + "Wykryto więcej niż jeden rekord DMARC. Rekomendujemy pozostawienie jednego z nich - " + "obecność wielu rekordów może powodować problemy w działaniu niektórych implementacji " + "mechanizmu DMARC.", + ), + ( + "There is an SPF record instead of DMARC one on the '_dmarc' subdomain.", + "Zamiast rekordu DMARC wykryto rekord SPF w subdomenie '_dmarc'.", + ), + ( + "DMARC record uses an invalid tag. Please refer to https://datatracker.ietf.org/doc/html/rfc7489#section-6.3 " + "for the list of available tags.", + "Rekord DMARC zawiera niepoprawne pole. Pod adresem " + "https://cert.pl/posts/2021/10/mechanizmy-weryfikacji-nadawcy-wiadomosci/#dmarc-pola " + "znajdziesz opis przykładowych pól, które mogą znaleźć się w takim rekordzie, a w specyfikacji mechanizmu " + "DMARC pod adresem https://datatracker.ietf.org/doc/html/rfc7489#section-6.3 - opis wszystkich pól.", + ), + ( + "DMARC report URI is invalid. The report URI should be an e-mail address prefixed with mailto:.", + "Adres raportów DMARC jest niepoprawny. Powinien to być adres e-mail rozpoczynający się od mailto:.", + ), + ( + "The destination of a DMARC report URI does not indicate that it accepts reports for the domain.", + "Adres raportów DMARC nie wskazuje, że przyjmuje raporty z tej domeny.", + ), + ( + "Subdomain policy (sp=) should be reject for parked domains", + "Polityka subdomen (sp=) powinna być ustawiona na 'reject' dla domen " + "niesłużących do wysyłki poczty - serwer odbiorcy powinien odrzucać wiadomości z takich domen.", + ), + ( + "Policy (p=) should be reject for parked domains", + "Polityka (p=) powinna być ustawiona na 'reject' dla domen niesłużących " + "do wysyłki poczty - serwer odbiorcy powinien odrzucać wiadomości z takich domen.", + ), + ( + "Unrelated TXT record found in the '_dmarc' subdomain. We recommend removing it, as such unrelated " + "records may cause problems with some DMARC implementations.", + "Znaleziono niepowiązane rekordy TXT w subdomenie '_dmarc'. Rekomendujemy ich usunięcie, ponieważ " + "niektóre serwery mogą w takiej sytuacji odrzucić konfigurację DMARC jako błędną.", + ), + ( + "The domain of the email address in a DMARC report URI is missing MX records. That means, that this domain " + "may not receive DMARC reports.", + "Domena adresu e-mail w adresie raportów DMARC nie zawiera rekordów MX. Oznacza to, że raporty DMARC mogą nie być " + "poprawnie dostarczane.", + ), + ( + "DMARC policy is 'none', which means that besides reporting no action will be taken. The policy describes what " + "action the recipient server should take when noticing a message that doesn't pass the verification. 'quarantine' policy " + "suggests the recipient server to flag the message as spam and 'reject' policy suggests the recipient " + "server to reject the message. We recommend using the 'quarantine' or 'reject' policy.\n\n" + "When testing the DMARC mechanism, to minimize the risk of correct messages not being delivered, " + "the 'none' policy may be used. Such tests are recommended especially when the domain is used to " + "send a large number of e-mails using various tools and not delivering a correct message is " + "unacceptable. In such cases the reports should be closely monitored, and the target setting should " + "be 'quarantine' or 'reject'.", + "Polityka DMARC jest ustawiona na 'none', co oznacza, że oprócz raportowania, żadna dodatkowa akcja nie zostanie " + "wykonana. Polityka DMARC opisuje serwerowi odbiorcy, jaką akcję powinien podjąć, gdy wiadomość nie zostanie " + "poprawnie zweryfikowana. Polityka 'quarantine' oznacza, że taka wiadomość powinna zostać oznaczona jako spam, a polityka 'reject' - że " + "powinna zostać odrzucona przez serwer odbiorcy. Rekomendujemy korzystanie z polityki 'quarantine' lub 'reject'.\n\n" + "W trakcie testów działania mechanizmu DMARC, w celu zmniejszenia ryzyka, że poprawne wiadomości zostaną " + "odrzucone, może być tymczasowo stosowane ustawienie 'none'. Takie testy są szczególnie zalecane, jeśli " + "domena służy do wysyłki dużej liczby wiadomości przy użyciu różnych narzędzi, a potencjalne niedostarczenie " + "poprawnej wiadomości jest niedopuszczalne. W takich sytuacjach raporty powinny być dokładnie monitorowane, " + "a docelowym ustawieniem powinno być 'quarantine' lub 'reject'.", + ), + ( + "rua tag (destination for aggregate reports) not found", + "Nie znaleziono tagu 'rua' (odbiorca zagregowanych raportów).", + ), + ( + "Whitespace in domain name detected. Please provide a correct domain name.", + "Wykryto białe znaki w nazwie domeny. Prosimy o podanie poprawnej nazwy domeny.", + ), + ( + f"Unexpected character in domain detected: {PLACEHOLDER}. Please provide a correct domain name.", + f"Wykryto błędne znaki w nazwie domeny: {PLACEHOLDER}. Prosimy o podanie poprawnej nazwy domeny.", + ), + ( + "Any text after the all mechanism is ignored", + "Tekst umieszczony po dyrektywie 'all' zostanie zignorowany. Rekomendujemy jego usunięcie, lub, " + "jeśli jest niezbędnym elementem konfiguracji, umieszczenie przed dyrektywą 'all' rekordu SPF.", + ), + ( + "No DKIM signature found", + "Nie znaleziono podpisu DKIM. Rekomendujemy używanie wszystkich trzech mechanizmów: SPF, DKIM i DMARC, aby " + "zmniejszyć szansę, że sfałszowana wiadomość zostanie zaakceptowana przez serwer odbiorcy.", + ), + ( + "Found an invalid DKIM signature", + "Znaleziono niepoprawny podpis mechanizmu DKIM.", + ), + ( + "SPF records containing macros aren't supported by the system yet.", + "Rekordy SPF zawierające makra nie są jeszcze wspierane przez serwis.", + ), + ( + f"The resolution lifetime expired after {PLACEHOLDER}", + "Przekroczono czas oczekiwania na odpowiedź serwera DNS. Prosimy spróbować jeszcze raz.", + ), + ( + f"DMARC record at root of {PLACEHOLDER} has no effect", + f"Rekord DMARC w domenie '{PLACEHOLDER}' (zamiast w subdomenie '_dmarc') nie zostanie uwzględniony.", + ), + ( + "Found a DMARC record that starts with whitespace. Please remove the whitespace, as some " + "implementations may not process it correctly.", + "Wykryto rekord DMARC zaczynający się od spacji lub innych białych znaków. Rekomendujemy ich " + "usunięcie, ponieważ niektóre serwery pocztowe mogą nie zinterpretować takiego rekordu poprawnie.", + ), + ( + f"{PLACEHOLDER} does not have any MX records", + f"Rekord SPF w domenie {PLACEHOLDER} korzysta z dyrektywy SPF 'mx', lecz nie wykryto rekordów MX, w związku " + "z czym ta dyrektywa nie zadziała poprawnie.", + ), + ( + f"{PLACEHOLDER} does not have any A/AAAA records", + f"Rekord SPF w domenie {PLACEHOLDER} korzysta z dyrektywy SPF 'a', lecz nie wykryto rekordów A/AAAA, w związku " + "z czym ta dyrektywa nie zadziała poprawnie.", + ), + ( + f"{PLACEHOLDER} does not indicate that it accepts DMARC reports about {PLACEHOLDER} - Authorization record not found: {PLACEHOLDER}", + f"Domena {PLACEHOLDER} nie wskazuje, że przyjmuje raporty DMARC na temat domeny {PLACEHOLDER} - nie wykryto rekordu autoryzacyjnego. " + "Więcej informacji na temat rekordów autoryzacyjnych, czyli rekordów służących do zezwolenia na wysyłanie raportów DMARC do innej " + "domeny, można przeczytać pod adresem https://dmarc.org/2015/08/receiving-dmarc-reports-outside-your-domain/ .", + ), + ( + "SPF type DNS records found. Use of DNS Type SPF has been removed in the standards track version of SPF, RFC 7208. These records " + f"should be removed and replaced with TXT records: {PLACEHOLDER}", + "Wykryto rekordy DNS o typie SPF. Wykorzystanie rekordów tego typu zostało usunięte ze standardu – powinny zostać zastąpione rekordami " + "TXT. Obecność rekordów SPF nie stanowi wprost zagrożenia (jeśli obecne są również poprawne rekordy TXT), ale może prowadzić do pomyłek " + "(np. w sytuacji, gdy administrator wyedytuje tylko jeden z rekordów).", + ), + ( + "Requested to scan a domain that is a public suffix, i.e. a domain such as .com where anybody could " + "register their subdomain. Such domain don't have to have properly configured e-mail sender verification " + "mechanisms. Please make sure you really wanted to check such domain and not its subdomain.", + "Sprawdzają Państwo domenę z listy Public Suffix List (https://publicsuffix.org/) czyli taką jak .pl, gdzie " + "różne podmioty mogą zarejestrować swoje subdomeny. Takie domeny nie muszą mieć skonfigurowanych mechanizmów " + "weryfikacji nadawcy poczty - konfigurowane są one w subdomenach. Prosimy o weryfikację nazwy sprawdzanej domeny.", + ), + ( + "Requested to scan a top-level domain. Top-level domains don't have to have properly configured e-mail sender " + "verification mechanisms. Please make sure you really wanted to check such domain and not its subdomain." + "Besides, the domain is not known to the Public Suffix List (https://publicsuffix.org/) - please verify whether " + "it is correct.", + "Sprawdzają Państwo domenę najwyższego poziomu. Domeny najwyższego poziomu nie muszą mieć " + "skonfigurowanych mechanizmów weryfikacji nadawcy poczty - konfigurowane są one w subdomenach. Prosimy " + "o weryfikację nazwy sprawdzanej domeny. Domena nie występuje również na Public Suffix List " + "(https://publicsuffix.org/) - prosimy o weryfikację jej poprawności.", + ), + ( + "Please provide a correct domain name.", + "Proszę podać poprawną nazwę domeny.", + ), + ( + f"Failed to retrieve MX records for the domain of {PLACEHOLDER} email address {PLACEHOLDER} - All nameservers failed to answer the query {PLACEHOLDER}", + f"Nie udało się odczytać rekordów MX domeny adresu e-mail w dyrektywie {PLACEHOLDER}: {PLACEHOLDER} - serwery nazw nie odpowiedziały poprawnie na zapytanie.", + ), + ( + f"All nameservers failed to answer the query {PLACEHOLDER}. IN {PLACEHOLDER}", + f"Żaden z przypisanych serwerów nazw domen nie odpowiedział na zapytanie dotyczące domeny {PLACEHOLDER}.", + ), + ( + f"{PLACEHOLDER}: Expected {PLACEHOLDER} at position {PLACEHOLDER} (marked with {PLACEHOLDER}) in: {PLACEHOLDER}", + f"{SKIP_PLACEHOLDER}{SKIP_PLACEHOLDER}Rekord nie ma poprawnej składni. Błąd występuje na przybliżonej pozycji " + f"{PLACEHOLDER} (oznaczonej znakiem {PLACEHOLDER}) w rekordzie '{PLACEHOLDER}'", + ), + ( + "the p tag must immediately follow the v tag", + "Tag p (polityka DMARC) musi następować bezpośrednio po tagu v (wersji DMARC).", + ), + ( + 'The record is missing the required policy ("p") tag', + "Rekord nie zawiera tagu p, opisującego politykę - czyli akcję, która powinna zostać wykonana, gdy wiadomość nie " + "zostanie zweryfikowana poprawnie przy użyciu mechanizmu DMARC.", + ), + ( + f"{PLACEHOLDER} is not a valid ipv4 value{PLACEHOLDER}", + f"{PLACEHOLDER} nie jest poprawnym adresem IPv4.", + ), + ( + f"{PLACEHOLDER} is not a valid ipv6 value{PLACEHOLDER}", + f"{PLACEHOLDER} nie jest poprawnym adresem IPv6.", + ), + ( + "Some DMARC reporters might not send to more than two rua URIs", + "Niektóre implementacje DMARC mogą nie wysłać raportów do więcej niż dwóch odbiorców podanych w polu 'rua'.", + ), + ( + "Some DMARC reporters might not send to more than two ruf URIs", + "Niektóre implementacje DMARC mogą nie wysłać raportów do więcej niż dwóch odbiorców podanych w polu 'ruf'.", + ), + ( + f"The domain {PLACEHOLDER} does not exist", + f"Domena {PLACEHOLDER} nie istnieje.", + ), + ( + f"{PLACEHOLDER} is not a valid DMARC report URI - please make sure that the URI begins with a schema such as mailto:", + f"{PLACEHOLDER} nie jest poprawnym odbiorcą raportów DMARC - jeśli raporty DMARC mają być przesyłane na adres e-mail, " + "należy poprzedzić go przedrostkiem 'mailto:'.", + ), + ( + f"{PLACEHOLDER} is not a valid DMARC report URI", + f"{PLACEHOLDER} nie jest poprawnym odbiorcą raportów DMARC.", + ), + ( + f"{PLACEHOLDER} is not a valid DMARC tag", + f"'{PLACEHOLDER}' nie jest poprawnym tagiem DMARC.", + ), + ( + f"Tag {PLACEHOLDER} must have one of the following values: {PLACEHOLDER} - not {PLACEHOLDER}", + f"Tag {PLACEHOLDER} powinien mieć wartość spośród: {PLACEHOLDER} - wartość '{PLACEHOLDER}' nie jest dopuszczalna.", + ), + ( + "pct value is less than 100. This leads to inconsistent and unpredictable policy " + "enforcement. Consider using p=none to monitor results instead", + "Wartość tagu 'pct' wynosi mniej niż 100. Oznacza to, ze mechanizm DMARC zostanie " + "zastosowany do mniej niż 100% wiadomości, a więc konfiguracja nie będzie spójnie " + "egzekwowana. W celu monitorowania konfiguracji DMARC przed jej finalnym wdrożeniem " + "rekomendujemy użycie polityki 'none' i monitorowanie przychodzących raportów DMARC.", + ), + ( + "pct value must be an integer between 0 and 100", + "Wartość 'pct' (procent e-maili, do których zostanie zastosowana polityka DMARC) powinna " + "być liczbą całkowitą od 0 do 100.", + ), + ( + f"Duplicate include: {PLACEHOLDER}", + f"Domena {PLACEHOLDER} występuje wielokrotnie w tagu 'include'.", + ), + ( + "When 1 is present in the fo tag, including 0 is redundant", + "Jeśli w tagu 'fo' (określającym, kiedy wysyłać raport DMARC) jest włączona opcja 1 (oznaczająca, że raport jest " + "wysyłany jeśli wiadomość nie jest poprawnie zweryfikowana przez mechanizm SPF lub DKIM, nawet, jeśli " + "została zweryfikowana przez drugi z mechanizmów), opcja 0 (tj. wysyłka raportów, gdy wiadomość zostanie " + "zweryfikowana negatywnie przez oba mechanizmy) jest zbędna.", + ), + ( + "Including 0 and 1 fo tag values is redundant", + "Jeśli w tagu 'fo' (określającym, kiedy wysyłać raport DMARC) jest włączona opcja 1 (oznaczająca, że raport jest " + "wysyłany jeśli wiadomość nie jest poprawnie zweryfikowana przez mechanizm SPF lub DKIM, nawet, jeśli " + "została zweryfikowana przez drugi z mechanizmów), opcja 0 (tj. wysyłka raportów, gdy wiadomość zostanie " + "zweryfikowana negatywnie przez oba mechanizmy) jest zbędna.", + ), + ( + f"{PLACEHOLDER} is not a valid option for the DMARC {PLACEHOLDER} tag", + f"'{PLACEHOLDER}' nie jest poprawną opcją tagu '{PLACEHOLDER}'", + ), + ( + f"Domain checked by the SPF mechanism (from the RFC5321.MailFrom header: {PLACEHOLDER}) is not aligned with " + f"the DMARC record domain (from the RFC5322.From header: {PLACEHOLDER}). Read more about various e-mail From " + f"headers on https://dmarc.org/2016/07/how-many-from-addresses-are-there/", + f"Domena sprawdzana przez mechanizm SPF (z nagłówka RFC5321.MailFrom: {PLACEHOLDER}) nie jest zgodna z domeną " + f"rekordu DMARC (z nagłówka RFC5322.From: {PLACEHOLDER}). Na stronie " + f"https://dmarc.org/2016/07/how-many-from-addresses-are-there/ można przeczytać więcej o nagłówkach nadawcy wiadomości.", + ), + ( + f"Domain from the DKIM signature ({PLACEHOLDER}) is not aligned with the DMARC record domain " + f"(from the From header: {PLACEHOLDER}).", + f"Domena podpisu DKIM ({PLACEHOLDER}) nie jest zgodna z domeną rekordu " + f"DMARC (z nagłówka From: {PLACEHOLDER}).", + ), + ( + "Invalid or no e-mail domain in the message From header", + "Brak lub niepoprawna domena w nagłówku From e-maila.", + ), + ( + "The value of the pct tag must be an integer", + "Wartość tagu 'pct' musi być liczbą całkowitą.", + ), + ( + f"Failed to retrieve MX records for the domain of {PLACEHOLDER} email address {PLACEHOLDER} - The domain {PLACEHOLDER} does not exist", + f"Nie udało się pobrać rekordów MX domeny adresu e-mail podanego w tagu '{PLACEHOLDER}': {PLACEHOLDER} - domena {PLACEHOLDER} nie istnieje.", + ), + # dkimpy messages + ( + f"{PLACEHOLDER} value is not valid base64 {PLACEHOLDER}", + f"Wartość {PLACEHOLDER} nie jest poprawnie zakodowana algorytmem base64 {PLACEHOLDER}", + ), + ( + f"{PLACEHOLDER} value is not valid {PLACEHOLDER}", + f"Wartość {PLACEHOLDER} nie jest poprawna {PLACEHOLDER}", + ), + ( + f"missing {PLACEHOLDER}", + f"Brakujące pole {PLACEHOLDER}", + ), + ( + f"unknown signature algorithm: {PLACEHOLDER}", + f"Nieznany algorytm podpisu DKIM: {PLACEHOLDER}", + ), + ( + f"i= domain is not a subdomain of d= {PLACEHOLDER}", + f"Domena w polu i= nie jest subdomeną domeny w polu d= {PLACEHOLDER}", + ), + ( + f"{PLACEHOLDER} value is not a decimal integer {PLACEHOLDER}", + f"Wartość w polu {PLACEHOLDER} nie jest liczbą {PLACEHOLDER}", + ), + ( + f"q= value is not dns/txt {PLACEHOLDER}", + f"Wartość w polu q= nie jest równa 'dns/txt' {PLACEHOLDER}", + ), + ( + f"v= value is not 1 {PLACEHOLDER}", + f"Wartość w polu v= nie jest równa 1 {PLACEHOLDER}", + ), + ( + f"t= value is in the future {PLACEHOLDER}", + f"Czas w polu t= jest w przyszłości {PLACEHOLDER}", + ), + ( + f"x= value is past {PLACEHOLDER}", + f"Czas w polu x= jest w przeszłości {PLACEHOLDER}", + ), + ( + f"x= value is less than t= value {PLACEHOLDER}", + f"Czas w polu x= jest wcześniejszy niż w polu t= {PLACEHOLDER}", + ), + ( + f"Unexpected characters in RFC822 header: {PLACEHOLDER}", + f"Nieoczekiwane znaki w nagłówku RFC822: {PLACEHOLDER}", + ), + ( + f"missing public key: {PLACEHOLDER}", + f"Brakujący klucz publiczny: {PLACEHOLDER}", + ), + ( + "bad version", + "Niepoprawna wersja", + ), + ( + f"could not parse ed25519 public key {PLACEHOLDER}", + f"Nie udało się przetworzyć klucza publicznego ed25519 {PLACEHOLDER}", + ), + ( + f"incomplete RSA public key: {PLACEHOLDER}", + f"Niekompletny klucz publiczny RSA: {PLACEHOLDER}", + ), + ( + f"could not parse RSA public key {PLACEHOLDER}", + f"Nie udało się przetworzyć klucza publicznego RSA {PLACEHOLDER}", + ), + ( + f"unknown algorithm in k= tag: {PLACEHOLDER}", + f"Nieznana nazwa algorytmu w polu k=: {PLACEHOLDER}", + ), + ( + f"unknown service type in s= tag: {PLACEHOLDER}", + f"Nieznany typ usługi w polu s=: {PLACEHOLDER}", + ), + ( + "digest too large for modulus", + "Podpis jest dłuższy niż dopuszczają użyte parametry algorytmu szyfrującego.", + ), + ( + f"digest too large for modulus: {PLACEHOLDER}", + f"Podpis jest dłuższy niż dopuszczają użyte parametry algorytmu szyfrującego: {PLACEHOLDER}.", + ), + ( + f"body hash mismatch (got b'{PLACEHOLDER}', expected b'{PLACEHOLDER}')", + f"Niepoprawna suma kontrolna treści wiadomości (otrzymano '{PLACEHOLDER}', oczekiwano '{PLACEHOLDER}').", + ), + ( + f"public key too small: {PLACEHOLDER}", + f"Za mały klucz publiczny: {PLACEHOLDER}.", + ), + ( + f"Duplicate ARC-Authentication-Results for instance {PLACEHOLDER}", + f"Wykryto wiele nagłówków ARC-Authentication-Results dla instancji {PLACEHOLDER}.", + ), + ( + f"Duplicate ARC-Message-Signature for instance {PLACEHOLDER}", + f"Wykryto wiele nagłówków ARC-Message-Signature dla instancji {PLACEHOLDER}.", + ), + ( + f"Duplicate ARC-Seal for instance {PLACEHOLDER}", + f"Wykryto wiele nagłówków ARC-Seal dla instancji {PLACEHOLDER}.", + ), + ( + f"Incomplete ARC set for instance {PLACEHOLDER}", + f"Niekompletny zestaw nagłówków ARC dla instancji {PLACEHOLDER}.", + ), + ( + "h= tag not permitted in ARC-Seal header field", + "Tag h= nie jest dozwolony w nagłówku ARC-Seal.", + ), + ] +} + + +def _translate_using_dictionary( + message: str, + dictionary: List[Tuple[str, str]], + nonexistent_translation_handler: Optional[Callable[[str], str]] = None, +) -> str: + """Translates message according to a dictionary. + + For example, for the following dictionary: + + [ + (f"Input message one {PLACEHOLDER}.", f"Output message one {PLACEHOLDER}."), + (f"Input message two {PLACEHOLDER}.", f"Output message two {PLACEHOLDER}."), + ] + + message "Input message one 1234." will get translated to "Output message one 1234.". + + *note* the "from" and "to" messages must have the same number of placeholders - + and will have the same order of placeholders. + """ + for m_from, m_to in dictionary: + pattern = "^" + re.escape(m_from).replace(PLACEHOLDER, "((?:.|\n)*)") + "$" + regexp_match = re.match(pattern, message) + + # a dictionary rule matched the message + if regexp_match: + result = m_to + for matched in regexp_match.groups(): + placeholder_index = result.index(PLACEHOLDER) if PLACEHOLDER in result else len(result) + skip_placeholder_index = result.index(SKIP_PLACEHOLDER) if SKIP_PLACEHOLDER in result else len(result) + + if placeholder_index < skip_placeholder_index: + # replace first occurence of placeholder with the matched needle + result = result.replace(PLACEHOLDER, matched, 1) + elif skip_placeholder_index < placeholder_index: + result = result.replace(SKIP_PLACEHOLDER, "", 1) + return result + + if nonexistent_translation_handler: + return nonexistent_translation_handler(message) + else: + raise NotImplementedError(f"Unable to translate {message}") + + +def translate( + message: str, + language: Language, + nonexistent_translation_handler: Optional[Callable[[str], str]] = None, +) -> str: + if language == Language.en_US: + return message + + return _translate_using_dictionary( + message, + TRANSLATIONS[language], + nonexistent_translation_handler=nonexistent_translation_handler, + ) + + +def _translate_domain_result( + domain_result: DomainScanResult, + language: Language, + nonexistent_translation_handler: Optional[Callable[[str], str]] = None, +) -> DomainScanResult: + new_domain_result = copy.deepcopy(domain_result) + new_domain_result.spf.errors = [ + translate(error, language, nonexistent_translation_handler) for error in domain_result.spf.errors + ] + new_domain_result.spf.warnings = [ + translate(warning, language, nonexistent_translation_handler) for warning in domain_result.spf.warnings + ] + new_domain_result.dmarc.errors = [ + translate(error, language, nonexistent_translation_handler) for error in domain_result.dmarc.errors + ] + new_domain_result.dmarc.warnings = [ + translate(warning, language, nonexistent_translation_handler) for warning in domain_result.dmarc.warnings + ] + new_domain_result.warnings = [ + translate(warning, language, nonexistent_translation_handler) for warning in new_domain_result.warnings + ] + return new_domain_result + + +def _translate_dkim_result( + dkim_result: DKIMScanResult, + language: Language, + nonexistent_translation_handler: Optional[Callable[[str], str]] = None, +) -> DKIMScanResult: + new_dkim_result = copy.deepcopy(dkim_result) + new_dkim_result.errors = [ + translate(error, language, nonexistent_translation_handler) for error in dkim_result.errors + ] + new_dkim_result.warnings = [ + translate(warning, language, nonexistent_translation_handler) for warning in dkim_result.warnings + ] + return new_dkim_result + + +def translate_scan_result( + scan_result: ScanResult, + language: Language, + nonexistent_translation_handler: Optional[Callable[[str], str]] = None, +) -> ScanResult: + return ScanResult( + domain=_translate_domain_result(scan_result.domain, language, nonexistent_translation_handler) + if scan_result.domain + else None, + dkim=_translate_dkim_result(scan_result.dkim, language, nonexistent_translation_handler) + if scan_result.dkim + else None, + timestamp=scan_result.timestamp, + message_timestamp=scan_result.message_timestamp, + ) diff --git a/app/static/bootstrap.min.css b/app/static/bootstrap.min.css new file mode 100644 index 0000000..5946476 --- /dev/null +++ b/app/static/bootstrap.min.css @@ -0,0 +1,7 @@ +@charset "UTF-8";/*! + * Bootstrap v5.2.0 (https://getbootstrap.com/) + * Copyright 2011-2022 The Bootstrap Authors + * Copyright 2011-2022 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */:root{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#101c28;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#101c28;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:16,28,40;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-body-color-rgb:16,28,40;--bs-body-bg-rgb:255,255,255;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#101c28;--bs-body-bg:#fff;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-2xl:2rem;--bs-border-radius-pill:50rem;--bs-link-color:#0d6efd;--bs-link-hover-color:#0a58ca;--bs-code-color:#d63384;--bs-highlight-bg:#fff3cd}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;border:0;border-top:1px solid;opacity:.25}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.1875em;background-color:var(--bs-highlight-bg)}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:var(--bs-link-color);text-decoration:underline}a:hover{color:var(--bs-link-hover-color)}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:var(--bs-code-color);word-wrap:break-word}a>code{color:inherit}kbd{padding:.1875rem .375rem;font-size:.875em;color:var(--bs-body-bg);background-color:var(--bs-body-color);border-radius:.25rem}kbd kbd{padding:0;font-size:1em}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator{display:none!important}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}::file-selector-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#fff;border:1px solid var(--bs-border-color);border-radius:.375rem;max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:#6c757d}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{--bs-gutter-x:1.5rem;--bs-gutter-y:0;width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.6666666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.6666666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.6666666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.6666666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.6666666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.6666666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.table{--bs-table-color:var(--bs-body-color);--bs-table-bg:transparent;--bs-table-border-color:var(--bs-border-color);--bs-table-accent-bg:transparent;--bs-table-striped-color:var(--bs-body-color);--bs-table-striped-bg:rgba(0, 0, 0, 0.05);--bs-table-active-color:var(--bs-body-color);--bs-table-active-bg:rgba(0, 0, 0, 0.1);--bs-table-hover-color:var(--bs-body-color);--bs-table-hover-bg:rgba(0, 0, 0, 0.075);width:100%;margin-bottom:1rem;color:var(--bs-table-color);vertical-align:top;border-color:var(--bs-table-border-color)}.table>:not(caption)>*>*{padding:.5rem .5rem;background-color:var(--bs-table-bg);border-bottom-width:1px;box-shadow:inset 0 0 0 9999px var(--bs-table-accent-bg)}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table-group-divider{border-top:2px solid currentcolor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:1px 0}.table-bordered>:not(caption)>*>*{border-width:0 1px}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-borderless>:not(:first-child){border-top-width:0}.table-striped>tbody>tr:nth-of-type(odd)>*{--bs-table-accent-bg:var(--bs-table-striped-bg);color:var(--bs-table-striped-color)}.table-striped-columns>:not(caption)>tr>:nth-child(2n){--bs-table-accent-bg:var(--bs-table-striped-bg);color:var(--bs-table-striped-color)}.table-active{--bs-table-accent-bg:var(--bs-table-active-bg);color:var(--bs-table-active-color)}.table-hover>tbody>tr:hover>*{--bs-table-accent-bg:var(--bs-table-hover-bg);color:var(--bs-table-hover-color)}.table-primary{--bs-table-color:#000;--bs-table-bg:#cfe2ff;--bs-table-border-color:#bacbe6;--bs-table-striped-bg:#c5d7f2;--bs-table-striped-color:#000;--bs-table-active-bg:#bacbe6;--bs-table-active-color:#000;--bs-table-hover-bg:#bfd1ec;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-secondary{--bs-table-color:#000;--bs-table-bg:#e2e3e5;--bs-table-border-color:#cbccce;--bs-table-striped-bg:#d7d8da;--bs-table-striped-color:#000;--bs-table-active-bg:#cbccce;--bs-table-active-color:#000;--bs-table-hover-bg:#d1d2d4;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-success{--bs-table-color:#000;--bs-table-bg:#d1e7dd;--bs-table-border-color:#bcd0c7;--bs-table-striped-bg:#c7dbd2;--bs-table-striped-color:#000;--bs-table-active-bg:#bcd0c7;--bs-table-active-color:#000;--bs-table-hover-bg:#c1d6cc;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-info{--bs-table-color:#000;--bs-table-bg:#cff4fc;--bs-table-border-color:#badce3;--bs-table-striped-bg:#c5e8ef;--bs-table-striped-color:#000;--bs-table-active-bg:#badce3;--bs-table-active-color:#000;--bs-table-hover-bg:#bfe2e9;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-warning{--bs-table-color:#000;--bs-table-bg:#fff3cd;--bs-table-border-color:#e6dbb9;--bs-table-striped-bg:#f2e7c3;--bs-table-striped-color:#000;--bs-table-active-bg:#e6dbb9;--bs-table-active-color:#000;--bs-table-hover-bg:#ece1be;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-danger{--bs-table-color:#000;--bs-table-bg:#f8d7da;--bs-table-border-color:#dfc2c4;--bs-table-striped-bg:#eccccf;--bs-table-striped-color:#000;--bs-table-active-bg:#dfc2c4;--bs-table-active-color:#000;--bs-table-hover-bg:#e5c7ca;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-light{--bs-table-color:#000;--bs-table-bg:#f8f9fa;--bs-table-border-color:#dfe0e1;--bs-table-striped-bg:#ecedee;--bs-table-striped-color:#000;--bs-table-active-bg:#dfe0e1;--bs-table-active-color:#000;--bs-table-hover-bg:#e5e6e7;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-dark{--bs-table-color:#fff;--bs-table-bg:#101c28;--bs-table-border-color:#373b3e;--bs-table-striped-bg:#2c3034;--bs-table-striped-color:#fff;--bs-table-active-bg:#373b3e;--bs-table-active-color:#fff;--bs-table-hover-bg:#323539;--bs-table-hover-color:#fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + 1px);padding-bottom:calc(.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + 1px);padding-bottom:calc(.5rem + 1px);font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + 1px);padding-bottom:calc(.25rem + 1px);font-size:.875rem}.form-text{margin-top:.25rem;font-size:.875em;color:#6c757d}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#101c28;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:.375rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:#101c28;background-color:#fff;border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-control::-webkit-date-and-time-value{height:1.5em}.form-control::-moz-placeholder{color:#6c757d;opacity:1}.form-control::placeholder{color:#6c757d;opacity:1}.form-control:disabled{background-color:#e9ecef;opacity:1}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#101c28;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#101c28;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:#dde0e3}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:#dde0e3}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:#101c28;background-color:transparent;border:solid transparent;border-width:1px 0}.form-control-plaintext:focus{outline:0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + 2px);padding:.25rem .5rem;font-size:.875rem;border-radius:.25rem}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + 2px);padding:.5rem 1rem;font-size:1.25rem;border-radius:.5rem}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + 2px)}textarea.form-control-sm{min-height:calc(1.5em + .5rem + 2px)}textarea.form-control-lg{min-height:calc(1.5em + 1rem + 2px)}.form-control-color{width:3rem;height:calc(1.5em + .75rem + 2px);padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{border:0!important;border-radius:.375rem}.form-control-color::-webkit-color-swatch{border-radius:.375rem}.form-control-color.form-control-sm{height:calc(1.5em + .5rem + 2px)}.form-control-color.form-control-lg{height:calc(1.5em + 1rem + 2px)}.form-select{display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;-moz-padding-start:calc(0.75rem - 3px);font-size:1rem;font-weight:400;line-height:1.5;color:#101c28;background-color:#fff;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:1px solid #ced4da;border-radius:.375rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:#e9ecef}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 #101c28}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem;border-radius:.25rem}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem;border-radius:.5rem}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-reverse{padding-right:1.5em;padding-left:0;text-align:right}.form-check-reverse .form-check-input{float:right;margin-right:-1.5em;margin-left:0}.form-check-input{width:1em;height:1em;margin-top:.25em;vertical-align:top;background-color:#fff;background-repeat:no-repeat;background-position:center;background-size:contain;border:1px solid rgba(0,0,0,.25);-webkit-appearance:none;-moz-appearance:none;appearance:none;-webkit-print-color-adjust:exact;color-adjust:exact;print-color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-check-input:checked{background-color:#0d6efd;border-color:#0d6efd}.form-check-input:checked[type=checkbox]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#0d6efd;border-color:#0d6efd;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{cursor:default;opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{width:2em;margin-left:-2.5em;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-switch.form-check-reverse{padding-right:2.5em;padding-left:0}.form-switch.form-check-reverse .form-check-input{margin-right:-2.5em;margin-left:0}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.65}.form-range{width:100%;height:1.5rem;padding:0;background-color:transparent;-webkit-appearance:none;-moz-appearance:none;appearance:none}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;background-color:#0d6efd;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#b6d4fe}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;background-color:#0d6efd;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:#b6d4fe}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:#adb5bd}.form-range:disabled::-moz-range-thumb{background-color:#adb5bd}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-control-plaintext,.form-floating>.form-select{height:calc(3.5rem + 2px);line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;width:100%;height:100%;padding:1rem .75rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;border:1px solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control,.form-floating>.form-control-plaintext{padding:1rem .75rem}.form-floating>.form-control-plaintext::-moz-placeholder,.form-floating>.form-control::-moz-placeholder{color:transparent}.form-floating>.form-control-plaintext::placeholder,.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control-plaintext:not(:-moz-placeholder-shown),.form-floating>.form-control:not(:-moz-placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:focus,.form-floating>.form-control-plaintext:not(:placeholder-shown),.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:-webkit-autofill,.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:not(:-moz-placeholder-shown)~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label,.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:-webkit-autofill~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label{border-width:1px 0}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-floating,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-floating:focus-within,.input-group>.form-select:focus{z-index:3}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:3}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#101c28;text-align:center;white-space:nowrap;background-color:#e9ecef;border:1px solid #ced4da;border-radius:.375rem}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;border-radius:.5rem}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.25rem .5rem;font-size:.875rem;border-radius:.25rem}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-control,.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-select,.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-control,.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-select,.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.form-floating:not(:first-child)>.form-control,.input-group>.form-floating:not(:first-child)>.form-select,.input-group>:not(:first-child):not(.dropdown-menu):not(.form-floating):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:-1px;border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#198754}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(25,135,84,.9);border-radius:.375rem}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:#198754;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:#198754;box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-valid,.was-validated .form-select:valid{border-color:#198754}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{padding-right:4.125rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:#198754;box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.form-control-color.is-valid,.was-validated .form-control-color:valid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:#198754}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:#198754}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:#198754}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group .form-control.is-valid,.input-group .form-select.is-valid,.was-validated .input-group .form-control:valid,.was-validated .input-group .form-select:valid{z-index:1}.input-group .form-control.is-valid:focus,.input-group .form-select.is-valid:focus,.was-validated .input-group .form-control:valid:focus,.was-validated .input-group .form-select:valid:focus{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#dc3545}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(220,53,69,.9);border-radius:.375rem}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:#dc3545;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:#dc3545}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{padding-right:4.125rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.form-control-color.is-invalid,.was-validated .form-control-color:invalid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:#dc3545}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:#dc3545}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:#dc3545}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group .form-control.is-invalid,.input-group .form-select.is-invalid,.was-validated .input-group .form-control:invalid,.was-validated .input-group .form-select:invalid{z-index:2}.input-group .form-control.is-invalid:focus,.input-group .form-select.is-invalid:focus,.was-validated .input-group .form-control:invalid:focus,.was-validated .input-group .form-select:invalid:focus{z-index:3}.btn{--bs-btn-padding-x:0.75rem;--bs-btn-padding-y:0.375rem;--bs-btn-font-family: ;--bs-btn-font-size:1rem;--bs-btn-font-weight:400;--bs-btn-line-height:1.5;--bs-btn-color:#101c28;--bs-btn-bg:transparent;--bs-btn-border-width:1px;--bs-btn-border-color:transparent;--bs-btn-border-radius:0.375rem;--bs-btn-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.15),0 1px 1px rgba(0, 0, 0, 0.075);--bs-btn-disabled-opacity:0.65;--bs-btn-focus-box-shadow:0 0 0 0.25rem rgba(var(--bs-btn-focus-shadow-rgb), .5);display:inline-block;padding:var(--bs-btn-padding-y) var(--bs-btn-padding-x);font-family:var(--bs-btn-font-family);font-size:var(--bs-btn-font-size);font-weight:var(--bs-btn-font-weight);line-height:var(--bs-btn-line-height);color:var(--bs-btn-color);text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;border:var(--bs-btn-border-width) solid var(--bs-btn-border-color);border-radius:var(--bs-btn-border-radius);background-color:var(--bs-btn-bg);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn-check:focus+.btn,.btn:focus{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:active+.btn,.btn-check:checked+.btn,.btn.active,.btn.show,.btn:active{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color)}.btn-check:active+.btn:focus,.btn-check:checked+.btn:focus,.btn.active:focus,.btn.show:focus,.btn:active:focus{box-shadow:var(--bs-btn-focus-box-shadow)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{color:var(--bs-btn-disabled-color);pointer-events:none;background-color:var(--bs-btn-disabled-bg);border-color:var(--bs-btn-disabled-border-color);opacity:var(--bs-btn-disabled-opacity)}.btn-primary{--bs-btn-color:#fff;--bs-btn-bg:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0b5ed7;--bs-btn-hover-border-color:#0a58ca;--bs-btn-focus-shadow-rgb:49,132,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0a58ca;--bs-btn-active-border-color:#0a53be;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#0d6efd;--bs-btn-disabled-border-color:#0d6efd}.btn-secondary{--bs-btn-color:#fff;--bs-btn-bg:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#5c636a;--bs-btn-hover-border-color:#565e64;--bs-btn-focus-shadow-rgb:130,138,145;--bs-btn-active-color:#fff;--bs-btn-active-bg:#565e64;--bs-btn-active-border-color:#51585e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#6c757d;--bs-btn-disabled-border-color:#6c757d}.btn-success{--bs-btn-color:#fff;--bs-btn-bg:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#157347;--bs-btn-hover-border-color:#146c43;--bs-btn-focus-shadow-rgb:60,153,110;--bs-btn-active-color:#fff;--bs-btn-active-bg:#146c43;--bs-btn-active-border-color:#13653f;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#198754;--bs-btn-disabled-border-color:#198754}.btn-info{--bs-btn-color:#000;--bs-btn-bg:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#31d2f2;--bs-btn-hover-border-color:#25cff2;--bs-btn-focus-shadow-rgb:11,172,204;--bs-btn-active-color:#000;--bs-btn-active-bg:#3dd5f3;--bs-btn-active-border-color:#25cff2;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#0dcaf0;--bs-btn-disabled-border-color:#0dcaf0}.btn-warning{--bs-btn-color:#000;--bs-btn-bg:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffca2c;--bs-btn-hover-border-color:#ffc720;--bs-btn-focus-shadow-rgb:217,164,6;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffcd39;--bs-btn-active-border-color:#ffc720;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#ffc107;--bs-btn-disabled-border-color:#ffc107}.btn-danger{--bs-btn-color:#fff;--bs-btn-bg:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#bb2d3b;--bs-btn-hover-border-color:#b02a37;--bs-btn-focus-shadow-rgb:225,83,97;--bs-btn-active-color:#fff;--bs-btn-active-bg:#b02a37;--bs-btn-active-border-color:#a52834;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#dc3545;--bs-btn-disabled-border-color:#dc3545}.btn-light{--bs-btn-color:#000;--bs-btn-bg:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#d3d4d5;--bs-btn-hover-border-color:#c6c7c8;--bs-btn-focus-shadow-rgb:211,212,213;--bs-btn-active-color:#000;--bs-btn-active-bg:#c6c7c8;--bs-btn-active-border-color:#babbbc;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#f8f9fa;--bs-btn-disabled-border-color:#f8f9fa}.btn-dark{--bs-btn-color:#fff;--bs-btn-bg:#101c28;--bs-btn-border-color:#101c28;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#424649;--bs-btn-hover-border-color:#373b3e;--bs-btn-focus-shadow-rgb:66,70,73;--bs-btn-active-color:#fff;--bs-btn-active-bg:#4d5154;--bs-btn-active-border-color:#373b3e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#101c28;--bs-btn-disabled-border-color:#101c28}.btn-outline-primary{--bs-btn-color:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0d6efd;--bs-btn-hover-border-color:#0d6efd;--bs-btn-focus-shadow-rgb:13,110,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0d6efd;--bs-btn-active-border-color:#0d6efd;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0d6efd;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0d6efd;--bs-gradient:none}.btn-outline-secondary{--bs-btn-color:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#6c757d;--bs-btn-hover-border-color:#6c757d;--bs-btn-focus-shadow-rgb:108,117,125;--bs-btn-active-color:#fff;--bs-btn-active-bg:#6c757d;--bs-btn-active-border-color:#6c757d;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#6c757d;--bs-gradient:none}.btn-outline-success{--bs-btn-color:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#198754;--bs-btn-hover-border-color:#198754;--bs-btn-focus-shadow-rgb:25,135,84;--bs-btn-active-color:#fff;--bs-btn-active-bg:#198754;--bs-btn-active-border-color:#198754;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#198754;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#198754;--bs-gradient:none}.btn-outline-info{--bs-btn-color:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#0dcaf0;--bs-btn-hover-border-color:#0dcaf0;--bs-btn-focus-shadow-rgb:13,202,240;--bs-btn-active-color:#000;--bs-btn-active-bg:#0dcaf0;--bs-btn-active-border-color:#0dcaf0;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0dcaf0;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0dcaf0;--bs-gradient:none}.btn-outline-warning{--bs-btn-color:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffc107;--bs-btn-hover-border-color:#ffc107;--bs-btn-focus-shadow-rgb:255,193,7;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffc107;--bs-btn-active-border-color:#ffc107;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#ffc107;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#ffc107;--bs-gradient:none}.btn-outline-danger{--bs-btn-color:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#dc3545;--bs-btn-hover-border-color:#dc3545;--bs-btn-focus-shadow-rgb:220,53,69;--bs-btn-active-color:#fff;--bs-btn-active-bg:#dc3545;--bs-btn-active-border-color:#dc3545;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#dc3545;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#dc3545;--bs-gradient:none}.btn-outline-light{--bs-btn-color:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#f8f9fa;--bs-btn-hover-border-color:#f8f9fa;--bs-btn-focus-shadow-rgb:248,249,250;--bs-btn-active-color:#000;--bs-btn-active-bg:#f8f9fa;--bs-btn-active-border-color:#f8f9fa;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#f8f9fa;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#f8f9fa;--bs-gradient:none}.btn-outline-dark{--bs-btn-color:#101c28;--bs-btn-border-color:#101c28;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#101c28;--bs-btn-hover-border-color:#101c28;--bs-btn-focus-shadow-rgb:16,28,40;--bs-btn-active-color:#fff;--bs-btn-active-bg:#101c28;--bs-btn-active-border-color:#101c28;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#101c28;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#101c28;--bs-gradient:none}.btn-link{--bs-btn-font-weight:400;--bs-btn-color:var(--bs-link-color);--bs-btn-bg:transparent;--bs-btn-border-color:transparent;--bs-btn-hover-color:var(--bs-link-hover-color);--bs-btn-hover-border-color:transparent;--bs-btn-active-color:var(--bs-link-hover-color);--bs-btn-active-border-color:transparent;--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-border-color:transparent;--bs-btn-box-shadow:none;--bs-btn-focus-shadow-rgb:49,132,253;text-decoration:underline}.btn-link:focus{color:var(--bs-btn-color)}.btn-link:hover{color:var(--bs-btn-hover-color)}.btn-group-lg>.btn,.btn-lg{--bs-btn-padding-y:0.5rem;--bs-btn-padding-x:1rem;--bs-btn-font-size:1.25rem;--bs-btn-border-radius:0.5rem}.btn-group-sm>.btn,.btn-sm{--bs-btn-padding-y:0.25rem;--bs-btn-padding-x:0.5rem;--bs-btn-font-size:0.875rem;--bs-btn-border-radius:0.25rem}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropdown-center,.dropend,.dropstart,.dropup,.dropup-center{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{--bs-dropdown-min-width:10rem;--bs-dropdown-padding-x:0;--bs-dropdown-padding-y:0.5rem;--bs-dropdown-spacer:0.125rem;--bs-dropdown-font-size:1rem;--bs-dropdown-color:#101c28;--bs-dropdown-bg:#fff;--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-border-radius:0.375rem;--bs-dropdown-border-width:1px;--bs-dropdown-inner-border-radius:calc(0.375rem - 1px);--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-divider-margin-y:0.5rem;--bs-dropdown-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-dropdown-link-color:#101c28;--bs-dropdown-link-hover-color:#1e2125;--bs-dropdown-link-hover-bg:#e9ecef;--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:#adb5bd;--bs-dropdown-item-padding-x:1rem;--bs-dropdown-item-padding-y:0.25rem;--bs-dropdown-header-color:#6c757d;--bs-dropdown-header-padding-x:1rem;--bs-dropdown-header-padding-y:0.5rem;position:absolute;z-index:1000;display:none;min-width:var(--bs-dropdown-min-width);padding:var(--bs-dropdown-padding-y) var(--bs-dropdown-padding-x);margin:0;font-size:var(--bs-dropdown-font-size);color:var(--bs-dropdown-color);text-align:left;list-style:none;background-color:var(--bs-dropdown-bg);background-clip:padding-box;border:var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color);border-radius:var(--bs-dropdown-border-radius)}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:var(--bs-dropdown-spacer)}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:var(--bs-dropdown-spacer)}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:var(--bs-dropdown-spacer)}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:var(--bs-dropdown-spacer)}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:var(--bs-dropdown-divider-margin-y) 0;overflow:hidden;border-top:1px solid var(--bs-dropdown-divider-bg);opacity:1}.dropdown-item{display:block;width:100%;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);clear:both;font-weight:400;color:var(--bs-dropdown-link-color);text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0}.dropdown-item:focus,.dropdown-item:hover{color:var(--bs-dropdown-link-hover-color);background-color:var(--bs-dropdown-link-hover-bg)}.dropdown-item.active,.dropdown-item:active{color:var(--bs-dropdown-link-active-color);text-decoration:none;background-color:var(--bs-dropdown-link-active-bg)}.dropdown-item.disabled,.dropdown-item:disabled{color:var(--bs-dropdown-link-disabled-color);pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:var(--bs-dropdown-header-padding-y) var(--bs-dropdown-header-padding-x);margin-bottom:0;font-size:.875rem;color:var(--bs-dropdown-header-color);white-space:nowrap}.dropdown-item-text{display:block;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);color:var(--bs-dropdown-link-color)}.dropdown-menu-dark{--bs-dropdown-color:#dee2e6;--bs-dropdown-bg:#343a40;--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-box-shadow: ;--bs-dropdown-link-color:#dee2e6;--bs-dropdown-link-hover-color:#fff;--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-link-hover-bg:rgba(255, 255, 255, 0.15);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:#adb5bd;--bs-dropdown-header-color:#adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group{border-radius:.375rem}.btn-group>.btn-group:not(:first-child),.btn-group>.btn:not(:first-child){margin-left:-1px}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn.dropdown-toggle-split:first-child,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:-1px}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn~.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{--bs-nav-link-padding-x:1rem;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-link-color);--bs-nav-link-hover-color:var(--bs-link-hover-color);--bs-nav-link-disabled-color:#6c757d;display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);font-size:var(--bs-nav-link-font-size);font-weight:var(--bs-nav-link-font-weight);color:var(--bs-nav-link-color);text-decoration:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:var(--bs-nav-link-hover-color)}.nav-link.disabled{color:var(--bs-nav-link-disabled-color);pointer-events:none;cursor:default}.nav-tabs{--bs-nav-tabs-border-width:1px;--bs-nav-tabs-border-color:#dee2e6;--bs-nav-tabs-border-radius:0.375rem;--bs-nav-tabs-link-hover-border-color:#e9ecef #e9ecef #dee2e6;--bs-nav-tabs-link-active-color:#495057;--bs-nav-tabs-link-active-bg:#fff;--bs-nav-tabs-link-active-border-color:#dee2e6 #dee2e6 #fff;border-bottom:var(--bs-nav-tabs-border-width) solid var(--bs-nav-tabs-border-color)}.nav-tabs .nav-link{margin-bottom:calc(var(--bs-nav-tabs-border-width) * -1);background:0 0;border:var(--bs-nav-tabs-border-width) solid transparent;border-top-left-radius:var(--bs-nav-tabs-border-radius);border-top-right-radius:var(--bs-nav-tabs-border-radius)}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{isolation:isolate;border-color:var(--bs-nav-tabs-link-hover-border-color)}.nav-tabs .nav-link.disabled,.nav-tabs .nav-link:disabled{color:var(--bs-nav-link-disabled-color);background-color:transparent;border-color:transparent}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:var(--bs-nav-tabs-link-active-color);background-color:var(--bs-nav-tabs-link-active-bg);border-color:var(--bs-nav-tabs-link-active-border-color)}.nav-tabs .dropdown-menu{margin-top:calc(var(--bs-nav-tabs-border-width) * -1);border-top-left-radius:0;border-top-right-radius:0}.nav-pills{--bs-nav-pills-border-radius:0.375rem;--bs-nav-pills-link-active-color:#fff;--bs-nav-pills-link-active-bg:#0d6efd}.nav-pills .nav-link{background:0 0;border:0;border-radius:var(--bs-nav-pills-border-radius)}.nav-pills .nav-link:disabled{color:var(--bs-nav-link-disabled-color);background-color:transparent;border-color:transparent}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:var(--bs-nav-pills-link-active-color);background-color:var(--bs-nav-pills-link-active-bg)}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-basis:0;flex-grow:1;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{--bs-navbar-padding-x:0;--bs-navbar-padding-y:0.5rem;--bs-navbar-color:rgba(0, 0, 0, 0.55);--bs-navbar-hover-color:rgba(0, 0, 0, 0.7);--bs-navbar-disabled-color:rgba(0, 0, 0, 0.3);--bs-navbar-active-color:rgba(0, 0, 0, 0.9);--bs-navbar-brand-padding-y:0.3125rem;--bs-navbar-brand-margin-end:1rem;--bs-navbar-brand-font-size:1.25rem;--bs-navbar-brand-color:rgba(0, 0, 0, 0.9);--bs-navbar-brand-hover-color:rgba(0, 0, 0, 0.9);--bs-navbar-nav-link-padding-x:0.5rem;--bs-navbar-toggler-padding-y:0.25rem;--bs-navbar-toggler-padding-x:0.75rem;--bs-navbar-toggler-font-size:1.25rem;--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%280, 0, 0, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");--bs-navbar-toggler-border-color:rgba(0, 0, 0, 0.1);--bs-navbar-toggler-border-radius:0.375rem;--bs-navbar-toggler-focus-width:0.25rem;--bs-navbar-toggler-transition:box-shadow 0.15s ease-in-out;position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding:var(--bs-navbar-padding-y) var(--bs-navbar-padding-x)}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:var(--bs-navbar-brand-padding-y);padding-bottom:var(--bs-navbar-brand-padding-y);margin-right:var(--bs-navbar-brand-margin-end);font-size:var(--bs-navbar-brand-font-size);color:var(--bs-navbar-brand-color);text-decoration:none;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{color:var(--bs-navbar-brand-hover-color)}.navbar-nav{--bs-nav-link-padding-x:0;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-navbar-color);--bs-nav-link-hover-color:var(--bs-navbar-hover-color);--bs-nav-link-disabled-color:var(--bs-navbar-disabled-color);display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link.active,.navbar-nav .show>.nav-link{color:var(--bs-navbar-active-color)}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-navbar-color)}.navbar-text a,.navbar-text a:focus,.navbar-text a:hover{color:var(--bs-navbar-active-color)}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center}.navbar-toggler{padding:var(--bs-navbar-toggler-padding-y) var(--bs-navbar-toggler-padding-x);font-size:var(--bs-navbar-toggler-font-size);line-height:1;color:var(--bs-navbar-color);background-color:transparent;border:var(--bs-border-width) solid var(--bs-navbar-toggler-border-color);border-radius:var(--bs-navbar-toggler-border-radius);transition:var(--bs-navbar-toggler-transition)}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 var(--bs-navbar-toggler-focus-width)}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-image:var(--bs-navbar-toggler-icon-bg);background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-sm .offcanvas .offcanvas-header{display:none}.navbar-expand-sm .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-md .offcanvas .offcanvas-header{display:none}.navbar-expand-md .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-lg .offcanvas .offcanvas-header{display:none}.navbar-expand-lg .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xl .offcanvas .offcanvas-header{display:none}.navbar-expand-xl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xxl .offcanvas .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand .offcanvas .offcanvas-header{display:none}.navbar-expand .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-dark{--bs-navbar-color:rgba(255, 255, 255, 0.55);--bs-navbar-hover-color:rgba(255, 255, 255, 0.75);--bs-navbar-disabled-color:rgba(255, 255, 255, 0.25);--bs-navbar-active-color:#fff;--bs-navbar-brand-color:#fff;--bs-navbar-brand-hover-color:#fff;--bs-navbar-toggler-border-color:rgba(255, 255, 255, 0.1);--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.card{--bs-card-spacer-y:1rem;--bs-card-spacer-x:1rem;--bs-card-title-spacer-y:0.5rem;--bs-card-border-width:1px;--bs-card-border-color:var(--bs-border-color-translucent);--bs-card-border-radius:0.375rem;--bs-card-box-shadow: ;--bs-card-inner-border-radius:calc(0.375rem - 1px);--bs-card-cap-padding-y:0.5rem;--bs-card-cap-padding-x:1rem;--bs-card-cap-bg:rgba(0, 0, 0, 0.03);--bs-card-cap-color: ;--bs-card-height: ;--bs-card-color: ;--bs-card-bg:#fff;--bs-card-img-overlay-padding:1rem;--bs-card-group-margin:0.75rem;position:relative;display:flex;flex-direction:column;min-width:0;height:var(--bs-card-height);word-wrap:break-word;background-color:var(--bs-card-bg);background-clip:border-box;border:var(--bs-card-border-width) solid var(--bs-card-border-color);border-radius:var(--bs-card-border-radius)}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:var(--bs-card-spacer-y) var(--bs-card-spacer-x);color:var(--bs-card-color)}.card-title{margin-bottom:var(--bs-card-title-spacer-y)}.card-subtitle{margin-top:calc(-.5 * var(--bs-card-title-spacer-y));margin-bottom:0}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:var(--bs-card-spacer-x)}.card-header{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);margin-bottom:0;color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-bottom:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-header:first-child{border-radius:var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius) 0 0}.card-footer{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-top:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-footer:last-child{border-radius:0 0 var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius)}.card-header-tabs{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-bottom:calc(-1 * var(--bs-card-cap-padding-y));margin-left:calc(-.5 * var(--bs-card-cap-padding-x));border-bottom:0}.card-header-tabs .nav-link.active{background-color:var(--bs-card-bg);border-bottom-color:var(--bs-card-bg)}.card-header-pills{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-left:calc(-.5 * var(--bs-card-cap-padding-x))}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:var(--bs-card-img-overlay-padding);border-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom{border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card-group>.card{margin-bottom:var(--bs-card-group-margin)}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.accordion{--bs-accordion-color:#000;--bs-accordion-bg:#fff;--bs-accordion-transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out,border-radius 0.15s ease;--bs-accordion-border-color:var(--bs-border-color);--bs-accordion-border-width:1px;--bs-accordion-border-radius:0.375rem;--bs-accordion-inner-border-radius:calc(0.375rem - 1px);--bs-accordion-btn-padding-x:1.25rem;--bs-accordion-btn-padding-y:1rem;--bs-accordion-btn-color:var(--bs-body-color);--bs-accordion-btn-bg:var(--bs-accordion-bg);--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='var%28--bs-body-color%29'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");--bs-accordion-btn-icon-width:1.25rem;--bs-accordion-btn-icon-transform:rotate(-180deg);--bs-accordion-btn-icon-transition:transform 0.2s ease-in-out;--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%230c63e4'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");--bs-accordion-btn-focus-border-color:#86b7fe;--bs-accordion-btn-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-accordion-body-padding-x:1.25rem;--bs-accordion-body-padding-y:1rem;--bs-accordion-active-color:#0c63e4;--bs-accordion-active-bg:#e7f1ff}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:var(--bs-accordion-btn-padding-y) var(--bs-accordion-btn-padding-x);font-size:1rem;color:var(--bs-accordion-btn-color);text-align:left;background-color:var(--bs-accordion-btn-bg);border:0;border-radius:0;overflow-anchor:none;transition:var(--bs-accordion-transition)}@media (prefers-reduced-motion:reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:var(--bs-accordion-active-color);background-color:var(--bs-accordion-active-bg);box-shadow:inset 0 calc(var(--bs-accordion-border-width) * -1) 0 var(--bs-accordion-border-color)}.accordion-button:not(.collapsed)::after{background-image:var(--bs-accordion-btn-active-icon);transform:var(--bs-accordion-btn-icon-transform)}.accordion-button::after{flex-shrink:0;width:var(--bs-accordion-btn-icon-width);height:var(--bs-accordion-btn-icon-width);margin-left:auto;content:"";background-image:var(--bs-accordion-btn-icon);background-repeat:no-repeat;background-size:var(--bs-accordion-btn-icon-width);transition:var(--bs-accordion-btn-icon-transition)}@media (prefers-reduced-motion:reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;border-color:var(--bs-accordion-btn-focus-border-color);outline:0;box-shadow:var(--bs-accordion-btn-focus-box-shadow)}.accordion-header{margin-bottom:0}.accordion-item{color:var(--bs-accordion-color);background-color:var(--bs-accordion-bg);border:var(--bs-accordion-border-width) solid var(--bs-accordion-border-color)}.accordion-item:first-of-type{border-top-left-radius:var(--bs-accordion-border-radius);border-top-right-radius:var(--bs-accordion-border-radius)}.accordion-item:first-of-type .accordion-button{border-top-left-radius:var(--bs-accordion-inner-border-radius);border-top-right-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-item:last-of-type .accordion-button.collapsed{border-bottom-right-radius:var(--bs-accordion-inner-border-radius);border-bottom-left-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:last-of-type .accordion-collapse{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-body{padding:var(--bs-accordion-body-padding-y) var(--bs-accordion-body-padding-x)}.accordion-flush .accordion-collapse{border-width:0}.accordion-flush .accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush .accordion-item:first-child{border-top:0}.accordion-flush .accordion-item:last-child{border-bottom:0}.accordion-flush .accordion-item .accordion-button{border-radius:0}.breadcrumb{--bs-breadcrumb-padding-x:0;--bs-breadcrumb-padding-y:0;--bs-breadcrumb-margin-bottom:1rem;--bs-breadcrumb-bg: ;--bs-breadcrumb-border-radius: ;--bs-breadcrumb-divider-color:#6c757d;--bs-breadcrumb-item-padding-x:0.5rem;--bs-breadcrumb-item-active-color:#6c757d;display:flex;flex-wrap:wrap;padding:var(--bs-breadcrumb-padding-y) var(--bs-breadcrumb-padding-x);margin-bottom:var(--bs-breadcrumb-margin-bottom);font-size:var(--bs-breadcrumb-font-size);list-style:none;background-color:var(--bs-breadcrumb-bg);border-radius:var(--bs-breadcrumb-border-radius)}.breadcrumb-item+.breadcrumb-item{padding-left:var(--bs-breadcrumb-item-padding-x)}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:var(--bs-breadcrumb-item-padding-x);color:var(--bs-breadcrumb-divider-color);content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:var(--bs-breadcrumb-item-active-color)}.pagination{--bs-pagination-padding-x:0.75rem;--bs-pagination-padding-y:0.375rem;--bs-pagination-font-size:1rem;--bs-pagination-color:var(--bs-link-color);--bs-pagination-bg:#fff;--bs-pagination-border-width:1px;--bs-pagination-border-color:#dee2e6;--bs-pagination-border-radius:0.375rem;--bs-pagination-hover-color:var(--bs-link-hover-color);--bs-pagination-hover-bg:#e9ecef;--bs-pagination-hover-border-color:#dee2e6;--bs-pagination-focus-color:var(--bs-link-hover-color);--bs-pagination-focus-bg:#e9ecef;--bs-pagination-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-pagination-active-color:#fff;--bs-pagination-active-bg:#0d6efd;--bs-pagination-active-border-color:#0d6efd;--bs-pagination-disabled-color:#6c757d;--bs-pagination-disabled-bg:#fff;--bs-pagination-disabled-border-color:#dee2e6;display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;padding:var(--bs-pagination-padding-y) var(--bs-pagination-padding-x);font-size:var(--bs-pagination-font-size);color:var(--bs-pagination-color);text-decoration:none;background-color:var(--bs-pagination-bg);border:var(--bs-pagination-border-width) solid var(--bs-pagination-border-color);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:var(--bs-pagination-hover-color);background-color:var(--bs-pagination-hover-bg);border-color:var(--bs-pagination-hover-border-color)}.page-link:focus{z-index:3;color:var(--bs-pagination-focus-color);background-color:var(--bs-pagination-focus-bg);outline:0;box-shadow:var(--bs-pagination-focus-box-shadow)}.active>.page-link,.page-link.active{z-index:3;color:var(--bs-pagination-active-color);background-color:var(--bs-pagination-active-bg);border-color:var(--bs-pagination-active-border-color)}.disabled>.page-link,.page-link.disabled{color:var(--bs-pagination-disabled-color);pointer-events:none;background-color:var(--bs-pagination-disabled-bg);border-color:var(--bs-pagination-disabled-border-color)}.page-item:not(:first-child) .page-link{margin-left:-1px}.page-item:first-child .page-link{border-top-left-radius:var(--bs-pagination-border-radius);border-bottom-left-radius:var(--bs-pagination-border-radius)}.page-item:last-child .page-link{border-top-right-radius:var(--bs-pagination-border-radius);border-bottom-right-radius:var(--bs-pagination-border-radius)}.pagination-lg{--bs-pagination-padding-x:1.5rem;--bs-pagination-padding-y:0.75rem;--bs-pagination-font-size:1.25rem;--bs-pagination-border-radius:0.5rem}.pagination-sm{--bs-pagination-padding-x:0.5rem;--bs-pagination-padding-y:0.25rem;--bs-pagination-font-size:0.875rem;--bs-pagination-border-radius:0.25rem}.badge{--bs-badge-padding-x:0.65em;--bs-badge-padding-y:0.35em;--bs-badge-font-size:0.75em;--bs-badge-font-weight:700;--bs-badge-color:#fff;--bs-badge-border-radius:0.375rem;display:inline-block;padding:var(--bs-badge-padding-y) var(--bs-badge-padding-x);font-size:var(--bs-badge-font-size);font-weight:var(--bs-badge-font-weight);line-height:1;color:var(--bs-badge-color);text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:var(--bs-badge-border-radius)}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{--bs-alert-bg:transparent;--bs-alert-padding-x:1rem;--bs-alert-padding-y:1rem;--bs-alert-margin-bottom:1rem;--bs-alert-color:inherit;--bs-alert-border-color:transparent;--bs-alert-border:1px solid var(--bs-alert-border-color);--bs-alert-border-radius:0.375rem;position:relative;padding:var(--bs-alert-padding-y) var(--bs-alert-padding-x);margin-bottom:var(--bs-alert-margin-bottom);color:var(--bs-alert-color);background-color:var(--bs-alert-bg);border:var(--bs-alert-border);border-radius:var(--bs-alert-border-radius)}.alert-heading{color:inherit}.alert-link{font-weight:700}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{--bs-alert-color:#084298;--bs-alert-bg:#cfe2ff;--bs-alert-border-color:#b6d4fe}.alert-primary .alert-link{color:#06357a}.alert-secondary{--bs-alert-color:#41464b;--bs-alert-bg:#e2e3e5;--bs-alert-border-color:#d3d6d8}.alert-secondary .alert-link{color:#34383c}.alert-success{--bs-alert-color:#0f5132;--bs-alert-bg:#d1e7dd;--bs-alert-border-color:#badbcc}.alert-success .alert-link{color:#0c4128}.alert-info{--bs-alert-color:#055160;--bs-alert-bg:#cff4fc;--bs-alert-border-color:#b6effb}.alert-info .alert-link{color:#04414d}.alert-warning{--bs-alert-color:#664d03;--bs-alert-bg:#fff3cd;--bs-alert-border-color:#ffecb5}.alert-warning .alert-link{color:#523e02}.alert-danger{--bs-alert-color:#842029;--bs-alert-bg:#f8d7da;--bs-alert-border-color:#f5c2c7}.alert-danger .alert-link{color:#6a1a21}.alert-light{--bs-alert-color:#636464;--bs-alert-bg:#fefefe;--bs-alert-border-color:#fdfdfe}.alert-light .alert-link{color:#4f5050}.alert-dark{--bs-alert-color:#141619;--bs-alert-bg:#d3d3d4;--bs-alert-border-color:#bcbebf}.alert-dark .alert-link{color:#101214}@-webkit-keyframes progress-bar-stripes{0%{background-position-x:1rem}}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.progress{--bs-progress-height:1rem;--bs-progress-font-size:0.75rem;--bs-progress-bg:#e9ecef;--bs-progress-border-radius:0.375rem;--bs-progress-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-progress-bar-color:#fff;--bs-progress-bar-bg:#0d6efd;--bs-progress-bar-transition:width 0.6s ease;display:flex;height:var(--bs-progress-height);overflow:hidden;font-size:var(--bs-progress-font-size);background-color:var(--bs-progress-bg);border-radius:var(--bs-progress-border-radius)}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:var(--bs-progress-bar-color);text-align:center;white-space:nowrap;background-color:var(--bs-progress-bar-bg);transition:var(--bs-progress-bar-transition)}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:var(--bs-progress-height) var(--bs-progress-height)}.progress-bar-animated{-webkit-animation:1s linear infinite progress-bar-stripes;animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{-webkit-animation:none;animation:none}}.list-group{--bs-list-group-color:#101c28;--bs-list-group-bg:#fff;--bs-list-group-border-color:rgba(0, 0, 0, 0.125);--bs-list-group-border-width:1px;--bs-list-group-border-radius:0.375rem;--bs-list-group-item-padding-x:1rem;--bs-list-group-item-padding-y:0.5rem;--bs-list-group-action-color:#495057;--bs-list-group-action-hover-color:#495057;--bs-list-group-action-hover-bg:#f8f9fa;--bs-list-group-action-active-color:#101c28;--bs-list-group-action-active-bg:#e9ecef;--bs-list-group-disabled-color:#6c757d;--bs-list-group-disabled-bg:#fff;--bs-list-group-active-color:#fff;--bs-list-group-active-bg:#0d6efd;--bs-list-group-active-border-color:#0d6efd;display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:var(--bs-list-group-border-radius)}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>.list-group-item::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item-action{width:100%;color:var(--bs-list-group-action-color);text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:var(--bs-list-group-action-hover-color);text-decoration:none;background-color:var(--bs-list-group-action-hover-bg)}.list-group-item-action:active{color:var(--bs-list-group-action-active-color);background-color:var(--bs-list-group-action-active-bg)}.list-group-item{position:relative;display:block;padding:var(--bs-list-group-item-padding-y) var(--bs-list-group-item-padding-x);color:var(--bs-list-group-color);text-decoration:none;background-color:var(--bs-list-group-bg);border:var(--bs-list-group-border-width) solid var(--bs-list-group-border-color)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:var(--bs-list-group-disabled-color);pointer-events:none;background-color:var(--bs-list-group-disabled-bg)}.list-group-item.active{z-index:2;color:var(--bs-list-group-active-color);background-color:var(--bs-list-group-active-bg);border-color:var(--bs-list-group-active-border-color)}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:calc(var(--bs-list-group-border-width) * -1);border-top-width:var(--bs-list-group-border-width)}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child{border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child{border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:calc(var(--bs-list-group-border-width) * -1);border-left-width:var(--bs-list-group-border-width)}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child{border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child{border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:calc(var(--bs-list-group-border-width) * -1);border-left-width:var(--bs-list-group-border-width)}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child{border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child{border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:calc(var(--bs-list-group-border-width) * -1);border-left-width:var(--bs-list-group-border-width)}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child{border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child{border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:calc(var(--bs-list-group-border-width) * -1);border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child{border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child{border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:calc(var(--bs-list-group-border-width) * -1);border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child{border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child{border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:calc(var(--bs-list-group-border-width) * -1);border-left-width:var(--bs-list-group-border-width)}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 var(--bs-list-group-border-width)}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{color:#084298;background-color:#cfe2ff}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{color:#084298;background-color:#bacbe6}.list-group-item-primary.list-group-item-action.active{color:#fff;background-color:#084298;border-color:#084298}.list-group-item-secondary{color:#41464b;background-color:#e2e3e5}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{color:#41464b;background-color:#cbccce}.list-group-item-secondary.list-group-item-action.active{color:#fff;background-color:#41464b;border-color:#41464b}.list-group-item-success{color:#0f5132;background-color:#d1e7dd}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{color:#0f5132;background-color:#bcd0c7}.list-group-item-success.list-group-item-action.active{color:#fff;background-color:#0f5132;border-color:#0f5132}.list-group-item-info{color:#055160;background-color:#cff4fc}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{color:#055160;background-color:#badce3}.list-group-item-info.list-group-item-action.active{color:#fff;background-color:#055160;border-color:#055160}.list-group-item-warning{color:#664d03;background-color:#fff3cd}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{color:#664d03;background-color:#e6dbb9}.list-group-item-warning.list-group-item-action.active{color:#fff;background-color:#664d03;border-color:#664d03}.list-group-item-danger{color:#842029;background-color:#f8d7da}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{color:#842029;background-color:#dfc2c4}.list-group-item-danger.list-group-item-action.active{color:#fff;background-color:#842029;border-color:#842029}.list-group-item-light{color:#636464;background-color:#fefefe}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{color:#636464;background-color:#e5e5e5}.list-group-item-light.list-group-item-action.active{color:#fff;background-color:#636464;border-color:#636464}.list-group-item-dark{color:#141619;background-color:#d3d3d4}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{color:#141619;background-color:#bebebf}.list-group-item-dark.list-group-item-action.active{color:#fff;background-color:#141619;border-color:#141619}.btn-close{box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:#000;background:transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e") center/1em auto no-repeat;border:0;border-radius:.375rem;opacity:.5}.btn-close:hover{color:#000;text-decoration:none;opacity:.75}.btn-close:focus{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25);opacity:1}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:.25}.btn-close-white{filter:invert(1) grayscale(100%) brightness(200%)}.toast{--bs-toast-padding-x:0.75rem;--bs-toast-padding-y:0.5rem;--bs-toast-spacing:1.5rem;--bs-toast-max-width:350px;--bs-toast-font-size:0.875rem;--bs-toast-color: ;--bs-toast-bg:rgba(255, 255, 255, 0.85);--bs-toast-border-width:1px;--bs-toast-border-color:var(--bs-border-color-translucent);--bs-toast-border-radius:0.375rem;--bs-toast-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-toast-header-color:#6c757d;--bs-toast-header-bg:rgba(255, 255, 255, 0.85);--bs-toast-header-border-color:rgba(0, 0, 0, 0.05);width:var(--bs-toast-max-width);max-width:100%;font-size:var(--bs-toast-font-size);color:var(--bs-toast-color);pointer-events:auto;background-color:var(--bs-toast-bg);background-clip:padding-box;border:var(--bs-toast-border-width) solid var(--bs-toast-border-color);box-shadow:var(--bs-toast-box-shadow);border-radius:var(--bs-toast-border-radius)}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{position:absolute;z-index:1090;width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:var(--bs-toast-spacing)}.toast-header{display:flex;align-items:center;padding:var(--bs-toast-padding-y) var(--bs-toast-padding-x);color:var(--bs-toast-header-color);background-color:var(--bs-toast-header-bg);background-clip:padding-box;border-bottom:var(--bs-toast-border-width) solid var(--bs-toast-header-border-color);border-top-left-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width));border-top-right-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width))}.toast-header .btn-close{margin-right:calc(var(--bs-toast-padding-x) * -.5);margin-left:var(--bs-toast-padding-x)}.toast-body{padding:var(--bs-toast-padding-x);word-wrap:break-word}.modal{--bs-modal-zindex:1055;--bs-modal-width:500px;--bs-modal-padding:1rem;--bs-modal-margin:0.5rem;--bs-modal-color: ;--bs-modal-bg:#fff;--bs-modal-border-color:var(--bs-border-color-translucent);--bs-modal-border-width:1px;--bs-modal-border-radius:0.5rem;--bs-modal-box-shadow:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-modal-inner-border-radius:calc(0.5rem - 1px);--bs-modal-header-padding-x:1rem;--bs-modal-header-padding-y:1rem;--bs-modal-header-padding:1rem 1rem;--bs-modal-header-border-color:var(--bs-border-color);--bs-modal-header-border-width:1px;--bs-modal-title-line-height:1.5;--bs-modal-footer-gap:0.5rem;--bs-modal-footer-bg: ;--bs-modal-footer-border-color:var(--bs-border-color);--bs-modal-footer-border-width:1px;position:fixed;top:0;left:0;z-index:var(--bs-modal-zindex);display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:var(--bs-modal-margin);pointer-events:none}.modal.fade .modal-dialog{transition:transform .3s ease-out;transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - var(--bs-modal-margin) * 2)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - var(--bs-modal-margin) * 2)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;color:var(--bs-modal-color);pointer-events:auto;background-color:var(--bs-modal-bg);background-clip:padding-box;border:var(--bs-modal-border-width) solid var(--bs-modal-border-color);border-radius:var(--bs-modal-border-radius);outline:0}.modal-backdrop{--bs-backdrop-zindex:1050;--bs-backdrop-bg:#000;--bs-backdrop-opacity:0.5;position:fixed;top:0;left:0;z-index:var(--bs-backdrop-zindex);width:100vw;height:100vh;background-color:var(--bs-backdrop-bg)}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:var(--bs-backdrop-opacity)}.modal-header{display:flex;flex-shrink:0;align-items:center;justify-content:space-between;padding:var(--bs-modal-header-padding);border-bottom:var(--bs-modal-header-border-width) solid var(--bs-modal-header-border-color);border-top-left-radius:var(--bs-modal-inner-border-radius);border-top-right-radius:var(--bs-modal-inner-border-radius)}.modal-header .btn-close{padding:calc(var(--bs-modal-header-padding-y) * .5) calc(var(--bs-modal-header-padding-x) * .5);margin:calc(var(--bs-modal-header-padding-y) * -.5) calc(var(--bs-modal-header-padding-x) * -.5) calc(var(--bs-modal-header-padding-y) * -.5) auto}.modal-title{margin-bottom:0;line-height:var(--bs-modal-title-line-height)}.modal-body{position:relative;flex:1 1 auto;padding:var(--bs-modal-padding)}.modal-footer{display:flex;flex-shrink:0;flex-wrap:wrap;align-items:center;justify-content:flex-end;padding:calc(var(--bs-modal-padding) - var(--bs-modal-footer-gap) * .5);background-color:var(--bs-modal-footer-bg);border-top:var(--bs-modal-footer-border-width) solid var(--bs-modal-footer-border-color);border-bottom-right-radius:var(--bs-modal-inner-border-radius);border-bottom-left-radius:var(--bs-modal-inner-border-radius)}.modal-footer>*{margin:calc(var(--bs-modal-footer-gap) * .5)}@media (min-width:576px){.modal{--bs-modal-margin:1.75rem;--bs-modal-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15)}.modal-dialog{max-width:var(--bs-modal-width);margin-right:auto;margin-left:auto}.modal-sm{--bs-modal-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{--bs-modal-width:800px}}@media (min-width:1200px){.modal-xl{--bs-modal-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-footer,.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-footer,.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-footer,.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-footer,.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-footer,.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-footer,.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}}.tooltip{--bs-tooltip-zindex:1080;--bs-tooltip-max-width:200px;--bs-tooltip-padding-x:0.5rem;--bs-tooltip-padding-y:0.25rem;--bs-tooltip-margin: ;--bs-tooltip-font-size:0.875rem;--bs-tooltip-color:#fff;--bs-tooltip-bg:#000;--bs-tooltip-border-radius:0.375rem;--bs-tooltip-opacity:0.9;--bs-tooltip-arrow-width:0.8rem;--bs-tooltip-arrow-height:0.4rem;z-index:var(--bs-tooltip-zindex);display:block;padding:var(--bs-tooltip-arrow-height);margin:var(--bs-tooltip-margin);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-tooltip-font-size);word-wrap:break-word;opacity:0}.tooltip.show{opacity:var(--bs-tooltip-opacity)}.tooltip .tooltip-arrow{display:block;width:var(--bs-tooltip-arrow-width);height:var(--bs-tooltip-arrow-height)}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:0}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-top-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:0;width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-right-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:0}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-bottom-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:0;width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) 0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-left-color:var(--bs-tooltip-bg)}.tooltip-inner{max-width:var(--bs-tooltip-max-width);padding:var(--bs-tooltip-padding-y) var(--bs-tooltip-padding-x);color:var(--bs-tooltip-color);text-align:center;background-color:var(--bs-tooltip-bg);border-radius:var(--bs-tooltip-border-radius)}.popover{--bs-popover-zindex:1070;--bs-popover-max-width:276px;--bs-popover-font-size:0.875rem;--bs-popover-bg:#fff;--bs-popover-border-width:1px;--bs-popover-border-color:var(--bs-border-color-translucent);--bs-popover-border-radius:0.5rem;--bs-popover-inner-border-radius:calc(0.5rem - 1px);--bs-popover-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-popover-header-padding-x:1rem;--bs-popover-header-padding-y:0.5rem;--bs-popover-header-font-size:1rem;--bs-popover-header-color:var(--bs-heading-color);--bs-popover-header-bg:#f0f0f0;--bs-popover-body-padding-x:1rem;--bs-popover-body-padding-y:1rem;--bs-popover-body-color:#101c28;--bs-popover-arrow-width:1rem;--bs-popover-arrow-height:0.5rem;--bs-popover-arrow-border:var(--bs-popover-border-color);z-index:var(--bs-popover-zindex);display:block;max-width:var(--bs-popover-max-width);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-popover-font-size);word-wrap:break-word;background-color:var(--bs-popover-bg);background-clip:padding-box;border:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-radius:var(--bs-popover-border-radius)}.popover .popover-arrow{display:block;width:var(--bs-popover-arrow-width);height:var(--bs-popover-arrow-height)}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid;border-width:0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(var(--bs-popover-arrow-height) * -1 - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::after,.bs-popover-top>.popover-arrow::before{border-width:var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-top-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:var(--bs-popover-border-width);border-top-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(var(--bs-popover-arrow-height) * -1 - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::after,.bs-popover-end>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-right-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:var(--bs-popover-border-width);border-right-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(var(--bs-popover-arrow-height) * -1 - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::before{border-width:0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-bottom-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:var(--bs-popover-border-width);border-bottom-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:var(--bs-popover-arrow-width);margin-left:calc(var(--bs-popover-arrow-width) * -.5);content:"";border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-header-bg)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(var(--bs-popover-arrow-height) * -1 - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::after,.bs-popover-start>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) 0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-left-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:var(--bs-popover-border-width);border-left-color:var(--bs-popover-bg)}.popover-header{padding:var(--bs-popover-header-padding-y) var(--bs-popover-header-padding-x);margin-bottom:0;font-size:var(--bs-popover-header-font-size);color:var(--bs-popover-header-color);background-color:var(--bs-popover-header-bg);border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-top-left-radius:var(--bs-popover-inner-border-radius);border-top-right-radius:var(--bs-popover-inner-border-radius)}.popover-header:empty{display:none}.popover-body{padding:var(--bs-popover-body-padding-y) var(--bs-popover-body-padding-x);color:var(--bs-popover-body-color)}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%;list-style:none}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:#fff;text-align:center}.carousel-dark .carousel-control-next-icon,.carousel-dark .carousel-control-prev-icon{filter:invert(1) grayscale(100)}.carousel-dark .carousel-indicators [data-bs-target]{background-color:#000}.carousel-dark .carousel-caption{color:#000}.spinner-border,.spinner-grow{display:inline-block;width:var(--bs-spinner-width);height:var(--bs-spinner-height);vertical-align:var(--bs-spinner-vertical-align);border-radius:50%;-webkit-animation:var(--bs-spinner-animation-speed) linear infinite var(--bs-spinner-animation-name);animation:var(--bs-spinner-animation-speed) linear infinite var(--bs-spinner-animation-name)}@-webkit-keyframes spinner-border{to{transform:rotate(360deg)}}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-border-width:0.25em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-border;border:var(--bs-spinner-border-width) solid currentcolor;border-right-color:transparent}.spinner-border-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem;--bs-spinner-border-width:0.2em}@-webkit-keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-grow;background-color:currentcolor;opacity:0}.spinner-grow-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{--bs-spinner-animation-speed:1.5s}}.offcanvas,.offcanvas-lg,.offcanvas-md,.offcanvas-sm,.offcanvas-xl,.offcanvas-xxl{--bs-offcanvas-width:400px;--bs-offcanvas-height:30vh;--bs-offcanvas-padding-x:1rem;--bs-offcanvas-padding-y:1rem;--bs-offcanvas-color: ;--bs-offcanvas-bg:#fff;--bs-offcanvas-border-width:1px;--bs-offcanvas-border-color:var(--bs-border-color-translucent);--bs-offcanvas-box-shadow:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075)}@media (max-width:575.98px){.offcanvas-sm{position:fixed;bottom:0;z-index:1045;display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}}@media (max-width:575.98px) and (prefers-reduced-motion:reduce){.offcanvas-sm{transition:none}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}}@media (max-width:575.98px){.offcanvas-sm.show:not(.hiding),.offcanvas-sm.showing{transform:none}}@media (max-width:575.98px){.offcanvas-sm.hiding,.offcanvas-sm.show,.offcanvas-sm.showing{visibility:visible}}@media (min-width:576px){.offcanvas-sm{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-sm .offcanvas-header{display:none}.offcanvas-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:767.98px){.offcanvas-md{position:fixed;bottom:0;z-index:1045;display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}}@media (max-width:767.98px) and (prefers-reduced-motion:reduce){.offcanvas-md{transition:none}}@media (max-width:767.98px){.offcanvas-md.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}}@media (max-width:767.98px){.offcanvas-md.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}}@media (max-width:767.98px){.offcanvas-md.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}}@media (max-width:767.98px){.offcanvas-md.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}}@media (max-width:767.98px){.offcanvas-md.show:not(.hiding),.offcanvas-md.showing{transform:none}}@media (max-width:767.98px){.offcanvas-md.hiding,.offcanvas-md.show,.offcanvas-md.showing{visibility:visible}}@media (min-width:768px){.offcanvas-md{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-md .offcanvas-header{display:none}.offcanvas-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:991.98px){.offcanvas-lg{position:fixed;bottom:0;z-index:1045;display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}}@media (max-width:991.98px) and (prefers-reduced-motion:reduce){.offcanvas-lg{transition:none}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}}@media (max-width:991.98px){.offcanvas-lg.show:not(.hiding),.offcanvas-lg.showing{transform:none}}@media (max-width:991.98px){.offcanvas-lg.hiding,.offcanvas-lg.show,.offcanvas-lg.showing{visibility:visible}}@media (min-width:992px){.offcanvas-lg{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-lg .offcanvas-header{display:none}.offcanvas-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1199.98px){.offcanvas-xl{position:fixed;bottom:0;z-index:1045;display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}}@media (max-width:1199.98px) and (prefers-reduced-motion:reduce){.offcanvas-xl{transition:none}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}}@media (max-width:1199.98px){.offcanvas-xl.show:not(.hiding),.offcanvas-xl.showing{transform:none}}@media (max-width:1199.98px){.offcanvas-xl.hiding,.offcanvas-xl.show,.offcanvas-xl.showing{visibility:visible}}@media (min-width:1200px){.offcanvas-xl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xl .offcanvas-header{display:none}.offcanvas-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1399.98px){.offcanvas-xxl{position:fixed;bottom:0;z-index:1045;display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}}@media (max-width:1399.98px) and (prefers-reduced-motion:reduce){.offcanvas-xxl{transition:none}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}}@media (max-width:1399.98px){.offcanvas-xxl.show:not(.hiding),.offcanvas-xxl.showing{transform:none}}@media (max-width:1399.98px){.offcanvas-xxl.hiding,.offcanvas-xxl.show,.offcanvas-xxl.showing{visibility:visible}}@media (min-width:1400px){.offcanvas-xxl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xxl .offcanvas-header{display:none}.offcanvas-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}.offcanvas{position:fixed;bottom:0;z-index:1045;display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas.show:not(.hiding),.offcanvas.showing{transform:none}.offcanvas.hiding,.offcanvas.show,.offcanvas.showing{visibility:visible}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;justify-content:space-between;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x)}.offcanvas-header .btn-close{padding:calc(var(--bs-offcanvas-padding-y) * .5) calc(var(--bs-offcanvas-padding-x) * .5);margin-top:calc(var(--bs-offcanvas-padding-y) * -.5);margin-right:calc(var(--bs-offcanvas-padding-x) * -.5);margin-bottom:calc(var(--bs-offcanvas-padding-y) * -.5)}.offcanvas-title{margin-bottom:0;line-height:1.5}.offcanvas-body{flex-grow:1;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x);overflow-y:auto}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentcolor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{-webkit-animation:placeholder-glow 2s ease-in-out infinite;animation:placeholder-glow 2s ease-in-out infinite}@-webkit-keyframes placeholder-glow{50%{opacity:.2}}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;-webkit-animation:placeholder-wave 2s linear infinite;animation:placeholder-wave 2s linear infinite}@-webkit-keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.text-bg-primary{color:#fff!important;background-color:RGBA(13,110,253,var(--bs-bg-opacity,1))!important}.text-bg-secondary{color:#fff!important;background-color:RGBA(108,117,125,var(--bs-bg-opacity,1))!important}.text-bg-success{color:#fff!important;background-color:RGBA(25,135,84,var(--bs-bg-opacity,1))!important}.text-bg-info{color:#000!important;background-color:RGBA(13,202,240,var(--bs-bg-opacity,1))!important}.text-bg-warning{color:#000!important;background-color:RGBA(255,193,7,var(--bs-bg-opacity,1))!important}.text-bg-danger{color:#fff!important;background-color:RGBA(220,53,69,var(--bs-bg-opacity,1))!important}.text-bg-light{color:#000!important;background-color:RGBA(248,249,250,var(--bs-bg-opacity,1))!important}.text-bg-dark{color:#fff!important;background-color:RGBA(16,28,40,var(--bs-bg-opacity,1))!important}.link-primary{color:#0d6efd!important}.link-primary:focus,.link-primary:hover{color:#0a58ca!important}.link-secondary{color:#6c757d!important}.link-secondary:focus,.link-secondary:hover{color:#565e64!important}.link-success{color:#198754!important}.link-success:focus,.link-success:hover{color:#146c43!important}.link-info{color:#0dcaf0!important}.link-info:focus,.link-info:hover{color:#3dd5f3!important}.link-warning{color:#ffc107!important}.link-warning:focus,.link-warning:hover{color:#ffcd39!important}.link-danger{color:#dc3545!important}.link-danger:focus,.link-danger:hover{color:#b02a37!important}.link-light{color:#f8f9fa!important}.link-light:focus,.link-light:hover{color:#f9fafb!important}.link-dark{color:#101c28!important}.link-dark:focus,.link-dark:hover{color:#1a1e21!important}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:75%}.ratio-16x9{--bs-aspect-ratio:56.25%}.ratio-21x9{--bs-aspect-ratio:42.8571428571%}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-sm-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-md-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-lg-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xxl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:1px;min-height:1em;background-color:currentcolor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15)!important}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075)!important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175)!important}.shadow-none{box-shadow:none!important}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-0{border:0!important}.border-top{border-top:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-top-0{border-top:0!important}.border-end{border-right:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-start-0{border-left:0!important}.border-primary{--bs-border-opacity:1;border-color:rgba(var(--bs-primary-rgb),var(--bs-border-opacity))!important}.border-secondary{--bs-border-opacity:1;border-color:rgba(var(--bs-secondary-rgb),var(--bs-border-opacity))!important}.border-success{--bs-border-opacity:1;border-color:rgba(var(--bs-success-rgb),var(--bs-border-opacity))!important}.border-info{--bs-border-opacity:1;border-color:rgba(var(--bs-info-rgb),var(--bs-border-opacity))!important}.border-warning{--bs-border-opacity:1;border-color:rgba(var(--bs-warning-rgb),var(--bs-border-opacity))!important}.border-danger{--bs-border-opacity:1;border-color:rgba(var(--bs-danger-rgb),var(--bs-border-opacity))!important}.border-light{--bs-border-opacity:1;border-color:rgba(var(--bs-light-rgb),var(--bs-border-opacity))!important}.border-dark{--bs-border-opacity:1;border-color:rgba(var(--bs-dark-rgb),var(--bs-border-opacity))!important}.border-white{--bs-border-opacity:1;border-color:rgba(var(--bs-white-rgb),var(--bs-border-opacity))!important}.border-1{--bs-border-width:1px}.border-2{--bs-border-width:2px}.border-3{--bs-border-width:3px}.border-4{--bs-border-width:4px}.border-5{--bs-border-width:5px}.border-opacity-10{--bs-border-opacity:0.1}.border-opacity-25{--bs-border-opacity:0.25}.border-opacity-50{--bs-border-opacity:0.5}.border-opacity-75{--bs-border-opacity:0.75}.border-opacity-100{--bs-border-opacity:1}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-light{font-weight:300!important}.fw-lighter{font-weight:lighter!important}.fw-normal{font-weight:400!important}.fw-bold{font-weight:700!important}.fw-semibold{font-weight:600!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-color-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:#6c757d!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-bg-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:var(--bs-border-radius)!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:var(--bs-border-radius-sm)!important}.rounded-2{border-radius:var(--bs-border-radius)!important}.rounded-3{border-radius:var(--bs-border-radius-lg)!important}.rounded-4{border-radius:var(--bs-border-radius-xl)!important}.rounded-5{border-radius:var(--bs-border-radius-2xl)!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:var(--bs-border-radius-pill)!important}.rounded-top{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-end{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-bottom{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-start{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}} +/*# sourceMappingURL=bootstrap.min.css.map */ diff --git a/app/static/images/spinner.svg b/app/static/images/spinner.svg new file mode 100644 index 0000000..93744c8 --- /dev/null +++ b/app/static/images/spinner.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/static/index.css b/app/static/index.css new file mode 100644 index 0000000..7a8890e --- /dev/null +++ b/app/static/index.css @@ -0,0 +1,83 @@ +body { + background: #f5f5f5; +} + +body .container-fluid:first-child { + padding-top: 20px; +} + +a { + color: #155C25; +} + +.bg-primary, .btn-primary { + background-color: #155C25 !important; + border-color: #155C25 !important; +} + +@media(min-width: 600px) { + td.label { + width: 200px; + } +} + +@media(max-width: 599px) { + td.label { + width: 125px; + } +} + +.navbar { + margin-bottom: 20px; +} + +.navbar-brand img { + height: 2.5em; + margin-left: 1em; +} + +code { + overflow-wrap: anywhere; + color: #555 !important; +} + +.text-warning-dark { + color: #856404; +} + +td ul { + list-style-type: "- "; + margin: 5px 0 5px 5px; + padding-left: 10px; +} + +.input-group.with-margin { + margin: 10px 0; +} + +.card-highlight { + border-width: 3px; +} + +@media(max-width: 450px) { + .navbar-brand { + font-size: 16px; + } + .navbar-brand img { + height: 2em; + } +} + +.spinner { + display: block; + float: right; +} + +.mailto-link-with-button { + margin: 10px 0 20px; +} + +.mailto-link-with-button .copy-button-wrapper { + display: inline-block; + margin-left: 10px; +} diff --git a/app/templates/404.html b/app/templates/404.html new file mode 100644 index 0000000..1d9df2b --- /dev/null +++ b/app/templates/404.html @@ -0,0 +1,15 @@ +{% extends "custom_layout.html" %} + +{% block body %} +
+
+
+
{{ _("Error 404") }}
+
+ {{ _("Resource not found. Make sure the address is correct or ") }} + {{ _("go back to the homepage.") }} +
+
+
+
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..33d86f3 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,44 @@ + + + +{% macro copy_button(data) %} + + {# Source: MIT-licensed https://icon-sets.iconify.design/bi/clipboard/ #} + + + + + + + +{% endmacro %} + + + + + + + {% block title %}mailgoose{% endblock %} + + + + + {% block header_additional %} + {% endblock %} + + + + {% block navbar %} + {% endblock %} + +
+ {% block before_main_content %} + {% endblock %} + + {% block body %} + {% endblock %} +
+ + + diff --git a/app/templates/check_domain.html b/app/templates/check_domain.html new file mode 100644 index 0000000..f99e3ba --- /dev/null +++ b/app/templates/check_domain.html @@ -0,0 +1,25 @@ +{% extends "custom_layout.html" %} + +{% block body %} +
+
+
+
+
+
{% trans %}Check a domain{% endtrans %}
+
+
+ + + + {% trans %}Don't put an e-mail address here, only the part after "@"{% endtrans %} + +
+ +
+
+
+
+
+
+{% endblock %} diff --git a/app/templates/check_email.html b/app/templates/check_email.html new file mode 100644 index 0000000..fd07a0b --- /dev/null +++ b/app/templates/check_email.html @@ -0,0 +1,79 @@ +{% extends "custom_layout.html" %} + +{% block header_additional %} + +{% endblock %} + +{% block body %} +
+
+
+
+
+
{% trans %}Check by sending e-mail{% endtrans %}
+
+ + + + +
{% trans %}Waiting for the message to be received{% endtrans %}
+ +

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

+

+ {% 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 %} +

+
    +
  • {% trans %}make sure you sent the message to the correct e-mail address{% endtrans %},
  • +
  • {% 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 %}
  • + {% if site_contact_email %} + {# This wording is on purpose - we want the e-mail to be followed by a space, not a dot/comma to facillitate copying #} +
  • {{ pgettext("verb imperative", "contact") }} {{ site_contact_email }} {% trans %}if the above didn't solve the problem.{% endtrans %}
  • + {% endif %} +
+
+
+
+
+
+
+{% endblock %} diff --git a/app/templates/check_results.html b/app/templates/check_results.html new file mode 100644 index 0000000..8841fac --- /dev/null +++ b/app/templates/check_results.html @@ -0,0 +1,312 @@ +{% extends "custom_layout.html" %} + +{% macro render_problem(problem) -%} + {% set lines = problem.split('\n') %} + {% for line in lines %} + {{ line|mailgoose_urlize(target="_blank") }} + {% if not loop.last %} +
+ {% endif %} + {% endfor %} +{%- endmacro %} + +{% macro render_problems(problems) -%} + {% if problems|length > 1 %} +
    + {% for problem in problems|sort %} +
  • {{ render_problem(problem) }}
  • + {% endfor %} +
+ {% else %} + {# 1 or 0 #} + {% for problem in problems %}{{ render_problem(problem) }}{% endfor %} + {% endif %} +{%- endmacro %} + +{% macro card_header(title, data) -%} +
+ + {% if not data.valid %} + ❌ + {% elif data.record_could_not_be_fully_validated %} + ➖ + {% elif data.warnings %} + ⚠️ + {% else %} + ✓ + {% endif %} + {{ title }}: + {% if not data.valid %} + {% trans %}incorrect configuration{% endtrans %} + {% elif data.record_could_not_be_fully_validated %} + {% trans %}record couldn't be fully verified{% endtrans %} + {% elif data.warnings %} + {% trans %}configuration warnings{% endtrans %} + {% else %} + {% trans %}correct configuration{% endtrans %} + {% endif %} +
+{%- endmacro %} + +{% block body %} +
+
+
+ {% if error %} +
+
+ {{ error }} +
+
+ {% elif result.domain or result.dkim %} +

+ {% if not envelope_domain or not from_domain or from_domain == envelope_domain %} + {% if not envelope_domain %} + {% trans %}Domain{% endtrans %} {{ from_domain }} + {% elif not from_domain %} + {% trans %}Domain{% endtrans %} {{ envelope_domain }} + {% elif from_domain == envelope_domain %} + {% trans %}Domain{% endtrans %} {{ envelope_domain }} + {% endif %} + - + {% trans %}e-mail sender verification mechanisms check results:{% endtrans %} + {% else %} + {% trans %}E-mail sender verification mechanisms check results:{% endtrans %} + {% endif %} + + {% if result.domain and not result.dkim %}{% trans %}SPF and DMARC{% endtrans %} + {% elif not result.domain and result.dkim %}{% trans %}DKIM{% endtrans %} + {% else %}{% trans %}SPF, DMARC and DKIM{% endtrans %}{% endif %} +

+ +
+
+ {% if is_old %} + + {{ gettext( + "You are viewing results that are older than %(age_threshold_minutes)s minutes.", + age_threshold_minutes=age_threshold_minutes + ) + }} + {{ gettext( + "If you want to view up-to-date results, please run a new check.", + rescan_url=rescan_url + ) + }} + + {% endif %} + + + {% trans %}Check date: {% endtrans %}{{ result.timestamp.strftime("%Y-%m-%d %H:%M:%S") }}{% if result.message_timestamp %} + {{ gettext("(e-mail message from %(date_str)s)", date_str=result.message_timestamp.strftime("%Y-%m-%d %H:%M:%S")) }}{% endif %}.

+
+ + {% trans %}If you want to share the check results, please copy the following link:{% endtrans %} +
+ + {{ copy_button(url) }} +
+
+
+
+
+ {% if result.num_correct_mechanisms == result.num_checked_mechanisms %} + ✓ + {% elif result.has_not_valid_mechanisms %} + ❌ + {% else %} + ⚠️ + {% endif %} + {% trans %}Check summary{% endtrans %}: + + {{ result.num_correct_mechanisms }} + {% if result.num_correct_mechanisms == 0 %} + {{ pgettext("zero", "mechanisms") }} + {% elif result.num_correct_mechanisms == 1 %} + {% trans %}mechanism{% endtrans %} + {% else %} + {% trans %}mechanisms{% endtrans %} + {% endif %} + {% trans %}of{% endtrans %} + {{ result.num_checked_mechanisms }} + {% if result.num_correct_mechanisms == 0 %} + {{ pgettext("zero", "configured") }} + {% elif result.num_correct_mechanisms == 1 %} + {{ pgettext("singular", "configured") }} + {% else %} + {{ pgettext("plural", "configured") }} + {% endif %} + {% trans %}without issues.{% endtrans %} + +
+
+ + {% if result.domain %} + {% for warning in result.domain.warnings %} +
+
+ ⚠️ {{ warning }} +
+
+ {% endfor %} + +
+ {% if result.domain.spf_not_required_because_of_correct_dmarc %} +
+ {% trans %}SPF: the record is optional{% endtrans %} +
+
+

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

+

+ {% trans trimmed %} + However, we recommend configuring an SPF record if possible (even if the domain is not used + to send e-mails), because older mail servers may not support DMARC and use SPF for verification. + The combination of all protection mechanisms - SPF, DKIM and DMARC will allow other servers to properly + verify e-mail message authenticity in all cases. + {% endtrans %} +

+
+ {% else %} + {{ card_header("SPF", result.domain.spf) }} +
+ + + {% if envelope_domain %} + + + + + {% endif %} + {% if result.domain.spf.record %} + + + + + {% elif result.domain.spf.record_candidates %} + + + + + {% endif %} + + + + + + + + + +
{% 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 %} + {% for record in result.domain.spf.record_candidates %} + {{ record }}
+ {% endfor %} +
{% 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 %}
+
+ {% endif %} +
+ +
+ {{ card_header("DMARC", result.domain.dmarc) }} +
+ + + {% if from_domain %} + + + + + {% endif %} + {% if result.domain.dmarc.record %} + + + + + {% elif result.domain.dmarc.record_candidates %} + + + + + {% endif %} + + + + + + + + + +
{% 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 %} + {% for record in result.domain.dmarc.record_candidates %} + {{ record }}
+ {% endfor %} +
{% 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 %}
+
+
+ {% endif %} + + {% if result.dkim %} +
+ {{ card_header("DKIM", result.dkim) }} +
+ + + {% if dkim_domain %} + + + + + {% endif %} + + + + + + + + +
{% 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 %}
+
+
+ {% endif %} + + {% 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 %} +
+
+ {% if result.domain.spf.warnings or result.domain.dmarc.warnings or result.dkim.warnings %} +

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

+ {% endif %} + +

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

+ {% include "custom_failed_check_results_hints.html" %} +
+
+ {% endif %} + {% endif %} +
+
+
+{% endblock %} diff --git a/app/templates/custom_failed_check_results_hints.html b/app/templates/custom_failed_check_results_hints.html new file mode 100644 index 0000000..43a1f2f --- /dev/null +++ b/app/templates/custom_failed_check_results_hints.html @@ -0,0 +1 @@ +{# If you have any additional text to display if the checks have failed, please put it here. #} diff --git a/app/templates/custom_layout.html b/app/templates/custom_layout.html new file mode 100644 index 0000000..7a34588 --- /dev/null +++ b/app/templates/custom_layout.html @@ -0,0 +1,4 @@ +{% extends "base.html" %} + +{# If you want to change the layout in order to provide e.g. custom additional scripts #} +{# or a navbar with logo, mount a different file to this path using Docker volume mount. #} diff --git a/app/templates/custom_root_layout.html b/app/templates/custom_root_layout.html new file mode 100644 index 0000000..12d027b --- /dev/null +++ b/app/templates/custom_root_layout.html @@ -0,0 +1,21 @@ +{% extends "custom_layout.html" %} + +{# To customize the root (/) page of the system provide a different file (replacing this one) #} +{# using Docker Compose volume mount. With this, you may e.g. describe on the root page what are #} +{# the benefits of using SPF/DKIM/DMARC or link to your tutorials that describe setting up these #} +{# mechanisms. #} + +{% block body %} +
+
+
+ {% block check_by_sending_email %} + {% endblock %} +
+
+ {% block check_domain %} + {% endblock %} +
+
+
+{% endblock %} diff --git a/app/templates/root.html b/app/templates/root.html new file mode 100644 index 0000000..b155f11 --- /dev/null +++ b/app/templates/root.html @@ -0,0 +1,43 @@ +{% extends "custom_root_layout.html" %} + +{% block check_by_sending_email %} +
+
+
{% trans %}Check your configuration by sending an e-mail{% endtrans %}
+
+

+ {% trans trimmed %} + If you send a test e-mail to a specific address, the system will verify whether the + SPF, DKIM and DMARC mechanisms are set up correctly. + {% endtrans %} +

+

+ + {% trans trimmed %} + This path is recommended - when you send a test e-mail, we will be able to perform + more accurate checks than using a domain. + {% endtrans %} + +

+
+ {% trans %}Send an e-mail{% endtrans %} +
+
+{% endblock %} + +{% block check_domain %} +
+
+
{% trans %}Check a domain{% endtrans %}
+
+

+ {% trans trimmed %} + You may also verify the configuration by providing a domain. In that case, only the SPF + and DMARC mechanisms will be checked - to check DKIM, you need to send a test e-mail. + {% endtrans %} +

+
+ {% trans %}Check a domain{% endtrans %} +
+
+{% endblock %} diff --git a/app/translations/en_US/LC_MESSAGES/messages.po b/app/translations/en_US/LC_MESSAGES/messages.po new file mode 100644 index 0000000..a8e8bb0 --- /dev/null +++ b/app/translations/en_US/LC_MESSAGES/messages.po @@ -0,0 +1,269 @@ +#: app/templates/404.html:7 +msgid "Error 404" +msgstr "" + +#: app/templates/404.html:9 +msgid "Resource not found. Make sure the address is correct or " +msgstr "" + +#: app/templates/404.html:10 +msgid "go back to the homepage." +msgstr "" + +#: app/templates/base.html:6 +msgid "Copied" +msgstr "" + +#: app/templates/check_domain.html:9 app/templates/root.html:31 +#: app/templates/root.html:40 +msgid "Check a domain" +msgstr "" + +#: app/templates/check_domain.html:12 app/templates/check_results.html:74 +#: app/templates/check_results.html:76 app/templates/check_results.html:78 +#: app/templates/check_results.html:191 app/templates/check_results.html:231 +#: app/templates/check_results.html:272 +msgid "Domain" +msgstr "" + +#: app/templates/check_domain.html:15 +msgid "Don't put an e-mail address here, only the part after \"@\"" +msgstr "" + +#: app/templates/check_domain.html:18 +msgid "Check" +msgstr "" + +#: app/templates/check_email.html:44 +msgid "Check by sending e-mail" +msgstr "" + +#: app/templates/check_email.html:47 +msgid "" +"To verify e-mail configuration, send any e-mail message to the address " +"shown below:" +msgstr "" + +#: app/templates/check_email.html:57 +msgid "Waiting for the message to be received" +msgstr "" + +#: app/templates/check_email.html:60 +msgid "" +"As soon as the message is received, the page will automatically refresh -" +" you will then see the check results." +msgstr "" + +#: app/templates/check_email.html:63 +msgid "" +"If after a while you still don't see the results, that means that we " +"didn't receive your message. In that case:" +msgstr "" + +#: app/templates/check_email.html:66 +msgid "make sure you sent the message to the correct e-mail address" +msgstr "" + +#: app/templates/check_email.html:67 +msgid "" +"if you manage your own e-mail server, make sure that the server sent the " +"message correctly" +msgstr "" + +#: app/templates/check_email.html:70 +msgctxt "verb imperative" +msgid "contact" +msgstr "" + +#: app/templates/check_email.html:70 +msgid "if the above didn't solve the problem." +msgstr "" + +#: app/templates/check_results.html:49 +msgid "incorrect configuration" +msgstr "" + +#: app/templates/check_results.html:51 +msgid "record couldn't be fully verified" +msgstr "" + +#: app/templates/check_results.html:53 +msgid "configuration warnings" +msgstr "" + +#: app/templates/check_results.html:55 +msgid "correct configuration" +msgstr "" + +#: app/templates/check_results.html:81 +msgid "e-mail sender verification mechanisms check results:" +msgstr "" + +#: app/templates/check_results.html:83 +msgid "E-mail sender verification mechanisms check results:" +msgstr "" + +#: app/templates/check_results.html:86 +msgid "SPF and DMARC" +msgstr "" + +#: app/templates/check_results.html:87 +msgid "DKIM" +msgstr "" + +#: app/templates/check_results.html:88 +msgid "SPF, DMARC and DKIM" +msgstr "" + +#: app/templates/check_results.html:95 +#, python-format +msgid "" +"You are viewing results that are older than %(age_threshold_minutes)s " +"minutes." +msgstr "" + +#: app/templates/check_results.html:100 +#, python-format +msgid "" +"If you want to view up-to-date results, please run a new check." +msgstr "" + +#: app/templates/check_results.html:109 +msgid "Check date: " +msgstr "" + +#: app/templates/check_results.html:110 +#, python-format +msgid "(e-mail message from %(date_str)s)" +msgstr "" + +#: app/templates/check_results.html:113 +msgid "If you want to share the check results, please copy the following link:" +msgstr "" + +#: app/templates/check_results.html:129 +msgid "Check summary" +msgstr "" + +#: app/templates/check_results.html:133 +msgctxt "zero" +msgid "mechanisms" +msgstr "" + +#: app/templates/check_results.html:135 +msgid "mechanism" +msgstr "" + +#: app/templates/check_results.html:137 +msgid "mechanisms" +msgstr "" + +#: app/templates/check_results.html:139 +msgid "of" +msgstr "" + +#: app/templates/check_results.html:142 +msgctxt "zero" +msgid "configured" +msgstr "" + +#: app/templates/check_results.html:144 +msgctxt "singular" +msgid "configured" +msgstr "" + +#: app/templates/check_results.html:146 +msgctxt "plural" +msgid "configured" +msgstr "" + +#: app/templates/check_results.html:148 +msgid "without issues." +msgstr "" + +#: app/templates/check_results.html:165 +msgid "SPF: the record is optional" +msgstr "" + +#: app/templates/check_results.html:169 +msgid "" +"Because the DMARC record is configured correctly, the SPF record is not " +"required. Sending e-mail messages from this domain without using the SPF " +"mechanism is still possible - in that case, the messages need to have " +"correct DKIM signatures." +msgstr "" + +#: app/templates/check_results.html:176 +msgid "" +"However, we recommend configuring an SPF record if possible (even if the " +"domain is not used to send e-mails), because older mail servers may not " +"support DMARC and use SPF for verification. The combination of all " +"protection mechanisms - SPF, DKIM and DMARC will allow other servers to " +"properly verify e-mail message authenticity in all cases." +msgstr "" + +#: app/templates/check_results.html:197 app/templates/check_results.html:202 +#: app/templates/check_results.html:237 app/templates/check_results.html:242 +msgid "Record" +msgstr "" + +#: app/templates/check_results.html:202 app/templates/check_results.html:242 +msgid "Records" +msgstr "" + +#: app/templates/check_results.html:211 app/templates/check_results.html:251 +#: app/templates/check_results.html:277 +msgid "Warnings" +msgstr "" + +#: app/templates/check_results.html:212 app/templates/check_results.html:216 +#: app/templates/check_results.html:252 app/templates/check_results.html:256 +#: app/templates/check_results.html:278 app/templates/check_results.html:282 +msgid "none" +msgstr "" + +#: app/templates/check_results.html:215 app/templates/check_results.html:255 +#: app/templates/check_results.html:281 +msgid "Errors" +msgstr "" + +#: app/templates/check_results.html:294 +msgid "" +"To increase the chance that your configuration is interpreted by all " +"e-mail servers correctly, we recommend fixing all errors and warnings." +msgstr "" + +#: app/templates/check_results.html:302 +msgid "" +"After fixing the issues, please rerun the scan - some problems can be " +"detected only if earlier checks complete successfully." +msgstr "" + +#: app/templates/root.html:6 +msgid "Check your configuration by sending an e-mail" +msgstr "" + +#: app/templates/root.html:9 +msgid "" +"If you send a test e-mail to a specific address, the system will verify " +"whether the SPF, DKIM and DMARC mechanisms are " +"set up correctly." +msgstr "" + +#: app/templates/root.html:16 +msgid "" +"This path is recommended - when you send a test e-mail, we will be able " +"to perform more accurate checks than using a domain." +msgstr "" + +#: app/templates/root.html:23 +msgid "Send an e-mail" +msgstr "" + +#: app/templates/root.html:34 +msgid "" +"You may also verify the configuration by providing a domain. In that " +"case, only the SPF and DMARC mechanisms will be checked - " +"to check DKIM, you need to send a test e-mail." +msgstr "" diff --git a/app/translations/messages.pot b/app/translations/messages.pot new file mode 100644 index 0000000..a8e8bb0 --- /dev/null +++ b/app/translations/messages.pot @@ -0,0 +1,269 @@ +#: app/templates/404.html:7 +msgid "Error 404" +msgstr "" + +#: app/templates/404.html:9 +msgid "Resource not found. Make sure the address is correct or " +msgstr "" + +#: app/templates/404.html:10 +msgid "go back to the homepage." +msgstr "" + +#: app/templates/base.html:6 +msgid "Copied" +msgstr "" + +#: app/templates/check_domain.html:9 app/templates/root.html:31 +#: app/templates/root.html:40 +msgid "Check a domain" +msgstr "" + +#: app/templates/check_domain.html:12 app/templates/check_results.html:74 +#: app/templates/check_results.html:76 app/templates/check_results.html:78 +#: app/templates/check_results.html:191 app/templates/check_results.html:231 +#: app/templates/check_results.html:272 +msgid "Domain" +msgstr "" + +#: app/templates/check_domain.html:15 +msgid "Don't put an e-mail address here, only the part after \"@\"" +msgstr "" + +#: app/templates/check_domain.html:18 +msgid "Check" +msgstr "" + +#: app/templates/check_email.html:44 +msgid "Check by sending e-mail" +msgstr "" + +#: app/templates/check_email.html:47 +msgid "" +"To verify e-mail configuration, send any e-mail message to the address " +"shown below:" +msgstr "" + +#: app/templates/check_email.html:57 +msgid "Waiting for the message to be received" +msgstr "" + +#: app/templates/check_email.html:60 +msgid "" +"As soon as the message is received, the page will automatically refresh -" +" you will then see the check results." +msgstr "" + +#: app/templates/check_email.html:63 +msgid "" +"If after a while you still don't see the results, that means that we " +"didn't receive your message. In that case:" +msgstr "" + +#: app/templates/check_email.html:66 +msgid "make sure you sent the message to the correct e-mail address" +msgstr "" + +#: app/templates/check_email.html:67 +msgid "" +"if you manage your own e-mail server, make sure that the server sent the " +"message correctly" +msgstr "" + +#: app/templates/check_email.html:70 +msgctxt "verb imperative" +msgid "contact" +msgstr "" + +#: app/templates/check_email.html:70 +msgid "if the above didn't solve the problem." +msgstr "" + +#: app/templates/check_results.html:49 +msgid "incorrect configuration" +msgstr "" + +#: app/templates/check_results.html:51 +msgid "record couldn't be fully verified" +msgstr "" + +#: app/templates/check_results.html:53 +msgid "configuration warnings" +msgstr "" + +#: app/templates/check_results.html:55 +msgid "correct configuration" +msgstr "" + +#: app/templates/check_results.html:81 +msgid "e-mail sender verification mechanisms check results:" +msgstr "" + +#: app/templates/check_results.html:83 +msgid "E-mail sender verification mechanisms check results:" +msgstr "" + +#: app/templates/check_results.html:86 +msgid "SPF and DMARC" +msgstr "" + +#: app/templates/check_results.html:87 +msgid "DKIM" +msgstr "" + +#: app/templates/check_results.html:88 +msgid "SPF, DMARC and DKIM" +msgstr "" + +#: app/templates/check_results.html:95 +#, python-format +msgid "" +"You are viewing results that are older than %(age_threshold_minutes)s " +"minutes." +msgstr "" + +#: app/templates/check_results.html:100 +#, python-format +msgid "" +"If you want to view up-to-date results, please run a new check." +msgstr "" + +#: app/templates/check_results.html:109 +msgid "Check date: " +msgstr "" + +#: app/templates/check_results.html:110 +#, python-format +msgid "(e-mail message from %(date_str)s)" +msgstr "" + +#: app/templates/check_results.html:113 +msgid "If you want to share the check results, please copy the following link:" +msgstr "" + +#: app/templates/check_results.html:129 +msgid "Check summary" +msgstr "" + +#: app/templates/check_results.html:133 +msgctxt "zero" +msgid "mechanisms" +msgstr "" + +#: app/templates/check_results.html:135 +msgid "mechanism" +msgstr "" + +#: app/templates/check_results.html:137 +msgid "mechanisms" +msgstr "" + +#: app/templates/check_results.html:139 +msgid "of" +msgstr "" + +#: app/templates/check_results.html:142 +msgctxt "zero" +msgid "configured" +msgstr "" + +#: app/templates/check_results.html:144 +msgctxt "singular" +msgid "configured" +msgstr "" + +#: app/templates/check_results.html:146 +msgctxt "plural" +msgid "configured" +msgstr "" + +#: app/templates/check_results.html:148 +msgid "without issues." +msgstr "" + +#: app/templates/check_results.html:165 +msgid "SPF: the record is optional" +msgstr "" + +#: app/templates/check_results.html:169 +msgid "" +"Because the DMARC record is configured correctly, the SPF record is not " +"required. Sending e-mail messages from this domain without using the SPF " +"mechanism is still possible - in that case, the messages need to have " +"correct DKIM signatures." +msgstr "" + +#: app/templates/check_results.html:176 +msgid "" +"However, we recommend configuring an SPF record if possible (even if the " +"domain is not used to send e-mails), because older mail servers may not " +"support DMARC and use SPF for verification. The combination of all " +"protection mechanisms - SPF, DKIM and DMARC will allow other servers to " +"properly verify e-mail message authenticity in all cases." +msgstr "" + +#: app/templates/check_results.html:197 app/templates/check_results.html:202 +#: app/templates/check_results.html:237 app/templates/check_results.html:242 +msgid "Record" +msgstr "" + +#: app/templates/check_results.html:202 app/templates/check_results.html:242 +msgid "Records" +msgstr "" + +#: app/templates/check_results.html:211 app/templates/check_results.html:251 +#: app/templates/check_results.html:277 +msgid "Warnings" +msgstr "" + +#: app/templates/check_results.html:212 app/templates/check_results.html:216 +#: app/templates/check_results.html:252 app/templates/check_results.html:256 +#: app/templates/check_results.html:278 app/templates/check_results.html:282 +msgid "none" +msgstr "" + +#: app/templates/check_results.html:215 app/templates/check_results.html:255 +#: app/templates/check_results.html:281 +msgid "Errors" +msgstr "" + +#: app/templates/check_results.html:294 +msgid "" +"To increase the chance that your configuration is interpreted by all " +"e-mail servers correctly, we recommend fixing all errors and warnings." +msgstr "" + +#: app/templates/check_results.html:302 +msgid "" +"After fixing the issues, please rerun the scan - some problems can be " +"detected only if earlier checks complete successfully." +msgstr "" + +#: app/templates/root.html:6 +msgid "Check your configuration by sending an e-mail" +msgstr "" + +#: app/templates/root.html:9 +msgid "" +"If you send a test e-mail to a specific address, the system will verify " +"whether the SPF, DKIM and DMARC mechanisms are " +"set up correctly." +msgstr "" + +#: app/templates/root.html:16 +msgid "" +"This path is recommended - when you send a test e-mail, we will be able " +"to perform more accurate checks than using a domain." +msgstr "" + +#: app/templates/root.html:23 +msgid "Send an e-mail" +msgstr "" + +#: app/templates/root.html:34 +msgid "" +"You may also verify the configuration by providing a domain. In that " +"case, only the SPF and DMARC mechanisms will be checked - " +"to check DKIM, you need to send a test e-mail." +msgstr "" diff --git a/app/translations/pl_PL/LC_MESSAGES/messages.po b/app/translations/pl_PL/LC_MESSAGES/messages.po new file mode 100644 index 0000000..e277913 --- /dev/null +++ b/app/translations/pl_PL/LC_MESSAGES/messages.po @@ -0,0 +1,306 @@ +#: app/templates/404.html:7 +msgid "Error 404" +msgstr "Błąd 404" + +#: app/templates/404.html:9 +msgid "Resource not found. Make sure the address is correct or " +msgstr "Nie odnaleziono zasobu. Upewnij się, czy adres jest poprawny lub " + +#: app/templates/404.html:10 +msgid "go back to the homepage." +msgstr "przejdź do strony głównej." + +#: app/templates/base.html:6 +msgid "Copied" +msgstr "Skopiowano" + +#: app/templates/check_domain.html:9 app/templates/root.html:31 +#: app/templates/root.html:40 +msgid "Check a domain" +msgstr "Sprawdź domenę" + +#: app/templates/check_domain.html:12 app/templates/check_results.html:74 +#: app/templates/check_results.html:76 app/templates/check_results.html:78 +#: app/templates/check_results.html:191 app/templates/check_results.html:231 +#: app/templates/check_results.html:272 +msgid "Domain" +msgstr "Domena" + +#: app/templates/check_domain.html:15 +msgid "Don't put an e-mail address here, only the part after \"@\"" +msgstr "Nie podawaj całego adresu email, jedynie część po znaku \"@\"" + +#: app/templates/check_domain.html:18 +msgid "Check" +msgstr "Sprawdź" + +#: app/templates/check_email.html:44 +msgid "Check by sending e-mail" +msgstr "Sprawdź wysyłając e-mail" + +#: app/templates/check_email.html:47 +msgid "" +"To verify e-mail configuration, send any e-mail message to the address " +"shown below:" +msgstr "" +"Aby zweryfikować konfigurację poczty, wyślij dowolną wiadomość e-mail na " +"adres podany poniżej:" + +#: app/templates/check_email.html:57 +msgid "Waiting for the message to be received" +msgstr "Oczekiwanie na odbiór wiadomości" + +#: app/templates/check_email.html:60 +msgid "" +"As soon as the message is received, the page will automatically refresh -" +" you will then see the check results." +msgstr "" +"Jeśli wiadomość zostanie odebrana, strona odświeży się automatycznie - " +"zobaczysz wtedy wyniki sprawdzenia mechanizmów zabezpieczeń." + +#: app/templates/check_email.html:63 +msgid "" +"If after a while you still don't see the results, that means that we " +"didn't receive your message. In that case:" +msgstr "" +"Jeśli po dłuższym czasie nie widzisz wyników, to znaczy, że nie " +"otrzymaliśmy wiadomości. W takiej sytuacji:" + +#: app/templates/check_email.html:66 +msgid "make sure you sent the message to the correct e-mail address" +msgstr "upewnij się, że wysyłasz wiadomość na poprawny adres" + +#: app/templates/check_email.html:67 +msgid "" +"if you manage your own e-mail server, make sure that the server sent the " +"message correctly" +msgstr "" +"jeśli administrujesz serwerem pocztowym, sprawdź czy Twój serwer pocztowy" +" poprawnie wysłał wiadomość" + +#: app/templates/check_email.html:70 +msgctxt "verb imperative" +msgid "contact" +msgstr "skontaktuj się z" + +#: app/templates/check_email.html:70 +msgid "if the above didn't solve the problem." +msgstr "w przypadku, jeśli powyższe nie rozwiązało problemu." + +#: app/templates/check_results.html:49 +msgid "incorrect configuration" +msgstr "konfiguracja nieprawidłowa" + +#: app/templates/check_results.html:51 +msgid "record couldn't be fully verified" +msgstr "rekord nie mógł być w pełni sprawdzony" + +#: app/templates/check_results.html:53 +msgid "configuration warnings" +msgstr "uwagi dotyczące konfiguracji" + +#: app/templates/check_results.html:55 +msgid "correct configuration" +msgstr "konfiguracja prawidłowa" + +#: app/templates/check_results.html:81 +msgid "e-mail sender verification mechanisms check results:" +msgstr "wyniki testów mechanizmów zabezpieczeń poczty e-mail:" + +#: app/templates/check_results.html:83 +msgid "E-mail sender verification mechanisms check results:" +msgstr "Wyniki testów mechanizmów zabezpieczeń poczty e-mail:" + +#: app/templates/check_results.html:86 +msgid "SPF and DMARC" +msgstr "SPF i DMARC" + +#: app/templates/check_results.html:87 +msgid "DKIM" +msgstr "DKIM" + +#: app/templates/check_results.html:88 +msgid "SPF, DMARC and DKIM" +msgstr "SPF, DMARC i DKIM" + +#: app/templates/check_results.html:95 +#, python-format +msgid "" +"You are viewing results that are older than %(age_threshold_minutes)s " +"minutes." +msgstr "Oglądają Państwo wyniki starsze niż %(age_threshold_minutes)s minut." + +#: app/templates/check_results.html:100 +#, python-format +msgid "" +"If you want to view up-to-date results, please run a new check." +msgstr "" +"Jeśli chcą Państwo uzyskać najnowsze wyniki, prosimy wykonać ponowne sprawdzenie." + +#: app/templates/check_results.html:109 +msgid "Check date: " +msgstr "Data sprawdzenia: " + +#: app/templates/check_results.html:110 +#, python-format +msgid "(e-mail message from %(date_str)s)" +msgstr "(wiadomość e-mail z %(date_str)s)" + +#: app/templates/check_results.html:113 +msgid "If you want to share the check results, please copy the following link:" +msgstr "" +"Jeśli chcą Państwo udostępnić wyniki sprawdzenia, prosimy skopiować ten " +"link:" + +#: app/templates/check_results.html:129 +msgid "Check summary" +msgstr "Podsumowanie sprawdzenia" + +#: app/templates/check_results.html:133 +msgctxt "zero" +msgid "mechanisms" +msgstr "mechanizmów" + +#: app/templates/check_results.html:135 +msgid "mechanism" +msgstr "mechanizm" + +#: app/templates/check_results.html:137 +msgid "mechanisms" +msgstr "mechanizmy" + +#: app/templates/check_results.html:139 +msgid "of" +msgstr "z" + +#: app/templates/check_results.html:142 +msgctxt "zero" +msgid "configured" +msgstr "skonfigurowanych" + +#: app/templates/check_results.html:144 +msgctxt "singular" +msgid "configured" +msgstr "skonfigurowany" + +#: app/templates/check_results.html:146 +msgctxt "plural" +msgid "configured" +msgstr "skonfigurowane" + +#: app/templates/check_results.html:148 +msgid "without issues." +msgstr "bez zastrzeżeń." + +#: app/templates/check_results.html:165 +msgid "SPF: the record is optional" +msgstr "SPF: rekord opcjonalny" + +#: app/templates/check_results.html:169 +msgid "" +"Because the DMARC record is configured correctly, the SPF record is not " +"required. Sending e-mail messages from this domain without using the SPF " +"mechanism is still possible - in that case, the messages need to have " +"correct DKIM signatures." +msgstr "" +"Ponieważ rekord DMARC jest skonfigurowany poprawnie, rekord SPF nie jest " +"konieczny. Wysyłanie wiadomości z tej domeny bez wykorzystywania " +"mechanizmu SPF nadal jest możliwe - w takiej sytuacji wiadomości muszą " +"posiadać poprawny podpis DKIM." + +#: app/templates/check_results.html:176 +msgid "" +"However, we recommend configuring an SPF record if possible (even if the " +"domain is not used to send e-mails), because older mail servers may not " +"support DMARC and use SPF for verification. The combination of all " +"protection mechanisms - SPF, DKIM and DMARC will allow other servers to " +"properly verify e-mail message authenticity in all cases." +msgstr "" +"Rekomendujemy jednak ustawienie rekordu SPF, jeśli mają Państwo taką " +"możliwość (nawet, jeśli domena nie jest przeznaczona do wysyłki poczty), " +"ponieważ starsze serwery pocztowe mogą nie wspierać mechanizmu DMARC i " +"używają SPF. Połączenie wszystkich mechanizmów ochrony - SPF, DKIM oraz " +"DMARC umożliwi innym serwerom na poprawne zweryfikowanie wiadomości we " +"wszystkich przypadkach." + +#: app/templates/check_results.html:197 app/templates/check_results.html:202 +#: app/templates/check_results.html:237 app/templates/check_results.html:242 +msgid "Record" +msgstr "Rekord" + +#: app/templates/check_results.html:202 app/templates/check_results.html:242 +msgid "Records" +msgstr "Rekordy" + +#: app/templates/check_results.html:211 app/templates/check_results.html:251 +#: app/templates/check_results.html:277 +msgid "Warnings" +msgstr "Ostrzeżenia" + +#: app/templates/check_results.html:212 app/templates/check_results.html:216 +#: app/templates/check_results.html:252 app/templates/check_results.html:256 +#: app/templates/check_results.html:278 app/templates/check_results.html:282 +msgid "none" +msgstr "brak" + +#: app/templates/check_results.html:215 app/templates/check_results.html:255 +#: app/templates/check_results.html:281 +msgid "Errors" +msgstr "Błędy" + +#: app/templates/check_results.html:294 +msgid "" +"To increase the chance that your configuration is interpreted by all " +"e-mail servers correctly, we recommend fixing all errors and warnings." +msgstr "" +"Aby zwiększyć szansę, że Państwa konfiguracja zostanie poprawnie " +"zinterpretowana przez wszystkie serwery, rekomendujemy poprawę zarówno " +"błędów, jak i ostrzeżeń." + +#: app/templates/check_results.html:302 +msgid "" +"After fixing the issues, please rerun the scan - some problems can be " +"detected only if earlier checks complete successfully." +msgstr "" +"Po poprawie błędów prosimy ponowić skanowanie - niektóre błędy mogą " +"zostać znalezione dopiero po udanym wykonaniu wcześniejszych testów." + +#: app/templates/root.html:6 +msgid "Check your configuration by sending an e-mail" +msgstr "Sprawdź konfigurację wysyłając wiadomość e-mail" + +#: app/templates/root.html:9 +msgid "" +"If you send a test e-mail to a specific address, the system will verify " +"whether the SPF, DKIM and DMARC mechanisms are " +"set up correctly." +msgstr "" +"Gdy wyślesz testową wiadomość e-mail na specjalny adres, system " +"zweryfikuje poprawność konfiguracji mechanizmów SPF, DKIM " +"i DMARC." + +#: app/templates/root.html:16 +msgid "" +"This path is recommended - when you send a test e-mail, we will be able " +"to perform more accurate checks than using a domain." +msgstr "" +"Ta ścieżka jest przez nas rekomendowana – dzięki niej będziemy " +"w stanie wykonać dokładniejsze sprawdzenie, niż korzystając " +"z domeny." + +#: app/templates/root.html:23 +msgid "Send an e-mail" +msgstr "Wyślij e-mail" + +#: app/templates/root.html:34 +msgid "" +"You may also verify the configuration by providing a domain. In that " +"case, only the SPF and DMARC mechanisms will be checked - " +"to check DKIM, you need to send a test e-mail." +msgstr "" +"Możesz skorzystać także z opcji weryfikacji konfiguracji podając " +"domenę. W tym wypadku zostaną sprawdzone tylko mechanizmy SPF " +"i DMARC - dla sprawdzenia DKIM konieczne jest wysłanie " +"testowego e-maila." diff --git a/app/translations/requirements.txt b/app/translations/requirements.txt new file mode 100644 index 0000000..d069156 --- /dev/null +++ b/app/translations/requirements.txt @@ -0,0 +1,4 @@ +Babel==2.13.1 +Jinja2==3.1.2 +jinja2-simple-tags==0.5.0 +python-decouple==3.8 diff --git a/babel.cfg b/babel.cfg new file mode 100644 index 0000000..b838ad4 --- /dev/null +++ b/babel.cfg @@ -0,0 +1,3 @@ +[jinja2: **/**.html] +encoding = utf-8 +extensions = src.tags.BuildIDTag,src.tags.LanguageTag diff --git a/common/mail_receiver_utils.py b/common/mail_receiver_utils.py new file mode 100644 index 0000000..d43d0ef --- /dev/null +++ b/common/mail_receiver_utils.py @@ -0,0 +1,7 @@ +import string + + +def get_key_from_username(email_username: str) -> bytes: + prefix = "message-" + allowed_characters = string.ascii_letters + string.digits + ".-" + return (prefix + "".join(char for char in email_username if char in allowed_characters)).encode("ascii") diff --git a/common/wait-for-it.sh b/common/wait-for-it.sh new file mode 100755 index 0000000..629f201 --- /dev/null +++ b/common/wait-for-it.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash +# From https://github.com/vishnubob/wait-for-it/blob/master/wait-for-it.sh (MIT-licensed) +# +# The MIT License (MIT) +# Copyright (c) 2016 Giles Hall +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# Use this script to test if a given TCP host/port are available + +WAITFORIT_cmdname=${0##*/} + +echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } + +usage() +{ + cat << USAGE >&2 +Usage: + $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] + -h HOST | --host=HOST Host or IP under test + -p PORT | --port=PORT TCP port under test + Alternatively, you specify the host and port as host:port + -s | --strict Only execute subcommand if the test succeeds + -q | --quiet Don't output any status messages + -t TIMEOUT | --timeout=TIMEOUT + Timeout in seconds, zero for no timeout + -- COMMAND ARGS Execute command with args after the test finishes +USAGE + exit 1 +} + +wait_for() +{ + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + else + echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" + fi + WAITFORIT_start_ts=$(date +%s) + while : + do + if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then + nc -z $WAITFORIT_HOST $WAITFORIT_PORT + WAITFORIT_result=$? + else + (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 + WAITFORIT_result=$? + fi + if [[ $WAITFORIT_result -eq 0 ]]; then + WAITFORIT_end_ts=$(date +%s) + echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" + break + fi + sleep 1 + done + return $WAITFORIT_result +} + +wait_for_wrapper() +{ + # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 + if [[ $WAITFORIT_QUIET -eq 1 ]]; then + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + else + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + fi + WAITFORIT_PID=$! + trap "kill -INT -$WAITFORIT_PID" INT + wait $WAITFORIT_PID + WAITFORIT_RESULT=$? + if [[ $WAITFORIT_RESULT -ne 0 ]]; then + echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + fi + return $WAITFORIT_RESULT +} + +# process arguments +while [[ $# -gt 0 ]] +do + case "$1" in + *:* ) + WAITFORIT_hostport=(${1//:/ }) + WAITFORIT_HOST=${WAITFORIT_hostport[0]} + WAITFORIT_PORT=${WAITFORIT_hostport[1]} + shift 1 + ;; + --child) + WAITFORIT_CHILD=1 + shift 1 + ;; + -q | --quiet) + WAITFORIT_QUIET=1 + shift 1 + ;; + -s | --strict) + WAITFORIT_STRICT=1 + shift 1 + ;; + -h) + WAITFORIT_HOST="$2" + if [[ $WAITFORIT_HOST == "" ]]; then break; fi + shift 2 + ;; + --host=*) + WAITFORIT_HOST="${1#*=}" + shift 1 + ;; + -p) + WAITFORIT_PORT="$2" + if [[ $WAITFORIT_PORT == "" ]]; then break; fi + shift 2 + ;; + --port=*) + WAITFORIT_PORT="${1#*=}" + shift 1 + ;; + -t) + WAITFORIT_TIMEOUT="$2" + if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi + shift 2 + ;; + --timeout=*) + WAITFORIT_TIMEOUT="${1#*=}" + shift 1 + ;; + --) + shift + WAITFORIT_CLI=("$@") + break + ;; + --help) + usage + ;; + *) + echoerr "Unknown argument: $1" + usage + ;; + esac +done + +if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then + echoerr "Error: you need to provide a host and port to test." + usage +fi + +WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} +WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} +WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} +WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} + +# Check to see if timeout is from busybox? +WAITFORIT_TIMEOUT_PATH=$(type -p timeout) +WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) + +WAITFORIT_BUSYTIMEFLAG="" +if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then + WAITFORIT_ISBUSY=1 + # Check if busybox timeout uses -t flag + # (recent Alpine versions don't support -t anymore) + if timeout &>/dev/stdout | grep -q -e '-t '; then + WAITFORIT_BUSYTIMEFLAG="-t" + fi +else + WAITFORIT_ISBUSY=0 +fi + +if [[ $WAITFORIT_CHILD -gt 0 ]]; then + wait_for + WAITFORIT_RESULT=$? + exit $WAITFORIT_RESULT +else + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + wait_for_wrapper + WAITFORIT_RESULT=$? + else + wait_for + WAITFORIT_RESULT=$? + fi +fi + +if [[ $WAITFORIT_CLI != "" ]]; then + if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then + echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" + exit $WAITFORIT_RESULT + fi + exec "${WAITFORIT_CLI[@]}" +else + exit $WAITFORIT_RESULT +fi diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..fde867f --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,29 @@ +version: "3" + +services: + app: + build: + context: . + dockerfile: app/docker/Dockerfile + environment: + APP_DOMAIN: "app" + DB_URL: postgresql+psycopg2://postgres:postgres@db-test:5432/mailgoose + FORWARDED_ALLOW_IPS: "*" + LANGUAGE: en_US + REDIS_MESSAGE_DATA_EXPIRY_SECONDS: 864000 + REDIS_CONNECTION_STRING: redis://redis-test:6379/0 + command: bash -c "/wait-for-it.sh db-test:5432 -- uvicorn src.app:app --host 0.0.0.0 --port 8000 --proxy-headers" + db-test: + image: postgres:15.2-alpine + environment: + - POSTGRES_DB=mailgoose + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + redis-test: + image: redis + test: + profiles: ["test"] # This will prevent the service from starting by default + build: + context: . + dockerfile: test/Dockerfile + command: bash -c "/wait-for-it.sh app:8000 -- python -m unittest discover" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a35ab1e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,58 @@ +version: "3" + +x-common-configuration: + &common-configuration + restart: always + dns: + - 8.8.8.8 + - 8.8.4.4 + +services: + app: + build: + context: . + dockerfile: app/docker/Dockerfile + environment: + DB_URL: postgresql+psycopg2://postgres:postgres@db:5432/mailgoose + FORWARDED_ALLOW_IPS: "*" + REDIS_CONNECTION_STRING: redis://redis:6379/0 + env_file: + - .env + command: bash -c "/wait-for-it.sh db:5432 -- uvicorn src.app:app --host 0.0.0.0 --port 8000 --proxy-headers --workers 8" + ports: + - 8000:8000 + <<: *common-configuration + mail_receiver: + build: + context: . + dockerfile: mail_receiver/Dockerfile + command: python3 /opt/server.py + environment: + REDIS_CONNECTION_STRING: redis://redis:6379/0 + env_file: + - .env + volumes: + # Mounting the whole /etc/letsencrypt as the key may be a link to ../../archive/ + - /etc/letsencrypt/:/etc/letsencrypt:ro + ports: + - 25:25 + - 587:587 + <<: *common-configuration + db: + image: postgres:15.2-alpine + environment: + - POSTGRES_DB=mailgoose + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + volumes: + - data-db:/var/lib/postgresql/data + <<: *common-configuration + redis: + image: redis + volumes: + - data-redis:/data + <<: *common-configuration + +volumes: + data-db: + data-redis: diff --git a/env.example b/env.example new file mode 100644 index 0000000..ccce083 --- /dev/null +++ b/env.example @@ -0,0 +1,7 @@ +LANGUAGE=en_US + +APP_DOMAIN=change-this.example.com + +# 10 days to facillitate debugging. The data in Redis expires in order to make sure Redis never takes too much +# memory - the full scan logs are stored in a Postgres database. +REDIS_MESSAGE_DATA_EXPIRY_SECONDS=864000 diff --git a/mail_receiver/Dockerfile b/mail_receiver/Dockerfile new file mode 100644 index 0000000..4e9e008 --- /dev/null +++ b/mail_receiver/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-alpine3.18 + +RUN apk add tzdata + +ENV TZ=Europe/Warsaw +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +COPY mail_receiver/requirements.txt /requirements.txt +RUN pip install -r /requirements.txt + +COPY common/mail_receiver_utils.py /opt/mail_receiver_utils.py +COPY mail_receiver/server.py /opt/server.py diff --git a/mail_receiver/requirements.txt b/mail_receiver/requirements.txt new file mode 100644 index 0000000..132f0d6 --- /dev/null +++ b/mail_receiver/requirements.txt @@ -0,0 +1,3 @@ +aiosmtpd==1.4.4.post2 +python-decouple==3.8 +redis==5.0.1 diff --git a/mail_receiver/server.py b/mail_receiver/server.py new file mode 100644 index 0000000..73cc2a8 --- /dev/null +++ b/mail_receiver/server.py @@ -0,0 +1,134 @@ +import binascii +import datetime +import logging +import os +import ssl +import time +from email.message import Message as EmailMessage +from typing import Any, Dict, Optional, Sequence, Union + +import decouple +from aiosmtpd.controller import Controller +from aiosmtpd.handlers import Message as BaseMessageHandler +from aiosmtpd.smtp import SMTP, Envelope, Session +from mail_receiver_utils import get_key_from_username +from redis import Redis + +logging.basicConfig( + format="%(asctime)s %(levelname)-8s %(message)s", + level=logging.INFO, + datefmt="%Y-%m-%d %H:%M:%S", +) + +LOGGER = logging.getLogger(__name__) +REDIS = Redis.from_url(decouple.config("REDIS_CONNECTION_STRING")) +REDIS_MESSAGE_DATA_EXPIRY_SECONDS = decouple.config("REDIS_MESSAGE_DATA_EXPIRY_SECONDS") + +SSL_PRIVATE_KEY_PATH = decouple.config("SSL_PRIVATE_KEY_PATH", default=None) +SSL_CERTIFICATE_PATH = decouple.config("SSL_CERTIFICATE_PATH", default=None) + +if SSL_PRIVATE_KEY_PATH and SSL_CERTIFICATE_PATH: + assert os.path.exists(SSL_PRIVATE_KEY_PATH) + assert os.path.exists(SSL_CERTIFICATE_PATH) + LOGGER.info("SSL key and certificate exist, creating context") + SSL_CONTEXT: Optional[ssl.SSLContext] = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + assert SSL_CONTEXT + SSL_CONTEXT.load_cert_chain(SSL_CERTIFICATE_PATH, SSL_PRIVATE_KEY_PATH) +else: + LOGGER.info("SSL key and certificate don't exist, not creating context") + SSL_CONTEXT = None + + +class EmailProcessingException(Exception): + pass + + +class RedisHandler(BaseMessageHandler): + def handle_DATA(self, server: SMTP, session: Session, envelope: Envelope) -> Any: + mail_from = envelope.mail_from + assert mail_from + + # We use the raw content, not the parsed message from handle_message, so that changes (e.g. headers reordering) + # don't break DKIM body hash. + content = envelope.original_content or b"" + + LOGGER.info("SSL: %s", session.ssl) + LOGGER.info( + "Raw message body bytes (hexlified): %s", + binascii.hexlify(content), + ) + + for rcpt_to in envelope.rcpt_tos: + try: + # We ignore the domain on purpose. Some e-mail providers (e.g. Wirtualna Polska, wp.pl), when + # tasked with sending an e-mail to a domain, if this domain contains a CNAME record, will + # actually send the e-mail to the CNAME destination. + rcpt_to_username, _ = rcpt_to.split("@", 1) + + key = get_key_from_username(rcpt_to_username) + + LOGGER.info( + "Saving message: %s -> %s (%s bytes) to Redis under key %s", + mail_from, + rcpt_to, + len(content), + key, + ) + REDIS.setex(key, REDIS_MESSAGE_DATA_EXPIRY_SECONDS, content) + REDIS.setex( + key + b"-timestamp", + REDIS_MESSAGE_DATA_EXPIRY_SECONDS, + datetime.datetime.now().isoformat(), + ) + REDIS.setex( + key + b"-sender", + REDIS_MESSAGE_DATA_EXPIRY_SECONDS, + mail_from, + ) + LOGGER.info("Saved") + except Exception: + LOGGER.exception( + "Exception while processing message: %s -> %s", + mail_from, + rcpt_to, + ) + + # The exception details are passed to the recipient server - therefore we raise + # a generic exception so that we don't leak implementation details. + raise EmailProcessingException("Internal server error when processing message") + + result = super().handle_DATA(server, session, envelope) + return result + + def handle_message(self, message: EmailMessage) -> None: + pass + + +class MailgooseSMTP(SMTP): + # This is a hack to support some misconfigured SMTP servers that send AUTH parameter in MAIL FROM even if + # we don't advertise such capability in EHLO (https://www.rfc-editor.org/rfc/rfc4954.html). + def _getparams(self, params: Sequence[str]) -> Optional[Dict[str, Union[str, bool]]]: + result = super()._getparams(params) + if result and "AUTH" in result: + del result["AUTH"] + return result + + +class ControllerOptionalTLS(Controller): + def factory(self) -> SMTP: + if SSL_CONTEXT: + return MailgooseSMTP(self.handler, tls_context=SSL_CONTEXT) + else: + return MailgooseSMTP(self.handler) + + +# We change the line length as some broken servers don't follow RFC +SMTP.line_length_limit = 102400 + +controller_25 = ControllerOptionalTLS(RedisHandler(), hostname="0.0.0.0", port=25) +controller_25.start() # type: ignore +controller_587 = ControllerOptionalTLS(RedisHandler(), hostname="0.0.0.0", port=587) +controller_587.start() # type: ignore + +while True: + time.sleep(3600) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c1f5059 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[tool.isort] +profile = "black" + +[tool.black] +line-length = 120 + +[tool.liccheck] +authorized_licenses = [ + "bsd", + "apache 2.0", + "Apache License 2.0", + "apache software", + "isc", + "isc license (iscl)", + "gnu lesser general public license v2 or later (lgplv2+)", + "gnu library or lesser general public license (lgpl)", + "mozilla public license 2.0 (mpl 2.0)", + "mit", + "python software foundation", + "the unlicense (unlicense)", + "cc0 1.0 universal (cc0 1.0) public domain dedication", +] + +[tool.liccheck.authorized_packages] +# The license name in the package is too generic ("DFSG approved") to be whitelisted in `authorized_licenses` +dkimpy = "1.1.5" diff --git a/scripts/test b/scripts/test new file mode 100755 index 0000000..f0d4d73 --- /dev/null +++ b/scripts/test @@ -0,0 +1,7 @@ +#!/bin/bash + +docker compose -f docker-compose.test.yml down --remove-orphans +docker compose -f docker-compose.test.yml up -d --build + +docker compose -f docker-compose.test.yml build test +docker compose -f docker-compose.test.yml run test diff --git a/scripts/update_translation_files b/scripts/update_translation_files new file mode 100755 index 0000000..4f472bb --- /dev/null +++ b/scripts/update_translation_files @@ -0,0 +1,37 @@ +#!/bin/bash + +LOCALES="pl_PL en_US" + +cd $(dirname $0)/.. + +if [ ! -d .venv.translations ] +then + python3 -m venv .venv.translations +fi + +. .venv.translations/bin/activate + +pip install -r app/translations/requirements.txt + +PYTHONPATH=app pybabel extract \ + --omit-header \ + --strip-comments \ + -F babel.cfg \ + -o app/translations/messages.pot \ + app + +for locale in $LOCALES +do + mkdir -p app/translations/$locale/LC_MESSAGES + pybabel update \ + --omit-header \ + --init-missing \ + -l $locale \ + -i app/translations/messages.pot \ + -d app/translations/ + + # Remove the last newline so that we don't conflict with linters + sed -i -z '$ s/\n$//' app/translations/$locale/LC_MESSAGES/messages.po +done + +sed -i -z '$ s/\n$//' app/translations/messages.pot diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..dc12d45 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,4 @@ +[flake8] +# whitespace before :, too long line, line break before binary operator +ignore = E203,E501,W503 +exclude = .venv.translations diff --git a/test/Dockerfile b/test/Dockerfile new file mode 100644 index 0000000..edb939d --- /dev/null +++ b/test/Dockerfile @@ -0,0 +1,13 @@ +FROM python:latest + +COPY test/requirements.txt /requirements.txt +RUN pip install -r /requirements.txt + +COPY common/wait-for-it.sh /wait-for-it.sh + +RUN mkdir /test +COPY test/*.py /test + +WORKDIR /test/ + +CMD python -m unittest discover diff --git a/test/base.py b/test/base.py new file mode 100644 index 0000000..31b9c3d --- /dev/null +++ b/test/base.py @@ -0,0 +1,15 @@ +from typing import Any, Dict +from unittest import TestCase + +import requests +from config import APP_URL + + +class BaseTestCase(TestCase): + def check_domain(self, domain: str) -> str: + response = requests.post(APP_URL + "/check-domain/scan", {"domain": domain}) + return response.text.replace("\n", " ") + + def check_domain_api_v1(self, domain: str) -> Dict[str, Any]: + response = requests.post(APP_URL + "/api/v1/check-domain?domain=" + domain) + return response.json() # type: ignore diff --git a/test/config.py b/test/config.py new file mode 100644 index 0000000..e4dbdf3 --- /dev/null +++ b/test/config.py @@ -0,0 +1,2 @@ +TEST_DOMAIN = "test.mailgoose.cert.pl" +APP_URL = "http://app:8000" diff --git a/test/requirements.txt b/test/requirements.txt new file mode 100644 index 0000000..2c24336 --- /dev/null +++ b/test/requirements.txt @@ -0,0 +1 @@ +requests==2.31.0 diff --git a/test/test_api.py b/test/test_api.py new file mode 100644 index 0000000..2a2b0ed --- /dev/null +++ b/test/test_api.py @@ -0,0 +1,42 @@ +from base import BaseTestCase +from config import TEST_DOMAIN + + +class APITestCase(BaseTestCase): + def test_dmarc_starts_with_whitespace(self) -> None: + result = self.check_domain_api_v1("starts-with-whitespace.dmarc." + TEST_DOMAIN) + del result["result"]["timestamp"] + self.assertEqual( + result, + { + "result": { + "domain": { + "spf": { + "valid": False, + "errors": [ + "Valid SPF record not found. We recommend using all three mechanisms: SPF, DKIM and DMARC " + "to decrease the possibility of successful e-mail message spoofing.", + ], + "warnings": [], + "record_not_found": True, + "record_could_not_be_fully_validated": False, + }, + "dmarc": { + "record_candidates": [" v=DMARC1; p=none"], + "valid": False, + "tags": {}, + "errors": [ + "Found a DMARC record that starts with whitespace. Please remove the whitespace, as some " + "implementations may not process it correctly.", + ], + "warnings": [], + "record_not_found": False, + }, + "spf_not_required_because_of_correct_dmarc": False, + "domain": "starts-with-whitespace.dmarc.test.mailgoose.cert.pl", + "base_domain": "cert.pl", + "warnings": [], + }, + }, + }, + ) diff --git a/test/test_dmarc.py b/test/test_dmarc.py new file mode 100644 index 0000000..8d4bbed --- /dev/null +++ b/test/test_dmarc.py @@ -0,0 +1,97 @@ +import binascii +import os +import re + +from base import BaseTestCase +from config import TEST_DOMAIN + +CONFIG_WITH_WARNINGS_REGEX = r"DMARC:\s*configuration warnings" +INCORRECT_CONFIG_REGEX = r"DMARC:\s*incorrect configuration" +CORRECT_CONFIG_REGEX = r"DMARC:\s*correct configuration" + + +class DMARCTestCase(BaseTestCase): + def test_correct(self) -> None: + result = self.check_domain("correct.dmarc." + TEST_DOMAIN) + assert re.search(CORRECT_CONFIG_REGEX, result) + assert not re.search(INCORRECT_CONFIG_REGEX, result) + + def test_nonexistent_dmarc(self) -> None: + result = self.check_domain(binascii.hexlify(os.urandom(20)).decode("ascii") + ".com") + assert re.search(INCORRECT_CONFIG_REGEX, result) + assert not re.search(CORRECT_CONFIG_REGEX, result) + assert ( + "Valid DMARC record not found. We recommend using all three mechanisms: SPF, DKIM and DMARC " + "to decrease the possibility of successful e-mail message spoofing." + ) in result + + def test_starts_with_whitespace(self) -> None: + result = self.check_domain("starts-with-whitespace.dmarc." + TEST_DOMAIN) + assert not re.search(CORRECT_CONFIG_REGEX, result) + assert re.search(INCORRECT_CONFIG_REGEX, result) + assert ( + "Found a DMARC record that starts with whitespace. Please remove the whitespace, as some " + "implementations may not process it correctly." + ) in result + + def test_none_policy(self) -> None: + result = self.check_domain("none-policy.dmarc." + TEST_DOMAIN) + assert re.search(CONFIG_WITH_WARNINGS_REGEX, result) + assert not re.search(CORRECT_CONFIG_REGEX, result) + assert not re.search(INCORRECT_CONFIG_REGEX, result) + assert ( + "DMARC policy is 'none', which means that besides reporting no action will be taken. The policy describes what " + "action the recipient server should take when noticing a message that doesn't pass the verification. 'quarantine' policy " + "suggests the recipient server to flag the message as spam and 'reject' policy suggests the recipient " + "server to reject the message. We recommend using the 'quarantine' or 'reject' policy." + ) in result + + def test_unrelated_records(self) -> None: + result = self.check_domain("contains-unrelated-records.dmarc." + TEST_DOMAIN) + assert re.search(CONFIG_WITH_WARNINGS_REGEX, result) + assert not re.search(CORRECT_CONFIG_REGEX, result) + assert not re.search(INCORRECT_CONFIG_REGEX, result) + assert ( + "Unrelated TXT record found in the '_dmarc' subdomain. We recommend removing it, as such unrelated " + "records may cause problems with some DMARC implementations." + ) in result + + def test_public_suffix(self) -> None: + result = self.check_domain("gov.pl") + assert ( + "Requested to scan a domain that is a public suffix, i.e. a domain such as .com where anybody could " + "register their subdomain. Such domain don't have to have properly configured e-mail sender verification " + "mechanisms. Please make sure you really wanted to check such domain and not its subdomain." + ) in result + + def test_syntax_error(self) -> None: + result = self.check_domain("syntax-error.dmarc." + TEST_DOMAIN) + assert re.search(INCORRECT_CONFIG_REGEX, result) + assert not re.search(CORRECT_CONFIG_REGEX, result) + assert ( + "Error: Expected end_of_statement or tag_value at position 10 (marked with ➞) in: v=DMARC1; ➞=none" + ) in result or ( + "Error: Expected tag_value or end_of_statement at position 10 (marked with ➞) in: v=DMARC1; ➞=none" + ) in result + + def test_syntax_error_policy_location(self) -> None: + result = self.check_domain("syntax-error-policy-location.dmarc." + TEST_DOMAIN) + assert re.search(INCORRECT_CONFIG_REGEX, result) + assert not re.search(CORRECT_CONFIG_REGEX, result) + assert ("the p tag must immediately follow the v tag") in result + + def test_rua_no_mailto(self) -> None: + result = self.check_domain("rua-no-mailto.dmarc." + TEST_DOMAIN) + assert re.search(INCORRECT_CONFIG_REGEX, result) + assert not re.search(CORRECT_CONFIG_REGEX, result) + assert ( + "dmarc@mailgoose.cert.pl is not a valid DMARC report URI - please make sure that the URI begins " + "with a schema such as mailto:" + ) in result + + def test_rua_double_mailto(self) -> None: + result = self.check_domain("rua-double-mailto.dmarc." + TEST_DOMAIN) + assert re.search(INCORRECT_CONFIG_REGEX, result) + assert not re.search(CORRECT_CONFIG_REGEX, result) + assert "mailto:mailto:dmarc@mailgoose.cert.pl is not a valid DMARC report URI" in result + assert "please make sure that the URI begins with a schema:" not in result diff --git a/test/test_spf.py b/test/test_spf.py new file mode 100644 index 0000000..c7cd04d --- /dev/null +++ b/test/test_spf.py @@ -0,0 +1,50 @@ +import re + +from base import BaseTestCase +from config import TEST_DOMAIN + +RECORD_COULD_NOT_BE_FULLY_VERIFIED_REGEX = r"SPF:\s*record couldn't be fully verified" +INCORRECT_CONFIG_REGEX = r"SPF:\s*incorrect configuration" +CORRECT_CONFIG_REGEX = r"SPF:\s*correct configuration" + + +class SPFTestCase(BaseTestCase): + def test_correct(self) -> None: + result = self.check_domain("correct.spf." + TEST_DOMAIN) + assert re.search(CORRECT_CONFIG_REGEX, result) + assert not re.search(INCORRECT_CONFIG_REGEX, result) + + def test_service_domains(self) -> None: + """Check whether domain names containing underscore are allowed.""" + result = self.check_domain("_spf.cert.pl") + assert re.search(CORRECT_CONFIG_REGEX, result) + assert not re.search(INCORRECT_CONFIG_REGEX, result) + + def test_macros(self) -> None: + result = self.check_domain("macros.spf." + TEST_DOMAIN) + assert re.search(RECORD_COULD_NOT_BE_FULLY_VERIFIED_REGEX, result) + assert not re.search(CORRECT_CONFIG_REGEX, result) + assert not re.search(INCORRECT_CONFIG_REGEX, result) + assert "SPF records containing macros aren't supported by the system yet." in result + + def test_syntax_error(self) -> None: + result = self.check_domain("syntax-error.spf." + TEST_DOMAIN) + assert re.search(INCORRECT_CONFIG_REGEX, result) + assert not re.search(CORRECT_CONFIG_REGEX, result) + assert ( + "syntax-error.spf.test.mailgoose.cert.pl: Expected end_of_statement or " + "mechanism at position 7 (marked with ➞) in: v=spf1 ➞=bcdefgh" + ) in result or ( + "syntax-error.spf.test.mailgoose.cert.pl: Expected mechanism or " + "end_of_statement at position 7 (marked with ➞) in: v=spf1 ➞=bcdefgh" + ) in result + + def test_problematic_include(self) -> None: + result = self.check_domain("includes-other-domain.spf." + TEST_DOMAIN) + assert re.search(INCORRECT_CONFIG_REGEX, result) + assert not re.search(CORRECT_CONFIG_REGEX, result) + assert ( + "The SPF record's include chain has a reference to the includes-yet-another-domain.spf.test.mailgoose.cert.pl " + "domain that doesn't have an SPF record. When using directives such as 'include' " + "or 'redirect' remember that the destination domain must have a correct SPF record." + ) in result