[
  {
    "path": ".dockerignore",
    "content": "/node_modules\n/mailpit\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: [axllent]\nthanks_dev: u/gh/axllent\n"
  },
  {
    "path": ".github/SECURITY.md",
    "content": "# Reporting security vulnerabilities\n\nYour efforts to responsibly disclose your findings are appreciated.\n\n**Please do not report security vulnerabilities through public GitHub issues.**\n\nIf you believe you have found a security vulnerability, you can report it using one of the following methods:\n\n1. **GitHub Security Advisory (Recommended):** Use the \"Report a vulnerability\" button in the [Security tab](../../security/advisories/new) of this repository.\n2. **Email:** Send your findings to security@axllent.org\n\nYour report should include:\n\n- Mailpit version\n- A vulnerability description\n- Reproduction steps (if applicable)\n- Any other details you think are likely to be important\n\nYou should receive an initial acknowledgement within 24 hours in most cases, and will be kept updated throughout the process.\n\nWith your consent, your contributions will be publicly acknowledged.\n"
  },
  {
    "path": ".github/cliff.toml",
    "content": "## https://git-cliff.org/\n[changelog]\nbody = \"\"\"\n{% if version %}\\\n  \\n## [{{ version }}]\n{% else %}\\\n  \\n## Unreleased\n{% endif %}\\\n{% for group, commits in commits | group_by(attribute=\"group\") %}\n  ### {{ group | striptags | trim | upper_first }}\\\n  {% for commit in commits %}\n    - {{ commit.message | upper_first }}\\\n  {% endfor %}\n{% endfor %}\\n\n\"\"\"\nfooter = \"\"\nheader = \"# Changelog\\n\\nNotable changes to Mailpit will be documented in this file.\"\npostprocessors = [\n  {pattern = \"reponse\", replace = \"response\"},\n  {pattern = \"messsage\", replace = \"message\"},\n  {pattern = '(?i) go modules', replace = \" Go dependencies\"},\n  {pattern = '(?i) node modules', replace = \" node dependencies\"},\n  {pattern = '#([0-9]+)', replace = \"[#$1](https://github.com/axllent/mailpit/issues/$1)\"},\n]\ntrim = true\n\n[git]\n# HTML comments added for grouping order, stripped on generation\ncommit_parsers = [\n  {body = \".*security\", group = \"<!-- 1 -->Security\"},\n  {message = \"(?i)^security\", group = \"<!-- 1 -->Security\"},\n  {message = \"(?i)^feat\", group = \"<!-- 2 -->Feature\"},\n  {message = \"(?i)^chore\", group = \"<!-- 3 -->Chore\"},\n  {message = \"(?i)^libs\", group = \"<!-- 3 -->Chore\"},\n  {message = \"(?i)^ui\", group = \"<!-- 3 -->Chore\"},\n  {message = \"(?i)^api\", group = \"<!-- 4 -->API\"},\n  {message = \"(?i)^fix\", group = \"<!-- 5 -->Fix\"},\n  {message = \"(?i)^doc\", group = \"<!-- 6 -->Documentation\", default_scope = \"unscoped\"},\n  {message = \"(?i)^swagger\", group = \"<!-- 6 -->Documentation\", default_scope = \"unscoped\"},\n  {message = \"(?i)^test\", group = \"<!-- 7 -->Test\"},\n]\n\n# Exclude commits that are not matched by any commit parser.\n# filter_commits = true\n# Order releases topologically instead of chronologically.\n# topo_order = true\n# Order of commits in each group/release within the changelog.\n# Allowed values: newest, oldest\nsort_commits = \"oldest\"\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "# To get started with Dependabot version updates, you'll need to specify which\n# package ecosystems to update and where the package manifests are located.\n# Please see the documentation for all configuration options:\n# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates\n\nversion: 2\nupdates:\n  - package-ecosystem: \"gomod\"\n    directory: \"/\" # Location of package manifests\n    schedule:\n      interval: \"quarterly\"\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\" # Location of package manifests\n    schedule:\n      interval: \"quarterly\"\n  - package-ecosystem: \"docker\"\n    directory: \"/\" # Location of package manifests\n    schedule:\n      interval: \"quarterly\"\n  - package-ecosystem: \"npm\"\n    directory: \"/\"\n    schedule:\n      interval: \"quarterly\"\n"
  },
  {
    "path": ".github/workflows/build-docker-edge.yml",
    "content": "on:\n  push:\n    branches: [ develop ]\n\nname: Build docker edge images\njobs:\n  docker:\n    runs-on: ubuntu-latest\n    steps:\n    - name: Checkout repository\n      uses: actions/checkout@v6\n      with:\n          fetch-depth: 0 # required for github-action-get-previous-tag\n\n    - name: Set up QEMU\n      uses: docker/setup-qemu-action@v3\n\n    - name: Set up Docker Buildx\n      uses: docker/setup-buildx-action@v3\n\n    - name: Log into Docker Hub\n      uses: docker/login-action@v3\n      with:\n        username: ${{ secrets.DOCKER_USERNAME }}\n        password: ${{ secrets.DOCKER_ACCESS_TOKEN }}\n\n    - name: Log into GitHub Container Registry\n      uses: docker/login-action@v3\n      with:\n        registry: ghcr.io\n        username: ${{ github.repository_owner }}\n        password: ${{ github.token }}\n\n    - name: Get previous git tag\n      uses: WyriHaximus/github-action-get-previous-tag@v2\n      id: previous-tag\n\n    - name: Get short SHA\n      uses: benjlevesque/short-sha@v3.0\n      id: short-sha\n\n    - name: Build and push\n      uses: docker/build-push-action@v6\n      with:\n        context: .\n        platforms: linux/386,linux/amd64,linux/arm64\n        build-args: |\n          \"VERSION=${{ steps.previous-tag.outputs.tag }}-${{ steps.short-sha.outputs.sha }}\"\n        push: true\n        tags: |\n          axllent/mailpit:edge\n          ghcr.io/${{ github.repository }}:edge\n"
  },
  {
    "path": ".github/workflows/build-docker.yml",
    "content": "on:\n  release:\n    types: [created]\n\nname: Build docker images\njobs:\n  docker:\n    runs-on: ubuntu-latest\n    steps:\n    - name: Checkout repository\n      uses: actions/checkout@v6\n\n    - name: Set up QEMU\n      uses: docker/setup-qemu-action@v3\n\n    - name: Set up Docker Buildx\n      uses: docker/setup-buildx-action@v3\n\n    - name: Log into Docker Hub\n      uses: docker/login-action@v3\n      with:\n        username: ${{ secrets.DOCKER_USERNAME }}\n        password: ${{ secrets.DOCKER_ACCESS_TOKEN }}\n\n    - name: Log into GitHub Container Registry\n      uses: docker/login-action@v3\n      with:\n        registry: ghcr.io\n        username: ${{ github.repository_owner }}\n        password: ${{ github.token }}\n\n    - name: Parse semver\n      id: semver_parser \n      uses: booxmedialtd/ws-action-parse-semver@v1.4.7\n      with:\n        input_string: '${{ github.ref_name }}'\n        version_extractor_regex: 'v(.*)$'\n\n    - name: Build and push\n      uses: docker/build-push-action@v6\n      with:\n        context: .\n        platforms: linux/386,linux/amd64,linux/arm64\n        build-args: |\n          \"VERSION=${{ github.ref_name }}\"\n        push: true\n        tags: |\n          axllent/mailpit:latest\n          axllent/mailpit:${{ github.ref_name }}\n          axllent/mailpit:v${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }}\n          ghcr.io/${{ github.repository }}:${{ github.ref_name }}\n          ghcr.io/${{ github.repository }}:v${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }}\n          ghcr.io/${{ github.repository }}:latest\n"
  },
  {
    "path": ".github/workflows/close-stale-issues.yml",
    "content": "name: Close stale issues\non:\n  schedule:\n    - cron: \"30 1 * * *\"\n\njobs:\n  close-issues:\n    runs-on: ubuntu-latest\n    permissions:\n      issues: write\n      pull-requests: write\n    steps:\n      - uses: actions/stale@v10.1.1\n        with:\n          days-before-issue-stale: 7\n          days-before-issue-close: 3\n          exempt-issue-labels: \"enhancement,bug,awaiting feedback\"\n          stale-issue-label: \"stale\"\n          close-issue-reason: \"completed\"\n          stale-issue-message: \"This issue has been marked as stale because it has been open for 7 days with no activity.\"\n          close-issue-message: \"This issue was closed because there has been no activity since being marked as stale.\"\n          days-before-pr-stale: -1\n          days-before-pr-close: -1\n          repo-token: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/codeql-analysis.yml",
    "content": "# For most projects, this workflow file will not need changing; you simply need\n# to commit it to your repository.\n#\n# You may wish to alter this file to override the set of languages analyzed,\n# or to provide custom queries or build logic.\n#\n# ******** NOTE ********\n# We have attempted to detect the languages in your repository. Please check\n# the `language` matrix defined below to confirm you have the correct set of\n# supported CodeQL languages.\n#\nname: \"CodeQL\"\n\non:\n  push:\n    branches: [ \"develop\" ]\n  pull_request:\n    # The branches below must be a subset of the branches above\n    branches: [ \"develop\" ]\n  schedule:\n    - cron: '34 23 * * 4'\n\njobs:\n  analyze:\n    name: Analyze\n    runs-on: ubuntu-latest\n    permissions:\n      actions: read\n      contents: read\n      security-events: write\n\n    strategy:\n      fail-fast: false\n      matrix:\n        language: [ 'go', 'javascript' ]\n        # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]\n        # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support\n\n    steps:\n    - name: Checkout repository\n      uses: actions/checkout@v6\n\n    # Initializes the CodeQL tools for scanning.\n    - name: Initialize CodeQL\n      uses: github/codeql-action/init@v3\n      with:\n        languages: ${{ matrix.language }}\n        # If you wish to specify custom queries, you can do so here or in a config file.\n        # By default, queries listed here will override any specified in a config file.\n        # Prefix the list here with \"+\" to use these queries and those in the config file.\n        \n        # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs\n        # queries: security-extended,security-and-quality\n\n        \n    # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java).\n    # If this step fails, then you should remove it and run the build manually (see below)\n    - name: Autobuild\n      uses: github/codeql-action/autobuild@v3\n\n    # ℹ️ Command-line programs to run using the OS shell.\n    # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun\n\n    #   If the Autobuild fails above, remove it and uncomment the following three lines. \n    #   modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.\n\n    # - run: |\n    #   echo \"Run, Build Application using script\"\n    #   ./location_of_script_within_repo/buildscript.sh\n\n    - name: Perform CodeQL Analysis\n      uses: github/codeql-action/analyze@v3\n"
  },
  {
    "path": ".github/workflows/release-build.yml",
    "content": "on:\n  release:\n    types: [created]\n\nname: Build & release\njobs:\n  releases-matrix:\n    name: Build\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        goos: [linux, windows, darwin]\n        goarch: [\"386\", amd64, arm, arm64]\n        exclude:\n          - goarch: \"386\"\n            goos: darwin\n          - goarch: \"386\"\n            goos: windows\n          - goarch: arm\n            goos: darwin\n          - goarch: arm\n            goos: windows\n    steps:\n    - uses: actions/checkout@v6\n\n    # build the assets\n    - uses: actions/setup-node@v6\n      with:\n        node-version: 22\n        cache: 'npm'\n    - run: echo \"Building assets for ${{ github.ref_name }}\"\n    - run: npm install\n    - run: npm run package\n\n    # build the binaries\n    - uses: wangyoucao577/go-release-action@v1\n      with:\n        github_token: ${{ secrets.GITHUB_TOKEN }}\n        goos: ${{ matrix.goos }}\n        goarch: ${{ matrix.goarch }}\n        binary_name: \"mailpit\"\n        pre_command: export CGO_ENABLED=0\n        asset_name: mailpit-${{ matrix.goos }}-${{ matrix.goarch }}\n        extra_files: LICENSE README.md\n        md5sum: false\n        overwrite: true\n        retry: 5\n        ldflags: -w -X \"github.com/axllent/mailpit/config.Version=${{ github.ref_name }}\"\n"
  },
  {
    "path": ".github/workflows/tests-rqlite.yml",
    "content": "name: Tests (rqlite)\non:\n  pull_request:\n    branches: [ develop, 'feature/**' ]\n  push:\n    branches: [ develop, 'feature/**' ]\n\njobs:\n  test-rqlite:\n    runs-on: ubuntu-latest\n    services:\n      rqlite:\n        image: rqlite/rqlite:latest\n        ports:\n          - 4001:4001\n        env:\n          # the HTTP address the rqlite node should advertise\n          HTTP_ADV_ADDR: \"localhost:4001\"\n    steps:\n      - uses: actions/checkout@v6\n      - name: Setup Go\n        uses: actions/setup-go@v6\n        with:\n          go-version: 'stable'\n          cache-dependency-path: \"**/*.sum\"\n      - run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck -v\n        env:\n          # set Mailpit to use the rqlite service container\n          MP_DATABASE: \"http://localhost:4001\"\n"
  },
  {
    "path": ".github/workflows/tests.yml",
    "content": "name: Tests\non:\n  pull_request:\n    branches: [ develop, 'feature/**' ]\n  push:\n    branches: [ develop, 'feature/**' ]\njobs:\n  test:\n    strategy:\n      matrix:\n        go-version: [stable]\n        os: [ubuntu-latest, windows-latest, macos-latest]\n    runs-on: ${{ matrix.os }}\n    steps:\n    - uses: actions/setup-go@v6\n      with:\n        go-version: ${{ matrix.go-version }}\n        cache: false\n    - uses: actions/checkout@v6\n    - name: Set up Go environment\n      uses: actions/cache@v5\n      with:\n        path: |\n          ~/.cache/go-build\n          ~/go\n        key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}\n        restore-keys: |\n          ${{ runner.os }}-go-\n    - name: Test Go linting (gofmt)\n      if: startsWith(matrix.os, 'ubuntu') == true\n      # https://olegk.dev/github-actions-and-go\n      run: gofmt -s -w . && git diff --exit-code\n    - name: Run Go tests\n      run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck -v\n    - name: Run Go benchmarking\n      run: go test -p 1 ./internal/storage ./internal/html2text -bench=.\n    \n    # build the assets\n    - name: Set up node environment\n      if: startsWith(matrix.os, 'ubuntu') == true\n      uses: actions/setup-node@v6\n      with:\n        node-version: 22\n        cache: 'npm'\n    - name: Install JavaScript dependencies\n      if: startsWith(matrix.os, 'ubuntu') == true\n      run: npm install\n    - name: Run JavaScript linting\n      if: startsWith(matrix.os, 'ubuntu') == true\n      run: npm run lint\n    - name: Test JavaScript packaging\n      if: startsWith(matrix.os, 'ubuntu') == true\n      run: npm run package\n\n    # # validate the swagger file\n    # - name: Validate OpenAPI definition\n    #   if: startsWith(matrix.os, 'ubuntu') == true\n    #   uses: swaggerexpert/swagger-editor-validate@v1\n    #   with:\n    #     definition-file: server/ui/api/v1/swagger.json\n    #     default-timeout: 20000\n"
  },
  {
    "path": ".gitignore",
    "content": "/node_modules/\n/send\n/sendmail/sendmail\n/server/ui/dist\n/Makefile\n/mailpit*\n/.idea\n*.old\n*.db\n"
  },
  {
    "path": ".prettierignore",
    "content": "# Not within the scope of Prettier\n**/*.yml\n**/*.yaml\n**/*.json\n**/*.md\n**/*.css\n**/*.html\n**/*.scss\ncomposer.lock\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n    \"[vue]\": {\n        \"editor.defaultFormatter\": \"esbenp.prettier-vscode\"\n    },\n    \"[javascript]\": {\n        \"editor.defaultFormatter\": \"esbenp.prettier-vscode\"\n    },\n    \"cSpell.words\": [\n        \"AUTHCRAMMD\",\n        \"AUTHLOGIN\",\n        \"AUTHPLAIN\",\n        \"bordercolor\",\n        \"CRAMMD\",\n        \"dateparse\",\n        \"EHLO\",\n        \"ESMTP\",\n        \"EXPN\",\n        \"gofmt\",\n        \"Healthz\",\n        \"HTTPIP\",\n        \"Inlines\",\n        \"jhillyerd\",\n        \"leporo\",\n        \"lithammer\",\n        \"livez\",\n        \"Mechs\",\n        \"navhtml\",\n        \"neostandard\",\n        \"nolint\",\n        \"popperjs\",\n        \"readyz\",\n        \"RSET\",\n        \"shortuuid\",\n        \"SMTPTLS\",\n        \"swaggerexpert\",\n        \"UITLS\",\n        \"VRFY\",\n        \"writef\"\n    ]\n}\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\nNotable changes to Mailpit will be documented in this file.\n\n## [v1.29.3]\n\n### Security\n- Enhance CORS origin handling to respect host:port distinctions\n- Limit proxy requests to 50MB to prevent OOM attacks\n- Enhance HTML sanitization in message view\n- Enhance HTML sanitization in screenshot generation\n- Escape ContentID in HTML replacement to prevent regex injection\n\n### Chore\n- Use last release + git hash in Docker edge versions\n- Bump minimatch from 10.2.2 to 10.2.4\n- Refactor code with go fix\n- Switch to math/rand/v2\n- Refactor API send authentication logic\n- Refactor events websocket middleware\n- Set timeout for HTTP client in webhook Send function\n- Use local hostname for EHLO/HELO in SMTP communication\n- Simplify HTML decoding function in screenshot generation using DOMParser\n- Set margin & padding to HTML screenshot to prevent transparent top/left border\n- Replace localStorage retrieval with a dedicated function for default release addresses\n- Limit subject length to 100 characters in browser notifications\n- Improve transaction handling in pruneMessages and fix loop continuation in InitDB\n- Update Content-Disposition header to use inline display and escape filename\n- Refactor timezone handling in searchQueryBuilder\n- Update Go dependencies\n- Update node dependencies\n\n### Fix\n- Update SQL query to use tenant when using is:tagged filter\n\n\n## [v1.29.2]\n\n### Security\n- Prevent Server-Side Request Forgery (SSRF) via Link Check API ([GHSA-mpf7-p9x7-96r3](https://github.com/axllent/mailpit/security/advisories/GHSA-mpf7-p9x7-96r3))\n\n### Chore\n- Upgrade eslint JavaScript linting\n- Update Go dependencies\n- Update node dependencies\n- Update caniemail test database\n\n### Fix\n- Update install instructions when setting INSTALL_PATH\n- Include 8BITMIME in SMTPD EHLO response ([#648](https://github.com/axllent/mailpit/issues/648))\n\n\n## [v1.29.1]\n\n### Chore\n- Add CORS error logging and update error messages for failed CORS requests\n- Bump axios from 1.13.4 to 1.13.5\n- Update Go dependencies\n- Update node dependencies\n\n### Fix\n- Enable \"Mark all read\" button (Inbox) when new message is received\n\n\n## [v1.29.0]\n\n### Feature\n- Include message attachment checksums (MD5, SHA1 & SHA254) in API message summary\n- Option to display/hide attachment information in message view in web UI including checksums, content type & disposition\n\n### Chore\n- Add support for multi-origin CORS settings and apply to events websocket ([#630](https://github.com/axllent/mailpit/issues/630))\n- Add support for webhook delay ([#627](https://github.com/axllent/mailpit/issues/627))\n- Update Go dependencies\n- Update node dependencies\n\n### Test\n- Add CORS tests\n- Add message summary attachment checksum tests\n\n\n## [v1.28.4]\n\n### Chore\n- Increase allowed SMTP email address length to 1024 chars & return clearer SMTP responses for failures ([#620](https://github.com/axllent/mailpit/issues/620))\n- Update Go dependencies\n- Update node dependencies\n\n### Fix\n- Ensure SMTP HELO/EHLO command is issued before MAIL FROM as per RFC 5321 ([#621](https://github.com/axllent/mailpit/issues/621))\n- Prevent nested MAIL command during an active SMTP transaction ([#623](https://github.com/axllent/mailpit/issues/623))\n- Avoid error on image type assertion in thumbnail generation\n\n\n## [v1.28.3]\n\n### Security\n- Ensure SMTP TO & FROM addresses are RFC 5322 compliant and prevent header injection ([GHSA-54wq-72mp-cq7c](https://github.com/axllent/mailpit/security/advisories/GHSA-54wq-72mp-cq7c))\n- Prevent Server-Side Request Forgery (SSRF) via HTML Check API ([GHSA-6jxm-fv7w-rw5j](https://github.com/axllent/mailpit/security/advisories/GHSA-6jxm-fv7w-rw5j))\n\n### Chore\n- Fix formatting and update reporting instructions in SECURITY.md ([#614](https://github.com/axllent/mailpit/issues/614))\n- Allow `@` character in message tags & set max length to 100 characters per tag\n- Update Go dependencies\n- Update node dependencies\n\n### Fix\n- Correctly render default addresses in release modal after settings change ([#594](https://github.com/axllent/mailpit/issues/594))\n- Correctly detect macOS group in install.sh ([#619](https://github.com/axllent/mailpit/issues/619))\n- Auto-tagging using SMTP username using plain auth ([#617](https://github.com/axllent/mailpit/issues/617))\n- Validate maximum lengths of email addresses - RFC5321 (section 4.5.3.1)\n\n### Test\n- Update tag tests with length limits and `@` character\n- Add SMTP tests for address compliancy (RFC 5322) and header injection\n- Add maximum email length validation tests - RFC5321 (section 4.5.3.1)\n\n\n## [v1.28.2]\n\n### Security\n- Prevent Cross-Site WebSocket Hijacking (CSWSH) allowing unauthenticated access to message data [CVE-2026-22689](https://github.com/axllent/mailpit/security/advisories/GHSA-524m-q5m7-79mm)\n\n### Feature\n- Allow default mail addresses to be set when releasing message ([#594](https://github.com/axllent/mailpit/issues/594))\n\n### Chore\n- Remove webkit warnings about missing template / render functions\n- Avoid empty URL query parameter when returning to inbox from message view\n\n\n## [v1.28.1]\n\n### Security\n- Restrict screenshot proxy to only support asset links contained in messages [CVE-2026-21859](https://github.com/axllent/mailpit/security/advisories/GHSA-8v65-47jx-7mfr)\n\n### Chore\n- Bump actions/checkout from 5 to 6 ([#610](https://github.com/axllent/mailpit/issues/610))\n- Bump actions/cache from 4 to 5 ([#607](https://github.com/axllent/mailpit/issues/607))\n- Bump actions/stale from 10.0.0 to 10.1.1 ([#604](https://github.com/axllent/mailpit/issues/604))\n- Bump actions/setup-node from 5 to 6 ([#598](https://github.com/axllent/mailpit/issues/598))\n- Bump esbuild from 0.25.12 to 0.27.2 ([#611](https://github.com/axllent/mailpit/issues/611))\n- Update Go dependencies\n- Update node dependencies\n\n### Test\n- Add inline message tests\n- Increase swagger test timeout\n\n\n## [v1.28.0]\n\n### Feature\n- Optionally propagate SMTP errors ([#588](https://github.com/axllent/mailpit/issues/588))\n\n### Chore\n- Update Go dependencies\n- Update node dependencies\n- Update caniemail test database\n\n\n## [v1.27.11]\n\n### Chore\n- Update Go dependencies\n- Update node dependencies\n- Add type assertion for value in imaging assignment\n\n\n## [v1.27.10]\n\n### Security\n- Prevent potential information disclosure via indirect expvar library (Prometheus)\n\n### Chore\n- Add tooltip to messages nav dropdown\n- Update GitHub Actions\n- Add tooltip to messages nav dropdown\n- Update GitHub Actions\n- Update Go dependencies\n- Update node dependencies\n\n\n## [v1.27.9]\n\n### Chore\n- UI tweaks to pagination layout for clearer navigation ([#568](https://github.com/axllent/mailpit/issues/568))\n- Add margin to icons in release and delete buttons for consistent spacing\n- Update navbar theme to use data-bs-theme attribute for consistency\n- Update Go dependencies\n- Update node dependencies\n\n\n## [v1.27.8]\n\n### Chore\n- Update Go dependencies\n- Update node dependencies\n- Update caniemail test database\n\n\n## [v1.27.7]\n\n### Fix\n- Move HELO/EHLO hostname setting to the correct position in SMTP client creation ([#558](https://github.com/axllent/mailpit/issues/558))\n\n\n## [v1.27.6]\n\n### Feature\n- Add optional --no-release-check to version subcommand ([#557](https://github.com/axllent/mailpit/issues/557))\n\n### Chore\n- Set HELO/EHLO hostname when connecting to external SMTP server ([#556](https://github.com/axllent/mailpit/issues/556))\n- Update Go dependencies\n- Update node dependencies\n\n\n## [v1.27.5]\n\n### Chore\n- Update Go dependencies\n- Update node dependencies\n- Update caniemail test database\n\n### Fix\n- Support optional UIDL argument in POP3 server ([#552](https://github.com/axllent/mailpit/issues/552))\n\n\n## [v1.27.4]\n\n### Feature\n- Allow rejected SMTP recipients to be silently dropped ([#549](https://github.com/axllent/mailpit/issues/549))\n\n### Chore\n- Update Go dependencies\n- Update node dependencies\n- Update caniemail test database\n\n\n## [v1.27.3]\n\n### Fix\n- Fix sendmail when using an  `--smtp-addr <ip>:<port>` ([#542](https://github.com/axllent/mailpit/issues/542))\n\n\n## [v1.27.2]\n\n### Security\n- Prevent integer overflow conversion to uint64\n- Add ReadHeaderTimeout to Prometheus metrics server\n\n### Feature\n- Add ability to generate self-signed (snakeoil) certificates for UI, SMTP and POP3 ([#539](https://github.com/axllent/mailpit/issues/539))\n\n### Chore\n- Allow sendmail to send to untrusted TLS server\n- Update eslint config, remove neostandard\n- Refactor JS functions and remove unused parameters\n- Update Go dependencies\n- Update node dependencies\n\n### Fix\n- Use MaxMessages to determine pruning ([#536](https://github.com/axllent/mailpit/issues/536))\n- Support angle brackets for text/plain URLs with spaces ([#535](https://github.com/axllent/mailpit/issues/535))\n- Do not check latest release for Prometheus statistics ([#522](https://github.com/axllent/mailpit/issues/522))\n\n\n## [v1.27.1]\n\n### Chore\n- Allow unknown href link protocols in HTML view such as myapp:// ([#532](https://github.com/axllent/mailpit/issues/532))\n- Update Go dependencies\n- Update node dependencies\n\n\n## [v1.27.0]\n\n### Chore\n- Remove unused functionality/deadcode (golangci-lint)\n- Refactor error handling and resource management across multiple files (golangci-lint)\n- Refactor API Swagger definitions and remove unused structs\n- Bump minimum Go version to v1.24.3 for jhillyerd/enmime/v2\n- Switch version checks & self-updater to use ghru/v2\n- Update Go dependencies\n- Update node dependencies\n\n### Fix\n- Align websocket new message values with global Message Summary (no null values) ([#526](https://github.com/axllent/mailpit/issues/526))\n\n\n## [v1.26.2]\n\n### Feature\n- Store username with messages, auto-tag, and UI display ([#521](https://github.com/axllent/mailpit/issues/521))\n- Allow version checking to be disabled ([#524](https://github.com/axllent/mailpit/issues/524))\n\n### Chore\n- Apply linting to all JavaScript/Vue files with eslint & prettier\n- Update Go dependencies\n- Update node dependencies\n\n### Fix\n- Improve version polling, add thread safety and exponential backoff ([#523](https://github.com/axllent/mailpit/issues/523))\n\n### Test\n- Add JavaScript linting tests to CI\n- Add Go linting (gofmt) to CI\n\n\n## [v1.26.1]\n\n### Feature\n- Add relay config to preserve (keep) original Message-IDs when relaying messages ([#515](https://github.com/axllent/mailpit/issues/515))\n\n### Chore\n- Update Go dependencies\n- Update node dependencies\n- Update caniemail testing database\n\n### Fix\n- Add optional message_num argument in POP3 LIST command ([#518](https://github.com/axllent/mailpit/issues/518))\n- Use float64 for returned SQL value types for rqlite compatibility ([#520](https://github.com/axllent/mailpit/issues/520))\n\n### Test\n- Add small delay in POP3 test after disconnection to allow for background deletion in rqlite\n- Add automated tests using the rqlite database\n\n\n## [v1.26.0]\n\n### Feature\n- Send API allow separate auth ([#504](https://github.com/axllent/mailpit/issues/504))\n- Add Prometheus exporter ([#505](https://github.com/axllent/mailpit/issues/505))\n\n### Chore\n- Add MP_DATA_FILE deprecation warning\n- Update Go dependencies\n- Update node dependencies\n\n### Fix\n- Ignore basic auth for OPTIONS requests to API when CORS is set\n- Fix sendmail symlink detection for macOS ([#514](https://github.com/axllent/mailpit/issues/514))\n\n\n## [v1.25.1]\n\n### Chore\n- Switch from unnecessary float64 to uint64 API values for App Information, message & attachment sizes\n- Extend latest version cache expiration from 5 to 15 minutes\n- Lighten outline-secondary buttons in dark mode\n- Add note to swagger docs about API date formats\n- Update Go dependencies\n- Update node dependencies\n\n### Fix\n- Update bootstrap5-tags to fix text pasting in message release modal ([#498](https://github.com/axllent/mailpit/issues/498))\n\n\n## [v1.25.0]\n\n### Feature\n- Add option to hide the \"Delete all\" button in web UI ([#495](https://github.com/axllent/mailpit/issues/495))\n\n### Chore\n- Upgrade to jhillyerd/enmime/v2\n- Switch yaml parser to github.com/goccy/go-yaml\n- Tweak UI to improve contrast between read & unread messages\n- Adjust UI margin for side navigation\n- Update Go dependencies\n- Update node dependencies\n- Update caniemail database\n\n### Fix\n- Include SMTPUTF8 capability in SMTP EHLO response ([#496](https://github.com/axllent/mailpit/issues/496))\n\n### Documentation\n- Switch to git-cliff for changelog generation\n- Add Message ListUnsubscribe to swagger / API documentation ([#494](https://github.com/axllent/mailpit/issues/494))\n\n\n## [v1.24.2]\n\n### Feature\n- Display unread count in app badge ([#485](https://github.com/axllent/mailpit/issues/485))\n\n### Chore\n- Install script improvements & better error handling ([#482](https://github.com/axllent/mailpit/issues/482))\n- Update Go dependencies\n- Update node dependencies\n- Update caniemail database\n\n\n## [v1.24.1]\n\n### Feature\n- Add ability to mark all search results as read ([#476](https://github.com/axllent/mailpit/issues/476))\n\n### Chore\n- Bump node version to 22 for binary releases\n- Improve error message for From header parsing failure ([#477](https://github.com/axllent/mailpit/issues/477))\n- Update Go dependencies\n- Update node dependencies\n\n\n## [v1.24.0]\n\n### Feature\n- Add TLS relay support and refactor relay function ([#471](https://github.com/axllent/mailpit/issues/471))\n- Add TLS forwarding support and refactor forwarding function\n\n### Chore\n- Update Go dependencies\n- Standardize error message casing\n- Update Go dependencies\n- Update node dependencies\n\n\n## [v1.23.2]\n\n### Chore\n- Update node dependencies\n- Use `Message-ID` header instead of `Message-Id` when generating new IDs (RFC 5322)\n- Improve inline HTML Check style detection ([#467](https://github.com/axllent/mailpit/issues/467))\n- Update Go dependencies\n\n### Test\n- Add tests for inline HTML Checks\n\n\n## [v1.23.1]\n\n### Chore\n- Replace PrismJS with highlight.js for HTML syntax highlighting\n- Update Go dependencies\n- Update node dependencies\n\n### Fix\n- Allow searching messages using only Cyrillic characters ([#450](https://github.com/axllent/mailpit/issues/450))\n- Prevent cropping bottom of label characters in web UI ([#457](https://github.com/axllent/mailpit/issues/457))\n\n\n## [v1.23.0]\n\n### Feature\n- Add configuration to set message compression level in db (0-3) ([#447](https://github.com/axllent/mailpit/issues/447) & [#448](https://github.com/axllent/mailpit/issues/448))\n- Add configuration to explicitly disable HTTP compression in web UI/API ([#448](https://github.com/axllent/mailpit/issues/448))\n- Add configuration to disable SQLite WAL mode for NFS compatibility\n\n### Chore\n- Avoid shell in Docker health check ([#444](https://github.com/axllent/mailpit/issues/444))\n- Handle BLOB storage for default database differently to rqlite to reduce memory overhead ([#447](https://github.com/axllent/mailpit/issues/447))\n- Optimize ZSTD encoder for fastest compression of messages ([#447](https://github.com/axllent/mailpit/issues/447))\n- Minor speed & memory improvements when storing messages\n- Update Go dependencies\n- Update node dependencies\n\n### Fix\n- Display the correct STARTTLS or TLS runtime option on startup ([#446](https://github.com/axllent/mailpit/issues/446))\n\n### Test\n- Add tests for message compression levels\n\n\n## [v1.22.3]\n\n### Feature\n- Add dump feature to export all raw messages to a local directory ([#443](https://github.com/axllent/mailpit/issues/443))\n\n### Chore\n- Specify Docker health check start period and interval ([#439](https://github.com/axllent/mailpit/issues/439))\n- Update Go dependencies\n- Update node dependencies\n\n### Fix\n- Replace TrimLeft with TrimPrefix for webroot path handling ([#441](https://github.com/axllent/mailpit/issues/441))\n- Include font/woff content type to embedded controller\n- Update Swagger JSON to prevent overflow ([#442](https://github.com/axllent/mailpit/issues/442))\n- Correctly detect maximum SMTP recipient limits, add test\n\n\n## [v1.22.2]\n\n### Chore\n- Replace http.FileServer with custom controller to correctly encode gzipped error responses for embed.FS\n- Enable browser cache for embedded web UI assets\n- Update Go dependencies\n- Update node dependencies / esbuild\n\n### Fix\n- Remove recursive HTML regeneration in embedded HTML view ([#434](https://github.com/axllent/mailpit/issues/434))\n- Add missing \"latest\" route to message attachment API endpoint ([#437](https://github.com/axllent/mailpit/issues/437))\n\n\n## [v1.22.1]\n\n### Feature\n- Add optional UI setting to skip \"Delete all\" & \"Mark all read\" confirmation dialogs([#428](https://github.com/axllent/mailpit/issues/428))\n- Add optional query parameter for HTML message iframe embedding ([#434](https://github.com/axllent/mailpit/issues/434))\n\n### Chore\n- Bump actions/stale from 9.0.0 to 9.1.0 ([#432](https://github.com/axllent/mailpit/issues/432))\n- Add API CORS policy to HTML preview routes ([#434](https://github.com/axllent/mailpit/issues/434))\n- Update Go dependencies\n- Update node dependencies\n\n\n## [v1.22.0]\n\n### Feature\n- Add Chaos functionality to test integration handling of SMTP error responses ([#402](https://github.com/axllent/mailpit/issues/402), [#110](https://github.com/axllent/mailpit/issues/110), [#144](https://github.com/axllent/mailpit/issues/144) & [#268](https://github.com/axllent/mailpit/issues/268))\n- Option to override the From email address in SMTP relay configuration ([#414](https://github.com/axllent/mailpit/issues/414))\n- SMTP auto-forwarding option ([#414](https://github.com/axllent/mailpit/issues/414))\n\n### Chore\n- Update Go dependencies\n- Update node dependencies\n\n### Fix\n- Correct date formatting in TestMakeHeaders\n- Update command `npm run update-caniemail` save path ([#422](https://github.com/axllent/mailpit/issues/422))\n\n\n## [v1.21.8]\n\n### Chore\n- Update Go dependencies\n- Update node dependencies\n\n### Fix\n- Remove unused FOREIGN KEY REFERENCES in message_tags table ([#374](https://github.com/axllent/mailpit/issues/374))\n\n\n## [v1.21.7]\n\n### Chore\n- Display \"From\" details in message sidebar (desktop) ([#403](https://github.com/axllent/mailpit/issues/403))\n- Display \"To\" details in mobile messages list\n- Stricter SMTP 'MAIL FROM' & 'RCPT TO' handling ([#409](https://github.com/axllent/mailpit/issues/409))\n- Move smtpd & pop3 modules to internal\n- Bump Go version for automated testing\n- Update Go dependencies\n- Update node dependencies\n\n### Fix\n- Prevent splitting multi-byte characters in message snippets ([#404](https://github.com/axllent/mailpit/issues/404))\n- Ignore unsupported optional SMTP 'MAIL FROM' parameters ([#407](https://github.com/axllent/mailpit/issues/407))\n\n### Test\n- Add smtpd tests\n\n\n## [v1.21.6]\n\n### Feature\n- Add support for sending inline attachments via HTTP API ([#399](https://github.com/axllent/mailpit/issues/399))\n- Include Mailpit label (if set) in webhook HTTP header ([#400](https://github.com/axllent/mailpit/issues/400))\n\n### Chore\n- Update Go dependencies\n- Update node dependencies\n- Update caniemail database\n\n### Fix\n- Message view not updating when deleting messages from search ([#395](https://github.com/axllent/mailpit/issues/395))\n\n\n## [v1.21.5]\n\n### Chore\n- Make symlink detection more specific to contain \"sendmail\" in the name ([#391](https://github.com/axllent/mailpit/issues/391))\n- Update Go dependencies\n- Update node dependencies\n- Update caniemail database\n\n\n## [v1.21.4]\n\n### Bugfix\n- Fix external CSS stylesheet loading in HTML preview ([#388](https://github.com/axllent/mailpit/issues/388))\n\n\n## [v1.21.3]\n\n### Chore\n- Add swagger examples & API code restructure\n- Upgrade Alpine packages on Docker build\n- Update node dependencies\n- Mute Dart Sass deprecation notices\n- Minor UI tweaks\n- Update Go dependencies\n\n\n## [v1.21.2]\n\n### Feature\n- Add additional ignored flags to sendmail ([#384](https://github.com/axllent/mailpit/issues/384))\n\n### Chore\n- Update node dependencies\n- Update Go dependencies\n- Remove legacy Tags column from message DB table\n\n### Fix\n- Fix browser notification request on Edge ([#89](https://github.com/axllent/mailpit/issues/89))\n\n\n## [v1.21.1]\n\n### Feature\n- Add ability to search for messages containing inline images (`has:inline`)\n- Add ability to search by size smaller or larger than a value (eg: `larger:1M` / `smaller:2.5M`)\n\n### Chore\n- Separate attachments and inline images in download nav and badges ([#379](https://github.com/axllent/mailpit/issues/379))\n- Update Go dependencies\n\n\n## [v1.21.0]\n\n### Feature\n- Experimental Unix socket support for HTTPD & SMTPD ([#373](https://github.com/axllent/mailpit/issues/373))\n\n### Fix\n- Allow multiple item selection on macOS with Cmd-click  ([#378](https://github.com/axllent/mailpit/issues/378))\n\n\n## [v1.20.7]\n\n### Chore\n- Update caniemail database\n\n### Fix\n- SQL error deleting a tag while using tenant-id ([#374](https://github.com/axllent/mailpit/issues/374))\n\n### Test\n- Add tenantIDs to tests\n\n\n## [v1.20.6]\n\n### Chore\n- Update node dependencies\n- Update minimum Go version (1.22.0)\n- Update Go dependencies\n- Code cleanup\n- Update swagger file tests\n- Update node dependencies\n- Bump Go compile version to 1.23\n\n\n## [v1.20.5]\n\n### Chore\n- Improve link detection in the HTML preview\n- Improve tag detection in UI\n- Use consistent margins for Mailpit label if set\n- Update node dependencies\n\n### Fix\n- Use correct parameter order in SpamAssassin socket detection ([#364](https://github.com/axllent/mailpit/issues/364))\n\n\n## [v1.20.4]\n\n### Chore\n- Upgrade vue-css-donut-chart & related charts\n- Update node dependencies\n- Update Go dependencies\n\n### Fix\n- Relax URL detection in link check tool ([#357](https://github.com/axllent/mailpit/issues/357))\n\n\n## [v1.20.3]\n\n### Chore\n- Do not recenter selected messages in sidebar on every new message\n- Update Go dependencies\n- Update node dependencies\n- Update caniemail database\n\n### Fix\n- Disable automatic HTML/Text character detection when charset is provided ([#348](https://github.com/axllent/mailpit/issues/348))\n\n\n## [v1.20.2]\n\n### Feature\n- Web UI notifications of smtpd & POP3 errors ([#347](https://github.com/axllent/mailpit/issues/347))\n\n### Chore\n- Add smtpd server logging in the CLI ([#347](https://github.com/axllent/mailpit/issues/347))\n- Add debug database storage logging\n- Update node dependencies\n- Update Go dependencies\n\n\n## [v1.20.1]\n\n### Chore\n- Show icon attachment in new side navigation message listing ([#345](https://github.com/axllent/mailpit/issues/345))\n- Live load up to 100 new messages in sidebar ([#336](https://github.com/axllent/mailpit/issues/336))\n- Shift inbox pagination to inbox component\n\n### Fix\n- Correctly decode X-Tags message headers (RFC 2047) ([#344](https://github.com/axllent/mailpit/issues/344))\n\n\n## [v1.20.0]\n\n### Feature\n- List messages in side nav when viewing message for easy navigation ([#336](https://github.com/axllent/mailpit/issues/336))\n- Add option to control message retention by age ([#338](https://github.com/axllent/mailpit/issues/338))\n\n### Chore\n- Make internal tagging methods private\n- Update node dependencies\n- Update Go dependencies\n- Update caniemail database\n\n### Fix\n- Prevent Vue race condition to initialize dayjs relativeTime plugin\n- Return `text/plain` header for message delete request\n- Better regexp to detect tags in search\n- Prevent potential JavaScript errors caused by race condition\n\n\n## [v1.19.3]\n\n### Security\n- Prevent bypass of Contend Security Policy using stored XSS, and sanitize preview HTML data (DOMPurify)\n\n### Chore\n- Display nicer noscript message when JavaScript is disabled\n- Update Go dependencies\n\n\n## [v1.19.2]\n\n### Chore\n- Update Go dependencies\n\n### Fix\n- Update Inbox \"Delete All\" count when new messages are detected ([#334](https://github.com/axllent/mailpit/issues/334))\n\n\n## [v1.19.1]\n\n### Feature\n- Add optional relay recipient blocklist ([#333](https://github.com/axllent/mailpit/issues/333))\n\n### Chore\n- Bump docker/build-push-action from 5 to 6 ([#327](https://github.com/axllent/mailpit/issues/327))\n- Bump esbuild from 0.21.5 to 0.22.0 ([#326](https://github.com/axllent/mailpit/issues/326))\n- Bump esbuild to version 0.23.0\n- Equal column widths in About modal\n- Update Go dependencies\n\n\n## [v1.19.0]\n\n### Feature\n- Add option to disable auto-tagging for plus-addresses & X-Tags ([#323](https://github.com/axllent/mailpit/issues/323))\n- Add ability to rename and delete tags globally\n\n### Chore\n- Update Go dependencies\n- Update node dependencies\n\n\n## [v1.18.7]\n\n### Feature\n- Add optional label to identify Mailpit instance ([#316](https://github.com/axllent/mailpit/issues/316))\n\n### Chore\n- Handle websocket errors caused by persistent connection failures ([#319](https://github.com/axllent/mailpit/issues/319))\n- Refactor JavaScript, use arrow functions instead of \"self\" aliasing\n\n### Test\n- Add POP3 integration tests\n\n\n## [v1.18.6]\n\n### Chore\n- Handle POP3 RSET command\n- Delete multiple POP3 messages in single action\n- Update Go dependencies\n- Update node dependencies\n- Update caniemail database\n\n### Fix\n- POP3 size output to show compatible sizes ([#312](https://github.com/axllent/mailpit/issues/312))\n- POP3 end of file reached error ([#315](https://github.com/axllent/mailpit/issues/315))\n\n\n## [v1.18.5]\n\n### Feature\n- Add pagination & limits to URL parameters ([#303](https://github.com/axllent/mailpit/issues/303))\n\n### Chore\n- Update Go dependencies\n- Update node dependencies\n\n\n## [v1.18.4]\n\n### Chore\n- Clone new Docker images to ghcr.io ([#302](https://github.com/axllent/mailpit/issues/302))\n- Update Go dependencies\n- Update node dependencies\n\n\n## [v1.18.3]\n\n### Feature\n- ICalendar (ICS) viewer ([#298](https://github.com/axllent/mailpit/issues/298))\n\n### Chore\n- Update node dependencies\n- Update Go dependencies\n\n### Fix\n- Add dot stuffing for POP3 ([#300](https://github.com/axllent/mailpit/issues/300))\n\n\n## [v1.18.2]\n\n### Chore\n- Update node dependencies\n\n### Fix\n- Replace invalid Windows username characters in sendmail ([#294](https://github.com/axllent/mailpit/issues/294))\n\n\n## [v1.18.1]\n\n### Feature\n- Return queued Message ID in SMTP response ([#293](https://github.com/axllent/mailpit/issues/293))\n\n### Chore\n- Simplify JSON HTTP responses\n- Update Go dependencies\n- Update node dependencies\n\n\n## [v1.18.0]\n\n### Feature\n- New search filter prefix `addressed:` includes From, To, Cc, Bcc & Reply-To\n- Search filter support for auto-tagging\n- Set tagging filters via a config file\n- API endpoint for sending ([#278](https://github.com/axllent/mailpit/issues/278))\n\n### Chore\n- Auto-update relative received message times\n- Replace moment JS library with dayjs\n- Improve tag sorting in web UI, ignore casing\n- Remove function duplication - use common tools.InArray()\n- JSON key case-consistency for posted API data (backwards-compatible)\n- Update go-release-action\n- Update Go dependencies\n- Update node dependencies\n\n\n## [v1.17.1]\n\n### Chore\n- Clearer error messages for read/write permission failures ([#281](https://github.com/axllent/mailpit/issues/281))\n- Update Go dependencies\n- Update node dependencies\n\n### Fix\n- Prevent error when two identical tags are added at the exact same time ([#283](https://github.com/axllent/mailpit/issues/283))\n\n\n## [v1.17.0]\n\n### Feature\n- Add UI settings screen\n- Option to auto relay for matching recipient expression only ([#274](https://github.com/axllent/mailpit/issues/274))\n\n### Chore\n- Remove deprecated --disable-html-check option\n- Move Link check & HTML check features out of beta\n- Update API documentation regarding date/time searches & timezones\n- Replace disintegration/imaging with kovidgoyal/imaging to fix CVE-2023-36308\n- Auto-rotate thumbnail images based on exif data\n- Update Go dependencies\n- Update node dependencies\n- Update caniemail database\n\n### Fix\n- Add delay to close database on fatal exit ([#280](https://github.com/axllent/mailpit/issues/280))\n\n\n## [v1.16.0]\n\n### Feature\n- Option to use rqlite database storage ([#254](https://github.com/axllent/mailpit/issues/254))\n- Add optional tenant ID to isolate data in shared databases ([#254](https://github.com/axllent/mailpit/issues/254))\n- Search support for before: and after: dates ([#252](https://github.com/axllent/mailpit/issues/252))\n\n### Chore\n- Switch database flag/env to `--database` / `MP_DATABASE`\n- Update Go dependencies\n- Update node dependencies\n- Update caniemail test database\n\n### Fix\n- Extract plus addresses from email addresses only, not names\n- Prevent conditional JS error when global mailbox tag list is modified via auto/plus-address tagging while viewing a message\n- Remove duplicated authentication check ([#276](https://github.com/axllent/mailpit/issues/276))\n\n\n## [v1.15.1]\n\n### Feature\n- Add readyz subcommand for Docker healthcheck ([#270](https://github.com/axllent/mailpit/issues/270))\n\n### Chore\n- Add labels to Docker image ([#267](https://github.com/axllent/mailpit/issues/267))\n- Code cleanup, remove redundant functionality\n\n\n## [v1.15.0]\n\n### Feature\n- Add SMTP TLS option ([#265](https://github.com/axllent/mailpit/issues/265))\n\n### Chore\n- Update Go dependencies\n- Update node dependencies\n\n### Fix\n- Enforce SMTP STARTTLS by default if authentication is set\n\n\n## [v1.14.4]\n\n### Feature\n- Allow setting SMTP relay configuration values via environment variables ([#262](https://github.com/axllent/mailpit/issues/262))\n\n### Chore\n- Reorder CLI flags to group by related functionality\n- Update caniemail test data\n\n\n## [v1.14.3]\n\n### Chore\n- Update Go dependencies\n- Update node dependencies\n\n### Fix\n- Prevent crash when calculating deleted space percentage (divide by zero)\n\n\n## [v1.14.2]\n\n### Chore\n- Allow setting of multiple message tags via plus addresses ([#253](https://github.com/axllent/mailpit/issues/253))\n\n### Fix\n- Prevent runtime error when calculating total messages size of empty table ([#263](https://github.com/axllent/mailpit/issues/263))\n\n\n## [v1.14.1]\n\n### Feature\n- Set message tags using plus addressing ([#253](https://github.com/axllent/mailpit/issues/253))\n- Option to enforce TitleCasing for all newly created tags\n\n### Chore\n- Update Go dependencies\n- Update node dependencies\n- Tag names now allow `.` and must be a minimum of 1 character\n\n### Fix\n- Handle null values in Mailpit settings, set DeletedSize=0 if null\n\n\n## [v1.14.0]\n\n### Feature\n- Optional POP3 server ([#249](https://github.com/axllent/mailpit/issues/249))\n\n### Chore\n- Better handling of automatic database compression (vacuuming) after deleting messages\n- Switch to short uuid format for database IDs\n- Security improvements (gosec)\n- Refactor storage library\n- Update Go dependencies\n- Update node dependencies\n\n### Documentation\n- Add edge Docker images for latest unreleased features\n\n\n## [v1.13.3]\n\n### Feature\n- Add reply-to:<search> search filter ([#247](https://github.com/axllent/mailpit/issues/247))\n\n### Chore\n- Update \"About\" modal layout when new version is available\n- Compress database only when >= 1% of total message size has been deleted\n- Update Go dependencies\n- Update node dependencies\n\n### API\n- Include Reply-To information in message summaries for message list & websocket events\n\n\n## [v1.13.2]\n\n### Feature\n- Add option to log output to file ([#246](https://github.com/axllent/mailpit/issues/246))\n\n### Chore\n- Update esbuild\n- Bump actions build requirement versions\n- Update Go dependencies\n- Update node dependencies\n- Update caniemail data\n\n\n## [v1.13.1]\n\n### Feature\n- Add TLSRequired option for smtpd ([#241](https://github.com/axllent/mailpit/issues/241))\n\n### Chore\n- Only show number of messages ignored statistics if `--ignore-duplicate-ids` is set\n- Update Go dependencies\n- Update node dependencies\n\n### Fix\n- Workaround for specific field searches containing unicode characters ([#239](https://github.com/axllent/mailpit/issues/239))\n\n\n## [v1.13.0]\n\n### Feature\n- Add optional SpamAssassin integration to display scores ([#233](https://github.com/axllent/mailpit/issues/233))\n- Display List-Unsubscribe & List-Unsubscribe-Post header info with syntax validation ([#236](https://github.com/axllent/mailpit/issues/236))\n- Add option to disable SMTP reverse DNS (rDNS) lookup ([#230](https://github.com/axllent/mailpit/issues/230))\n\n### Chore\n- Update node dependencies\n- Update Go dependencies\n- Compress compiled assets with `npm run build`\n\n### Fix\n- Sendmail support for `-f 'Name <email@example.com>'` format\n- Display multiple whitespace characters in message subject & recipient names ([#238](https://github.com/axllent/mailpit/issues/238))\n\n\n## [v1.12.1]\n\n### Feature\n- Add option to only allow SMTP recipients matching a regular expression (disable open-relay behaviour [#219](https://github.com/axllent/mailpit/issues/219))\n\n### Chore\n- Standardize error logging & formatting\n- Update node dependencies\n- Automatically refresh connected browsers if Mailpit is upgraded (version change)\n- Significantly increase database performance using WAL (Write-Ahead-Log)\n\n### Fix\n- Log total deleted messages when deleting all messages from search\n- Prevent rare error from websocket connection (unexpected non-whitespace character)\n- Log total deleted messages when auto-pruning messages (--max)\n\n### Test\n- Run tests on Linux, Windows & Mac\n\n\n## [v1.12.0]\n\n### Chore\n- Refresh search results when search resubmitted or active tag filter clicked\n- Standardize error logging & formatting\n- Convert to many-to-many message tag relationships\n- Update Go dependencies\n- Update node dependencies\n- Update caniemail test data\n- Use memory pointer for internal message parsing & storage\n- Include runtime statistics in API (info) & UI (About)\n\n\n## [v1.11.1]\n\n### Chore\n- Allow multiple tags  to be searched using Ctrl-click ([#216](https://github.com/axllent/mailpit/issues/216))\n- Update Go dependencies\n- Update node dependencies\n\n### Fix\n- Fix regression to support for search query params to all `/latest` endpoints ([#206](https://github.com/axllent/mailpit/issues/206))\n\n### Test\n- Add new `ingest` subcommand to import an email file or maildir folder over SMTP\n\n\n## [v1.11.0]\n\n### Feature\n- Add configuration option to set maximum SMTP recipients ([#205](https://github.com/axllent/mailpit/issues/205))\n\n### Chore\n- Update Go dependencies\n- Update node dependencies\n\n### API\n- Allow ID \"latest\" for message summary, headers, raw version & HTML/link checks\n\n\n## [v1.10.4]\n\n### Fix\n- Remove JS debug information for favicon\n\n\n## [v1.10.3]\n\n### Feature\n- Add @ as valid character for webroot ([#215](https://github.com/axllent/mailpit/issues/215))\n\n### Chore\n- Update caniemail library & add `hr` element test\n- Update Go dependencies\n- Update node dependencies\n\n### Fix\n- New favicon notification badge to fix rendering issues ([#210](https://github.com/axllent/mailpit/issues/210))\n\n\n## [v1.10.2]\n\n### Feature\n- Allow port binding using hostname\n\n### Chore\n- Clearer log messages for bound SMTP & HTTP addresses\n- Add favicon fallback font (sans-serif) for unread count\n- Update Go dependencies\n- Update node dependencies\n- Enable tag colors by default\n\n\n## [v1.10.1]\n\n### Chore\n- Use NextReader() instead of ReadMessage() for websocket reading ([#207](https://github.com/axllent/mailpit/issues/207))\n- Update Go dependencies\n- Update node dependencies\n\n### Fix\n- Prevent JavaScript error if message is missing `From` header ([#209](https://github.com/axllent/mailpit/issues/209))\n\n### Documentation\n- Revert BinaryResponse type to string\n\n\n## [v1.10.0]\n\n### Feature\n- Add URL redirect (`/view/latest`) to view latest message in web UI ([#166](https://github.com/axllent/mailpit/issues/166))\n- Option to allow untrusted HTTPS certificates for screenshots & link checking ([#204](https://github.com/axllent/mailpit/issues/204))\n- Support search query params to /latest endpoints ([#206](https://github.com/axllent/mailpit/issues/206))\n\n### Chore\n- Update Go dependencies\n- Update node dependencies\n\n### Fix\n- Correctly close websockets on client disconnect ([#207](https://github.com/axllent/mailpit/issues/207))\n\n\n## [v1.9.10]\n\n### Chore\n- Fix column width in search view\n- Update caniemail test data\n- Update Go dependencies\n- Update node dependencies\n\n### Fix\n- Correctly display \"About\" modal when update check fails (resolves [#199](https://github.com/axllent/mailpit/issues/199))\n\n### Documentation\n- Update documentation links\n\n\n## [v1.9.9]\n\n### Feature\n- Reset message date on release ([#194](https://github.com/axllent/mailpit/issues/194))\n- Set optional webhook for received messages ([#195](https://github.com/axllent/mailpit/issues/195))\n\n### Chore\n- Move html2text module to internal/html2text\n- Update Go dependencies\n- Update node dependencies\n\n\n## [v1.9.8]\n\n### Chore\n- Replace html2text modules with simplified internal function\n- Replace satori/go.uuid with github.com/google/uuid ([#190](https://github.com/axllent/mailpit/issues/190))\n- Update Go dependencies\n- Update node dependencies\n\n### Documentation\n- Update swagger documentation\n\n### Test\n- Add html2text tests\n- Add test to validate swagger.json\n\n\n## [v1.9.7]\n\n### Chore\n- Update Go dependencies & minimum Go version (1.21)\n- Downgrade microcosm-cc/bluemonday, revert to Go 1.20\n- Update node dependencies\n\n### Fix\n- Enable delete button when new messages arrive\n\n\n## [v1.9.6]\n\n### Chore\n- Display message previews on separate line ([#175](https://github.com/axllent/mailpit/issues/175))\n- Update Go dependencies\n- Update node dependencies\n\n\n## [v1.9.5]\n\n### Feature\n- Display email previews ([#175](https://github.com/axllent/mailpit/issues/175))\n- Add `reindex` subcommand to reindex all messages\n\n### Fix\n- Correctly detect tags in search (UI)\n- HTML message preview background color when switching themes in Chrome\n\n### Test\n- Add snippet tests\n- Add message summary tests\n\n\n## [v1.9.4]\n\n### Feature\n- Set auth credentials directly from environment variables\n\n### Chore\n- Add option to delete a message after release\n- Remove some flags deprecated 08/2022\n- Update Go dependencies\n- Update node dependencies\n\n\n## [v1.9.3]\n\n### Chore\n- Only queue broadcast events if clients are connected\n- Move utils/* packages to internal/*\n- Update internal import paths\n- Move storage package to internal/storage\n- Update internal/storage import paths\n- Display \"Loading messages\" instead of \"No results\" while loading results\n- Do not show excluded search tags as \"current\" in nav\n\n### Test\n- Add tests for ArgsParser & CleanTag\n- Add more API tests\n- Add endpoints for integration tests\n\n\n## [v1.9.2]\n\n### Chore\n- Reset pagination when returning to inbox from search\n- Update node dependencies\n\n### Fix\n- Delete all messages matching search when more than 1000 results\n\n### Test\n- Add search delete tests\n- Add message tag tests\n\n\n## [v1.9.1]\n\n### Chore\n- Better support for mobile screen sizes\n- Link email addresses in message summary to search\n- Update Go dependencies\n- Update caniemail data\n- Set 404 page when loading a non-existent message\n\n\n## [v1.9.0]\n\n### Feature\n- New search filter `[!]is:tagged`\n- Improved search parser\n\n### Chore\n- Update node dependencies\n- Rewrite web UI, add URL routing and components\n- Update Go dependencies\n- Update minimum Go version to 1.20\n\n### API\n- Add endpoint to return all tags in use\n- Delete by search filter\n- Remove redundant `Read` status from message (always true)\n\n### Fix\n- Correctly escape certain characters in search (eg: `'`)\n\n### Test\n- Bump Go version to 1.21\n\n\n## [v1.8.4]\n\n### Fix\n- Correctly decode proxy links containing HTML entities (screenshots)\n\n\n## [v1.8.3]\n\n### Feature\n- HTML screenshots\n\n### Chore\n- Group message tabs on mobile\n- Update node dependencies\n\n\n## [v1.8.2]\n\n### Feature\n- Workaround for non-RFC-compliant message headers containing <CR><CR><LF>\n- Link check to test message links\n\n### Chore\n- Set hostname in page meta title to identify Mailpit instance\n- Update Go libs\n\n### Build\n- Update wangyoucao577/go-release-action@v1.39\n\n\n## [v1.8.1]\n\n### Chore\n- Update Go dependencies\n- Update node dependencies\n\n### Fix\n- Exclude <script type=\"application/json\"> from HTML check tests\n- Exclude \"sendmail\" from recipients list when using `mailpit sendmail <options>`\n- Check/set message Reply-To using SMTP FROM\n\n### Documentation\n- Add pagination to swagger search documentation\n\n\n## [v1.8.0]\n\n### Feature\n- HTML check to test & score mail client compatibility with HTML emails\n\n### Chore\n- Pagination support for search, all results\n- Remove `<base />` tag if set in HTML preview\n- Add flag to block all access to remote CSS and fonts (CSP)\n- Update Go dependencies\n- Update node dependencies\n\n### Fix\n- Add basePath to swagger.json if webroot is specified\n\n### Documentation\n- Update swagger docs\n- Update brew installation instructions\n\n\n## [v1.7.1]\n\n### Chore\n- Update node dependencies\n- Update dark mode loading background color\n- Dark mode color adjustments\n- Wrap HTML source lines\n- Update Go dependencies\n\n\n## [v1.7.0]\n\n### Chore\n- Theme toggler - auto, light and dark themes\n- Update Go dependencies\n- Update node dependencies\n\n### API\n- Set raw message Content-Type to UTF-8\n- Ignore SMTP relay error when one of multiple recipients doesn't exist\n\n### Build\n- Define Vue build options in esbuild\n\n\n## [v1.6.22]\n\n### Feature\n- Clearer SMTP error messages\n\n### Chore\n- Upgrade node dependencies\n- Update Go dependencies\n\n\n## [v1.6.21]\n\n### Chore\n- More accurate clickable hyperlink logic in plain text messages\n\n\n## [v1.6.20]\n\n### Feature\n- Convert links into clickable hyperlinks in plain text message content\n\n### Chore\n- Update node dependencies\n\n\n## [v1.6.19]\n\n### Fix\n- Only display sendmail help when sendmail subcommand is invoked\n\n\n## [v1.6.18]\n\n### Chore\n- Display message tags below subject in message overview\n- Add option to enable tag colors based on tag name hash\n\n### API\n- Sort tags before saving\n\n\n## [v1.6.17]\n\n### Fix\n- Add single dash arguments support to sendmail command ([#123](https://github.com/axllent/mailpit/issues/123))\n\n\n## [v1.6.16]\n\n### Bugfix\n- Fix sendmail/startup panic\n\n\n## [v1.6.15]\n\n### Feature\n- Add `sendmail -bs` functionality\n\n\n## [v1.6.14]\n\n### Feature\n- Set tags via X-Tags message header\n- Add ability to delete or mark search results read\n\n### Chore\n- Update node dependencies\n\n\n## [v1.6.13]\n\n### Feature\n- Add SMTP LOGIN authentication method for message relay\n\n\n## [v1.6.12]\n\n### Feature\n- Add Message-Id to MessageSummary ([#116](https://github.com/axllent/mailpit/issues/116))\n\n### Documentation\n- Update swagger field descriptions, add MessageID\n\n\n## [v1.6.11]\n\n### Chore\n- Check for secure context instead of HTTPS ([#114](https://github.com/axllent/mailpit/issues/114))\n- Update Go dependencies\n- Update node dependencies\n\n\n## [v1.6.10]\n\n### Chore\n- Remove \"Noto Color Emoji\" from default bootstrap font list\n- Update Go dependencies\n- Update node dependencies\n\n\n## [v1.6.9]\n\n### Chore\n- Update Go dependencies\n- Update node dependencies\n\n### API\n- Return blank 200 response for OPTIONS requests (CORS)\n\n### Bugfix\n- Correctly escape JS cid regex\n\n\n## [v1.6.8]\n\n### Feature\n- Add `-S` short flag for sendmail `--smtp-addr`\n- Add allowlist to filter recipients before relaying messages ([#109](https://github.com/axllent/mailpit/issues/109))\n\n### Bugfix\n- Fix Date display when message doesn't contain a Date header\n\n\n## [v1.6.7]\n\n### Bugfix\n- Fix auto-deletion cron\n\n\n## [v1.6.6]\n\n### Feature\n- Option to ignore duplicate Message-IDs\n\n### Chore\n- Style Undisclosed recipients in message view\n- Update Go dependencies\n- Update node dependencies\n\n### API\n- Include correct start value in search response\n- Set Access-Control-Allow-Headers when --api-cors is set\n\n### Documentation\n- Update swagger field descriptions\n\n\n## [v1.6.5]\n\n### Feature\n- Add Access-Control-Allow-Methods methods when CORS origin is set\n\n\n## [v1.6.4]\n\n### Bugfix\n- Fix UI images not displaying when multiple cid names overlap\n\n\n## [v1.6.3]\n\n### Feature\n- Display clickable toast notifications for new messages\n\n\n## [v1.6.2]\n\n### Bugfix\n- If set use return-path address as SMTP from address\n\n\n## [v1.6.1]\n\n### Bugfix\n- Add API release route again (bad merge)\n\n\n## [v1.6.0]\n\n### Feature\n- Inject/update Bcc header for missing addresses when SMTP recipients do not match message headers\n\n### Chore\n- Update node dependencies\n- Update Go dependencies\n- Message release functionality\n- Display Return-Path if different to the From address\n\n### API\n- Include Return-Path in message summary data\n- Message relay / release\n- Enable cross-origin resource sharing (CORS) configuration\n\n\n## [v1.5.5]\n\n### Feature\n- Update listen regex to allow IPv6 addresses ([#85](https://github.com/axllent/mailpit/issues/85))\n\n### Documentation\n- Add Docker image tag for major/minor version\n\n\n## [v1.5.4]\n\n### Feature\n- Mobile and tablet HTML preview toggle in desktop mode\n\n\n## [v1.5.3]\n\n### Bugfix\n- Enable SMTP auth flags to be set via env\n\n\n## [v1.5.2]\n\n### Chore\n- Tab to view formatted message headers\n\n### API\n- Include Reply-To in message summary (including Web UI)\n\n\n## [v1.5.1]\n\n### Feature\n- Add 'o', 'b' & 's'  ignored flags for sendmail\n\n### Chore\n- Update node dependencies\n- Update Go dependencies\n\n\n## [v1.5.0]\n\n### Feature\n- Option to use message dates as received dates (new messages only)\n- Options to support auth without STARTTLS, and accept any login\n- Rename SSL to TLS, add deprecation warnings to flags & ENV variables referring to SSL\n- Download raw message, HTML/text body parts or attachments via single button\n- OpenAPI / Swagger schema\n\n### API\n- Return received datetime when message does not contain a date header\n\n### Bugfix\n- Fix JavaScript error when adding the first tag manually\n\n\n## [v1.4.0]\n\n### Feature\n- Option to use message dates as received dates (new messages only)\n- Options to support auth without STARTTLS, and accept any login\n- Rename SSL to TLS, add deprecation warnings to flags & ENV variables referring to SSL\n\n### API\n- Return received datetime when message does not contain a date header\n\n\n## [v1.3.11]\n\n### Feature\n- Expand custom webroot path to include a-z A-Z 0-9 _ . - and /\n\n### Documentation\n- Expose default ports (1025/tcp 8025/tcp)\n\n\n## [v1.3.10]\n\n### Chore\n- Update node dependencies\n\n### Bugfix\n- Fix search with existing emails\n\n\n## [v1.3.9]\n\n### Feature\n- Add Cc and Bcc search filters\n\n### Chore\n- Update Go dependencies\n- Update node dependencies\n\n\n## [v1.3.8]\n\n### Chore\n- Compress SVG icons\n\n### Bugfix\n- Restore notification icon\n\n\n## [v1.3.7]\n\n### Feature\n- Add Kubernetes API health (livez/readyz) endpoints\n\n### Chore\n- Upgrade to esbuild 0.17.5\n\n\n## [v1.3.6]\n\n### Chore\n- Update Go dependencies\n- Update node dependencies\n\n### Bugfix\n- Correctly index missing 'From' header in database\n\n\n## [v1.3.5]\n\n### Bugfix\n- Include HTML link text in search data\n\n\n## [v1.3.4]\n\n### Bugfix\n- Allow tags to be set from MP_TAG environment\n\n\n## [v1.3.3]\n\n### Bugfix\n- Allow tags to be set from MP_TAG environment\n\n\n## [v1.3.2]\n\n### Build\n- Temporarily disable arm (32) Docker build\n\n\n## [v1.3.1]\n\n### Chore\n- Rename \"results\" to \"result\" when singular message returned\n- Upgrade esbuild & axios\n\n### Bugfix\n- Append trailing slash to custom webroot for UI & API\n\n\n## [v1.3.0]\n\n### Chore\n- Update node dependencies\n- Update Go dependencies\n\n### Build\n- Remove duplicate bootstrap CSS\n\n\n## [v1.2.9]\n\n### Bugfix\n- Delay 200ms to set `target=\"_blank\"` for all rendered email links\n\n\n## [v1.2.8]\n\n### Feature\n- Message tags and auto-tagging\n\n### Bugfix\n- Return empty arrays rather than null for message To, CC, BCC, Inlines & Attachments\n\n\n## [v1.2.7]\n\n### Feature\n- Allow custom webroot\n\n\n## [v1.2.6]\n\n### Chore\n- Update node dependencies\n- Update Go dependencies\n\n### API\n- Provide structs of API v1 responses for use in client code\n\n\n## [1.2.5]\n\n### Chore\n- Bump build action to use node 18\n- Theme changes\n- Load first page if paginated list returns 0 results\n- Broadcast \"delete all\" action to reload all connected clients\n\n\n## [1.2.4]\n\n### Bugfix\n- Fix mail download link\n\n\n## [1.2.3]\n\n### Chore\n- Prevent double message index request on websocket connect\n\n### API\n- Add limit and start parameters to search\n\n\n## [1.2.2]\n\n### Chore\n- Update Go dependencies\n\n### API\n- Add API endpoint to return message headers\n\n### Test\n- Add API test for raw & message headers\n\n\n## [1.2.1]\n\n### Chore\n- Add about app modal with version update notification\n- Update frontend modules\n\n\n## [1.2.0]\n\n### Feature\n- Add REST API\n\n### Chore\n- Hide delete all / mark all read in message view\n- Changes to use new data API\n\n### Test\n- Add API tests\n\n\n## [1.1.7]\n\n### Chore\n- Add documentation link (wiki)\n\n### Fix\n- Workaround for Safari source matching bug blocking event listener\n- Normalize running binary name detection (Windows)\n\n\n## [1.1.5]\n\n### Chore\n- Support for inline images using filenames instead of cid\n\n### Build\n- Switch to esbuild-sass-plugin\n\n\n## [1.1.4]\n\n### Security\n- Add restrictive HTTP Content-Security-Policy\n\n### Feature\n- Add --quiet flag to display only errors\n\n### Chore\n- Remove left & right borders (message list)\n- Add favicon unread message counter\n- Minor UI color change & unread count position adjustment\n\n\n## [1.1.3]\n\n### Fix\n- Update message download link\n\n\n## [1.1.2]\n\n### Chore\n- Allow reverse proxy subdirectories\n\n\n## [1.1.1]\n\n### Chore\n- Attachment icons and image thumbnails\n\n\n## [1.1.0]\n\n### Chore\n- Add previous/next message links\n- HTML source & highlighting\n\n\n## [1.0.0]\n\n### Feature\n- Search parser improvements\n- Search parser improvements\n- Multiple message selection for group actions using shift/ctrl click\n\n### Chore\n- Update frontend modules & esbuild\n- Update frontend modules & esbuild\n- Display unknown recipients as as `Undisclosed recipients`\n- Post data using 'application/json'\n\n\n## [1.0.0-beta1]\n\n### Feature\n- Switch backend storage to use SQLite\n\n### Chore\n- Resize preview iframe on load\n\n\n## [0.1.5]\n\n### Feature\n- Improved message search - any order & phrase quoting\n\n### Chore\n- Resize iframes with viewport resize\n- Change breakpoints for mobile view of messages\n\n\n## [0.1.4]\n\n### Feature\n- Email compression in storage\n\n### Chore\n- Mobile compatibility improvements & functionality\n\n### Test\n- Database total/unread statistics tests\n- Enable testing on feature branches\n\n\n## [0.1.3]\n\n### Feature\n- Mark all messages as read\n\n### Chore\n- Update pagination values when new mail arrives when not on first page\n- Minor UI tweaks\n- Add reset search button\n- Better error handling when connection to server is broken\n\n\n## [0.1.2]\n\n### Security\n- Use strconv.Atoi() for safe string to int conversions\n- Sanitize mailbox names\n- Don't allow tar files containing a \"..\"\n\n### Feature\n- Optional browser notifications (HTTPS only)\n\n\n## [0.1.1]\n\n### Bugfix\n- Fix env variable for MP_UI_SSL_KEY\n\n\n## [0.1.0]\n\n### Feature\n- SMTP STARTTLS & SMTP authentication support\n\n\n## [0.0.9]\n\n### Feature\n- HTTPS option for web UI\n\n### Test\n- Memory & physical database tests\n\n### Bugfix\n- Include read status in search results\n\n\n## [0.0.8]\n\n### Chore\n- Add project links to help in CLI\n\n### Bugfix\n- Fix total/unread count after failed message inserts\n\n\n## [0.0.7]\n\n### Feature\n- : Add multi-arch docker image\n\n### Bugfix\n- Command flag should be `--auth-file`\n\n\n## [0.0.6]\n\n### Bugfix\n- Disable CGO when building multi-arch binaries\n\n\n## [0.0.5]\n\n### Feature\n- Basic authentication support\n\n\n## [0.0.4]\n\n### Chore\n- Cater for messages without From email address\n- Add space in To fields\n- Minor UI & logging changes\n- Cater for messages without From email address\n- Add space in To fields\n- Add date to console log\n\n### Test\n- Add search tests\n\n### Bugfix\n- Update to clover-v2.0.0-alpha.2 to fix sorting\n\n\n## [0.0.3]\n\n### Bugfix\n- Update to clover-v2.0.0-alpha.2 to fix sorting\n\n\n## [0.0.2]\n\n### Feature\n- Unread statistics\n\n\n## [0.0.1-beta]\n\n\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to Mailpit\n\nThank you for your interest in contributing to Mailpit! \n\n## Reporting issues and feature requests\n\nIf you find a bug or have a feature request, please [open an issue](https://github.com/axllent/mailpit/issues) and provide as much detail as possible. Please **do not** report security issues here (see below).\n\n\n## Reporting security issues\n\nPlease do not report security issues publicly in GitHub. Refer to [SECURITY document](https://github.com/axllent/mailpit/blob/develop/.github/SECURITY.md) for instructions and contact information.\n\n\n## Contributing code\n\nPlease ensure your code is clean and well-commented, and [passes linting](https://mailpit.axllent.org/docs/development/code-linting/) before submitting a Pull Request. Contributions should enhance the functionality or usability of Mailpit, focusing on quality over quantity.\n\nNote that while assistance from AI tools is perfectly acceptable, **\"[vibe coded](https://en.wikipedia.org/wiki/Vibe_coding)\" pull requests will most likely not be accepted.**\nWe value the unique insights and creativity that individual contributors bring to the project.\n\nThank you for your understanding and for contributing to Mailpit!\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM golang:alpine AS builder\n\nARG VERSION=dev\n\nCOPY . /app\n\nWORKDIR /app\n\nRUN  apk upgrade && apk add git npm && \\\nnpm install && npm run package && \\\nCGO_ENABLED=0 go build -ldflags \"-s -w -X github.com/axllent/mailpit/config.Version=${VERSION}\" -o /mailpit\n\nFROM alpine:latest\n\nLABEL org.opencontainers.image.title=\"Mailpit\" \\\n  org.opencontainers.image.description=\"An email and SMTP testing tool with API for developers\" \\\n  org.opencontainers.image.source=\"https://github.com/axllent/mailpit\" \\\n  org.opencontainers.image.url=\"https://mailpit.axllent.org\" \\\n  org.opencontainers.image.documentation=\"https://mailpit.axllent.org/docs/\" \\\n  org.opencontainers.image.licenses=\"MIT\"\n\nCOPY --from=builder /mailpit /mailpit\n\nRUN apk upgrade --no-cache && apk add --no-cache tzdata\n\nEXPOSE 1025/tcp 1110/tcp 8025/tcp\n\nHEALTHCHECK --interval=15s --start-period=10s --start-interval=1s CMD [\"/mailpit\", \"readyz\"]\n\nENTRYPOINT [\"/mailpit\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "The MIT License (MIT)\nCopyright (c) 2022-Now() Ralph Slooten\n\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n\"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE\nLIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION\nWITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "<h1 align=\"center\">\n  Mailpit - email testing for developers\n</h1>\n\n<div align=\"center\">\n    <a href=\"https://github.com/axllent/mailpit/actions/workflows/tests.yml\"><img src=\"https://github.com/axllent/mailpit/actions/workflows/tests.yml/badge.svg\" alt=\"CI Tests status\"></a>\n    <a href=\"https://github.com/axllent/mailpit/actions/workflows/release-build.yml\"><img src=\"https://github.com/axllent/mailpit/actions/workflows/release-build.yml/badge.svg\" alt=\"CI build status\"></a>\n    <a href=\"https://github.com/axllent/mailpit/actions/workflows/build-docker.yml\"><img src=\"https://github.com/axllent/mailpit/actions/workflows/build-docker.yml/badge.svg\" alt=\"CI Docker build status\"></a>\n    <a href=\"https://github.com/axllent/mailpit/actions/workflows/codeql-analysis.yml\"><img src=\"https://github.com/axllent/mailpit/actions/workflows/codeql-analysis.yml/badge.svg\" alt=\"Code quality\"></a>\n    <a href=\"https://goreportcard.com/report/github.com/axllent/mailpit\"><img src=\"https://goreportcard.com/badge/github.com/axllent/mailpit\" alt=\"Go Report Card\"></a>\n    <br>\n    <a href=\"https://github.com/axllent/mailpit/releases/latest\"><img src=\"https://img.shields.io/github/v/release/axllent/mailpit.svg\" alt=\"Latest release\"></a>\n    <a href=\"https://hub.docker.com/r/axllent/mailpit\"><img src=\"https://img.shields.io/docker/pulls/axllent/mailpit.svg\" alt=\"Docker pulls\"></a>\n</div>\n<br>\n<p align=\"center\">\n  <a href=\"https://mailpit.axllent.org\">Website</a>  •\n  <a href=\"https://mailpit.axllent.org/docs/\">Documentation</a>  •\n  <a href=\"https://mailpit.axllent.org/docs/api-v1/\">API</a>\n</p>\n\n<hr>\n\n**Mailpit** is a small, fast, low memory, zero-dependency, multi-platform email testing tool & API for developers.\n\nIt acts as an SMTP server, provides a modern web interface to view & test captured emails, and includes an API for automated integration testing.\n\nMailpit was originally **inspired** by MailHog which is [no longer maintained](https://github.com/mailhog/MailHog/issues/442#issuecomment-1493415258) and hasn't seen active development or security updates for a few years now.\n\n![Mailpit](https://raw.githubusercontent.com/axllent/mailpit/develop/server/ui-src/screenshot.png)\n\n\n## Features\n\n- Runs entirely from a single [static binary](https://mailpit.axllent.org/docs/install/) or multi-architecture [Docker images](https://mailpit.axllent.org/docs/install/docker/)\n- Modern web UI with advanced [mail search](https://mailpit.axllent.org/docs/usage/search-filters/) to view emails (formatted HTML, highlighted HTML source, text, headers, raw source, and MIME attachments\nincluding image thumbnails), including optional [HTTPS](https://mailpit.axllent.org/docs/configuration/http/) & [authentication](https://mailpit.axllent.org/docs/configuration/http/)\n- [SMTP server](https://mailpit.axllent.org/docs/configuration/smtp/) with optional STARTTLS or SSL/TLS, authentication (including an \"accept any\" mode)\n- A [REST API](https://mailpit.axllent.org/docs/api-v1/) for integration testing\n- Real-time web UI updates using web sockets for new mail & optional [browser notifications](https://mailpit.axllent.org/docs/usage/notifications/) when new mail is received\n- Optional [POP3 server](https://mailpit.axllent.org/docs/configuration/pop3/) to download captured message directly into your email client\n- [HTML check](https://mailpit.axllent.org/docs/usage/html-check/) to test & score mail client compatibility with HTML emails\n- [Link check](https://mailpit.axllent.org/docs/usage/link-check/) to test message links (HTML & text) & linked images\n- [Spam check](https://mailpit.axllent.org/docs/usage/spamassassin/) to test message \"spamminess\" using a running SpamAssassin server\n- [Create screenshots](https://mailpit.axllent.org/docs/usage/html-screenshots/) of HTML messages via web UI\n- Mobile and tablet HTML preview toggle in desktop mode\n- [Message tagging](https://mailpit.axllent.org/docs/usage/tagging/) including manual tagging or automated tagging using filtering and \"plus addressing\"\n- [SMTP relaying](https://mailpit.axllent.org/docs/configuration/smtp-relay/) (message release) - relay messages via a different SMTP server including an optional allowlist of accepted recipients\n- [SMTP forwarding](https://mailpit.axllent.org/docs/configuration/smtp-forward/) - automatically forward messages via a different SMTP server to predefined email addresses\n- Fast message [storing & processing](https://mailpit.axllent.org/docs/configuration/email-storage/) - ingesting 100-200 emails per second over SMTP depending on CPU, network speed & email size,\neasily handling tens of thousands of emails, with automatic email pruning (by default keeping the most recent 500 emails)\n- [Chaos](https://mailpit.axllent.org/docs/integration/chaos/) feature to enable configurable SMTP errors to test application resilience\n- `List-Unsubscribe` syntax validation\n- Optional [webhook](https://mailpit.axllent.org/docs/integration/webhook/) for received messages\n\n\n## Installation\n\nThe Mailpit web UI listens by default on `http://0.0.0.0:8025` and the SMTP port on `0.0.0.0:1025`.\n\nMailpit runs as a single binary and can be installed in different ways:\n\n\n### Install via package managers\n\n- **Mac**: `brew install mailpit` (to run automatically in the background: `brew services start mailpit`)\n- **Arch Linux**: available in the AUR as `mailpit`\n- **FreeBSD**: `pkg install mailpit`\n\n\n### Install via script (Linux & Mac)\n\nLinux & Mac users can install it directly to `/usr/local/bin/mailpit` with:\n\n```shell\nsudo sh < <(curl -sL https://raw.githubusercontent.com/axllent/mailpit/develop/install.sh)\n```\n\nYou can also change the install path to something else by setting the `INSTALL_PATH` environment, for example:\n\n```shell\nsudo INSTALL_PATH=/usr/bin sh < <(curl -sL https://raw.githubusercontent.com/axllent/mailpit/develop/install.sh)\n```\n\n\n### Download static binary (Windows, Linux and Mac)\n\nStatic binaries can always be found on the [releases](https://github.com/axllent/mailpit/releases/latest). The `mailpit` binary can be extracted and copied to your `$PATH`, or simply run as `./mailpit`.\n\n\n### Docker\n\nSee [Docker instructions](https://mailpit.axllent.org/docs/install/docker/) for 386, amd64 & arm64 images.\n\n\n### Compile from source\n\nTo build Mailpit from source, see [Building from source](https://mailpit.axllent.org/docs/install/source/).\n\n\n## Usage\n\nRun `mailpit -h` to see options. More information can be seen in [the docs](https://mailpit.axllent.org/docs/configuration/runtime-options/).\n\nIf installed using homebrew, you may run `brew services start mailpit` to always run mailpit automatically.\n\n\n### Testing Mailpit\n\nPlease refer to [the documentation](https://mailpit.axllent.org/docs/install/testing/) on how to easily test email delivery to Mailpit.\n\n\n### Configuring sendmail\n\nMailpit's SMTP server (default on port 1025), so you will likely need to configure your sending application to deliver mail via that port. \nA common MTA (Mail Transfer Agent) that delivers system emails to an SMTP server is `sendmail`, used by many applications, including PHP. \nMailpit can also act as substitute for sendmail. For instructions on how to set this up, please refer to the [sendmail documentation](https://mailpit.axllent.org/docs/install/sendmail/).\n\n---\n\n<p align=\"center\">\n  For team features, multiple inboxes, and a hosted setup, try\n  <a href=\"https://mailtrap.io/?ref=mailpit\">Mailtrap</a>, our friendly companion.\n</p>\n"
  },
  {
    "path": "cmd/dump.go",
    "content": "package cmd\n\nimport (\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/internal/dump\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/spf13/cobra\"\n)\n\n// dumpCmd represents the dump command\nvar dumpCmd = &cobra.Command{\n\tUse:   \"dump <database> <output-dir>\",\n\tShort: \"Dump all messages from a database to a directory\",\n\tLong: `Dump all messages stored in Mailpit into a local directory as individual files.\n\nThe database can either be the database file (eg: --database /var/lib/mailpit/mailpit.db) or a\nURL of a running Mailpit instance (eg: --http http://127.0.0.1/). If dumping over HTTP, the URL\nshould be the base URL of your running Mailpit instance, not the link to the API itself.`,\n\tArgs: cobra.ExactArgs(1),\n\tRun: func(_ *cobra.Command, args []string) {\n\t\tif err := dump.Sync(args[0]); err != nil {\n\t\t\tlogger.Log().Fatal(err)\n\t\t}\n\t},\n}\n\nfunc init() {\n\trootCmd.AddCommand(dumpCmd)\n\n\tdumpCmd.Flags().SortFlags = false\n\n\tdumpCmd.Flags().StringVar(&config.Database, \"database\", config.Database, \"Dump messages directly from a database file\")\n\tdumpCmd.Flags().StringVar(&config.TenantID, \"tenant-id\", config.TenantID, \"Database tenant ID to isolate data (optional)\")\n\tdumpCmd.Flags().StringVar(&dump.URL, \"http\", dump.URL, \"Dump messages via HTTP API (base URL of running Mailpit instance)\")\n\tdumpCmd.Flags().BoolVarP(&logger.VerboseLogging, \"verbose\", \"v\", logger.VerboseLogging, \"Verbose logging\")\n}\n"
  },
  {
    "path": "cmd/ingest.go",
    "content": "package cmd\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/mail\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/axllent/mailpit/internal/logger\"\n\tsendmail \"github.com/axllent/mailpit/sendmail/cmd\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar (\n\tingestRecent int\n)\n\n// ingestCmd represents the ingest command\nvar ingestCmd = &cobra.Command{\n\tUse:   \"ingest <file|folder> ...[file|folder]\",\n\tShort: \"Ingest a file or folder of emails for testing\",\n\tLong: `Ingest a file or folder of emails for testing.\n\nThis command will scan the folder for emails and deliver them via SMTP to a running \nMailpit server. Each email must be a separate file (eg: Maildir format, not mbox).\nThe --recent flag will only consider files with a modification date within the last X days.`,\n\t// Hidden: true,\n\tArgs: cobra.MinimumNArgs(1),\n\tRun: func(_ *cobra.Command, args []string) {\n\t\tvar count int\n\t\tvar total int\n\t\tvar per100start = time.Now()\n\n\t\tfor _, a := range args {\n\t\t\terr := filepath.Walk(a,\n\t\t\t\tfunc(path string, info os.FileInfo, err error) error {\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tlogger.Log().Error(err)\n\t\t\t\t\t\treturn nil\n\t\t\t\t\t}\n\t\t\t\t\tif !isFile(path) {\n\t\t\t\t\t\treturn nil\n\t\t\t\t\t}\n\n\t\t\t\t\tif ingestRecent > 0 && time.Since(info.ModTime()) > time.Duration(ingestRecent)*24*time.Hour {\n\t\t\t\t\t\treturn nil\n\t\t\t\t\t}\n\n\t\t\t\t\tf, err := os.Open(filepath.Clean(path))\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tlogger.Log().Errorf(\"%s: %s\", path, err.Error())\n\t\t\t\t\t\treturn nil\n\t\t\t\t\t}\n\t\t\t\t\tdefer func() { _ = f.Close() }()\n\n\t\t\t\t\tbody, err := io.ReadAll(f)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tlogger.Log().Errorf(\"%s: %s\", path, err.Error())\n\t\t\t\t\t\treturn nil\n\t\t\t\t\t}\n\n\t\t\t\t\tmsg, err := mail.ReadMessage(bytes.NewReader(body))\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tlogger.Log().Errorf(\"error parsing message body: %s\", err.Error())\n\t\t\t\t\t\treturn nil\n\t\t\t\t\t}\n\n\t\t\t\t\trecipients := []string{}\n\t\t\t\t\t// get all recipients in To, Cc and Bcc\n\t\t\t\t\tif to, err := msg.Header.AddressList(\"To\"); err == nil {\n\t\t\t\t\t\tfor _, a := range to {\n\t\t\t\t\t\t\trecipients = append(recipients, a.Address)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif cc, err := msg.Header.AddressList(\"Cc\"); err == nil {\n\t\t\t\t\t\tfor _, a := range cc {\n\t\t\t\t\t\t\trecipients = append(recipients, a.Address)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif bcc, err := msg.Header.AddressList(\"Bcc\"); err == nil {\n\t\t\t\t\t\tfor _, a := range bcc {\n\t\t\t\t\t\t\trecipients = append(recipients, a.Address)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tif sendmail.FromAddr == \"\" {\n\t\t\t\t\t\tif fromAddresses, err := msg.Header.AddressList(\"From\"); err == nil {\n\t\t\t\t\t\t\tsendmail.FromAddr = fromAddresses[0].Address\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tif len(recipients) == 0 {\n\t\t\t\t\t\t// Bcc\n\t\t\t\t\t\trecipients = []string{sendmail.FromAddr}\n\t\t\t\t\t}\n\n\t\t\t\t\treturnPath := strings.Trim(msg.Header.Get(\"Return-Path\"), \"<>\")\n\t\t\t\t\tif returnPath == \"\" {\n\t\t\t\t\t\tif fromAddresses, err := msg.Header.AddressList(\"From\"); err == nil {\n\t\t\t\t\t\t\treturnPath = fromAddresses[0].Address\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\terr = sendmail.Send(sendmail.SMTPAddr, returnPath, recipients, body)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tlogger.Log().Errorf(\"error sending mail: %s (%s)\", err.Error(), path)\n\t\t\t\t\t\treturn nil\n\t\t\t\t\t}\n\n\t\t\t\t\tcount++\n\t\t\t\t\ttotal++\n\t\t\t\t\tif count%100 == 0 {\n\t\t\t\t\t\tlogger.Log().Infof(\"[%s] 100 messages in %s\", format(total), time.Since(per100start))\n\n\t\t\t\t\t\tper100start = time.Now()\n\t\t\t\t\t}\n\n\t\t\t\t\treturn nil\n\t\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.Log().Error(err)\n\t\t\t}\n\t\t}\n\n\t},\n}\n\nfunc init() {\n\trootCmd.AddCommand(ingestCmd)\n\n\tingestCmd.Flags().StringVarP(&sendmail.SMTPAddr, \"smtp-addr\", \"S\", sendmail.SMTPAddr, \"SMTP server address\")\n\tingestCmd.Flags().IntVarP(&ingestRecent, \"recent\", \"r\", 0, \"Only ingest messages from the last X days (default all)\")\n}\n\n// IsFile returns if a path is a file\nfunc isFile(path string) bool {\n\tinfo, err := os.Stat(path)\n\tif os.IsNotExist(err) || !info.Mode().IsRegular() {\n\t\treturn false\n\t}\n\n\treturn true\n}\n\n// Format a an integer 10000 => 10,000\nfunc format(n int) string {\n\tin := fmt.Sprintf(\"%d\", n)\n\tnumOfDigits := len(in)\n\tif n < 0 {\n\t\tnumOfDigits-- // First character is the - sign (not a digit)\n\t}\n\tnumOfCommas := (numOfDigits - 1) / 3\n\n\tout := make([]byte, len(in)+numOfCommas)\n\tif n < 0 {\n\t\tin, out[0] = in[1:], '-'\n\t}\n\n\tfor i, j, k := len(in)-1, len(out)-1, 0; ; i, j = i-1, j-1 {\n\t\tout[j] = in[i]\n\t\tif i == 0 {\n\t\t\treturn string(out)\n\t\t}\n\t\tif k++; k == 3 {\n\t\t\tj, k = j-1, 0\n\t\t\tout[j] = ','\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "cmd/readyz.go",
    "content": "package cmd\n\nimport (\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"path\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar (\n\tuseHTTPS bool\n)\n\n// readyzCmd represents the healthcheck command\nvar readyzCmd = &cobra.Command{\n\tUse:   \"readyz\",\n\tShort: \"Run a healthcheck to test if Mailpit is running\",\n\tLong: `This command connects to the /readyz endpoint of a running Mailpit server\nand exits with a status of 0 if the connection is successful, else with a \nstatus 1 if unhealthy.\n\nIf running within Docker, it should automatically detect environment\nsettings to determine the HTTP bind interface & port.\n`,\n\tRun: func(_ *cobra.Command, _ []string) {\n\t\twebroot := strings.TrimRight(path.Join(\"/\", config.Webroot, \"/\"), \"/\") + \"/\"\n\t\tproto := \"http\"\n\t\tif useHTTPS {\n\t\t\tproto = \"https\"\n\t\t}\n\n\t\turi := fmt.Sprintf(\"%s://%s%sreadyz\", proto, config.HTTPListen, webroot)\n\n\t\tconf := &http.Transport{\n\t\t\tIdleConnTimeout:       time.Second * 5,\n\t\t\tExpectContinueTimeout: time.Second * 5,\n\t\t\tTLSHandshakeTimeout:   time.Second * 5,\n\t\t\t// do not verify TLS if this instance is using HTTPS as we connect using IP\n\t\t\t// so won't be the same as the cert\n\t\t\tTLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec\n\t\t}\n\t\tclient := &http.Client{Transport: conf}\n\n\t\tres, err := client.Get(uri)\n\t\tif err != nil || res.StatusCode != 200 {\n\t\t\tos.Exit(1)\n\t\t}\n\t},\n}\n\nfunc init() {\n\trootCmd.AddCommand(readyzCmd)\n\n\tif len(os.Getenv(\"MP_UI_BIND_ADDR\")) > 0 {\n\t\tconfig.HTTPListen = os.Getenv(\"MP_UI_BIND_ADDR\")\n\t}\n\n\tif len(os.Getenv(\"MP_WEBROOT\")) > 0 {\n\t\tconfig.Webroot = os.Getenv(\"MP_WEBROOT\")\n\t}\n\n\tconfig.UITLSCert = os.Getenv(\"MP_UI_TLS_CERT\")\n\n\tif config.UITLSCert != \"\" {\n\t\tuseHTTPS = true\n\t}\n\n\treadyzCmd.Flags().StringVarP(&config.HTTPListen, \"listen\", \"l\", config.HTTPListen, \"Set the HTTP bind interface & port\")\n\treadyzCmd.Flags().StringVar(&config.Webroot, \"webroot\", config.Webroot, \"Set the webroot for web UI & API\")\n\treadyzCmd.Flags().BoolVar(&useHTTPS, \"https\", useHTTPS, \"Connect via HTTPS (ignores HTTPS validation)\")\n}\n"
  },
  {
    "path": "cmd/reindex.go",
    "content": "package cmd\n\nimport (\n\t\"os\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/axllent/mailpit/internal/storage\"\n\t\"github.com/spf13/cobra\"\n)\n\n// reindexCmd represents the reindex command\nvar reindexCmd = &cobra.Command{\n\tUse:   \"reindex <database>\",\n\tShort: \"Reindex the database\",\n\tLong: `This will reindex all messages in the entire database.\n\nIf you have several thousand messages in your mailbox, then it is advised to shut down\nMailpit while you reindex as this process will likely result in database locking issues.`,\n\tArgs: cobra.ExactArgs(1),\n\tRun: func(_ *cobra.Command, args []string) {\n\t\tconfig.Database = args[0]\n\t\tconfig.MaxMessages = 0\n\n\t\tif err := storage.InitDB(); err != nil {\n\t\t\tlogger.Log().Error(err)\n\t\t\tos.Exit(1)\n\t\t}\n\n\t\tstorage.ReindexAll()\n\t},\n}\n\nfunc init() {\n\trootCmd.AddCommand(reindexCmd)\n}\n"
  },
  {
    "path": "cmd/root.go",
    "content": "// Package cmd is the main application\npackage cmd\n\nimport (\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/internal/auth\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/axllent/mailpit/internal/prometheus\"\n\t\"github.com/axllent/mailpit/internal/smtpd\"\n\t\"github.com/axllent/mailpit/internal/smtpd/chaos\"\n\t\"github.com/axllent/mailpit/internal/storage\"\n\t\"github.com/axllent/mailpit/internal/tools\"\n\t\"github.com/axllent/mailpit/server\"\n\t\"github.com/axllent/mailpit/server/webhook\"\n\t\"github.com/spf13/cobra\"\n)\n\n// rootCmd represents the base command when called without any subcommands\nvar rootCmd = &cobra.Command{\n\tUse:   \"mailpit\",\n\tShort: \"Mailpit is an email testing tool for developers\",\n\tLong: `Mailpit is an email testing tool for developers.\n\nIt acts as an SMTP server, and provides a web interface to view all captured emails.\n\nDocumentation:\n  https://github.com/axllent/mailpit\n  https://mailpit.axllent.org/docs/`,\n\tRun: func(_ *cobra.Command, _ []string) {\n\t\tif err := config.VerifyConfig(); err != nil {\n\t\t\tlogger.Log().Error(err.Error())\n\t\t\tos.Exit(1)\n\t\t}\n\t\tif err := storage.InitDB(); err != nil {\n\t\t\tlogger.Log().Fatal(err.Error())\n\t\t\tos.Exit(1)\n\t\t}\n\n\t\t// Start Prometheus metrics if enabled\n\t\tswitch prometheus.GetMode() {\n\t\tcase \"integrated\":\n\t\t\tprometheus.StartUpdater()\n\t\tcase \"separate\":\n\t\t\tgo prometheus.StartSeparateServer()\n\t\t}\n\n\t\tgo server.Listen()\n\n\t\tif err := smtpd.Listen(); err != nil {\n\t\t\tstorage.Close()\n\t\t\tlogger.Log().Fatal(err.Error())\n\t\t\tos.Exit(1)\n\t\t}\n\t},\n}\n\n// Execute adds all child commands to the root command and sets flags appropriately.\n// This is called by main.main(). It only needs to happen once to the rootCmd.\nfunc Execute() {\n\terr := rootCmd.Execute()\n\tif err != nil {\n\t\tos.Exit(1)\n\t}\n}\n\nfunc init() {\n\t// hide autocompletion\n\trootCmd.CompletionOptions.HiddenDefaultCmd = true\n\trootCmd.Flags().SortFlags = false\n\t// hide help command\n\trootCmd.SetHelpCommand(&cobra.Command{Hidden: true})\n\t// hide help flag\n\trootCmd.PersistentFlags().BoolP(\"help\", \"h\", false, \"This help\")\n\trootCmd.PersistentFlags().Lookup(\"help\").Hidden = true\n\n\t// load and warn deprecated ENV vars\n\tinitDeprecatedConfigFromEnv()\n\n\t// load environment variables\n\tinitConfigFromEnv()\n\n\trootCmd.Flags().StringVarP(&config.Database, \"database\", \"d\", config.Database, \"Database to store persistent data\")\n\trootCmd.Flags().BoolVar(&config.DisableWAL, \"disable-wal\", config.DisableWAL, \"Disable WAL for local database (allows NFS mounted DBs)\")\n\trootCmd.Flags().BoolVar(&config.DisableVersionCheck, \"disable-version-check\", config.DisableVersionCheck, \"Disable version update checking\")\n\trootCmd.Flags().IntVar(&config.Compression, \"compression\", config.Compression, \"Compression level to store raw messages (0-3)\")\n\trootCmd.Flags().StringVar(&config.Label, \"label\", config.Label, \"Optional label identify this Mailpit instance\")\n\trootCmd.Flags().StringVar(&config.TenantID, \"tenant-id\", config.TenantID, \"Database tenant ID to isolate data\")\n\trootCmd.Flags().IntVarP(&config.MaxMessages, \"max\", \"m\", config.MaxMessages, \"Max number of messages to store\")\n\trootCmd.Flags().StringVar(&config.MaxAge, \"max-age\", config.MaxAge, \"Max age of messages in either (h)ours or (d)ays (eg: 3d)\")\n\trootCmd.Flags().BoolVar(&config.UseMessageDates, \"use-message-dates\", config.UseMessageDates, \"Use message dates as the received dates\")\n\trootCmd.Flags().BoolVar(&config.IgnoreDuplicateIDs, \"ignore-duplicate-ids\", config.IgnoreDuplicateIDs, \"Ignore duplicate messages (by Message-ID)\")\n\trootCmd.Flags().StringVar(&logger.LogFile, \"log-file\", logger.LogFile, \"Log output to file instead of stdout\")\n\trootCmd.Flags().BoolVarP(&logger.QuietLogging, \"quiet\", \"q\", logger.QuietLogging, \"Quiet logging (errors only)\")\n\trootCmd.Flags().BoolVarP(&logger.VerboseLogging, \"verbose\", \"v\", logger.VerboseLogging, \"Verbose logging\")\n\n\t// Web UI / API\n\trootCmd.Flags().StringVarP(&config.HTTPListen, \"listen\", \"l\", config.HTTPListen, \"HTTP bind interface & port for UI\")\n\trootCmd.Flags().StringVar(&config.Webroot, \"webroot\", config.Webroot, \"Set the webroot for web UI & API\")\n\trootCmd.Flags().StringVar(&config.UIAuthFile, \"ui-auth-file\", config.UIAuthFile, \"A password file for web UI & API authentication\")\n\trootCmd.Flags().StringVar(&config.UITLSCert, \"ui-tls-cert\", config.UITLSCert, \"TLS certificate for web UI (HTTPS) - requires ui-tls-key\")\n\trootCmd.Flags().StringVar(&config.UITLSKey, \"ui-tls-key\", config.UITLSKey, \"TLS key for web UI (HTTPS) - requires ui-tls-cert\")\n\trootCmd.Flags().StringVar(&server.AccessControlAllowOrigin, \"api-cors\", server.AccessControlAllowOrigin, \"Set CORS origin(s) for the API, comma-separated (eg: example.com,foo.com)\")\n\trootCmd.Flags().BoolVar(&config.BlockRemoteCSSAndFonts, \"block-remote-css-and-fonts\", config.BlockRemoteCSSAndFonts, \"Block access to remote CSS & fonts\")\n\trootCmd.Flags().BoolVar(&config.AllowInternalHTTPRequests, \"allow-internal-http-requests\", config.AllowInternalHTTPRequests, \"Allow link-checker & screenshots to access internal IP addresses\")\n\trootCmd.Flags().StringVar(&config.EnableSpamAssassin, \"enable-spamassassin\", config.EnableSpamAssassin, \"Enable integration with SpamAssassin\")\n\trootCmd.Flags().BoolVar(&config.AllowUntrustedTLS, \"allow-untrusted-tls\", config.AllowUntrustedTLS, \"Do not verify HTTPS certificates (link checker & screenshots)\")\n\trootCmd.Flags().BoolVar(&config.DisableHTTPCompression, \"disable-http-compression\", config.DisableHTTPCompression, \"Disable HTTP compression support (web UI & API)\")\n\trootCmd.Flags().BoolVar(&config.HideDeleteAllButton, \"hide-delete-all-button\", config.HideDeleteAllButton, \"Hide the \\\"Delete all\\\" button in the web UI\")\n\n\t// Send API\n\trootCmd.Flags().StringVar(&config.SendAPIAuthFile, \"send-api-auth-file\", config.SendAPIAuthFile, \"A password file for Send API authentication\")\n\trootCmd.Flags().BoolVar(&config.SendAPIAuthAcceptAny, \"send-api-auth-accept-any\", config.SendAPIAuthAcceptAny, \"Accept any username and password for the Send API endpoint, including none\")\n\n\t// SMTP server\n\trootCmd.Flags().StringVarP(&config.SMTPListen, \"smtp\", \"s\", config.SMTPListen, \"SMTP bind interface and port\")\n\trootCmd.Flags().StringVar(&config.SMTPAuthFile, \"smtp-auth-file\", config.SMTPAuthFile, \"A password file for SMTP authentication\")\n\trootCmd.Flags().BoolVar(&config.SMTPAuthAcceptAny, \"smtp-auth-accept-any\", config.SMTPAuthAcceptAny, \"Accept any SMTP username and password, including none\")\n\trootCmd.Flags().StringVar(&config.SMTPTLSCert, \"smtp-tls-cert\", config.SMTPTLSCert, \"TLS certificate for SMTP (STARTTLS) - requires smtp-tls-key\")\n\trootCmd.Flags().StringVar(&config.SMTPTLSKey, \"smtp-tls-key\", config.SMTPTLSKey, \"TLS key for SMTP (STARTTLS) - requires smtp-tls-cert\")\n\trootCmd.Flags().BoolVar(&config.SMTPRequireSTARTTLS, \"smtp-require-starttls\", config.SMTPRequireSTARTTLS, \"Require SMTP client use STARTTLS\")\n\trootCmd.Flags().BoolVar(&config.SMTPRequireTLS, \"smtp-require-tls\", config.SMTPRequireTLS, \"Require client use SSL/TLS\")\n\trootCmd.Flags().BoolVar(&config.SMTPAuthAllowInsecure, \"smtp-auth-allow-insecure\", config.SMTPAuthAllowInsecure, \"Allow insecure PLAIN & LOGIN SMTP authentication\")\n\trootCmd.Flags().BoolVar(&config.SMTPStrictRFCHeaders, \"smtp-strict-rfc-headers\", config.SMTPStrictRFCHeaders, \"Return SMTP error if message headers contain <CR><CR><LF>\")\n\trootCmd.Flags().IntVar(&config.SMTPMaxRecipients, \"smtp-max-recipients\", config.SMTPMaxRecipients, \"Maximum SMTP recipients allowed\")\n\trootCmd.Flags().StringVar(&config.SMTPAllowedRecipients, \"smtp-allowed-recipients\", config.SMTPAllowedRecipients, \"Only allow SMTP recipients matching a regular expression (default allow all)\")\n\trootCmd.Flags().BoolVar(&config.SMTPIgnoreRejectedRecipients, \"smtp-ignore-rejected-recipients\", config.SMTPIgnoreRejectedRecipients, \"Ignore rejected SMTP recipients with 2xx response\")\n\trootCmd.Flags().BoolVar(&smtpd.DisableReverseDNS, \"smtp-disable-rdns\", smtpd.DisableReverseDNS, \"Disable SMTP reverse DNS lookups\")\n\n\t// SMTP relay\n\trootCmd.Flags().StringVar(&config.SMTPRelayConfigFile, \"smtp-relay-config\", config.SMTPRelayConfigFile, \"SMTP relay configuration file to allow releasing messages\")\n\trootCmd.Flags().BoolVar(&config.SMTPRelayAll, \"smtp-relay-all\", config.SMTPRelayAll, \"Auto-relay all new messages via external SMTP server (caution!)\")\n\trootCmd.Flags().StringVar(&config.SMTPRelayMatching, \"smtp-relay-matching\", config.SMTPRelayMatching, \"Auto-relay new messages to only matching recipients (regular expression)\")\n\n\t// SMTP forwarding\n\trootCmd.Flags().StringVar(&config.SMTPForwardConfigFile, \"smtp-forward-config\", config.SMTPForwardConfigFile, \"SMTP forwarding configuration file for all messages\")\n\n\t// Chaos\n\trootCmd.Flags().BoolVar(&chaos.Enabled, \"enable-chaos\", chaos.Enabled, \"Enable Chaos functionality (API / web UI)\")\n\trootCmd.Flags().StringVar(&config.ChaosTriggers, \"chaos-triggers\", config.ChaosTriggers, \"Enable Chaos & set the triggers for SMTP server\")\n\n\t// POP3 server\n\trootCmd.Flags().StringVar(&config.POP3Listen, \"pop3\", config.POP3Listen, \"POP3 server bind interface and port\")\n\trootCmd.Flags().StringVar(&config.POP3AuthFile, \"pop3-auth-file\", config.POP3AuthFile, \"A password file for POP3 server authentication (enables POP3 server)\")\n\trootCmd.Flags().StringVar(&config.POP3TLSCert, \"pop3-tls-cert\", config.POP3TLSCert, \"Optional TLS certificate for POP3 server - requires pop3-tls-key\")\n\trootCmd.Flags().StringVar(&config.POP3TLSKey, \"pop3-tls-key\", config.POP3TLSKey, \"Optional TLS key for POP3 server - requires pop3-tls-cert\")\n\n\t// Tagging\n\trootCmd.Flags().StringVarP(&config.CLITagsArg, \"tag\", \"t\", config.CLITagsArg, \"Tag new messages matching filters\")\n\trootCmd.Flags().StringVar(&config.TagsConfig, \"tags-config\", config.TagsConfig, \"Load tags filters from yaml configuration file\")\n\trootCmd.Flags().BoolVar(&tools.TagsTitleCase, \"tags-title-case\", tools.TagsTitleCase, \"TitleCase new tags generated from plus-addresses and X-Tags\")\n\trootCmd.Flags().StringVar(&config.TagsDisable, \"tags-disable\", config.TagsDisable, \"Disable auto-tagging, comma separated (eg: plus-addresses,x-tags)\")\n\trootCmd.Flags().BoolVar(&config.TagsUsername, \"tags-username\", config.TagsUsername, \"Auto-tag messages with the authenticated username\")\n\n\t// Prometheus metrics\n\trootCmd.Flags().StringVar(&config.PrometheusListen, \"enable-prometheus\", config.PrometheusListen, \"Enable Prometheus metrics: true|false|<ip:port> (eg:'0.0.0.0:9090')\")\n\n\t// Webhook\n\trootCmd.Flags().StringVar(&config.WebhookURL, \"webhook-url\", config.WebhookURL, \"Send a webhook request for new messages\")\n\trootCmd.Flags().IntVar(&webhook.RateLimit, \"webhook-limit\", webhook.RateLimit, \"Limit webhook requests per second\")\n\trootCmd.Flags().IntVar(&webhook.Delay, \"webhook-delay\", webhook.Delay, \"Delay in seconds before sending webhook requests (default 0)\")\n\n\t// DEPRECATED FLAG 2024/04/12 - but will not be removed to maintain backwards compatibility\n\trootCmd.Flags().StringVar(&config.Database, \"db-file\", config.Database, \"Database file to store persistent data\")\n\trootCmd.Flags().Lookup(\"db-file\").Hidden = true\n\n\t// DEPRECATED FLAGS 2023/03/12\n\trootCmd.Flags().StringVar(&config.UITLSCert, \"ui-ssl-cert\", config.UITLSCert, \"SSL certificate for web UI - requires ui-ssl-key\")\n\trootCmd.Flags().StringVar(&config.UITLSKey, \"ui-ssl-key\", config.UITLSKey, \"SSL key for web UI - requires ui-ssl-cert\")\n\trootCmd.Flags().StringVar(&config.SMTPTLSCert, \"smtp-ssl-cert\", config.SMTPTLSCert, \"SSL certificate for SMTP - requires smtp-ssl-key\")\n\trootCmd.Flags().StringVar(&config.SMTPTLSKey, \"smtp-ssl-key\", config.SMTPTLSKey, \"SSL key for SMTP - requires smtp-ssl-cert\")\n\trootCmd.Flags().Lookup(\"ui-ssl-cert\").Hidden = true\n\trootCmd.Flags().Lookup(\"ui-ssl-cert\").Deprecated = \"use --ui-tls-cert\"\n\trootCmd.Flags().Lookup(\"ui-ssl-key\").Hidden = true\n\trootCmd.Flags().Lookup(\"ui-ssl-key\").Deprecated = \"use --ui-tls-key\"\n\trootCmd.Flags().Lookup(\"smtp-ssl-cert\").Hidden = true\n\trootCmd.Flags().Lookup(\"smtp-ssl-cert\").Deprecated = \"use --smtp-tls-cert\"\n\trootCmd.Flags().Lookup(\"smtp-ssl-key\").Hidden = true\n\trootCmd.Flags().Lookup(\"smtp-ssl-key\").Deprecated = \"use --smtp-tls-key\"\n\n\t// DEPRECATED FLAGS 2024/03/16\n\trootCmd.Flags().BoolVar(&config.SMTPRequireSTARTTLS, \"smtp-tls-required\", config.SMTPRequireSTARTTLS, \"smtp-require-starttls\")\n\trootCmd.Flags().Lookup(\"smtp-tls-required\").Hidden = true\n\trootCmd.Flags().Lookup(\"smtp-tls-required\").Deprecated = \"use --smtp-require-starttls\"\n\n\t// DEPRECATED FLAG 2024/04/13 - no longer used\n\trootCmd.Flags().BoolVar(&config.DisableHTMLCheck, \"disable-html-check\", config.DisableHTMLCheck, \"Disable the HTML check functionality (web UI & API)\")\n\trootCmd.Flags().Lookup(\"disable-html-check\").Hidden = true\n}\n\n// Load settings from environment\nfunc initConfigFromEnv() {\n\t// General\n\tif len(os.Getenv(\"MP_DATABASE\")) > 0 {\n\t\tconfig.Database = os.Getenv(\"MP_DATABASE\")\n\t}\n\n\tconfig.DisableWAL = getEnabledFromEnv(\"MP_DISABLE_WAL\")\n\n\tconfig.DisableVersionCheck = getEnabledFromEnv(\"MP_DISABLE_VERSION_CHECK\")\n\n\tif len(os.Getenv(\"MP_COMPRESSION\")) > 0 {\n\t\tconfig.Compression, _ = strconv.Atoi(os.Getenv(\"MP_COMPRESSION\"))\n\t}\n\n\tconfig.TenantID = os.Getenv(\"MP_TENANT_ID\")\n\n\tconfig.Label = os.Getenv(\"MP_LABEL\")\n\n\tif len(os.Getenv(\"MP_MAX_MESSAGES\")) > 0 {\n\t\tconfig.MaxMessages, _ = strconv.Atoi(os.Getenv(\"MP_MAX_MESSAGES\"))\n\t}\n\tif len(os.Getenv(\"MP_MAX_AGE\")) > 0 {\n\t\tconfig.MaxAge = os.Getenv(\"MP_MAX_AGE\")\n\t}\n\tif getEnabledFromEnv(\"MP_USE_MESSAGE_DATES\") {\n\t\tconfig.UseMessageDates = true\n\t}\n\tif getEnabledFromEnv(\"MP_IGNORE_DUPLICATE_IDS\") {\n\t\tconfig.IgnoreDuplicateIDs = true\n\t}\n\tif len(os.Getenv(\"MP_LOG_FILE\")) > 0 {\n\t\tlogger.LogFile = os.Getenv(\"MP_LOG_FILE\")\n\t}\n\tif getEnabledFromEnv(\"MP_QUIET\") {\n\t\tlogger.QuietLogging = true\n\t}\n\tif getEnabledFromEnv(\"MP_VERBOSE\") {\n\t\tlogger.VerboseLogging = true\n\t}\n\n\t// Web UI & API\n\tif len(os.Getenv(\"MP_UI_BIND_ADDR\")) > 0 {\n\t\tconfig.HTTPListen = os.Getenv(\"MP_UI_BIND_ADDR\")\n\t}\n\tif len(os.Getenv(\"MP_WEBROOT\")) > 0 {\n\t\tconfig.Webroot = os.Getenv(\"MP_WEBROOT\")\n\t}\n\tconfig.UIAuthFile = os.Getenv(\"MP_UI_AUTH_FILE\")\n\tif err := auth.SetUIAuth(os.Getenv(\"MP_UI_AUTH\")); err != nil {\n\t\tlogger.Log().Error(err.Error())\n\t}\n\tconfig.UITLSCert = os.Getenv(\"MP_UI_TLS_CERT\")\n\tconfig.UITLSKey = os.Getenv(\"MP_UI_TLS_KEY\")\n\tif len(os.Getenv(\"MP_API_CORS\")) > 0 {\n\t\tserver.AccessControlAllowOrigin = os.Getenv(\"MP_API_CORS\")\n\t}\n\tif getEnabledFromEnv(\"MP_BLOCK_REMOTE_CSS_AND_FONTS\") {\n\t\tconfig.BlockRemoteCSSAndFonts = true\n\t}\n\tif getEnabledFromEnv(\"MP_ALLOW_INTERNAL_HTTP_REQUESTS\") {\n\t\tconfig.AllowInternalHTTPRequests = true\n\t}\n\tif len(os.Getenv(\"MP_ENABLE_SPAMASSASSIN\")) > 0 {\n\t\tconfig.EnableSpamAssassin = os.Getenv(\"MP_ENABLE_SPAMASSASSIN\")\n\t}\n\tif getEnabledFromEnv(\"MP_ALLOW_UNTRUSTED_TLS\") {\n\t\tconfig.AllowUntrustedTLS = true\n\t}\n\tif getEnabledFromEnv(\"MP_DISABLE_HTTP_COMPRESSION\") {\n\t\tconfig.DisableHTTPCompression = true\n\t}\n\tif getEnabledFromEnv(\"MP_HIDE_DELETE_ALL_BUTTON\") {\n\t\tconfig.HideDeleteAllButton = true\n\t}\n\n\t// Send API\n\tconfig.SendAPIAuthFile = os.Getenv(\"MP_SEND_API_AUTH_FILE\")\n\tif err := auth.SetSendAPIAuth(os.Getenv(\"MP_SEND_API_AUTH\")); err != nil {\n\t\tlogger.Log().Error(err.Error())\n\t}\n\tif getEnabledFromEnv(\"MP_SEND_API_AUTH_ACCEPT_ANY\") {\n\t\tconfig.SendAPIAuthAcceptAny = true\n\t}\n\n\t// SMTP server\n\tif len(os.Getenv(\"MP_SMTP_BIND_ADDR\")) > 0 {\n\t\tconfig.SMTPListen = os.Getenv(\"MP_SMTP_BIND_ADDR\")\n\t}\n\tconfig.SMTPAuthFile = os.Getenv(\"MP_SMTP_AUTH_FILE\")\n\tif err := auth.SetSMTPAuth(os.Getenv(\"MP_SMTP_AUTH\")); err != nil {\n\t\tlogger.Log().Error(err.Error())\n\t}\n\tif getEnabledFromEnv(\"MP_SMTP_AUTH_ACCEPT_ANY\") {\n\t\tconfig.SMTPAuthAcceptAny = true\n\t}\n\tconfig.SMTPTLSCert = os.Getenv(\"MP_SMTP_TLS_CERT\")\n\tconfig.SMTPTLSKey = os.Getenv(\"MP_SMTP_TLS_KEY\")\n\tif getEnabledFromEnv(\"MP_SMTP_REQUIRE_STARTTLS\") {\n\t\tconfig.SMTPRequireSTARTTLS = true\n\t}\n\tif getEnabledFromEnv(\"MP_SMTP_REQUIRE_TLS\") {\n\t\tconfig.SMTPRequireTLS = true\n\t}\n\tif getEnabledFromEnv(\"MP_SMTP_AUTH_ALLOW_INSECURE\") {\n\t\tconfig.SMTPAuthAllowInsecure = true\n\t}\n\tif getEnabledFromEnv(\"MP_SMTP_STRICT_RFC_HEADERS\") {\n\t\tconfig.SMTPStrictRFCHeaders = true\n\t}\n\tif len(os.Getenv(\"MP_SMTP_MAX_RECIPIENTS\")) > 0 {\n\t\tconfig.SMTPMaxRecipients, _ = strconv.Atoi(os.Getenv(\"MP_SMTP_MAX_RECIPIENTS\"))\n\t}\n\tif len(os.Getenv(\"MP_SMTP_ALLOWED_RECIPIENTS\")) > 0 {\n\t\tconfig.SMTPAllowedRecipients = os.Getenv(\"MP_SMTP_ALLOWED_RECIPIENTS\")\n\t}\n\tif getEnabledFromEnv(\"MP_SMTP_IGNORE_REJECTED_RECIPIENTS\") {\n\t\tconfig.SMTPIgnoreRejectedRecipients = true\n\t}\n\tif getEnabledFromEnv(\"MP_SMTP_DISABLE_RDNS\") {\n\t\tsmtpd.DisableReverseDNS = true\n\t}\n\n\t// SMTP relay\n\tconfig.SMTPRelayConfigFile = os.Getenv(\"MP_SMTP_RELAY_CONFIG\")\n\tif getEnabledFromEnv(\"MP_SMTP_RELAY_ALL\") {\n\t\tconfig.SMTPRelayAll = true\n\t}\n\tconfig.SMTPRelayMatching = os.Getenv(\"MP_SMTP_RELAY_MATCHING\")\n\tconfig.SMTPRelayConfig = config.SMTPRelayConfigStruct{}\n\tconfig.SMTPRelayConfig.Host = os.Getenv(\"MP_SMTP_RELAY_HOST\")\n\tif len(os.Getenv(\"MP_SMTP_RELAY_PORT\")) > 0 {\n\t\tconfig.SMTPRelayConfig.Port, _ = strconv.Atoi(os.Getenv(\"MP_SMTP_RELAY_PORT\"))\n\t}\n\tconfig.SMTPRelayConfig.STARTTLS = getEnabledFromEnv(\"MP_SMTP_RELAY_STARTTLS\")\n\tconfig.SMTPRelayConfig.TLS = getEnabledFromEnv(\"MP_SMTP_RELAY_TLS\")\n\tconfig.SMTPRelayConfig.AllowInsecure = getEnabledFromEnv(\"MP_SMTP_RELAY_ALLOW_INSECURE\")\n\tconfig.SMTPRelayConfig.Auth = os.Getenv(\"MP_SMTP_RELAY_AUTH\")\n\tconfig.SMTPRelayConfig.Username = os.Getenv(\"MP_SMTP_RELAY_USERNAME\")\n\tconfig.SMTPRelayConfig.Password = os.Getenv(\"MP_SMTP_RELAY_PASSWORD\")\n\tconfig.SMTPRelayConfig.Secret = os.Getenv(\"MP_SMTP_RELAY_SECRET\")\n\tconfig.SMTPRelayConfig.ReturnPath = os.Getenv(\"MP_SMTP_RELAY_RETURN_PATH\")\n\tconfig.SMTPRelayConfig.OverrideFrom = os.Getenv(\"MP_SMTP_RELAY_OVERRIDE_FROM\")\n\tconfig.SMTPRelayConfig.AllowedRecipients = os.Getenv(\"MP_SMTP_RELAY_ALLOWED_RECIPIENTS\")\n\tconfig.SMTPRelayConfig.BlockedRecipients = os.Getenv(\"MP_SMTP_RELAY_BLOCKED_RECIPIENTS\")\n\tconfig.SMTPRelayConfig.PreserveMessageIDs = getEnabledFromEnv(\"MP_SMTP_RELAY_PRESERVE_MESSAGE_IDS\")\n\tconfig.SMTPRelayConfig.ForwardSMTPErrors = getEnabledFromEnv(\"MP_SMTP_RELAY_FWD_SMTP_ERRORS\")\n\n\t// SMTP forwarding\n\tconfig.SMTPForwardConfigFile = os.Getenv(\"MP_SMTP_FORWARD_CONFIG\")\n\tconfig.SMTPForwardConfig = config.SMTPForwardConfigStruct{}\n\tconfig.SMTPForwardConfig.Host = os.Getenv(\"MP_SMTP_FORWARD_HOST\")\n\tif len(os.Getenv(\"MP_SMTP_FORWARD_PORT\")) > 0 {\n\t\tconfig.SMTPForwardConfig.Port, _ = strconv.Atoi(os.Getenv(\"MP_SMTP_FORWARD_PORT\"))\n\t}\n\tconfig.SMTPForwardConfig.STARTTLS = getEnabledFromEnv(\"MP_SMTP_FORWARD_STARTTLS\")\n\tconfig.SMTPForwardConfig.TLS = getEnabledFromEnv(\"MP_SMTP_FORWARD_TLS\")\n\tconfig.SMTPForwardConfig.AllowInsecure = getEnabledFromEnv(\"MP_SMTP_FORWARD_ALLOW_INSECURE\")\n\tconfig.SMTPForwardConfig.Auth = os.Getenv(\"MP_SMTP_FORWARD_AUTH\")\n\tconfig.SMTPForwardConfig.Username = os.Getenv(\"MP_SMTP_FORWARD_USERNAME\")\n\tconfig.SMTPForwardConfig.Password = os.Getenv(\"MP_SMTP_FORWARD_PASSWORD\")\n\tconfig.SMTPForwardConfig.Secret = os.Getenv(\"MP_SMTP_FORWARD_SECRET\")\n\tconfig.SMTPForwardConfig.ReturnPath = os.Getenv(\"MP_SMTP_FORWARD_RETURN_PATH\")\n\tconfig.SMTPForwardConfig.OverrideFrom = os.Getenv(\"MP_SMTP_FORWARD_OVERRIDE_FROM\")\n\tconfig.SMTPForwardConfig.To = os.Getenv(\"MP_SMTP_FORWARD_TO\")\n\tconfig.SMTPForwardConfig.ForwardSMTPErrors = getEnabledFromEnv(\"MP_SMTP_FORWARD_FWD_SMTP_ERRORS\")\n\n\t// Chaos\n\tchaos.Enabled = getEnabledFromEnv(\"MP_ENABLE_CHAOS\")\n\tconfig.ChaosTriggers = os.Getenv(\"MP_CHAOS_TRIGGERS\")\n\n\t// POP3 server\n\tif len(os.Getenv(\"MP_POP3_BIND_ADDR\")) > 0 {\n\t\tconfig.POP3Listen = os.Getenv(\"MP_POP3_BIND_ADDR\")\n\t}\n\tconfig.POP3AuthFile = os.Getenv(\"MP_POP3_AUTH_FILE\")\n\tif err := auth.SetPOP3Auth(os.Getenv(\"MP_POP3_AUTH\")); err != nil {\n\t\tlogger.Log().Error(err.Error())\n\t}\n\tconfig.POP3TLSCert = os.Getenv(\"MP_POP3_TLS_CERT\")\n\tconfig.POP3TLSKey = os.Getenv(\"MP_POP3_TLS_KEY\")\n\n\t// Tagging\n\tconfig.CLITagsArg = os.Getenv(\"MP_TAG\")\n\tconfig.TagsConfig = os.Getenv(\"MP_TAGS_CONFIG\")\n\ttools.TagsTitleCase = getEnabledFromEnv(\"MP_TAGS_TITLE_CASE\")\n\tconfig.TagsDisable = os.Getenv(\"MP_TAGS_DISABLE\")\n\tconfig.TagsUsername = getEnabledFromEnv(\"MP_TAGS_USERNAME\")\n\n\t// Prometheus metrics\n\tif len(os.Getenv(\"MP_ENABLE_PROMETHEUS\")) > 0 {\n\t\tconfig.PrometheusListen = os.Getenv(\"MP_ENABLE_PROMETHEUS\")\n\t}\n\n\t// Webhook\n\tif len(os.Getenv(\"MP_WEBHOOK_URL\")) > 0 {\n\t\tconfig.WebhookURL = os.Getenv(\"MP_WEBHOOK_URL\")\n\t}\n\tif len(os.Getenv(\"MP_WEBHOOK_LIMIT\")) > 0 {\n\t\twebhook.RateLimit, _ = strconv.Atoi(os.Getenv(\"MP_WEBHOOK_LIMIT\"))\n\t}\n\tif len(os.Getenv(\"MP_WEBHOOK_DELAY\")) > 0 {\n\t\twebhook.Delay, _ = strconv.Atoi(os.Getenv(\"MP_WEBHOOK_DELAY\"))\n\t}\n\n\t// Demo mode\n\tconfig.DemoMode = getEnabledFromEnv(\"MP_DEMO_MODE\")\n}\n\n// load deprecated settings from environment and warn\nfunc initDeprecatedConfigFromEnv() {\n\t// deprecated 2024/04/12 - but will not be removed to maintain backwards compatibility\n\tif len(os.Getenv(\"MP_DATA_FILE\")) > 0 {\n\t\tlogger.Log().Warn(\"ENV MP_DATA_FILE has been deprecated, use MP_DATABASE\")\n\t\tconfig.Database = os.Getenv(\"MP_DATA_FILE\")\n\t}\n\t// deprecated 2023/03/12\n\tif len(os.Getenv(\"MP_UI_SSL_CERT\")) > 0 {\n\t\tlogger.Log().Warn(\"ENV MP_UI_SSL_CERT has been deprecated, use MP_UI_TLS_CERT\")\n\t\tconfig.UITLSCert = os.Getenv(\"MP_UI_SSL_CERT\")\n\t}\n\t// deprecated 2023/03/12\n\tif len(os.Getenv(\"MP_UI_SSL_KEY\")) > 0 {\n\t\tlogger.Log().Warn(\"ENV MP_UI_SSL_KEY has been deprecated, use MP_UI_TLS_KEY\")\n\t\tconfig.UITLSKey = os.Getenv(\"MP_UI_SSL_KEY\")\n\t}\n\t// deprecated 2023/03/12\n\tif len(os.Getenv(\"MP_SMTP_SSL_CERT\")) > 0 {\n\t\tlogger.Log().Warn(\"ENV MP_SMTP_CERT has been deprecated, use MP_SMTP_TLS_CERT\")\n\t\tconfig.SMTPTLSCert = os.Getenv(\"MP_SMTP_SSL_CERT\")\n\t}\n\t// deprecated 2023/03/12\n\tif len(os.Getenv(\"MP_SMTP_SSL_KEY\")) > 0 {\n\t\tlogger.Log().Warn(\"ENV MP_SMTP_KEY has been deprecated, use MP_SMTP_TLS_KEY\")\n\t\tconfig.SMTPTLSKey = os.Getenv(\"MP_SMTP_SMTP_KEY\")\n\t}\n\t// deprecated 2023/12/10\n\tif getEnabledFromEnv(\"MP_STRICT_RFC_HEADERS\") {\n\t\tlogger.Log().Warn(\"ENV MP_STRICT_RFC_HEADERS has been deprecated, use MP_SMTP_STRICT_RFC_HEADERS\")\n\t\tconfig.SMTPStrictRFCHeaders = true\n\t}\n\t// deprecated 2024/03.16\n\tif getEnabledFromEnv(\"MP_SMTP_TLS_REQUIRED\") {\n\t\tlogger.Log().Warn(\"ENV MP_SMTP_TLS_REQUIRED has been deprecated, use MP_SMTP_REQUIRE_STARTTLS\")\n\t\tconfig.SMTPRequireSTARTTLS = true\n\t}\n\tif getEnabledFromEnv(\"MP_DISABLE_HTML_CHECK\") {\n\t\tlogger.Log().Warn(\"ENV MP_DISABLE_HTML_CHECK has been deprecated and is no longer used\")\n\t\tconfig.DisableHTMLCheck = true\n\t}\n}\n\n// Wrapper to get a boolean from an environment variable\nfunc getEnabledFromEnv(k string) bool {\n\tif len(os.Getenv(k)) > 0 {\n\t\tv := strings.ToLower(os.Getenv(k))\n\t\treturn v == \"1\" || v == \"true\" || v == \"yes\"\n\t}\n\n\treturn false\n}\n"
  },
  {
    "path": "cmd/sendmail.go",
    "content": "package cmd\n\nimport (\n\t\"os\"\n\n\tsendmail \"github.com/axllent/mailpit/sendmail/cmd\"\n\t\"github.com/spf13/cobra\"\n)\n\n// sendmailCmd represents the sendmail command\nvar sendmailCmd = &cobra.Command{\n\tUse:   \"sendmail [flags] [recipients]\",\n\tShort: \"A sendmail command replacement for Mailpit\",\n\tRun: func(_ *cobra.Command, _ []string) {\n\t\tsendmail.Run()\n\t},\n}\n\nfunc init() {\n\trootCmd.AddCommand(sendmailCmd)\n\tvar ignored string\n\n\t// print out manual help screen\n\tsendmailCmd.SetHelpTemplate(sendmail.HelpTemplate([]string{os.Args[0], \"sendmail\"}))\n\n\t// these are simply repeated for cli consistency as cobra/viper does not allow\n\t// multi-letter single-dash variables (-bs)\n\tsendmailCmd.Flags().StringVarP(&sendmail.FromAddr, \"from\", \"f\", sendmail.FromAddr, \"SMTP sender\")\n\tsendmailCmd.Flags().StringVarP(&sendmail.SMTPAddr, \"smtp-addr\", \"S\", sendmail.SMTPAddr, \"SMTP server address\")\n\tsendmailCmd.Flags().BoolVarP(&sendmail.UseB, \"ignored-b\", \"b\", false, \"Handle SMTP commands on standard input (use as -bs)\")\n\tsendmailCmd.Flags().BoolVarP(&sendmail.UseS, \"ignored-s\", \"s\", false, \"Handle SMTP commands on standard input (use as -bs)\")\n\tsendmailCmd.Flags().BoolP(\"verbose\", \"v\", false, \"Verbose mode (sends debug output to stderr)\")\n\tsendmailCmd.Flags().BoolP(\"ignored-i\", \"i\", false, \"Ignored\")\n\tsendmailCmd.Flags().BoolP(\"ignored-o\", \"o\", false, \"Ignored\")\n\tsendmailCmd.Flags().BoolP(\"ignored-t\", \"t\", false, \"Ignored\")\n\tsendmailCmd.Flags().StringVarP(&ignored, \"ignored-name\", \"F\", \"\", \"Ignored\")\n\tsendmailCmd.Flags().StringVarP(&ignored, \"ignored-bits\", \"B\", \"\", \"Ignored\")\n\tsendmailCmd.Flags().StringVarP(&ignored, \"ignored-errors\", \"e\", \"\", \"Ignored\")\n}\n"
  },
  {
    "path": "cmd/version.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"runtime\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/spf13/cobra\"\n)\n\n// versionCmd represents the version command\nvar versionCmd = &cobra.Command{\n\tUse:   \"version\",\n\tShort: \"Display the current version & update information\",\n\tLong:  `Display the current version & update information (if available).`,\n\tRun: func(cmd *cobra.Command, _ []string) {\n\t\tupdate, _ := cmd.Flags().GetBool(\"update\")\n\t\tnoReleaseCheck, _ := cmd.Flags().GetBool(\"no-release-check\")\n\n\t\tif update {\n\t\t\t// Update the application\n\t\t\trel, err := config.GHRUConfig.SelfUpdate()\n\t\t\tif err != nil {\n\t\t\t\tfmt.Printf(\"Error updating: %s\\n\", err)\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\n\t\t\tfmt.Printf(\"Updated %s to version %s\\n\", os.Args[0], rel.Tag)\n\t\t\tos.Exit(0)\n\t\t}\n\n\t\tfmt.Printf(\"%s %s compiled with %s on %s/%s\\n\",\n\t\t\tos.Args[0], config.Version, runtime.Version(), runtime.GOOS, runtime.GOARCH)\n\n\t\tif !noReleaseCheck {\n\t\t\trelease, err := config.GHRUConfig.Latest()\n\t\t\tif err != nil {\n\t\t\t\tfmt.Printf(\"Error checking for latest release: %s\\n\", err)\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\n\t\t\t// The latest version is the same version\n\t\t\tif release.Tag == config.Version {\n\t\t\t\tos.Exit(0)\n\t\t\t}\n\n\t\t\t// A newer release is available\n\t\t\tfmt.Printf(\n\t\t\t\t\"\\nUpdate available: %s\\nRun `%s version -u` to update (requires read/write access to install directory).\\n\",\n\t\t\t\trelease.Tag,\n\t\t\t\tos.Args[0],\n\t\t\t)\n\t\t}\n\t},\n}\n\nfunc init() {\n\trootCmd.AddCommand(versionCmd)\n\n\tversionCmd.Flags().\n\t\tBoolP(\"update\", \"u\", false, \"update to latest version\")\n\tversionCmd.Flags().\n\t\tBool(\"no-release-check\", false, \"do not check online for the latest release version\")\n}\n"
  },
  {
    "path": "config/config.go",
    "content": "// Package config handles the application configuration\npackage config\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"os\"\n\t\"path\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/axllent/ghru/v2\"\n\t\"github.com/axllent/mailpit/internal/auth\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/axllent/mailpit/internal/smtpd/chaos\"\n\t\"github.com/axllent/mailpit/internal/snakeoil\"\n\t\"github.com/axllent/mailpit/internal/spamassassin\"\n\t\"github.com/axllent/mailpit/internal/tools\"\n)\n\nvar (\n\t// Version is the Mailpit version, updated with every release\n\tVersion = \"dev\"\n\n\t// GHRUConfig is the configuration for the GitHub Release Updater\n\t// used to check for updates and self-update\n\tGHRUConfig = ghru.Config{\n\t\tRepo:           \"axllent/mailpit\",\n\t\tArchiveName:    \"mailpit-{{.OS}}-{{.Arch}}\",\n\t\tBinaryName:     \"mailpit\",\n\t\tCurrentVersion: Version,\n\t}\n\n\t// SMTPListen to listen on <interface>:<port>\n\tSMTPListen = \"[::]:1025\"\n\n\t// HTTPListen to listen on <interface>:<port>\n\tHTTPListen = \"[::]:8025\"\n\n\t// Database for mail (optional)\n\tDatabase string\n\n\t// DisableWAL will disable Write-Ahead Logging in SQLite\n\t// @see https://sqlite.org/wal.html\n\tDisableWAL bool\n\n\t// Compression is the compression level used to store raw messages in the database:\n\t// 0 = off, 1 = fastest (default), 2 = standard, 3 = best compression\n\tCompression = 1\n\n\t// TenantID is an optional prefix to be applied to all database tables,\n\t// allowing multiple isolated instances of Mailpit to share a database.\n\tTenantID string\n\n\t// Label to identify this Mailpit instance (optional).\n\t// This gets applied to web UI, SMTP and optional POP3 server.\n\tLabel string\n\n\t// MaxMessages is the maximum number of messages a mailbox can have (auto-pruned every minute)\n\tMaxMessages = 500\n\n\t// MaxAge is the maximum age of messages (auto-pruned every hour).\n\t// Value can be either <int>h for hours or <int>d for days\n\tMaxAge string\n\n\t// MaxAgeInHours is the maximum age of messages in hours, set with parseMaxAge() using MaxAge value\n\tMaxAgeInHours int\n\n\t// UseMessageDates sets the Created date using the message date, not the delivered date\n\tUseMessageDates bool\n\n\t// UITLSCert file\n\tUITLSCert string\n\n\t// UITLSKey file\n\tUITLSKey string\n\n\t// UIAuthFile for UI & API authentication\n\tUIAuthFile string\n\n\t// Webroot to define the base path for the UI and API\n\tWebroot = \"/\"\n\n\t// DisableHTTPCompression will explicitly disable HTTP compression in the web UI and API\n\tDisableHTTPCompression bool\n\n\t// SendAPIAuthFile for Send API authentication\n\tSendAPIAuthFile string\n\n\t// SendAPIAuthAcceptAny accepts any username/password for the send API endpoint, including none\n\tSendAPIAuthAcceptAny bool\n\n\t// SMTPTLSCert file\n\tSMTPTLSCert string\n\n\t// SMTPTLSKey file\n\tSMTPTLSKey string\n\n\t// SMTPRequireSTARTTLS to enforce the use of STARTTLS\n\t// The only allowed commands are NOOP, EHLO, STARTTLS and QUIT (as specified in RFC 3207) until\n\t// the connection is upgraded to TLS i.e. until STARTTLS is issued.\n\tSMTPRequireSTARTTLS bool\n\n\t// SMTPRequireTLS to allow only SSL/TLS connections for all connections\n\t//\n\tSMTPRequireTLS bool\n\n\t// SMTPAuthFile for SMTP authentication\n\tSMTPAuthFile string\n\n\t// SMTPAuthAllowInsecure allows PLAIN & LOGIN unencrypted authentication\n\tSMTPAuthAllowInsecure bool\n\n\t// SMTPAuthAcceptAny accepts any username/password including none\n\tSMTPAuthAcceptAny bool\n\n\t// SMTPMaxRecipients is the maximum number of recipients a message may have.\n\t// The SMTP RFC states that an server must handle a minimum of 100 recipients\n\t// however some servers accept more.\n\tSMTPMaxRecipients = 100\n\n\t// IgnoreDuplicateIDs will skip messages with the same ID\n\tIgnoreDuplicateIDs bool\n\n\t// BlockRemoteCSSAndFonts used to disable remote CSS & fonts\n\tBlockRemoteCSSAndFonts = false\n\n\t// AllowInternalHTTPRequests will allow HTTP requests to internal IP addresses (e.g., loopback, private, link-local, or multicast) when set to true.\n\t// This policy applies to both link checking and screenshot generation (proxy) features and is disabled by default for security reasons.\n\tAllowInternalHTTPRequests = false\n\n\t// CLITagsArg is used to map the CLI args\n\tCLITagsArg string\n\n\t// ValidTagRegexp represents a valid tag\n\tValidTagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\\-\\ \\_\\.@]){1,100}$`)\n\n\t// TagsConfig is a yaml file to pre-load tags\n\tTagsConfig string\n\n\t// TagFilters are used to apply tags to new mail\n\tTagFilters []autoTag\n\n\t// TagsDisable accepts a comma-separated list of tag types to disable\n\t// including x-tags & plus-addresses\n\tTagsDisable string\n\n\t// TagsUsername enables auto-tagging messages with the authenticated username\n\tTagsUsername bool\n\n\t// SMTPRelayConfigFile to parse a yaml file and store config of the relay SMTP server\n\tSMTPRelayConfigFile string\n\n\t// SMTPRelayConfig to parse a yaml file and store config of the the relay SMTP server\n\tSMTPRelayConfig SMTPRelayConfigStruct\n\n\t// ReleaseEnabled is whether message releases are enabled, requires a valid SMTPRelayConfigFile\n\tReleaseEnabled = false\n\n\t// SMTPRelayAll is whether to relay all incoming messages via pre-configured SMTP server.\n\t// Use with extreme caution!\n\tSMTPRelayAll = false\n\n\t// SMTPRelayMatching if set, will auto-release to recipients matching this regular expression\n\tSMTPRelayMatching string\n\n\t// SMTPRelayMatchingRegexp is the compiled version of SMTPRelayMatching\n\tSMTPRelayMatchingRegexp *regexp.Regexp\n\n\t// SMTPForwardConfigFile to parse a yaml file and store config of the forwarding SMTP server\n\tSMTPForwardConfigFile string\n\n\t// SMTPForwardConfig to parse a yaml file and store config of the forwarding SMTP server\n\tSMTPForwardConfig SMTPForwardConfigStruct\n\n\t// SMTPStrictRFCHeaders will return an error if the email headers contain <CR><CR><LF> (\\r\\r\\n)\n\t// @see https://github.com/axllent/mailpit/issues/87 & https://github.com/axllent/mailpit/issues/153\n\tSMTPStrictRFCHeaders bool\n\n\t// SMTPAllowedRecipients if set, will only accept recipients matching this regular expression\n\tSMTPAllowedRecipients string\n\n\t// SMTPAllowedRecipientsRegexp is the compiled version of SMTPAllowedRecipients\n\tSMTPAllowedRecipientsRegexp *regexp.Regexp\n\n\t// SMTPIgnoreRejectedRecipients if true, will accept emails to rejected recipients with 2xx response but silently drop them\n\tSMTPIgnoreRejectedRecipients bool\n\n\t// POP3Listen address - if set then Mailpit will start the POP3 server and listen on this address\n\tPOP3Listen = \"[::]:1110\"\n\n\t// POP3AuthFile for POP3 authentication\n\tPOP3AuthFile string\n\n\t// POP3TLSCert TLS certificate\n\tPOP3TLSCert string\n\n\t// POP3TLSKey TLS certificate key\n\tPOP3TLSKey string\n\n\t// EnableSpamAssassin must be either <host>:<port> or \"postmark\"\n\tEnableSpamAssassin string\n\n\t// HideDeleteAllButton hides the delete all button in the web UI\n\tHideDeleteAllButton bool\n\n\t// WebhookURL for calling\n\tWebhookURL string\n\n\t// ContentSecurityPolicy for HTTP server - set via VerifyConfig()\n\tContentSecurityPolicy string\n\n\t// AllowUntrustedTLS allows untrusted HTTPS connections link checking & screenshot generation\n\tAllowUntrustedTLS bool\n\n\t// PrometheusListen address for Prometheus metrics server\n\t// Empty = disabled, true= use existing web server, address = separate server\n\tPrometheusListen string\n\n\t// ChaosTriggers are parsed and set in the chaos module\n\tChaosTriggers string\n\n\t// DisableHTMLCheck DEPRECATED 2024/04/13 - kept here to display console warning only\n\tDisableHTMLCheck = false\n\n\t// DisableVersionCheck disables version checking\n\tDisableVersionCheck bool\n\n\t// DemoMode disables SMTP relay, link checking & HTTP send functionality\n\tDemoMode = false\n)\n\n// AutoTag struct for auto-tagging\ntype autoTag struct {\n\tMatch string\n\tTags  []string\n}\n\n// SMTPRelayConfigStruct struct for parsing yaml & storing variables\ntype SMTPRelayConfigStruct struct {\n\tHost                    string         `yaml:\"host\"`               // SMTP host\n\tPort                    int            `yaml:\"port\"`               // SMTP port\n\tSTARTTLS                bool           `yaml:\"starttls\"`           // whether to use STARTTLS\n\tTLS                     bool           `yaml:\"tls\"`                // whether to use TLS\n\tAllowInsecure           bool           `yaml:\"allow-insecure\"`     // allow insecure authentication, ignore TLS validation\n\tAuth                    string         `yaml:\"auth\"`               // none, plain, login, cram-md5\n\tUsername                string         `yaml:\"username\"`           // plain & cram-md5\n\tPassword                string         `yaml:\"password\"`           // plain\n\tSecret                  string         `yaml:\"secret\"`             // cram-md5\n\tReturnPath              string         `yaml:\"return-path\"`        // allow overriding the bounce address\n\tOverrideFrom            string         `yaml:\"override-from\"`      // allow overriding of the from address\n\tAllowedRecipients       string         `yaml:\"allowed-recipients\"` // regex, if set needs to match for mails to be relayed\n\tAllowedRecipientsRegexp *regexp.Regexp // compiled regexp using AllowedRecipients\n\tBlockedRecipients       string         `yaml:\"blocked-recipients\"` // regex, if set prevents relating to these addresses\n\tBlockedRecipientsRegexp *regexp.Regexp // compiled regexp using BlockedRecipients\n\tPreserveMessageIDs      bool           `yaml:\"preserve-message-ids\"` // preserve the original Message-ID when relaying\n\tForwardSMTPErrors       bool           `yaml:\"forward-smtp-errors\"`  // whether to log smtp-errors or forward them to upstream-client\n\n\t// DEPRECATED 2024/03/12\n\tRecipientAllowlist string `yaml:\"recipient-allowlist\"`\n}\n\n// SMTPForwardConfigStruct struct for parsing yaml & storing variables\ntype SMTPForwardConfigStruct struct {\n\tTo                string `yaml:\"to\"`                  // comma-separated list of email addresses\n\tHost              string `yaml:\"host\"`                // SMTP host\n\tPort              int    `yaml:\"port\"`                // SMTP port\n\tSTARTTLS          bool   `yaml:\"starttls\"`            // whether to use STARTTLS\n\tTLS               bool   `yaml:\"tls\"`                 // whether to use TLS\n\tAllowInsecure     bool   `yaml:\"allow-insecure\"`      // allow insecure authentication, ignore TLS validation\n\tAuth              string `yaml:\"auth\"`                // none, plain, login, cram-md5\n\tUsername          string `yaml:\"username\"`            // plain & cram-md5\n\tPassword          string `yaml:\"password\"`            // plain\n\tSecret            string `yaml:\"secret\"`              // cram-md5\n\tReturnPath        string `yaml:\"return-path\"`         // allow overriding the bounce address\n\tOverrideFrom      string `yaml:\"override-from\"`       // allow overriding of the from address\n\tForwardSMTPErrors bool   `yaml:\"forward-smtp-errors\"` // whether to log smtp-errors or forward them to upstream-client\n}\n\n// VerifyConfig wil do some basic checking\nfunc VerifyConfig() error {\n\tcssFontRestriction := \"*\"\n\tif BlockRemoteCSSAndFonts {\n\t\tcssFontRestriction = \"'self'\"\n\t}\n\n\t// The default Content Security Policy is updates on every application page load to replace script-src 'self'\n\t// with a random nonce ID to prevent XSS. This applies to the Mailpit app & API.\n\t// See server.middleWareFunc()\n\tContentSecurityPolicy = fmt.Sprintf(\n\t\t\"default-src 'self'; script-src 'self'; style-src %s 'unsafe-inline'; frame-src 'self'; img-src * data: blob:; font-src %s data:; media-src 'self'; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self';\",\n\t\tcssFontRestriction, cssFontRestriction,\n\t)\n\n\tif Database != \"\" && isDir(Database) {\n\t\tDatabase = filepath.Join(Database, \"mailpit.db\")\n\t}\n\n\tif Compression < 0 || Compression > 3 {\n\t\treturn errors.New(\"[db] compression level must be between 0 and 3\")\n\t}\n\n\tLabel = tools.Normalize(Label)\n\n\tif err := parseMaxAge(); err != nil {\n\t\treturn err\n\t}\n\n\tTenantID = DBTenantID(TenantID)\n\tif TenantID != \"\" {\n\t\tlogger.Log().Infof(\"[db] using tenant \\\"%s\\\"\", TenantID)\n\t}\n\n\tre := regexp.MustCompile(`.*:\\d+$`)\n\tif _, _, isSocket := tools.UnixSocket(SMTPListen); !isSocket && !re.MatchString(SMTPListen) {\n\t\treturn errors.New(\"[smtp] bind should be in the format of <ip>:<port>\")\n\t}\n\tif _, _, isSocket := tools.UnixSocket(HTTPListen); !isSocket && !re.MatchString(HTTPListen) {\n\t\treturn errors.New(\"[ui] HTTP bind should be in the format of <ip>:<port>\")\n\t}\n\n\t// Web UI & API\n\tif UIAuthFile != \"\" {\n\t\tUIAuthFile = filepath.Clean(UIAuthFile)\n\n\t\tif !isFile(UIAuthFile) {\n\t\t\treturn fmt.Errorf(\"[ui] HTTP password file not found or readable: %s\", UIAuthFile)\n\t\t}\n\n\t\tb, err := os.ReadFile(UIAuthFile)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := auth.SetUIAuth(string(b)); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif UITLSCert != \"\" && UITLSKey == \"\" || UITLSCert == \"\" && UITLSKey != \"\" {\n\t\treturn errors.New(\"[ui] you must provide both a UI TLS certificate and a key\")\n\t}\n\n\tif UITLSCert != \"\" {\n\t\tif strings.HasPrefix(UITLSCert, \"sans:\") {\n\t\t\t// generate a self-signed certificate\n\t\t\tUITLSCert = snakeoil.Public(UITLSCert)\n\t\t} else {\n\t\t\tUITLSCert = filepath.Clean(UITLSCert)\n\t\t}\n\n\t\tif strings.HasPrefix(UITLSKey, \"sans:\") {\n\t\t\t// generate a self-signed key\n\t\t\tUITLSKey = snakeoil.Private(UITLSKey)\n\t\t} else {\n\t\t\tUITLSKey = filepath.Clean(UITLSKey)\n\t\t}\n\n\t\tif !isFile(UITLSCert) {\n\t\t\treturn fmt.Errorf(\"[ui] TLS certificate not found or readable: %s\", UITLSCert)\n\t\t}\n\n\t\tif !isFile(UITLSKey) {\n\t\t\treturn fmt.Errorf(\"[ui] TLS key not found or readable: %s\", UITLSKey)\n\t\t}\n\t}\n\n\t// Send API\n\tif SendAPIAuthFile != \"\" {\n\t\tSendAPIAuthFile = filepath.Clean(SendAPIAuthFile)\n\n\t\tif !isFile(SendAPIAuthFile) {\n\t\t\treturn fmt.Errorf(\"[send-api] password file not found or readable: %s\", SendAPIAuthFile)\n\t\t}\n\n\t\tb, err := os.ReadFile(SendAPIAuthFile)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := auth.SetSendAPIAuth(string(b)); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tlogger.Log().Info(\"[send-api] enabling basic authentication\")\n\t}\n\n\tif auth.SendAPICredentials != nil && SendAPIAuthAcceptAny {\n\t\treturn errors.New(\"[send-api] authentication cannot use both credentials and --send-api-auth-accept-any\")\n\t}\n\n\tif SendAPIAuthAcceptAny && auth.UICredentials != nil {\n\t\tlogger.Log().Info(\"[send-api] disabling authentication\")\n\t}\n\n\t// Prometheus configuration validation\n\tif PrometheusListen != \"\" {\n\t\tmode := strings.ToLower(strings.TrimSpace(PrometheusListen))\n\t\tif mode != \"true\" && mode != \"false\" {\n\t\t\t// Validate as address for separate server mode\n\t\t\t_, err := net.ResolveTCPAddr(\"tcp\", PrometheusListen)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"[prometheus] %s\", err.Error())\n\t\t\t}\n\t\t} else if mode == \"true\" {\n\t\t\tlogger.Log().Info(\"[prometheus] enabling metrics\")\n\t\t}\n\t}\n\n\t// SMTP server\n\tif SMTPTLSCert != \"\" && SMTPTLSKey == \"\" || SMTPTLSCert == \"\" && SMTPTLSKey != \"\" {\n\t\treturn errors.New(\"[smtp] you must provide both an SMTP TLS certificate and a key\")\n\t}\n\n\tif SMTPTLSCert != \"\" {\n\t\tif strings.HasPrefix(SMTPTLSCert, \"sans:\") {\n\t\t\t// generate a self-signed certificate\n\t\t\tSMTPTLSCert = snakeoil.Public(SMTPTLSCert)\n\t\t} else {\n\t\t\tSMTPTLSCert = filepath.Clean(SMTPTLSCert)\n\t\t}\n\n\t\tif strings.HasPrefix(SMTPTLSKey, \"sans:\") {\n\t\t\t// generate a self-signed key\n\t\t\tSMTPTLSKey = snakeoil.Private(SMTPTLSKey)\n\t\t} else {\n\t\t\tSMTPTLSKey = filepath.Clean(SMTPTLSKey)\n\t\t}\n\n\t\tif !isFile(SMTPTLSCert) {\n\t\t\treturn fmt.Errorf(\"[smtp] TLS certificate not found or readable: %s\", SMTPTLSCert)\n\t\t}\n\n\t\tif !isFile(SMTPTLSKey) {\n\t\t\treturn fmt.Errorf(\"[smtp] TLS key not found or readable: %s\", SMTPTLSKey)\n\t\t}\n\t} else if SMTPRequireTLS {\n\t\treturn errors.New(\"[smtp] TLS cannot be required without an SMTP TLS certificate and key\")\n\t} else if SMTPRequireSTARTTLS {\n\t\treturn errors.New(\"[smtp] STARTTLS cannot be required without an SMTP TLS certificate and key\")\n\t}\n\tif SMTPRequireSTARTTLS && SMTPAuthAllowInsecure || SMTPRequireTLS && SMTPAuthAllowInsecure {\n\t\treturn errors.New(\"[smtp] TLS cannot be required with --smtp-auth-allow-insecure\")\n\t}\n\tif SMTPRequireSTARTTLS && SMTPRequireTLS {\n\t\treturn errors.New(\"[smtp] TLS & STARTTLS cannot be required together\")\n\t}\n\n\tif SMTPAuthFile != \"\" {\n\t\tSMTPAuthFile = filepath.Clean(SMTPAuthFile)\n\n\t\tif !isFile(SMTPAuthFile) {\n\t\t\treturn fmt.Errorf(\"[smtp] password file not found or readable: %s\", SMTPAuthFile)\n\t\t}\n\n\t\tb, err := os.ReadFile(SMTPAuthFile)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := auth.SetSMTPAuth(string(b)); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif !SMTPAuthAllowInsecure {\n\t\t\t// https://www.rfc-editor.org/rfc/rfc4954\n\t\t\t// A server implementation MUST implement a configuration in which\n\t\t\t// it does NOT permit any plaintext password mechanisms, unless either\n\t\t\t// the STARTTLS [SMTP-TLS] command has been negotiated or some other\n\t\t\t// mechanism that protects the session from password snooping has been\n\t\t\t// provided.  Server sites SHOULD NOT use any configuration which\n\t\t\t// permits a plaintext password mechanism without such a protection\n\t\t\t// mechanism against password snooping.\n\t\t\tSMTPRequireSTARTTLS = true\n\t\t}\n\t}\n\n\tif auth.SMTPCredentials != nil && SMTPAuthAcceptAny {\n\t\treturn errors.New(\"[smtp] authentication cannot use both credentials and --smtp-auth-accept-any\")\n\t}\n\n\tif SMTPTLSCert == \"\" && (auth.SMTPCredentials != nil || SMTPAuthAcceptAny) && !SMTPAuthAllowInsecure {\n\t\treturn errors.New(\"[smtp] authentication requires STARTTLS or TLS encryption, run with `--smtp-auth-allow-insecure` to allow insecure authentication\")\n\t}\n\n\tif err := parseChaosTriggers(); err != nil {\n\t\treturn fmt.Errorf(\"[chaos] %s\", err.Error())\n\t}\n\n\tif chaos.Enabled {\n\t\tlogger.Log().Info(\"[chaos] is enabled\")\n\t}\n\n\t// POP3 server\n\tif POP3TLSCert != \"\" {\n\t\tif strings.HasPrefix(POP3TLSCert, \"sans:\") {\n\t\t\t// generate a self-signed certificate\n\t\t\tPOP3TLSCert = snakeoil.Public(POP3TLSCert)\n\t\t} else {\n\t\t\tPOP3TLSCert = filepath.Clean(POP3TLSCert)\n\t\t}\n\t\tif strings.HasPrefix(POP3TLSKey, \"sans:\") {\n\t\t\t// generate a self-signed key\n\t\t\tPOP3TLSKey = snakeoil.Private(POP3TLSKey)\n\t\t} else {\n\t\t\tPOP3TLSKey = filepath.Clean(POP3TLSKey)\n\t\t}\n\n\t\tif !isFile(POP3TLSCert) {\n\t\t\treturn fmt.Errorf(\"[pop3] TLS certificate not found or readable: %s\", POP3TLSCert)\n\t\t}\n\n\t\tif !isFile(POP3TLSKey) {\n\t\t\treturn fmt.Errorf(\"[pop3] TLS key not found or readable: %s\", POP3TLSKey)\n\t\t}\n\t}\n\tif POP3TLSCert != \"\" && POP3TLSKey == \"\" || POP3TLSCert == \"\" && POP3TLSKey != \"\" {\n\t\treturn errors.New(\"[pop3] you must provide both a POP3 TLS certificate and a key\")\n\t}\n\tif POP3Listen != \"\" {\n\t\t_, err := net.ResolveTCPAddr(\"tcp\", POP3Listen)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"[pop3] %s\", err.Error())\n\t\t}\n\t}\n\tif POP3AuthFile != \"\" {\n\t\tPOP3AuthFile = filepath.Clean(POP3AuthFile)\n\n\t\tif !isFile(POP3AuthFile) {\n\t\t\treturn fmt.Errorf(\"[pop3] password file not found or readable: %s\", POP3AuthFile)\n\t\t}\n\n\t\tb, err := os.ReadFile(POP3AuthFile)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := auth.SetPOP3Auth(string(b)); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// Web root\n\tvalidWebrootRe := regexp.MustCompile(`[^0-9a-zA-Z\\/\\-\\_\\.@]`)\n\tif validWebrootRe.MatchString(Webroot) {\n\t\treturn fmt.Errorf(\"invalid characters in Webroot (%s). Valid chars include: [a-z A-Z 0-9 _ . - / @]\", Webroot)\n\t}\n\n\ts := strings.TrimRight(path.Join(\"/\", Webroot, \"/\"), \"/\") + \"/\"\n\tWebroot = s\n\n\tif WebhookURL != \"\" && !isValidURL(WebhookURL) {\n\t\treturn fmt.Errorf(\"webhook URL does not appear to be a valid URL (%s)\", WebhookURL)\n\t}\n\n\t// DEPRECATED 2024/04/13\n\tif DisableHTMLCheck {\n\t\tlogger.Log().Warn(\"--disable-html-check has been deprecated and is no longer used\")\n\t}\n\n\tif EnableSpamAssassin != \"\" {\n\t\tspamassassin.SetService(EnableSpamAssassin)\n\t\tlogger.Log().Infof(\"[spamassassin] enabled via %s\", EnableSpamAssassin)\n\n\t\tif err := spamassassin.Ping(); err != nil {\n\t\t\tlogger.Log().Warnf(\"[spamassassin] ping: %s\", err.Error())\n\t\t}\n\t}\n\n\t// load tag filters & options\n\tTagFilters = []autoTag{}\n\tif err := loadTagsFromArgs(CLITagsArg); err != nil {\n\t\treturn err\n\t}\n\tif err := loadTagsFromConfig(TagsConfig); err != nil {\n\t\treturn err\n\t}\n\tif err := parseTagsDisable(TagsDisable); err != nil {\n\t\treturn err\n\t}\n\n\tif SMTPAllowedRecipients != \"\" {\n\t\trestrictRegexp, err := regexp.Compile(SMTPAllowedRecipients)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"[smtp] failed to compile smtp-allowed-recipients regexp: %s\", err.Error())\n\t\t}\n\n\t\tSMTPAllowedRecipientsRegexp = restrictRegexp\n\t\tlogger.Log().Infof(\"[smtp] only allowing recipients matching regexp: %s\", SMTPAllowedRecipients)\n\t}\n\n\tif SMTPIgnoreRejectedRecipients {\n\t\tif SMTPAllowedRecipientsRegexp == nil {\n\t\t\tlogger.Log().Warn(\"[smtp] ignoring rejected recipients has no effect without setting smtp-allowed-recipients\")\n\t\t} else {\n\t\t\tlogger.Log().Info(\"[smtp] ignoring rejected recipients\")\n\t\t}\n\t}\n\n\tif err := parseRelayConfig(SMTPRelayConfigFile); err != nil {\n\t\treturn err\n\t}\n\n\t// separate relay config validation to account for environment variables\n\tif err := validateRelayConfig(); err != nil {\n\t\treturn err\n\t}\n\n\tif !ReleaseEnabled && SMTPRelayAll || !ReleaseEnabled && SMTPRelayMatching != \"\" {\n\t\treturn errors.New(\"[relay] a relay configuration must be set to auto-relay any messages\")\n\t}\n\n\tif SMTPRelayMatching != \"\" {\n\t\tif SMTPRelayAll {\n\t\t\tlogger.Log().Warnf(\"[relay] ignoring smtp-relay-matching when smtp-relay-all is enabled\")\n\t\t} else {\n\t\t\tre, err := regexp.Compile(SMTPRelayMatching)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"[relay] failed to compile smtp-relay-matching regexp: %s\", err.Error())\n\t\t\t}\n\n\t\t\tSMTPRelayMatchingRegexp = re\n\t\t\tlogger.Log().Infof(\n\t\t\t\t\"[relay] auto-relaying new messages to recipients matching \\\"%s\\\" via %s:%d\",\n\t\t\t\tSMTPRelayMatching, SMTPRelayConfig.Host, SMTPRelayConfig.Port,\n\t\t\t)\n\t\t}\n\t}\n\n\tif SMTPRelayAll {\n\t\t// this deserves a warning\n\t\tlogger.Log().Warnf(\"[relay] auto-relaying all new messages via %s:%d\", SMTPRelayConfig.Host, SMTPRelayConfig.Port)\n\t}\n\n\tif err := parseForwardConfig(SMTPForwardConfigFile); err != nil {\n\t\treturn err\n\t}\n\n\t// separate forwarding config validation to account for environment variables\n\tif err := validateForwardConfig(); err != nil {\n\t\treturn err\n\t}\n\n\tif DemoMode {\n\t\tMaxMessages = 1000\n\t\t// this deserves a warning\n\t\tlogger.Log().Info(\"demo mode enabled\")\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "config/tags.go",
    "content": "package config\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/axllent/mailpit/internal/tools\"\n\t\"github.com/goccy/go-yaml\"\n)\n\nvar (\n\t// TagsDisablePlus disables message tagging using plus-addresses (user+tag@example.com) - set via verifyConfig()\n\tTagsDisablePlus bool\n\n\t// TagsDisableXTags disables message tagging via the X-Tags header - set via verifyConfig()\n\tTagsDisableXTags bool\n)\n\ntype yamlTags struct {\n\tFilters []yamlTag `yaml:\"filters\"`\n}\n\ntype yamlTag struct {\n\tMatch string `yaml:\"match\"`\n\tTags  string `yaml:\"tags\"`\n}\n\n// Load tags from a configuration from a file, if set\nfunc loadTagsFromConfig(c string) error {\n\tif c == \"\" {\n\t\treturn nil // not set, ignore\n\t}\n\n\tc = filepath.Clean(c)\n\n\tif !isFile(c) {\n\t\treturn fmt.Errorf(\"[tags] configuration file not found or unreadable: %s\", c)\n\t}\n\n\tdata, err := os.ReadFile(c)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"[tags] %s\", err.Error())\n\t}\n\n\tconf := yamlTags{}\n\n\tif err := yaml.Unmarshal(data, &conf); err != nil {\n\t\treturn err\n\t}\n\n\tif conf.Filters == nil {\n\t\treturn fmt.Errorf(\"[tags] missing tag: array in %s\", c)\n\t}\n\n\tfor _, t := range conf.Filters {\n\t\ttags := strings.Split(t.Tags, \",\")\n\t\tTagFilters = append(TagFilters, autoTag{Match: t.Match, Tags: tags})\n\t}\n\n\tlogger.Log().Debugf(\"[tags] loaded %s from config %s\", tools.Plural(len(conf.Filters), \"tag filter\", \"tag filters\"), c)\n\n\treturn nil\n}\n\nfunc loadTagsFromArgs(c string) error {\n\tif c == \"\" {\n\t\treturn nil // not set, ignore\n\t}\n\n\targs := tools.ArgsParser(c)\n\n\tfor _, a := range args {\n\t\tt := strings.Split(a, \"=\")\n\t\tif len(t) > 1 {\n\t\t\tmatch := strings.TrimSpace(strings.ToLower(strings.Join(t[1:], \"=\")))\n\t\t\ttags := strings.Split(t[0], \",\")\n\t\t\tTagFilters = append(TagFilters, autoTag{Match: match, Tags: tags})\n\t\t} else {\n\t\t\treturn fmt.Errorf(\"[tag] error parsing tags (%s)\", a)\n\t\t}\n\t}\n\n\tlogger.Log().Debugf(\"[tags] loaded %s from CLI args\", tools.Plural(len(args), \"tag filter\", \"tag filters\"))\n\n\treturn nil\n}\n\nfunc parseTagsDisable(s string) error {\n\ts = strings.TrimSpace(s)\n\tif s == \"\" {\n\t\treturn nil\n\t}\n\n\tparts := strings.SplitSeq(strings.ToLower(s), \",\")\n\n\tfor p := range parts {\n\t\tswitch strings.TrimSpace(p) {\n\t\tcase \"x-tags\", \"xtags\":\n\t\t\tTagsDisableXTags = true\n\t\tcase \"plus-addresses\", \"plus-addressing\":\n\t\t\tTagsDisablePlus = true\n\t\tdefault:\n\t\t\treturn fmt.Errorf(\"[tags] invalid --tags-disable option: %s\", p)\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "config/utils.go",
    "content": "package config\n\nimport (\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/axllent/mailpit/internal/tools\"\n)\n\n// IsFile returns whether a file exists and is readable\nfunc isFile(path string) bool {\n\tf, err := os.Open(filepath.Clean(path))\n\tdefer func() { _ = f.Close() }()\n\treturn err == nil\n}\n\n// IsDir returns whether a path is a directory\nfunc isDir(path string) bool {\n\tinfo, err := os.Stat(path)\n\tif err != nil || os.IsNotExist(err) || !info.IsDir() {\n\t\treturn false\n\t}\n\n\treturn true\n}\n\nfunc isValidURL(s string) bool {\n\tu, err := url.ParseRequestURI(s)\n\tif err != nil {\n\t\treturn false\n\t}\n\n\treturn strings.HasPrefix(u.Scheme, \"http\")\n}\n\n// DBTenantID converts a tenant ID to a DB-friendly value if set\nfunc DBTenantID(s string) string {\n\ts = tools.Normalize(s)\n\tif s != \"\" {\n\t\tre := regexp.MustCompile(`[^a-zA-Z0-9\\_]`)\n\t\ts = re.ReplaceAllString(s, \"_\")\n\t\tif !strings.HasSuffix(s, \"_\") {\n\t\t\ts = s + \"_\"\n\t\t}\n\t}\n\n\treturn s\n}\n"
  },
  {
    "path": "config/validators.go",
    "content": "package config\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net/mail\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/axllent/mailpit/internal/smtpd/chaos\"\n\t\"github.com/goccy/go-yaml\"\n)\n\n// Parse the --max-age value (if set)\nfunc parseMaxAge() error {\n\tif MaxAge == \"\" {\n\t\treturn nil\n\t}\n\n\tre := regexp.MustCompile(`^\\d+(h|d)$`)\n\tif !re.MatchString(MaxAge) {\n\t\treturn fmt.Errorf(\"max-age must be either <int>h for hours or <int>d for days: %s\", MaxAge)\n\t}\n\n\tif before, ok := strings.CutSuffix(MaxAge, \"h\"); ok {\n\t\thours, err := strconv.Atoi(before)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tMaxAgeInHours = hours\n\n\t\treturn nil\n\t}\n\n\tdays, err := strconv.Atoi(strings.TrimSuffix(MaxAge, \"d\"))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tlogger.Log().Debugf(\"[db] auto-deleting messages older than %s\", MaxAge)\n\n\tMaxAgeInHours = days * 24\n\treturn nil\n}\n\n// Parse the SMTPRelayConfigFile (if set)\nfunc parseRelayConfig(c string) error {\n\tif c == \"\" {\n\t\treturn nil\n\t}\n\n\tc = filepath.Clean(c)\n\n\tif !isFile(c) {\n\t\treturn fmt.Errorf(\"[relay] configuration not found or readable: %s\", c)\n\t}\n\n\tdata, err := os.ReadFile(c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err := yaml.Unmarshal(data, &SMTPRelayConfig); err != nil {\n\t\treturn err\n\t}\n\n\tif SMTPRelayConfig.Host == \"\" {\n\t\treturn errors.New(\"[relay] host not set\")\n\t}\n\n\t// DEPRECATED 2024/03/12\n\tif SMTPRelayConfig.RecipientAllowlist != \"\" {\n\t\tlogger.Log().Warn(\"[relay] 'recipient-allowlist' is deprecated, use 'allowed-recipients' instead\")\n\t\tif SMTPRelayConfig.AllowedRecipients == \"\" {\n\t\t\tSMTPRelayConfig.AllowedRecipients = SMTPRelayConfig.RecipientAllowlist\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// Validate the SMTPRelayConfig (if Host is set)\nfunc validateRelayConfig() error {\n\tif SMTPRelayConfig.Host == \"\" {\n\t\treturn nil\n\t}\n\n\tif SMTPRelayConfig.Port == 0 {\n\t\tSMTPRelayConfig.Port = 25 // default\n\t}\n\n\tSMTPRelayConfig.Auth = strings.ToLower(SMTPRelayConfig.Auth)\n\n\tif SMTPRelayConfig.Auth == \"\" || SMTPRelayConfig.Auth == \"none\" || SMTPRelayConfig.Auth == \"false\" {\n\t\tSMTPRelayConfig.Auth = \"none\"\n\t} else if SMTPRelayConfig.Auth == \"plain\" {\n\t\tif SMTPRelayConfig.Username == \"\" || SMTPRelayConfig.Password == \"\" {\n\t\t\treturn fmt.Errorf(\"[relay] host username or password not set for PLAIN authentication\")\n\t\t}\n\t} else if SMTPRelayConfig.Auth == \"login\" {\n\t\tSMTPRelayConfig.Auth = \"login\"\n\t\tif SMTPRelayConfig.Username == \"\" || SMTPRelayConfig.Password == \"\" {\n\t\t\treturn fmt.Errorf(\"[relay] host username or password not set for LOGIN authentication\")\n\t\t}\n\t} else if strings.HasPrefix(SMTPRelayConfig.Auth, \"cram\") {\n\t\tSMTPRelayConfig.Auth = \"cram-md5\"\n\t\tif SMTPRelayConfig.Username == \"\" || SMTPRelayConfig.Secret == \"\" {\n\t\t\treturn fmt.Errorf(\"[relay] host username or secret not set for CRAM-MD5 authentication\")\n\t\t}\n\t} else {\n\t\treturn fmt.Errorf(\"[relay] authentication method not supported: %s\", SMTPRelayConfig.Auth)\n\t}\n\n\tif SMTPRelayConfig.AllowedRecipients != \"\" {\n\t\tre, err := regexp.Compile(SMTPRelayConfig.AllowedRecipients)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"[relay] failed to compile recipient allowlist regexp: %s\", err.Error())\n\t\t}\n\n\t\tSMTPRelayConfig.AllowedRecipientsRegexp = re\n\t\tlogger.Log().Infof(\"[relay] recipient allowlist is active with the following regexp: %s\", SMTPRelayConfig.AllowedRecipients)\n\t}\n\n\tif SMTPRelayConfig.BlockedRecipients != \"\" {\n\t\tre, err := regexp.Compile(SMTPRelayConfig.BlockedRecipients)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"[relay] failed to compile recipient blocklist regexp: %s\", err.Error())\n\t\t}\n\n\t\tSMTPRelayConfig.BlockedRecipientsRegexp = re\n\t\tlogger.Log().Infof(\"[relay] recipient blocklist is active with the following regexp: %s\", SMTPRelayConfig.BlockedRecipients)\n\t}\n\n\tif SMTPRelayConfig.OverrideFrom != \"\" {\n\t\tm, err := mail.ParseAddress(SMTPRelayConfig.OverrideFrom)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"[relay] override-from is not a valid email address: %s\", SMTPRelayConfig.OverrideFrom)\n\t\t}\n\n\t\tSMTPRelayConfig.OverrideFrom = m.Address\n\t}\n\n\tif SMTPRelayConfig.STARTTLS && SMTPRelayConfig.TLS {\n\t\treturn fmt.Errorf(\"[relay] TLS & STARTTLS cannot be required together\")\n\t}\n\n\tReleaseEnabled = true\n\n\tlogger.Log().Infof(\"[relay] enabling message relaying via %s:%d\", SMTPRelayConfig.Host, SMTPRelayConfig.Port)\n\n\treturn nil\n}\n\n// Parse the SMTPForwardConfigFile (if set)\nfunc parseForwardConfig(c string) error {\n\tif c == \"\" {\n\t\treturn nil\n\t}\n\n\tc = filepath.Clean(c)\n\n\tif !isFile(c) {\n\t\treturn fmt.Errorf(\"[forward] configuration not found or readable: %s\", c)\n\t}\n\n\tdata, err := os.ReadFile(c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err := yaml.Unmarshal(data, &SMTPForwardConfig); err != nil {\n\t\treturn err\n\t}\n\n\tif SMTPForwardConfig.Host == \"\" {\n\t\treturn errors.New(\"[forward] host not set\")\n\t}\n\n\treturn nil\n}\n\n// Validate the SMTPForwardConfig (if Host is set)\nfunc validateForwardConfig() error {\n\tif SMTPForwardConfig.Host == \"\" {\n\t\treturn nil\n\t}\n\n\tif SMTPForwardConfig.Port == 0 {\n\t\tSMTPForwardConfig.Port = 25 // default\n\t}\n\n\tSMTPForwardConfig.Auth = strings.ToLower(SMTPForwardConfig.Auth)\n\n\tif SMTPForwardConfig.Auth == \"\" || SMTPForwardConfig.Auth == \"none\" || SMTPForwardConfig.Auth == \"false\" {\n\t\tSMTPForwardConfig.Auth = \"none\"\n\t} else if SMTPForwardConfig.Auth == \"plain\" {\n\t\tif SMTPForwardConfig.Username == \"\" || SMTPForwardConfig.Password == \"\" {\n\t\t\treturn fmt.Errorf(\"[forward] host username or password not set for PLAIN authentication\")\n\t\t}\n\t} else if SMTPForwardConfig.Auth == \"login\" {\n\t\tSMTPForwardConfig.Auth = \"login\"\n\t\tif SMTPForwardConfig.Username == \"\" || SMTPForwardConfig.Password == \"\" {\n\t\t\treturn fmt.Errorf(\"[forward] host username or password not set for LOGIN authentication\")\n\t\t}\n\t} else if strings.HasPrefix(SMTPForwardConfig.Auth, \"cram\") {\n\t\tSMTPForwardConfig.Auth = \"cram-md5\"\n\t\tif SMTPForwardConfig.Username == \"\" || SMTPForwardConfig.Secret == \"\" {\n\t\t\treturn fmt.Errorf(\"[forward] host username or secret not set for CRAM-MD5 authentication\")\n\t\t}\n\t} else {\n\t\treturn fmt.Errorf(\"[forward] authentication method not supported: %s\", SMTPForwardConfig.Auth)\n\t}\n\n\tif SMTPForwardConfig.To == \"\" {\n\t\treturn errors.New(\"[forward] To addresses missing\")\n\t}\n\n\tto := []string{}\n\taddresses := strings.SplitSeq(SMTPForwardConfig.To, \",\")\n\tfor a := range addresses {\n\t\ta = strings.TrimSpace(a)\n\t\tm, err := mail.ParseAddress(a)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"[forward] To address is not a valid email address: %s\", a)\n\t\t}\n\t\tto = append(to, m.Address)\n\t}\n\n\tif len(to) == 0 {\n\t\treturn errors.New(\"[forward] no valid To addresses found\")\n\t}\n\n\t// overwrite the To field with the cleaned up list\n\tSMTPForwardConfig.To = strings.Join(to, \",\")\n\n\tif SMTPForwardConfig.OverrideFrom != \"\" {\n\t\tm, err := mail.ParseAddress(SMTPForwardConfig.OverrideFrom)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"[forward] override-from is not a valid email address: %s\", SMTPForwardConfig.OverrideFrom)\n\t\t}\n\n\t\tSMTPForwardConfig.OverrideFrom = m.Address\n\t}\n\n\tif SMTPForwardConfig.STARTTLS && SMTPForwardConfig.TLS {\n\t\treturn fmt.Errorf(\"[forward] TLS & STARTTLS cannot be required together\")\n\t}\n\n\tlogger.Log().Infof(\"[forward] enabling message forwarding to %s via %s:%d\", SMTPForwardConfig.To, SMTPForwardConfig.Host, SMTPForwardConfig.Port)\n\n\treturn nil\n}\n\nfunc parseChaosTriggers() error {\n\tif ChaosTriggers == \"\" {\n\t\treturn nil\n\t}\n\n\tre := regexp.MustCompile(`^([a-zA-Z0-0]+):(\\d\\d\\d):(\\d+(\\.\\d)?)$`)\n\n\tparts := strings.SplitSeq(ChaosTriggers, \",\")\n\tfor p := range parts {\n\t\tp = strings.TrimSpace(p)\n\t\tif !re.MatchString(p) {\n\t\t\treturn fmt.Errorf(\"invalid argument: %s\", p)\n\t\t}\n\n\t\tmatches := re.FindAllStringSubmatch(p, 1)\n\t\tkey := matches[0][1]\n\t\terrorCode, err := strconv.Atoi(matches[0][2])\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tprobability, err := strconv.Atoi(matches[0][3])\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := chaos.Set(key, errorCode, probability); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "esbuild.config.mjs",
    "content": "import * as esbuild from \"esbuild\";\nimport pluginVue from \"esbuild-plugin-vue-next\";\nimport { sassPlugin } from \"esbuild-sass-plugin\";\n\nconst doWatch = process.env.WATCH === \"true\";\nconst doMinify = process.env.MINIFY === \"true\";\n\nconst ctx = await esbuild.context({\n\tentryPoints: [\"server/ui-src/app.js\", \"server/ui-src/docs.js\"],\n\tbundle: true,\n\tminify: doMinify,\n\tsourcemap: false,\n\tdefine: {\n\t\t__VUE_OPTIONS_API__: \"true\",\n\t\t__VUE_PROD_DEVTOOLS__: \"false\",\n\t\t__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: \"false\",\n\t},\n\toutdir: \"server/ui/dist/\",\n\tplugins: [\n\t\tpluginVue(),\n\t\tsassPlugin({\n\t\t\tsilenceDeprecations: [\"import\"],\n\t\t\tquietDeps: true,\n\t\t}),\n\t],\n\tloader: {\n\t\t\".svg\": \"file\",\n\t\t\".woff\": \"file\",\n\t\t\".woff2\": \"file\",\n\t},\n\tlogLevel: \"info\",\n});\n\nif (doWatch) {\n\tawait ctx.watch();\n} else {\n\tawait ctx.rebuild();\n\tctx.dispose();\n}\n"
  },
  {
    "path": "eslint.config.js",
    "content": "import eslintConfigPrettier from \"eslint-config-prettier/flat\";\nimport globals from \"globals\";\nimport { includeIgnoreFile } from \"@eslint/compat\";\nimport js from \"@eslint/js\";\nimport vue from \"eslint-plugin-vue\";\nimport { fileURLToPath } from \"node:url\";\n\nconst gitignorePath = fileURLToPath(new URL(\".gitignore\", import.meta.url));\n\nexport default [\n\t/* Use .gitignore to prevent linting of irrelevant files */\n\tincludeIgnoreFile(gitignorePath, \".gitignore\"),\n\n\t/* ESLint's recommended rules */\n\t{\n\t\tfiles: [\"**/*.js\", \"**/*.vue\"],\n\t\tlanguageOptions: { globals: { ...globals.browser, ...globals.node } },\n\t\trules: js.configs.recommended.rules,\n\t},\n\n\t/* Vue-specific rules */\n\t...vue.configs[\"flat/recommended\"],\n\n\t/* Prettier is responsible for formatting, so we disable conflicting rules */\n\teslintConfigPrettier,\n\n\t/* Our custom rules */\n\t{\n\t\trules: {\n\t\t\t/* Always use arrow functions for tidiness and consistency */\n\t\t\t\"prefer-arrow-callback\": \"error\",\n\n\t\t\t/* Always use camelCase for variable names */\n\t\t\tcamelcase: [\n\t\t\t\t\"error\",\n\t\t\t\t{\n\t\t\t\t\tignoreDestructuring: false,\n\t\t\t\t\tignoreGlobals: true,\n\t\t\t\t\tignoreImports: false,\n\t\t\t\t\tproperties: \"never\",\n\t\t\t\t},\n\t\t\t],\n\n\t\t\t/* The default case in switch statements must always be last */\n\t\t\t\"default-case-last\": \"error\",\n\n\t\t\t/* Always use dot notation where possible (e.g. `obj.val` over `obj['val']`) */\n\t\t\t\"dot-notation\": \"error\",\n\n\t\t\t/* Always use `===` and `!==` for comparisons unless unambiguous */\n\t\t\teqeqeq: [\"error\", \"smart\"],\n\n\t\t\t/* Never use `eval()` as it violates our CSP and can lead to security issues */\n\t\t\t\"no-eval\": \"error\",\n\t\t\t\"no-implied-eval\": \"error\",\n\n\t\t\t/* Prevents accidental use of template literals in plain strings, e.g. \"my ${var}\" */\n\t\t\t\"no-template-curly-in-string\": \"error\",\n\n\t\t\t/* Avoid unnecessary ternary operators */\n\t\t\t\"no-unneeded-ternary\": \"error\",\n\n\t\t\t/* Avoid unused expressions that have no purpose */\n\t\t\t\"no-unused-expressions\": \"error\",\n\n\t\t\t/* Always use `const` or `let` to make scope behaviour clear */\n\t\t\t\"no-var\": \"error\",\n\n\t\t\t/* Always use shorthand syntax for objects where possible, e.g. { a, b() { } } */\n\t\t\t\"object-shorthand\": \"error\",\n\n\t\t\t/* Always use `const` for variables that are never reassigned */\n\t\t\t\"prefer-const\": \"error\",\n\t\t},\n\t},\n];\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/axllent/mailpit\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/PuerkitoBio/goquery v1.11.0\n\tgithub.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de\n\tgithub.com/axllent/ghru/v2 v2.1.0\n\tgithub.com/axllent/semver v1.0.0\n\tgithub.com/goccy/go-yaml v1.19.2\n\tgithub.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab\n\tgithub.com/gorilla/mux v1.8.1\n\tgithub.com/gorilla/websocket v1.5.3\n\tgithub.com/jhillyerd/enmime/v2 v2.3.0\n\tgithub.com/klauspost/compress v1.18.4\n\tgithub.com/kovidgoyal/imaging v1.8.20\n\tgithub.com/leporo/sqlf v1.4.0\n\tgithub.com/lithammer/shortuuid/v4 v4.2.0\n\tgithub.com/mneis/go-telnet v0.0.0-20221017141824-6f643e477c62\n\tgithub.com/pkg/errors v0.9.1\n\tgithub.com/prometheus/client_golang v1.23.2\n\tgithub.com/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8\n\tgithub.com/sirupsen/logrus v1.9.4\n\tgithub.com/spf13/cobra v1.10.2\n\tgithub.com/spf13/pflag v1.0.10\n\tgithub.com/tg123/go-htpasswd v1.2.4\n\tgithub.com/vanng822/go-premailer v1.32.0\n\tgolang.org/x/crypto v0.48.0\n\tgolang.org/x/net v0.51.0\n\tgolang.org/x/text v0.34.0\n\tgolang.org/x/time v0.15.0\n\tmodernc.org/sqlite v1.46.1\n)\n\nrequire (\n\tgithub.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 // indirect\n\tgithub.com/andybalholm/cascadia v1.3.3 // indirect\n\tgithub.com/beorn7/perks v1.0.1 // indirect\n\tgithub.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect\n\tgithub.com/cespare/xxhash/v2 v2.3.0 // indirect\n\tgithub.com/clipperhouse/displaywidth v0.11.0 // indirect\n\tgithub.com/clipperhouse/uax29/v2 v2.7.0 // indirect\n\tgithub.com/dustin/go-humanize v1.0.1 // indirect\n\tgithub.com/fatih/color v1.18.0 // indirect\n\tgithub.com/goccy/go-json v0.10.5 // indirect\n\tgithub.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect\n\tgithub.com/google/uuid v1.6.0 // indirect\n\tgithub.com/gorilla/css v1.0.1 // indirect\n\tgithub.com/inbucket/html2text v1.0.0 // indirect\n\tgithub.com/inconshreveable/mousetrap v1.1.0 // indirect\n\tgithub.com/kovidgoyal/go-parallel v1.1.1 // indirect\n\tgithub.com/kovidgoyal/go-shm v1.0.0 // indirect\n\tgithub.com/mattn/go-colorable v0.1.14 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/mattn/go-runewidth v0.0.21 // indirect\n\tgithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect\n\tgithub.com/ncruces/go-strftime v1.0.0 // indirect\n\tgithub.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect\n\tgithub.com/olekukonko/errors v1.2.0 // indirect\n\tgithub.com/olekukonko/ll v0.1.7 // indirect\n\tgithub.com/olekukonko/tablewriter v1.1.3 // indirect\n\tgithub.com/prometheus/client_model v0.6.2 // indirect\n\tgithub.com/prometheus/common v0.67.5 // indirect\n\tgithub.com/prometheus/procfs v0.20.1 // indirect\n\tgithub.com/reiver/go-oi v1.0.0 // indirect\n\tgithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect\n\tgithub.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect\n\tgithub.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect\n\tgithub.com/valyala/bytebufferpool v1.0.0 // indirect\n\tgithub.com/vanng822/css v1.0.1 // indirect\n\tgo.yaml.in/yaml/v2 v2.4.4 // indirect\n\tgolang.org/x/image v0.36.0 // indirect\n\tgolang.org/x/mod v0.33.0 // indirect\n\tgolang.org/x/sys v0.42.0 // indirect\n\tgoogle.golang.org/protobuf v1.36.11 // indirect\n\tmodernc.org/libc v1.70.0 // indirect\n\tmodernc.org/mathutil v1.7.1 // indirect\n\tmodernc.org/memory v1.11.0 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI=\ngithub.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec=\ngithub.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=\ngithub.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=\ngithub.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=\ngithub.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=\ngithub.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=\ngithub.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=\ngithub.com/axllent/ghru/v2 v2.1.0 h1:zNW96KO+rmXggizZhHzIX7MExOiV4jx+63Y9nXlwLV0=\ngithub.com/axllent/ghru/v2 v2.1.0/go.mod h1:8l7s1phdc375vvf8LHxT7wnJqXlThdHJR5EBtHNWhTg=\ngithub.com/axllent/semver v1.0.0 h1:FDekA0alnMed5bWVWjUwBS+6QouZZkmPXsGVmOfjWOg=\ngithub.com/axllent/semver v1.0.0/go.mod h1:ySHHYLyFX3vKAALmaO8TOOJkzGRsUNmzFiIWwPm8li8=\ngithub.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=\ngithub.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=\ngithub.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=\ngithub.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=\ngithub.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=\ngithub.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=\ngithub.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=\ngithub.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=\ngithub.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=\ngithub.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=\ngithub.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=\ngithub.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=\ngithub.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=\ngithub.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=\ngithub.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=\ngithub.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=\ngithub.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=\ngithub.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=\ngithub.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=\ngithub.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=\ngithub.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=\ngithub.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=\ngithub.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=\ngithub.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=\ngithub.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=\ngithub.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=\ngithub.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=\ngithub.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=\ngithub.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=\ngithub.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=\ngithub.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=\ngithub.com/inbucket/html2text v1.0.0 h1:N5kza++4uBBDJ2Z3KUnTRyPNoBcW+YfOgNiNmNB+sgs=\ngithub.com/inbucket/html2text v1.0.0/go.mod h1:5TrhXQKGU+LXurODaSm55Y9eXoPBRnYiOz4x2XfUoJU=\ngithub.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=\ngithub.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=\ngithub.com/jhillyerd/enmime/v2 v2.3.0 h1:Y/pzQanyU8nkSgB2npXX8Dha5OItJE/QwbDJM4sf/kU=\ngithub.com/jhillyerd/enmime/v2 v2.3.0/go.mod h1:mGKXAP45l6pF6HZiaLhgSYsgteJskaSIYmEZXpw6ZpI=\ngithub.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=\ngithub.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=\ngithub.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811uiUXyI7ok=\ngithub.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw=\ngithub.com/kovidgoyal/go-shm v1.0.0 h1:HJEel9D1F9YhULvClEHJLawoRSj/1u/EDV7MJbBPgQo=\ngithub.com/kovidgoyal/go-shm v1.0.0/go.mod h1:Yzb80Xf9L3kaoB2RGok9hHwMIt7Oif61kT6t3+VnZds=\ngithub.com/kovidgoyal/imaging v1.8.20 h1:74GZ7C2rIm3rqmGEjK1GvvPOOnJ0SS5iDOa6Flfo0b0=\ngithub.com/kovidgoyal/imaging v1.8.20/go.mod h1:d3phGYkTChGYkY4y++IjpHgUGhWGELDc2NEQAqxwZZg=\ngithub.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=\ngithub.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=\ngithub.com/leporo/sqlf v1.4.0 h1:SyWnX/8GSGOzVmanG0Ub1c04mR9nNl6Tq3IeFKX2/4c=\ngithub.com/leporo/sqlf v1.4.0/go.mod h1:pgN9yKsAnQ+2ewhbZogr98RcasUjPsHF3oXwPPhHvBw=\ngithub.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c=\ngithub.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y=\ngithub.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=\ngithub.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=\ngithub.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=\ngithub.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=\ngithub.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=\ngithub.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=\ngithub.com/mneis/go-telnet v0.0.0-20221017141824-6f643e477c62 h1:XMG5DklHoioVYysfYglOB7vRBg/LOUJZy2mq2QyedLg=\ngithub.com/mneis/go-telnet v0.0.0-20221017141824-6f643e477c62/go.mod h1:niAM5cni0I/47IFA995xQfeK58Mkbb7FHJjacY4OGQg=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=\ngithub.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=\ngithub.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=\ngithub.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=\ngithub.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=\ngithub.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo=\ngithub.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=\ngithub.com/olekukonko/ll v0.1.7 h1:WyK1YZwOTUKHEXZz3VydBDT5t3zDqa9yI8iJg5PHon4=\ngithub.com/olekukonko/ll v0.1.7/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw=\ngithub.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA=\ngithub.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM=\ngithub.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=\ngithub.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=\ngithub.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=\ngithub.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=\ngithub.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=\ngithub.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=\ngithub.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=\ngithub.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=\ngithub.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=\ngithub.com/reiver/go-oi v1.0.0 h1:nvECWD7LF+vOs8leNGV/ww+F2iZKf3EYjYZ527turzM=\ngithub.com/reiver/go-oi v1.0.0/go.mod h1:RrDBct90BAhoDTxB1fenZwfykqeGvhI6LsNfStJoEkI=\ngithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=\ngithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=\ngithub.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=\ngithub.com/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8 h1:BoxiqWvhprOB2isgM59s8wkgKwAoyQH66Twfmof41oE=\ngithub.com/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8/go.mod h1:xF/KoXmrRyahPfo5L7Szb5cAAUl53dMWBh9cMruGEZg=\ngithub.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=\ngithub.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=\ngithub.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=\ngithub.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=\ngithub.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=\ngithub.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=\ngithub.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=\ngithub.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=\ngithub.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=\ngithub.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=\ngithub.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngithub.com/tg123/go-htpasswd v1.2.4 h1:HgH8KKCjdmo7jjXWN9k1nefPBd7Be3tFCTjc2jPraPU=\ngithub.com/tg123/go-htpasswd v1.2.4/go.mod h1:EKThQok9xHkun6NBMynNv6Jmu24A33XdZzzl4Q7H1+0=\ngithub.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=\ngithub.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=\ngithub.com/vanng822/css v1.0.1 h1:10yiXc4e8NI8ldU6mSrWmSWMuyWgPr9DZ63RSlsgDw8=\ngithub.com/vanng822/css v1.0.1/go.mod h1:tcnB1voG49QhCrwq1W0w5hhGasvOg+VQp9i9H1rCM1w=\ngithub.com/vanng822/go-premailer v1.32.0 h1:dW1y2IKSBQyYIwMc9comDA2e+00/pJ1kVXf3v4sqAJo=\ngithub.com/vanng822/go-premailer v1.32.0/go.mod h1:4gVC6Hs+ESjSSfB1ohMwLqwuGoJ76cc0c2VM7DYqr0s=\ngithub.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=\ngo.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=\ngo.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=\ngo.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=\ngo.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=\ngo.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=\ngolang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=\ngolang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=\ngolang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=\ngolang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=\ngolang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=\ngolang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=\ngolang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=\ngolang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=\ngolang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=\ngolang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=\ngolang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=\ngolang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=\ngolang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=\ngolang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=\ngolang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=\ngolang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=\ngolang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=\ngolang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=\ngolang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=\ngolang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=\ngolang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=\ngolang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=\ngolang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=\ngolang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=\ngolang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=\ngolang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=\ngolang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=\ngolang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=\ngolang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=\ngolang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=\ngolang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=\ngolang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=\ngolang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=\ngolang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=\ngolang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=\ngolang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=\ngolang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=\ngolang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=\ngolang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=\ngolang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=\ngolang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=\ngolang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=\ngolang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=\ngolang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=\ngolang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=\ngolang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=\ngolang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=\ngolang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=\ngolang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=\ngolang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=\ngolang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=\ngolang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=\ngolang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngoogle.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=\ngoogle.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\nmodernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=\nmodernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=\nmodernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=\nmodernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=\nmodernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=\nmodernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=\nmodernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=\nmodernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=\nmodernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=\nmodernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=\nmodernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=\nmodernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=\nmodernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=\nmodernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=\nmodernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=\nmodernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=\nmodernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=\nmodernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=\nmodernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=\nmodernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=\nmodernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=\nmodernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=\nmodernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=\nmodernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=\nmodernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=\nmodernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=\nmodernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=\nmodernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=\n"
  },
  {
    "path": "install.sh",
    "content": "#!/bin/sh\n\n# This script will install the latest release of Mailpit.\n\n# Check dependencies is installed\nfor cmd in curl tar; do\n    if ! command -v \"$cmd\" >/dev/null 2>&1; then\n        echo \"Then $cmd command is required but not installed.\"\n        echo \"Please install $cmd and try again.\"\n        exit 1\n    fi\ndone\n\n# Check if the OS is supported.\nOS=\ncase \"$(uname -s)\" in\nLinux) OS=\"linux\" ;;\nDarwin) OS=\"darwin\" ;;\n*)\n    echo \"OS not supported.\"\n    exit 2\n    ;;\nesac\n\n# Detect the architecture of the OS.\nOS_ARCH=\ncase \"$(uname -m)\" in\nx86_64 | amd64)\n    OS_ARCH=\"amd64\"\n    ;;\ni?86 | x86)\n    OS_ARCH=\"386\"\n    ;;\naarch64 | arm64)\n    OS_ARCH=\"arm64\"\n    ;;\n*)\n    echo \"OS architecture not supported.\"\n    exit 2\n    ;;\nesac\n\nGH_REPO=\"axllent/mailpit\"\nINSTALL_PATH=\"${INSTALL_PATH:-/usr/local/bin}\"\nTIMEOUT=90\n# This is used to authenticate with the GitHub API. (Fix the public rate limiting issue)\n# Try the GITHUB_TOKEN environment variable is set globally.\nGITHUB_API_TOKEN=\"${GITHUB_TOKEN:-}\"\n\n# Update the default values if the user has set.\nwhile [ $# -gt 0 ]; do\n    case $1 in\n    --install-path)\n        shift\n        case \"$1\" in\n        */*)\n            # Remove trailing slashes from the path.\n            INSTALL_PATH=\"$(echo \"$1\" | sed 's#/\\+$##')\"\n            [ -z \"$INSTALL_PATH\" ] && INSTALL_PATH=\"/\"\n            ;;\n        esac\n        ;;\n    --auth | --auth-token | --github-token | --token)\n        shift\n        case \"$1\" in\n        gh*)\n            GITHUB_API_TOKEN=\"$1\"\n            ;;\n        esac\n        ;;\n    *) ;;\n    esac\n    shift\ndone\n\n# Description of the sort parameters for curl command.\n# -s: Silent mode.\n# -f: Fail silently on server errors.\n# -L: Follow redirects.\n# -m: Set maximum time allowed for the transfer.\n\nif [ -n \"$GITHUB_API_TOKEN\" ] && [ \"${#GITHUB_API_TOKEN}\" -gt 36 ]; then\n    CURL_OUTPUT=\"$(curl -sfL -m $TIMEOUT -H \"Authorization: Bearer $GITHUB_API_TOKEN\" https://api.github.com/repos/${GH_REPO}/releases/latest)\"\n    EXIT_CODE=$?\nelse\n    CURL_OUTPUT=\"$(curl -sfL -m $TIMEOUT https://api.github.com/repos/${GH_REPO}/releases/latest)\"\n    EXIT_CODE=$?\nfi\n\nVERSION=\"\"\nif [ $EXIT_CODE -eq 0 ]; then\n    # Extracts the latest version using jq, awk, or sed.\n    if command -v jq >/dev/null 2>&1; then\n        # Use jq -n because the output is not a valid JSON in sh.\n        VERSION=$(jq -n \"$CURL_OUTPUT\" | jq -r '.tag_name')\n    elif command -v awk >/dev/null 2>&1; then\n        VERSION=$(echo \"$CURL_OUTPUT\" | awk -F: '$1 ~ /tag_name/ {gsub(/[^v0-9\\.]+/, \"\", $2) ;print $2; exit}')\n    elif command -v sed >/dev/null 2>&1; then\n        VERSION=$(echo \"$CURL_OUTPUT\" | sed -n 's/.*\"tag_name\": *\"\\([^\"]*\\)\".*/\\1/p')\n    else\n        EXIT_CODE=3\n    fi\nfi\n\n# Validate the version.\ncase \"$VERSION\" in\nv[0-9][0-9\\.]*) ;;\n*)\n    echo \"There was an error trying to check what is the latest version of Mailpit.\"\n    echo \"Please try again later.\"\n    exit $EXIT_CODE\n    ;;\nesac\n\nTEMP_DIR=\"$(mktemp -qd)\"\nEXIT_CODE=$?\n# Ensure the temporary directory exists and is a directory.\nif [ -z \"$TEMP_DIR\" ] || [ ! -d \"$TEMP_DIR\" ]; then\n    echo \"ERROR: Creating temporary directory.\"\n    exit $EXIT_CODE\nfi\n\nGH_REPO_BIN=\"mailpit-${OS}-${OS_ARCH}.tar.gz\"\nif [ \"$INSTALL_PATH\" = \"/\" ]; then\n    INSTALL_BIN_PATH=\"/mailpit\"\nelse\n    INSTALL_BIN_PATH=\"${INSTALL_PATH}/mailpit\"\nfi\ncd \"$TEMP_DIR\" || EXIT_CODE=$?\nif [ $EXIT_CODE -eq 0 ]; then\n    # Download the latest release.\n    #\n    # Description of the sort parameters for curl command.\n    # -s: Silent mode.\n    # -f: Fail silently on server errors.\n    # -L: Follow redirects.\n    # -m: Set maximum time allowed for the transfer.\n    # -o: Write output to a file instead of stdout.\n    curl -sfL -m $TIMEOUT -o \"${GH_REPO_BIN}\" \"https://github.com/${GH_REPO}/releases/download/${VERSION}/${GH_REPO_BIN}\"\n    EXIT_CODE=$?\n\n    # The following conditions check each step of the installation.\n    # If there is an error in any of the steps, an error message is printed.\n\n    if [ $EXIT_CODE -eq 0 ]; then\n        if ! [ -f \"${GH_REPO_BIN}\" ]; then\n            EXIT_CODE=1\n            echo \"ERROR: Downloading latest release.\"\n        fi\n    fi\n\n    if [ $EXIT_CODE -eq 0 ]; then\n        tar zxf \"$GH_REPO_BIN\"\n        EXIT_CODE=$?\n        if [ $EXIT_CODE -ne 0 ]; then\n            echo \"ERROR: Extracting \\\"${GH_REPO_BIN}\\\".\"\n        fi\n    fi\n\n    if [ $EXIT_CODE -eq 0 ] && [ ! -d \"$INSTALL_PATH\" ]; then\n        mkdir -p \"${INSTALL_PATH}\"\n        EXIT_CODE=$?\n        if [ $EXIT_CODE -ne 0 ]; then\n            echo \"ERROR: Creating \\\"${INSTALL_PATH}\\\" directory.\"\n        fi\n    fi\n\n    if [ $EXIT_CODE -eq 0 ]; then\n        cp mailpit \"$INSTALL_BIN_PATH\"\n        EXIT_CODE=$?\n        if [ $EXIT_CODE -ne 0 ]; then\n            echo \"ERROR: Copying mailpit to \\\"${INSTALL_PATH}\\\" directory.\"\n        fi\n    fi\n\n    if [ $EXIT_CODE -eq 0 ]; then\n        chmod 755 \"$INSTALL_BIN_PATH\"\n        EXIT_CODE=$?\n        if [ $EXIT_CODE -ne 0 ]; then\n            echo \"ERROR: Setting permissions for \\\"$INSTALL_BIN_PATH\\\" binary.\"\n        fi\n    fi\n\n    # Set the owner and group to root:root if the script is run as root.\n    if [ $EXIT_CODE -eq 0 ] && [ \"$(id -u)\" -eq \"0\" ]; then\n        OWNER=\"root\"\n        GROUP=\"root\"\n        # Set the OWNER, GROUP variable when the OS not use the default root:root.\n        case \"$OS\" in\n        darwin) GROUP=\"wheel\" ;;\n        *) ;;\n        esac\n\n        chown \"${OWNER}:${GROUP}\" \"$INSTALL_BIN_PATH\"\n        EXIT_CODE=$?\n        if [ $EXIT_CODE -ne 0 ]; then\n            echo \"ERROR: Setting ownership for \\\"$INSTALL_BIN_PATH\\\" binary.\"\n        fi\n    fi\nelse\n    echo \"ERROR: Changing to temporary directory.\"\n    exit $EXIT_CODE\nfi\n\n# Cleanup the temporary directory.\nrm -rf \"$TEMP_DIR\"\n# Check the EXIT_CODE variable, and print the success or error message.\nif [ $EXIT_CODE -ne 0 ]; then\n    echo \"There was an error installing Mailpit.\"\n    exit $EXIT_CODE\nfi\n\necho \"Installed successfully to \\\"$INSTALL_BIN_PATH\\\".\"\nexit 0\n"
  },
  {
    "path": "internal/auth/auth.go",
    "content": "// Package auth handles the web UI and SMTP authentication\npackage auth\n\nimport (\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/tg123/go-htpasswd\"\n)\n\nvar (\n\t// UICredentials passwords\n\tUICredentials *htpasswd.File\n\t// SendAPICredentials passwords\n\tSendAPICredentials *htpasswd.File\n\t// SMTPCredentials passwords\n\tSMTPCredentials *htpasswd.File\n\t// POP3Credentials passwords\n\tPOP3Credentials *htpasswd.File\n)\n\n// SetUIAuth will set Basic Auth credentials required for the UI & API\nfunc SetUIAuth(s string) error {\n\tvar err error\n\n\tcredentials := credentialsFromString(s)\n\tif len(credentials) == 0 {\n\t\treturn nil\n\t}\n\n\tr := strings.NewReader(strings.Join(credentials, \"\\n\"))\n\n\tUICredentials, err = htpasswd.NewFromReader(r, htpasswd.DefaultSystems, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// SetSendAPIAuth will set Send API credentials\nfunc SetSendAPIAuth(s string) error {\n\tvar err error\n\n\tcredentials := credentialsFromString(s)\n\tif len(credentials) == 0 {\n\t\treturn nil\n\t}\n\n\tr := strings.NewReader(strings.Join(credentials, \"\\n\"))\n\n\tSendAPICredentials, err = htpasswd.NewFromReader(r, htpasswd.DefaultSystems, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// SetSMTPAuth will set SMTP credentials\nfunc SetSMTPAuth(s string) error {\n\tvar err error\n\n\tcredentials := credentialsFromString(s)\n\tif len(credentials) == 0 {\n\t\treturn nil\n\t}\n\n\tr := strings.NewReader(strings.Join(credentials, \"\\n\"))\n\n\tSMTPCredentials, err = htpasswd.NewFromReader(r, htpasswd.DefaultSystems, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// SetPOP3Auth will set POP3 server credentials\nfunc SetPOP3Auth(s string) error {\n\tvar err error\n\n\tcredentials := credentialsFromString(s)\n\tif len(credentials) == 0 {\n\t\treturn nil\n\t}\n\n\tr := strings.NewReader(strings.Join(credentials, \"\\n\"))\n\n\tPOP3Credentials, err = htpasswd.NewFromReader(r, htpasswd.DefaultSystems, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc credentialsFromString(s string) []string {\n\t// split string by any whitespace character\n\tre := regexp.MustCompile(`\\s+`)\n\n\twords := re.Split(s, -1)\n\tcredentials := []string{}\n\tfor _, w := range words {\n\t\tif w != \"\" {\n\t\t\tcredentials = append(credentials, w)\n\t\t}\n\t}\n\n\treturn credentials\n}\n"
  },
  {
    "path": "internal/dump/dump.go",
    "content": "// Package dump is used to export all messages from mailpit into a directory\npackage dump\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"path\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/axllent/mailpit/internal/storage\"\n\t\"github.com/axllent/mailpit/internal/tools\"\n\t\"github.com/axllent/mailpit/server/apiv1\"\n)\n\nvar (\n\tlinkRe = regexp.MustCompile(`(?i)^https?:\\/\\/`)\n\n\toutDir string\n\n\t// Base URL of mailpit instance\n\tbase string\n\n\t// URL is the base URL of a remove Mailpit instance\n\tURL string\n\n\tsummary = []storage.MessageSummary{}\n)\n\n// Sync will sync all messages from the specified database or API to the specified output directory\nfunc Sync(d string) error {\n\n\toutDir = path.Clean(d)\n\n\tif URL != \"\" {\n\t\tif !linkRe.MatchString(URL) {\n\t\t\treturn errors.New(\"invalid URL\")\n\t\t}\n\n\t\tbase = strings.TrimRight(URL, \"/\") + \"/\"\n\t}\n\n\tif base == \"\" && config.Database == \"\" {\n\t\treturn errors.New(\"no database or API URL specified\")\n\t}\n\n\tif !tools.IsDir(outDir) {\n\t\tif err := os.MkdirAll(outDir, 0755); /* #nosec */ err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err := loadIDs(); err != nil {\n\t\treturn err\n\t}\n\n\tif err := saveMessages(); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// LoadIDs will load all message IDs from the specified database or API\nfunc loadIDs() error {\n\tif base != \"\" {\n\t\t// remote\n\t\tlogger.Log().Debugf(\"Fetching messages summary from %s\", base)\n\t\tres, err := http.Get(base + \"api/v1/messages?limit=0\")\n\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tbody, err := io.ReadAll(res.Body)\n\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tvar data apiv1.MessagesSummary\n\t\tif err := json.Unmarshal(body, &data); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tsummary = data.Messages\n\n\t} else {\n\t\t// make sure the database isn't pruned while open\n\t\tconfig.MaxMessages = 0\n\n\t\tvar err error\n\t\t// local database\n\t\tif err = storage.InitDB(); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tlogger.Log().Debugf(\"Fetching messages summary from %s\", config.Database)\n\n\t\tsummary, err = storage.List(0, 0, 0)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif len(summary) == 0 {\n\t\treturn errors.New(\"no messages found\")\n\t}\n\n\treturn nil\n}\n\nfunc saveMessages() error {\n\tfor _, m := range summary {\n\t\tout := path.Join(outDir, m.ID+\".eml\")\n\n\t\t// skip if message exists\n\t\tif tools.IsFile(out) {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar b []byte\n\n\t\tif base != \"\" {\n\t\t\tres, err := http.Get(base + \"api/v1/message/\" + m.ID + \"/raw\")\n\n\t\t\tif err != nil {\n\t\t\t\tlogger.Log().Errorf(\"error fetching message %s: %s\", m.ID, err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tb, err = io.ReadAll(res.Body)\n\n\t\t\tif err != nil {\n\t\t\t\tlogger.Log().Errorf(\"error fetching message %s: %s\", m.ID, err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t} else {\n\t\t\tvar err error\n\t\t\tb, err = storage.GetMessageRaw(m.ID)\n\t\t\tif err != nil {\n\t\t\t\tlogger.Log().Errorf(\"error fetching message %s: %s\", m.ID, err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\n\t\tif err := os.WriteFile(out, b, 0644); /* #nosec */ err != nil {\n\t\t\tlogger.Log().Errorf(\"error writing message %s: %s\", m.ID, err.Error())\n\t\t\tcontinue\n\t\t}\n\n\t\t_ = os.Chtimes(out, m.Created, m.Created)\n\n\t\tlogger.Log().Debugf(\"Saved message %s to %s\", m.ID, out)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/html2text/html2text.go",
    "content": "// Package html2text is a simple library to convert HTML to plain text\npackage html2text\n\nimport (\n\t\"bytes\"\n\t\"log\"\n\t\"regexp\"\n\t\"strings\"\n\t\"unicode\"\n\n\t\"golang.org/x/net/html\"\n)\n\nvar (\n\tre      = regexp.MustCompile(`\\s+`)\n\tspaceRe = regexp.MustCompile(`(?mi)<\\/(div|p|td|th|h[1-6]|ul|ol|li|address|article|aside|blockquote|dl|dt|footer|header|hr|main|nav|pre|table|thead|tfoot|video)><`)\n\tbrRe    = regexp.MustCompile(`(?mi)<(br /|br)>`)\n\timgRe   = regexp.MustCompile(`(?mi)<(img)`)\n\tskip    = make(map[string]bool)\n)\n\nfunc init() {\n\tskip[\"script\"] = true\n\tskip[\"title\"] = true\n\tskip[\"head\"] = true\n\tskip[\"link\"] = true\n\tskip[\"meta\"] = true\n\tskip[\"style\"] = true\n\tskip[\"noscript\"] = true\n}\n\n// Strip will convert a HTML string to plain text\nfunc Strip(h string, includeLinks bool) string {\n\th = spaceRe.ReplaceAllString(h, \"</$1> <\")\n\th = brRe.ReplaceAllString(h, \" \")\n\th = imgRe.ReplaceAllString(h, \" <$1\")\n\tvar buffer bytes.Buffer\n\tdoc, err := html.Parse(strings.NewReader(h))\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\n\textract(doc, &buffer, includeLinks)\n\treturn clean(buffer.String())\n}\n\nfunc extract(node *html.Node, buff *bytes.Buffer, includeLinks bool) {\n\tif node.Type == html.TextNode {\n\t\tdata := node.Data\n\t\tif data != \"\" {\n\t\t\tbuff.WriteString(data)\n\t\t}\n\t}\n\tfor c := node.FirstChild; c != nil; c = c.NextSibling {\n\t\tif _, skip := skip[c.Data]; !skip {\n\t\t\tif includeLinks && c.Data == \"a\" {\n\t\t\t\tfor _, a := range c.Attr {\n\t\t\t\t\tif a.Key == \"href\" && strings.HasPrefix(strings.ToLower(a.Val), \"http\") {\n\t\t\t\t\t\tbuff.WriteString(\" \" + a.Val + \" \")\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\textract(c, buff, includeLinks)\n\t\t}\n\t}\n}\n\nfunc clean(text string) string {\n\t// replace \\uFEFF with space, see https://github.com/golang/go/issues/42274#issuecomment-1017258184\n\ttext = strings.ReplaceAll(text, string('\\uFEFF'), \" \")\n\n\t// remove non-printable characters\n\ttext = strings.Map(func(r rune) rune {\n\t\tif unicode.IsPrint(r) {\n\t\t\treturn r\n\t\t}\n\t\treturn []rune(\" \")[0]\n\t}, text)\n\n\ttext = re.ReplaceAllString(text, \" \")\n\n\treturn strings.TrimSpace(text)\n}\n"
  },
  {
    "path": "internal/html2text/html2text_test.go",
    "content": "package html2text\n\nimport \"testing\"\n\nfunc TestPlain(t *testing.T) {\n\ttests := map[string]string{}\n\ttests[\"this is a  test\"] = \"this is a test\"\n\ttests[\"thiS IS a Test\"] = \"thiS IS a Test\"\n\ttests[\"thiS IS a Test :-)\"] = \"thiS IS a Test :-)\"\n\ttests[\"<h1>This is a test.</h1> \"] = \"This is a test.\"\n\ttests[\"<p>Paragraph 1</p><p>Paragraph 2</p>\"] = \"Paragraph 1 Paragraph 2\"\n\ttests[\"<h1>Heading</h1><p>Paragraph</p>\"] = \"Heading Paragraph\"\n\ttests[\"<span>Alpha</span>bet <strong>chars</strong>\"] = \"Alphabet chars\"\n\ttests[\"<span><b>A</b>lpha</span>bet  chars.\"] = \"Alphabet chars.\"\n\ttests[\"<table><tr><td>First</td><td>Second</td></table>\"] = \"First Second\"\n\ttests[`<h1>Heading</h1>\n\t\t<p>Paragraph</p>`] = \"Heading Paragraph\"\n\ttests[`<h1>Heading</h1><p>   <a href=\"https://github.com\">linked text</a></p>`] = \"Heading linked text\"\n\t// broken html\n\ttests[`<h1>Heading</h3><p>   <a href=\"https://github.com\">linked text.`] = \"Heading linked text.\"\n\n\tfor str, expected := range tests {\n\t\tres := Strip(str, false)\n\t\tif res != expected {\n\t\t\tt.Log(\"error:\", res, \"!=\", expected)\n\t\t\tt.Fail()\n\t\t}\n\t}\n}\n\nfunc TestWithLinks(t *testing.T) {\n\ttests := map[string]string{}\n\ttests[\"this is a  test\"] = \"this is a test\"\n\ttests[\"thiS IS a Test\"] = \"thiS IS a Test\"\n\ttests[\"thiS IS a Test :-)\"] = \"thiS IS a Test :-)\"\n\ttests[\"<h1>This is a test.</h1> \"] = \"This is a test.\"\n\ttests[\"<p>Paragraph 1</p><p>Paragraph 2</p>\"] = \"Paragraph 1 Paragraph 2\"\n\ttests[\"<h1>Heading</h1><p>Paragraph</p>\"] = \"Heading Paragraph\"\n\ttests[\"<span>Alpha</span>bet <strong>chars</strong>\"] = \"Alphabet chars\"\n\ttests[\"<span><b>A</b>lpha</span>bet  chars.\"] = \"Alphabet chars.\"\n\ttests[\"<table><tr><td>First</td><td>Second</td></table>\"] = \"First Second\"\n\ttests[\"<h1>Heading</h1><p>Paragraph</p>\"] = \"Heading Paragraph\"\n\ttests[`<h1>Heading</h1>\n\t\t<p>Paragraph</p>`] = \"Heading Paragraph\"\n\ttests[`<h1>Heading</h1><p>   <a href=\"https://github.com\">linked text</a></p>`] = \"Heading https://github.com linked text\"\n\t// broken html\n\ttests[`<h1>Heading</h3><p>   <a href=\"https://github.com\">linked text.`] = \"Heading https://github.com linked text.\"\n\n\tfor str, expected := range tests {\n\t\tres := Strip(str, true)\n\t\tif res != expected {\n\t\t\tt.Log(\"error:\", res, \"!=\", expected)\n\t\t\tt.Fail()\n\t\t}\n\t}\n}\n\nfunc BenchmarkPlain(b *testing.B) {\n\tfor i := 0; i < b.N; i++ {\n\t\tStrip(htmlTestData, false)\n\t}\n}\n\nfunc BenchmarkLinks(b *testing.B) {\n\tfor i := 0; i < b.N; i++ {\n\t\tStrip(htmlTestData, true)\n\t}\n}\n\nvar htmlTestData = `<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">\n<html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns=\"http://www.w3.org/1999/xhtml\" lang=\"en\" xml:lang=\"en\" style=\"font-family: sans-serif; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; box-sizing: border-box;\" xml:lang=\"en\">\n  <head>\n    <meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\" />\n    <meta name=\"viewport\" content=\"width=device-width\" />\n    <title>[axllent/mailpit] Run failed: .github/workflows/tests.yml - feature/swagger (284335a)</title>\n    \n  </head>\n  <body style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;; font-size: 14px; line-height: 1.5; color: #24292e; background-color: #fff; margin: 0;\" bgcolor=\"#fff\">\n    <table align=\"center\" class=\"container-sm width-full\" width=\"100%\" style=\"box-sizing: border-box; border-spacing: 0; border-collapse: collapse; max-width: 544px; margin-right: auto; margin-left: auto; width: 100% !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n      <tr style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n        <td class=\"center p-3\" align=\"center\" valign=\"top\" style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 16px;\">\n          <center style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n            <table border=\"0\" cellspacing=\"0\" cellpadding=\"0\" align=\"center\" class=\"width-full container-md\" width=\"100%\" style=\"box-sizing: border-box; border-spacing: 0; border-collapse: collapse; max-width: 768px; margin-right: auto; margin-left: auto; width: 100% !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n  <tr style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n    <td align=\"center\" style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;\">\n              <table style=\"box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n  <tbody style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n    <tr style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n      <td height=\"16\" style=\"font-size: 16px; line-height: 16px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;\">&#160;</td>\n    </tr>\n  </tbody>\n</table>\n\n              <table border=\"0\" cellspacing=\"0\" cellpadding=\"0\" align=\"left\" width=\"100%\" style=\"box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n                <tr style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n                  <td class=\"text-left\" style=\"box-sizing: border-box; text-align: left !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;\" align=\"left\">\n                    <img src=\"https://github.githubassets.com/images/email/global/octocat-logo.png\" alt=\"GitHub\" width=\"32\" style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; border-style: none;\" />\n                    <h2 class=\"lh-condensed mt-2 text-normal\" style=\"box-sizing: border-box; margin-top: 8px !important; margin-bottom: 0; font-size: 24px; font-weight: 400 !important; line-height: 1.25 !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n                        [axllent/mailpit] .github/workflows/tests.yml workflow run\n\n                    </h2>\n                  </td>\n                </tr>\n              </table>\n              <table style=\"box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n  <tbody style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n    <tr style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n      <td height=\"16\" style=\"font-size: 16px; line-height: 16px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;\">&#160;</td>\n    </tr>\n  </tbody>\n</table>\n\n</td>\n  </tr>\n</table>\n            <table width=\"100%\" class=\"width-full\" style=\"box-sizing: border-box; border-spacing: 0; border-collapse: collapse; width: 100% !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n              <tr style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n                <td class=\"border rounded-2 d-block\" style=\"box-sizing: border-box; border-radius: 6px !important; display: block !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0; border: 1px solid #e1e4e8;\">\n                  <table align=\"center\" class=\"width-full text-center\" style=\"box-sizing: border-box; border-spacing: 0; border-collapse: collapse; width: 100% !important; text-align: center !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n                    <tr style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n                      <td style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;\">\n                        <table border=\"0\" cellspacing=\"0\" cellpadding=\"0\" align=\"center\" class=\"width-full\" width=\"100%\" style=\"box-sizing: border-box; border-spacing: 0; border-collapse: collapse; width: 100% !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n  <tr style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n    <td align=\"center\" style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;\">\n                          \n<table align=\"center\" class=\"border-bottom width-full text-center\" style=\"box-sizing: border-box; border-spacing: 0; border-collapse: collapse; border-bottom-width: 1px !important; border-bottom-color: #e1e4e8 !important; border-bottom-style: solid !important; width: 100% !important; text-align: center !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n  <tr style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n    <td class=\"d-block px-3 pt-3 p-sm-4\" style=\"box-sizing: border-box; display: block !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 24px;\">\n      <table border=\"0\" cellspacing=\"0\" cellpadding=\"0\" align=\"center\" class=\"width-full\" width=\"100%\" style=\"box-sizing: border-box; border-spacing: 0; border-collapse: collapse; width: 100% !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n  <tr style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n    <td align=\"center\" style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;\">\n        \n    <img src=\"https://github.githubassets.com/images/email/icons/actions.png\" width=\"56\" height=\"56\" alt=\"\" style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; border-style: none;\" />\n  <table style=\"box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n  <tbody style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n    <tr style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n      <td height=\"12\" style=\"font-size: 12px; line-height: 12px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;\">&#160;</td>\n    </tr>\n  </tbody>\n</table>\n\n<h3 class=\"lh-condensed\" style=\"box-sizing: border-box; margin-top: 0; margin-bottom: 0; font-size: 20px; font-weight: 600; line-height: 1.25 !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">.github/workflows/tests.yml: No jobs were run</h3>\n<table style=\"box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n  <tbody style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n    <tr style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n      <td height=\"16\" style=\"font-size: 16px; line-height: 16px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;\">&#160;</td>\n    </tr>\n  </tbody>\n</table>\n\n\n\n  <table border=\"0\" cellspacing=\"0\" cellpadding=\"0\" align=\"center\" class=\"width-full\" width=\"100%\" style=\"box-sizing: border-box; border-spacing: 0; border-collapse: collapse; width: 100% !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n  <tr style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n    <td align=\"center\" style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;\">\n    <table width=\"100%\" border=\"0\" cellspacing=\"0\" cellpadding=\"0\" style=\"box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n  <tr style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n    <td style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;\">\n      <table border=\"0\" cellspacing=\"0\" cellpadding=\"0\" width=\"100%\" style=\"box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n        <tr style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n          <td align=\"center\" style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;\">\n              <!--[if mso]> <table><tr><td align=\"center\" bgcolor=\"#28a745\"> <![endif]-->\n                <a href=\"https://github.com/axllent/mailpit/actions/runs/6522820865\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"btn btn-large btn-primary\" style=\"background-color: #1f883d !important; box-sizing: border-box; color: #fff; text-decoration: none; position: relative; display: inline-block; font-size: inherit; font-weight: 500; line-height: 1.5; white-space: nowrap; vertical-align: middle; cursor: pointer; -webkit-user-select: none; user-select: none; border-radius: .5em; -webkit-appearance: none; appearance: none; box-shadow: 0 1px 0 rgba(27,31,35,.1),inset 0 1px 0 rgba(255,255,255,.03); transition: background-color .2s cubic-bezier(0.3, 0, 0.5, 1); font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: .75em 1.5em; border: 1px solid #1f883d;\">View workflow run</a>\n              <!--[if mso]> </td></tr></table> <![endif]-->\n          </td>\n        </tr>\n      </table>\n    </td>\n  </tr>\n</table>\n\n</td>\n  </tr>\n</table>\n  <table style=\"box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n  <tbody style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n    <tr style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n      <td height=\"32\" style=\"font-size: 32px; line-height: 32px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;\">&#160;</td>\n    </tr>\n  </tbody>\n</table>\n\n\n</td>\n  </tr>\n</table>\n    </td>\n  </tr>\n</table>\n\n\n\n\n</td>\n  </tr>\n</table>\n                      </td>\n                    </tr>\n                  </table>\n                </td>\n              </tr>\n            </table>\n            <table border=\"0\" cellspacing=\"0\" cellpadding=\"0\" align=\"center\" class=\"width-full text-center\" width=\"100%\" style=\"box-sizing: border-box; border-spacing: 0; border-collapse: collapse; width: 100% !important; text-align: center !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n  <tr style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n    <td align=\"center\" style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;\">\n              <table style=\"box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n  <tbody style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n    <tr style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n      <td height=\"16\" style=\"font-size: 16px; line-height: 16px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;\">&#160;</td>\n    </tr>\n  </tbody>\n</table>\n\n              <table style=\"box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n  <tbody style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n    <tr style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n      <td height=\"16\" style=\"font-size: 16px; line-height: 16px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;\">&#160;</td>\n    </tr>\n  </tbody>\n</table>\n\n              <p class=\"f5 text-gray-light\" style=\"box-sizing: border-box; margin-top: 0; margin-bottom: 10px; color: #6a737d !important; font-size: 14px !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">  </p><p style=\"font-size: small; -webkit-text-size-adjust: none; color: #666; box-sizing: border-box; margin-top: 0; margin-bottom: 10px; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">&#8212;<br style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\" />You are receiving this because you are subscribed to this thread.<br style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\" /><a href=\"https://github.com/settings/notifications\" style=\"background-color: transparent; box-sizing: border-box; color: #0366d6; text-decoration: none; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">Manage your GitHub Actions notifications</a></p>\n\n</td>\n  </tr>\n</table>\n            <table border=\"0\" cellspacing=\"0\" cellpadding=\"0\" align=\"center\" class=\"width-full text-center\" width=\"100%\" style=\"box-sizing: border-box; border-spacing: 0; border-collapse: collapse; width: 100% !important; text-align: center !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n  <tr style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n    <td align=\"center\" style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;\">\n  <table style=\"box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n  <tbody style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n    <tr style=\"box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">\n      <td height=\"16\" style=\"font-size: 16px; line-height: 16px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;\">&#160;</td>\n    </tr>\n  </tbody>\n</table>\n\n  <p class=\"f6 text-gray-light\" style=\"box-sizing: border-box; margin-top: 0; margin-bottom: 10px; color: #6a737d !important; font-size: 12px !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;\">GitHub, Inc. &#12539;88 Colin P Kelly Jr Street &#12539;San Francisco, CA 94107</p>\n</td>\n  </tr>\n</table>\n\n          </center>\n        </td>\n      </tr>\n    </table>\n    <!-- prevent Gmail on iOS font size manipulation -->\n   <div style=\"display: none; white-space: nowrap; box-sizing: border-box; font: 15px/0 apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;;\"> &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; </div>\n  </body>\n</html>`\n"
  },
  {
    "path": "internal/htmlcheck/README.md",
    "content": "# HTML check\n\nThe database used for HTML support tests is based on [can I email](https://www.caniemail.com/).\n\nThe `caniemail-data.json` file used to determine client support is copied from the [API](https://www.caniemail.com/api/data.json)\n"
  },
  {
    "path": "internal/htmlcheck/caniemail-data.json",
    "content": "{\n\t\"api_version\":\"1.0.4\",\n\t\"last_update_date\":\"2026-02-16 15:39:06 +0000\",\n\t\"nicenames\":{\"family\":{\"gmail\":\"Gmail\",\"outlook\":\"Outlook\",\"yahoo\":\"Yahoo! Mail\",\"apple-mail\":\"Apple Mail\",\"aol\":\"AOL\",\"thunderbird\":\"Mozilla Thunderbird\",\"microsoft\":\"Microsoft\",\"samsung-email\":\"Samsung Email\",\"sfr\":\"SFR\",\"orange\":\"Orange\",\"protonmail\":\"ProtonMail\",\"hey\":\"HEY\",\"mail-ru\":\"Mail.ru\",\"fastmail\":\"Fastmail\",\"laposte\":\"LaPoste.net\",\"t-online-de\":\"T-online.de\",\"free-fr\":\"Free.fr\",\"gmx\":\"GMX\",\"web-de\":\"WEB.DE\",\"ionos-1and1\":\"1&1\",\"rainloop\":\"RainLoop\",\"wp-pl\":\"WP.pl\"},\"platform\":{\"desktop-app\":\"Desktop\",\"desktop-webmail\":\"Desktop Webmail\",\"mobile-webmail\":\"Mobile Webmail\",\"webmail\":\"Webmail\",\"ios\":\"iOS\",\"android\":\"Android\",\"windows\":\"Windows\",\"macos\":\"macOS\",\"windows-mail\":\"Windows Mail\",\"outlook-com\":\"Outlook.com\"},\"support\":{\"supported\":\"Supported\",\"mitigated\":\"Partially supported\",\"unsupported\":\"Not supported\",\"unknown\":\"Support unknown\",\"mixed\":\"Mixed support\"},\"category\":{\"html\":\"HTML\",\"css\":\"CSS\",\"image\":\"Image formats\",\"others\":\"Others\"}},\n\t\"data\":[\n\t\t{\n\t\t\t\"slug\":\"amp\",\n\t\t\t\"title\":\"AMP for Email\",\n\t\t\t\"description\":\"Support for rendering emails in the AMP format.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/amp/\",\n\t\t\t\"category\":\"others\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"amp4email\",\n\t\t\t\"last_test_date\":\"2020-03-31\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/amp.html\",\n\t\t\t\"test_results_url\":null,\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"n\"},\"ios\":{\"13.1\":\"n\"}},\"gmail\":{\"desktop-webmail\":{\"2020-04\":\"y\",\"2022-02\":\"y #1\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"},\"mobile-webmail\":{\"2020-04\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2020-01\":\"n\",\"2021-03\":\"n\"},\"ios\":{\"2020-01\":\"n\"},\"android\":{\"2020-01\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-10\":\"n\"},\"macos\":{\"2019-10\":\"n\",\"16.80\":\"n\"},\"outlook-com\":{\"2020-01\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2021-01\":\"y\",\"2022-02\":\"y #1\"},\"ios\":{\"2019-10\":\"n\",\"2022-12\":\"y\"},\"android\":{\"2019-10\":\"n\",\"2022-12\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-10\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"n\"},\"ios\":{\"2020-01\":\"n\"},\"android\":{\"2020-01\":\"n\"}},\"thunderbird\":{\"macos\":{\"68.4\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"n\"}},\"free-fr\":{\"desktop-webmail\":{\"2022-12\":\"n\"}},\"t-online-de\":{\"desktop-webmail\":{\"2022-12\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"n\"},\"android\":{\"2022-06\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"n\"},\"android\":{\"2022-06\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"android\":{\"2022-06\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Supported on compatible browsers. Refer to ‘supported platforms’ links listed below under resources.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"bimi\",\n\t\t\t\"title\":\"BIMI\",\n\t\t\t\"description\":\"BIMI (Brand Indicators for Message Identification) is a specification allowing for the display of brand logos next to authenticated e-mails.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/bimi/\",\n\t\t\t\"category\":\"others\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"bimi, logo, brand\",\n\t\t\t\"last_test_date\":\"2022-12-29\",\n\t\t\t\"test_url\":\"https://www.caniemail.com\",\n\t\t\t\"test_results_url\":null,\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"15\":\"n\",\"16\":\"y\"},\"ios\":{\"15\":\"n\",\"16\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2023-01\":\"y\"},\"ios\":{\"2023-01\":\"y\"},\"android\":{\"2023-01\":\"y\"},\"mobile-webmail\":{\"2023-01\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2023-01\":\"n\"},\"ios\":{\"2023-01\":\"n\"},\"android\":{\"2023-01\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2023-01\":\"n\"},\"macos\":{\"16.56\":\"n\"},\"outlook-com\":{\"2023-01\":\"n\"},\"ios\":{\"2023-01\":\"n\"},\"android\":{\"2023-01\":\"n\"}},\"samsung-email\":{\"android\":{\"6.0\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2023-01\":\"n\"},\"ios\":{\"2023-01\":\"n\"},\"android\":{\"2023-01\":\"n\"}},\"thunderbird\":{\"macos\":{\"78.14\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2023-01\":\"n\"},\"ios\":{\"2023-01\":\"n\"},\"android\":{\"2023-01\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2023-01\":\"y\"},\"ios\":{\"2023-01\":\"y\"},\"android\":{\"2023-01\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2023-01\":\"n\"},\"ios\":{\"2023-01\":\"n\"},\"android\":{\"2023-01\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2023-01\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2023-01\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2023-01\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2022-08\":\"y\"}},\"free-fr\":{\"desktop-webmail\":{\"2023-01\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2023-01\":\"n\"}},\"t-online-de\":{\"desktop-webmail\":{\"2023-01\":\"n\"}}},\n\t\t\t\"notes\":\"Data based on email clients providers own declarations.\",\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-accent-color\",\n\t\t\t\"title\":\"accent-color\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-accent-color/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"accent,color\",\n\t\t\t\"last_test_date\":\"2023-12-19\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-accent-color.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/LAzSmlkimAnFmnrtPjPuPjpT1rO\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"16\":\"n\",\"17\":\"n\",\"18\":\"n\",\"19\":\"n\",\"20\":\"n\",\"21\":\"y\"},\"ios\":{\"11\":\"n\",\"12\":\"n\",\"13\":\"n\",\"14\":\"y #1\",\"15\":\"y #1\"}},\"gmail\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"},\"mobile-webmail\":{\"2022-07\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2022-07\":\"n\"},\"macos\":{\"2022-07\":\"n\",\"16.80\":\"n\"},\"outlook-com\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"samsung-email\":{\"android\":{\"2022-07\":\"y #1\"}},\"sfr\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2022-07\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-07\":\"y #1\"}},\"fastmail\":{\"desktop-webmail\":{\"2022-07\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Supports `accent-color` but rendering depends on browser support.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-align-items\",\n\t\t\t\"title\":\"align-items\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-align-items/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"align,items,flexbox,grid\",\n\t\t\t\"last_test_date\":\"2023-12-19\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-align-items.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/FvYneb1dhiR4we6rAOf4AC02oFa6ksA0sTWxbEjgmt6Mg/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"11\":\"y\",\"12\":\"y\",\"13\":\"y\"},\"ios\":{\"11\":\"y\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-12\":\"n\"},\"ios\":{\"2020-12\":\"n\"},\"android\":{\"2020-12\":\"n\"},\"mobile-webmail\":{\"2020-12\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2021-02\":\"y\",\"2021-03\":\"n\"},\"ios\":{\"2021-03\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2021-03\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-12\":\"n\"},\"macos\":{\"2020-12\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-12\":\"y\"},\"ios\":{\"2020-12\":\"y\"},\"android\":{\"4.2048.4\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-12\":\"n\"},\"ios\":{\"2021-03\":\"n\"},\"android\":{\"6.16.2.1519779\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2020-12\":\"n\"},\"ios\":{\"2021-03\":\"n\"},\"android\":{\"2021-03\":\"n\"}},\"samsung-email\":{\"android\":{\"6.1.31.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-03\":\"y\"},\"ios\":{\"2021-03\":\"y\"},\"android\":{\"2021-03\":\"y\"}},\"thunderbird\":{\"macos\":{\"2020-12\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-03\":\"y\"},\"ios\":{\"2021-03\":\"y\"},\"android\":{\"2021-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-03\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-12\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y #1\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Supported. But a default style of `margin:auto` is applied on every element and can prevent the expected result.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-animation\",\n\t\t\t\"title\":\"animation\",\n\t\t\t\"description\":\"Tests for the shorthand `animation` property and its longhand equivalents.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-animation/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"keyframes\",\n\t\t\t\"last_test_date\":\"2023-12-19\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-animation.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/u4oWccYOFNNyTagHs2NSUZqJYQ3MssrqDMocBnRa35hf7/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-05\":\"n\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"},\"mobile-webmail\":{\"2021-05\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2021-05\":\"n\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2021-05\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2021-05\":\"n\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"}},\"samsung-email\":{\"android\":{\"6.1\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-05\":\"a #1\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"}},\"thunderbird\":{\"macos\":{\"78.10\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2021-05\":\"n\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-05\":\"n\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-05\":\"n\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2021-05\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-05\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"a #1\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"a #2\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. Animation properties are supported but `@keyframes` are incorrectly prefixed.\",\"2\":\"Partial. Only supports from and to keyframes. Does not support % keyframes\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-aspect-ratio\",\n\t\t\t\"title\":\"aspect-ratio\",\n\t\t\t\"description\":\"Sets a preferred aspect ratio for the element\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-aspect-ratio/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"ratio\",\n\t\t\t\"last_test_date\":\"2023-12-19\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-aspect-ratio.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/Mv0IO0vs3vTgRQuJ8IzyBfD6\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"14\":\"n\",\"15.0\":\"y\"},\"ios\":{\"11\":\"n\",\"12\":\"n\",\"13\":\"n\",\"14\":\"n\",\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-10\":\"n\"},\"ios\":{\"2021-10\":\"n\"},\"android\":{\"2021-10\":\"n\"},\"mobile-webmail\":{\"2021-10\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2021-10\":\"n\"},\"ios\":{\"2021-10\":\"n\"},\"android\":{\"2021-10\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2021-10\":\"n\"},\"macos\":{\"2021-10\":\"n\",\"16.80\":\"n\"},\"outlook-com\":{\"2021-10\":\"n\",\"2023-12\":\"n\"},\"ios\":{\"2021-10\":\"n\"},\"android\":{\"2021-10\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-10\":\"n\"},\"ios\":{\"2021-10\":\"n\"},\"android\":{\"6.37\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2021-10\":\"n\"},\"ios\":{\"2021-10\":\"n\"},\"android\":{\"2021-10\":\"n\"}},\"samsung-email\":{\"android\":{\"2021-10\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"y #1\"},\"android\":{\"2021-11\":\"y\"}},\"thunderbird\":{\"macos\":{\"78.10.2\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"y #1\"},\"android\":{\"2021-11\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-11\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-11\":\"n\"}},\"laposte\":{\"desktop-webmail\":{\"2021-10\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Requires iOS 15.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-at-font-face\",\n\t\t\t\"title\":\"@font-face\",\n\t\t\t\"description\":\"`@font-face` in CSS allows to include your own fonts inside an email.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-at-font-face/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"font face, web fonts, google fonts\",\n\t\t\t\"last_test_date\":\"2023-12-19\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-font-face.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/veY9MhuhgFeF1ly5crrhTXawfLJSwxgpYi27OElI7iSoc/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.2\":\"y\"},\"ios\":{\"10.3\":\"y\",\"12.3.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-07\":\"n #6\"},\"ios\":{\"2019-07\":\"n\"},\"android\":{\"2019-07\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-05\":\"a #2\",\"2021-03\":\"n #7\",\"2024-03\":\"n\"},\"ios\":{\"2019-07\":\"y\",\"2024-03\":\"n\"},\"android\":{\"2019-07\":\"a #1\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"a #3\",\"2007\":\"a #4 #5\",\"2010\":\"a #4 #5\",\"2013\":\"a #4 #5\",\"2016\":\"a #4 #5\",\"2019\":\"a #4\"},\"windows-mail\":{\"2020-01\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2019-07\":\"n\",\"2023-12\":\"n\"},\"ios\":{\"2.51.1\":\"y\",\"3.29.0\":\"n\"},\"android\":{\"2019-07\":\"n\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y #8\",\"2021-11\":\"y #8\"}},\"sfr\":{\"desktop-webmail\":{\"2019-07\":\"a #2\",\"2025-07\":\"n\"},\"ios\":{\"2019-07\":\"n\"},\"android\":{\"2019-07\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.7\":\"y\",\"78.5\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-01\":\"n\"},\"ios\":{\"2020-01\":\"n\"},\"android\":{\"2020-01\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-07\":\"n\"},\"ios\":{\"2019-07\":\"n\"},\"android\":{\"2019-07\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"a #2\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Only supported through a `<link>` tag.\",\"2\":\"Partial. Only supported directly through a `<style>` tag.\",\"3\":\"Buggy. Support depends on the version of IE installed.\",\"4\":\"Partial. The declaration is supported but distant fonts are ignored.\",\"5\":\"Buggy. Elements using a font declared with `@font-face` ignore the font stack and fall back to Times New Roman. Use `mso-generic-font-family` and `mso-font-alt` to control the fallback.\",\"6\":\"Not supported. Roboto and Google Sans can be used, but only because they're embedded with the webmail's own styles.\",\"7\":\"Not supported. The `@font-face` declaration is kept but the `src` property is removed.\",\"8\":\"Not supported when using a Microsoft email address; outlook, live, hotmail, etc.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-at-import\",\n\t\t\t\"title\":\"@import\",\n\t\t\t\"description\":\"This is the description of the `@import` property.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-at-import/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"css, style\",\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-placement.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/6vV9sx4RoRsdnkZBDjLWwSC18VcUQzJY00tlj2NVSxKKv/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"n\",\"2021-03\":\"n\"},\"ios\":{\"2019-08\":\"y\",\"2023-12\":\"n\"},\"android\":{\"2019-08\":\"y\",\"2023-12\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2019-02\":\"y\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2019-02\":\"n\",\"2023-12\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.8\":\"y\",\"78.5\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2020-01\":\"n\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"n\",\"2025-07\":\"n\"},\"ios\":{\"2019-08\":\"n\"},\"android\":{\"2019-08\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"n\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"n\"}},\"wp-pl\":{\"desktop-webmail\":{\"2023-12\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-at-keyframes\",\n\t\t\t\"title\":\"@keyframes\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-at-keyframes/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"animation\",\n\t\t\t\"last_test_date\":\"2021-05-25\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-animation.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/u4oWccYOFNNyTagHs2NSUZqJYQ3MssrqDMocBnRa35hf7/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-05\":\"n\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"},\"mobile-webmail\":{\"2021-05\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2021-05\":\"n #1\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2021-05\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2021-05\":\"n\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"}},\"samsung-email\":{\"android\":{\"6.1\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-05\":\"a #1\",\"2025-07\":\"n\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"}},\"thunderbird\":{\"macos\":{\"78.10\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-05\":\"n\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2021-05\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-05\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"a #1\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"a #2\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Not supported. The `@keyframes` declaration is kept but values are incorrectly prefixed.\",\"2\":\"Partial. Only supports from and to keyframes. Does not support % keyframes\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-at-media-device-pixel-ratio\",\n\t\t\t\"title\":\"@media (-webkit-device-pixel-ratio)\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-at-media-device-pixel-ratio/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"media queries, media query, media feature\",\n\t\t\t\"last_test_date\":\"2019-08-20\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-media.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/hMLCNCSKZYHkLgLOpIWltlnYjtagbNsrwzMxalc2VbghN/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.3\":\"y\"},\"ios\":{\"10.3\":\"y\",\"12.2\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-08\":\"n\"},\"ios\":{\"2019-08\":\"n\"},\"android\":{\"2019-08\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y #1\",\"2021-03\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-01\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\",\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2025-07\":\"n\"},\"ios\":{\"2019-08\":\"n\"},\"android\":{\"2019-08\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"n\",\"78.5\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-08\":\"n\"},\"ios\":{\"2019-08\":\"n\"},\"android\":{\"2019-08\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. The first rule inside a media query is not prefixed.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-at-media-hover\",\n\t\t\t\"title\":\"@media (hover), @media (any-hover)\",\n\t\t\t\"description\":\"This media query tests whether the user's input device[s] (i.e mouse, trackpad etc.) can hover over elements\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-at-media-hover/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"accessibility\"],\n\t\t\t\"keywords\":\"media, media query, hover, any-hover\",\n\t\t\t\"last_test_date\":\"2022-08-29\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-media-hover.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/onECpNVH8Dhv7BSLPXUbQ4s0O\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2022-08\":\"y\"},\"ios\":{\"2022-08\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"n\"},\"android\":{\"2022-08\":\"n\"},\"mobile-webmail\":{\"2022-08\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2022-08\":\"u\"},\"ios\":{\"2022-08\":\"u\"},\"android\":{\"2022-08\":\"u\"}},\"outlook\":{\"windows\":{\"2022-08\":\"n\"},\"windows-mail\":{\"2022-08\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2022-08\":\"y\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"samsung-email\":{\"android\":{\"10\":\"y\",\"11\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2024-03\":\"y\",\"2025-07\":\"n\"},\"ios\":{\"2024-03\":\"n\"},\"android\":{\"2024-03\":\"n\"}},\"thunderbird\":{\"macos\":{\"2022-08\":\"u\"}},\"aol\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"n\"},\"android\":{\"2022-08\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"n\"},\"android\":{\"2022-08\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2022-08\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-08\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2022-08\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2022-08\":\"u\",\"2025-07\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-at-media-orientation\",\n\t\t\t\"title\":\"@media (orientation)\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-at-media-orientation/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"media queries, media query, media feature, portrait, landscape\",\n\t\t\t\"last_test_date\":\"2019-08-20\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-media.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/hMLCNCSKZYHkLgLOpIWltlnYjtagbNsrwzMxalc2VbghN/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.3\":\"y\"},\"ios\":{\"10.3\":\"n\",\"12\":\"y\",\"13\":\"y\",\"15\":\"y\",\"18.3.2\":\"a #2\"}},\"gmail\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\",\"2025-04\":\"n\"},\"android\":{\"2019-08\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y #1\",\"2021-03\":\"y\",\"2024-04\":\"y\"},\"ios\":{\"2019-08\":\"n\",\"2024-04\":\"y\"},\"android\":{\"2019-08\":\"y\",\"2024-04\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-01\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"n\",\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2025-07\":\"n\"},\"ios\":{\"2019-08\":\"n\"},\"android\":{\"2019-08\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\",\"78.5\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-08\":\"n\"},\"ios\":{\"2019-08\":\"n\"},\"android\":{\"2019-08\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. The first rule inside a media query is not prefixed.\",\"2\":\"Partial. `orientation:portrait` is not supported.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-at-media-prefers-color-scheme\",\n\t\t\t\"title\":\"@media (prefers-color-scheme)\",\n\t\t\t\"description\":\"This media query allows to theme for system light and dark mode.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-at-media-prefers-color-scheme/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"accessibility\"],\n\t\t\t\"keywords\":\"media queries, media query, media feature, dark mode, light mode\",\n\t\t\t\"last_test_date\":\"2023-03-08\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-media-prefers-color-scheme.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/CBhafIa5yXDRKQKbV442rVFISXim84wMgXaoCqVFD8VTe/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.3\":\"n\",\"12.4\":\"y\",\"16.0\":\"y\"},\"ios\":{\"12.2\":\"n\",\"13.0\":\"y\",\"16.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-01\":\"n\",\"2022-12\":\"n\"},\"ios\":{\"2020-01\":\"n\",\"2022-12\":\"n\"},\"android\":{\"2020-01\":\"n\",\"2022-12\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\",\"2022-12\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"y\",\"2022-12\":\"y\",\"2024-04\":\"y\"},\"ios\":{\"2020-01\":\"n\",\"2022-12\":\"y\",\"2024-04\":\"y\"},\"android\":{\"2020-01\":\"n\",\"2022-12\":\"n\",\"2024-04\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-01\":\"n\"},\"macos\":{\"2019\":\"y\",\"16.70\":\"y #3\",\"16.80\":\"y #3\"},\"outlook-com\":{\"2019-07\":\"y\",\"2022-12\":\"y #3\"},\"ios\":{\"2020-01\":\"y\",\"2022-12\":\"y #3\"},\"android\":{\"2020-01\":\"n\",\"2022-12\":\"n #3\",\"2023-03\":\"y #3\"}},\"samsung-email\":{\"android\":{\"6.0\":\"n\",\"6.1\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2022-12\":\"y\",\"2025-07\":\"n\"},\"ios\":{\"2019-08\":\"n\",\"2022-12\":\"n\"},\"android\":{\"2019-08\":\"n\",\"2022-12\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.8\":\"n\",\"68.4\":\"y\",\"78.5\":\"n\",\"91.13\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2020-01\":\"n #1\"},\"ios\":{\"2020-01\":\"n #1\"},\"android\":{\"2020-01\":\"n #1\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-01\":\"n #1\",\"2022-12\":\"n #6\"},\"ios\":{\"2020-01\":\"n #1\",\"2022-12\":\"n\"},\"android\":{\"2020-01\":\"n #1\",\"2022-12\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\",\"2022-12\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\",\"2022-12\":\"n #5\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\",\"2022-12\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n #2\",\"2022-12\":\"y #4\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\",\"2022-12\":\"y\",\"2025-07\":\"n\"}},\"free-fr\":{\"desktop-webmail\":{\"2022-12\":\"y\"}},\"t-online-de\":{\"desktop-webmail\":{\"2022-12\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Not supported. `@media (prefers-color-scheme)` is transformed into `@media ( _filtered_a )`.\",\"2\":\"Not supported. `@media (prefers-color-scheme:dark)` is transformed into `@media none`.\",\"3\":\"Additional custom `data` attributes (`data-ogsc`, `data-ogac`, `data-ogsb`, `data-ogab`) are added when viewing an email in dark mode. See [this article](https://www.hteumeuleu.com/2021/emails-react-outlook-com-dark-mode/) for examples.\",\"4\":\"`@media (prefers-color-scheme:dark)` is transformed into `@media all` at run time if it applies.\",\"5\":\"Not supported. `@media (prefers-color-scheme:dark)` is transformed into `@media (false)`\",\"6\":\"Not supported. `@media (prefers-color-scheme:dark)` is transformed into `@media ()`\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-at-media-prefers-reduced-motion\",\n\t\t\t\"title\":\"@media (prefers-reduced-motion)\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-at-media-prefers-reduced-motion/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"accessibility\",\"performance\"],\n\t\t\t\"keywords\":\"media queries, media query, media feature, prefers-reduced-motion, animation, accessibility\",\n\t\t\t\"last_test_date\":\"2021-02-20\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-media-prefers-reduced-motion.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/e3GT3l1CxqBUoE3u9keC4WLf5\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"11\":\"y\",\"12\":\"y\",\"13\":\"y\"},\"ios\":{\"11\":\"y\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-12\":\"n\"},\"ios\":{\"2020-12\":\"n\"},\"android\":{\"2020-12\":\"n\"},\"mobile-webmail\":{\"2020-12\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2021-02\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2021-02\":\"y\"},\"android\":{\"2021-02\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"16005.13426.20316.0\":\"n\"},\"macos\":{\"2020-12\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-12\":\"y\"},\"ios\":{\"2020-12\":\"y\"},\"android\":{\"4.2048.4\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-12\":\"n\"},\"ios\":{\"2021-02\":\"n\"},\"android\":{\"6.16.2.1519779\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2020-12\":\"n\"},\"ios\":{\"2021-02\":\"n\"},\"android\":{\"2021-02\":\"n\"}},\"samsung-email\":{\"android\":{\"6.1.31.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-02\":\"y\"},\"ios\":{\"2021-02\":\"n\"},\"android\":{\"2021-02\":\"n\"}},\"thunderbird\":{\"macos\":{\"78.7\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-02\":\"a #2\"},\"ios\":{\"2021-02\":\"a #2\"},\"android\":{\"2021-02\":\"a #2\"}},\"hey\":{\"desktop-webmail\":{\"2021-02\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-02\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n #1\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Not supported. `@media (prefers-reduced-motion:reduce)` is transformed into `@media none`.\",\"2\":\"Partially supported. Not supported on `picture`.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-at-media\",\n\t\t\t\"title\":\"@media\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-at-media/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"media queries, media query, media feature\",\n\t\t\t\"last_test_date\":\"2023-12-13\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-media.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/hMLCNCSKZYHkLgLOpIWltlnYjtagbNsrwzMxalc2VbghN/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.3\":\"y\"},\"ios\":{\"10.3\":\"y\",\"12.2\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-08\":\"a #1 #7\",\"2020-01\":\"a #7\"},\"ios\":{\"2019-08\":\"a #1 #6 #7\",\"2020-01\":\"a #6 #7\"},\"android\":{\"2019-08\":\"a #1 #6 #7\",\"2022-07\":\"a #6 #7\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y #5\",\"2021-03\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"a #1\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-01\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"a #1 #10\"},\"outlook-com\":{\"2019-08\":\"a #1\",\"2023-12\":\"a #1 #10\"},\"ios\":{\"2019-08\":\"a #1\"},\"android\":{\"2019-08\":\"a #1\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\",\"6.0\":\"y\",\"6.1.90.16\":\"a #9\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2025-07\":\"n\"},\"ios\":{\"2019-08\":\"n\"},\"android\":{\"2019-08\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\",\"78.5\":\"n\",\"102.11\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-08\":\"a #1 #2\",\"2020-01\":\"a #2\"},\"ios\":{\"2019-08\":\"a #1 #2\",\"2020-01\":\"a #2\"},\"android\":{\"2019-08\":\"a #1 #2 #3\",\"2020-01\":\"a #2 #3\",\"2025-06\":\"a #2\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"a #1 #2\",\"2020-01\":\"a #2\"},\"ios\":{\"2019-02\":\"a #1 #2\",\"2020-01\":\"a #2\"},\"android\":{\"2019-02\":\"a #1 #2\",\"2020-01\":\"a #2\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\",\"2023-05\":\"a #8\"},\"ios\":{\"2020-03\":\"n\",\"2023-05\":\"a #8\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"a #1 #7\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y #1\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y #1\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Does not support nested media queries.\",\"2\":\"Partial. Only supports `screen`, `min-width`, `max-width`, `min-height` and `max-height` based media queries.\",\"3\":\"Buggy. Requires a double `<head>` hack to work.\",\"4\":\"Partial. Does not support simple `@media {}` declarations.\",\"5\":\"Buggy. The first rule inside a media query is not prefixed.\",\"6\":\"Partial. Not supported with non Google accounts.\",\"7\":\"Partial. Does not support height based media queries.\",\"8\":\"Partial. Does not support landscape media query.\",\"9\":\"Partial. Not supported with Hotmail/Outlook accounts.\",\"10\":\"Partial. Nested media queries are removed.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-at-supports\",\n\t\t\t\"title\":\"@supports\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-at-supports/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"feature queries\",\n\t\t\t\"last_test_date\":\"2020-05-25\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-at-supports.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/No78GouZXTsxEZCD6z4Hn2frAvg3tHBw1SRAP8SwPKsZ5/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-05\":\"n\"},\"ios\":{\"2020-05\":\"n\"},\"android\":{\"2020-05\":\"n\"},\"mobile-webmail\":{\"2020-05\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2020-05\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-05\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2020-05\":\"n\"},\"ios\":{\"2020-05\":\"n\"},\"android\":{\"2020-05\":\"n\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-05\":\"y\",\"2025-07\":\"n\"},\"ios\":{\"2020-05\":\"n\"},\"android\":{\"2020-05\":\"n\"}},\"thunderbird\":{\"macos\":{\"68.7\":\"y\",\"78.5\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2020-05\":\"y #1\"},\"ios\":{\"2020-05\":\"y #1\"},\"android\":{\"2020-05\":\"y #1\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-05\":\"y #1\"},\"ios\":{\"2020-05\":\"y #1\"},\"android\":{\"2020-05\":\"y #1\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-05\":\"n\"},\"ios\":{\"2020-05\":\"n\"},\"android\":{\"2020-05\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n #2\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Properties not supported by the client are replaced by `_filtered_a` inside the parenthesis.\",\"2\":\"Not supported. `@supports` is transformed into `@media not all`.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-backdrop-filter\",\n\t\t\t\"title\":\"backdrop-filter\",\n\t\t\t\"description\":\"Lets you apply graphical effects such as blurring or color shifting to the area behind an element.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-backdrop-filter/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"filter\",\n\t\t\t\"last_test_date\":\"2024-01-17\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-backdrop-filter.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/p4r7t9n30o7nh7vvfpn\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10\":\"y #1\",\"11\":\"u\",\"12\":\"u\",\"13\":\"y #1\"},\"ios\":{\"11\":\"n\",\"12\":\"n\",\"13\":\"y #1\",\"14\":\"y #1\",\"15\":\"y #1\"}},\"gmail\":{\"desktop-webmail\":{\"2024-01\":\"n\"},\"ios\":{\"2024-01\":\"n\"},\"android\":{\"2024-01\":\"n\"},\"mobile-webmail\":{\"2024-01\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2024-01\":\"u\"},\"ios\":{\"2024-01\":\"n\"},\"android\":{\"2024-01\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2024-01\":\"n\"},\"macos\":{\"2024-01\":\"y\"},\"outlook-com\":{\"2024-01\":\"n\"},\"ios\":{\"2024-01\":\"n\"},\"android\":{\"2024-01\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2024-01\":\"n\"},\"ios\":{\"2024-01\":\"n\"},\"android\":{\"2024-01\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2024-01\":\"n\"},\"ios\":{\"2024-01\":\"n\"},\"android\":{\"2024-01\":\"n\"}},\"samsung-email\":{\"android\":{\"2024-01\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2024-03\":\"y\"},\"ios\":{\"2024-03\":\"y\"},\"android\":{\"2024-03\":\"y\"}},\"thunderbird\":{\"macos\":{\"2024-01\":\"u\"}},\"protonmail\":{\"desktop-webmail\":{\"2024-01\":\"y\"},\"ios\":{\"2024-01\":\"y\"},\"android\":{\"2024-01\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2024-01\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-01\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2024-01\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2024-01\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2024-01\":\"n\"},\"ios\":{\"2024-01\":\"u\"},\"android\":{\"2024-01\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2024-01\":\"n\"},\"ios\":{\"2024-01\":\"u\"},\"android\":{\"2024-01\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2024-01\":\"u\"},\"android\":{\"2024-01\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Works with prefix `-webkit`\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-background-blend-mode\",\n\t\t\t\"title\":\"background-blend-mode\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-background-blend-mode/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-background.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/oxaaoE6R3ur4T9fAPzVsQ3G2R7p1c9axDm7LLgC3cKw0F/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"a #1\"},\"android\":{\"2019-02\":\"a #1\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2019-08\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2019-08\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\",\"6.0.04.6\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.5.0\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-background-clip\",\n\t\t\t\"title\":\"background-clip\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-background-clip/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-background.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/oxaaoE6R3ur4T9fAPzVsQ3G2R7p1c9axDm7LLgC3cKw0F/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"a #1\"},\"android\":{\"2019-02\":\"a #1\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2019-08\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2019-08\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\",\"6.0.04.6\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.5.0\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts. But it can be used in the `background` shorthand property instead.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-background-color\",\n\t\t\t\"title\":\"background-color\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-background-color/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-background.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/oxaaoE6R3ur4T9fAPzVsQ3G2R7p1c9axDm7LLgC3cKw0F/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"a #1\"},\"ios\":{\"2019-08\":\"y\",\"2021-10\":\"a #1\"},\"android\":{\"2019-08\":\"y\",\"2021-10\":\"a #1\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2019-02\":\"y\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\",\"6.0.04.6\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.5.0\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"a #1\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"a #1\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. Only supports [color keywords from CSS Level 1](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#color_keywords).\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-background-image\",\n\t\t\t\"title\":\"background-image\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-background-image/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2023-07-24\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-background.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/04SuPXr8tEGhWRlJ2Us6dA8BzgREpyxHYEmSBeyNuWyWo/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"y\",\"2023-07\":\"a #6\",\"2023-08\":\"y\"},\"ios\":{\"2018-09\":\"a #1\",\"2018-10\":\"y\",\"2019-02\":\"y\"},\"android\":{\"2018-09\":\"a #1\",\"2018-10\":\"y\",\"2019-02\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"y\",\"2024-04\":\"y\"},\"ios\":{\"2019-08\":\"y\",\"2024-04\":\"y\"},\"android\":{\"2019-08\":\"y\",\"2024-04\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"n #5\",\"2010\":\"n #5\",\"2013\":\"n #5\",\"2016\":\"n #5\",\"2019\":\"n #5\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"a #3 #4\",\"2021-10\":\"a #3\"},\"ios\":{\"2019-02\":\"a #3 #4\",\"2021-10\":\"a #3\"},\"android\":{\"2019-02\":\"a #3 #4\",\"2021-10\":\"a #3\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"a #3 #4\",\"2021-10\":\"a #3\"},\"ios\":{\"2019-02\":\"a #3 #4\",\"2021-10\":\"a #3\"},\"android\":{\"2019-02\":\"a #3 #4\",\"2021-10\":\"a #3\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"a #2\",\"6.0.04.6\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.5.0\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"t-online-de\":{\"desktop-webmail\":{\"2021-11\":\"n\"}},\"free-fr\":{\"desktop-webmail\":{\"2021-11\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"a #3\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"a #3\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\",\"2\":\"Buggy. Requires at least one `<img>` element in the email to download all images.\",\"3\":\"Partial. Does not support multiple values. The comma between two values is removed.\",\"4\":\"Partial. Images URL must be between quotes.\",\"5\":\"Background images can be used in VML. See [backgrounds.cm](https://backgrounds.cm/) and [VML documentation](https://docs.microsoft.com/en-us/windows/win32/vml/web-workshop---how-to-use-vml-on-web-pages-----fill--element).\",\"6\":\"Partial and buggy. Removes the entire `style` attribute or `<style>` tag when a `url()` function with a valid image URL is present. See [Gmail rolling out changes that strip CSS with background images](https://freshinbox.com/blog/gmail-rolling-out-changes-that-strip-background-image-css/) and [Gmail and background images](https://parcel.io/blog/gmail-and-background-images).\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-background-origin\",\n\t\t\t\"title\":\"background-origin\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-background-origin/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-background.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/oxaaoE6R3ur4T9fAPzVsQ3G2R7p1c9axDm7LLgC3cKw0F/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"a\"},\"android\":{\"2019-02\":\"a\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2019-08\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2019-08\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n #1\",\"2010\":\"n #1\",\"2013\":\"n #1\",\"2016\":\"n #1\",\"2019\":\"n #1\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\",\"6.0.04.6\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.5.0\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Background images origin can be used in VML with the `origin` attribute. See [VML documentation](https://docs.microsoft.com/en-us/windows/win32/vml/origin-attribute--fill--vml).\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-background-position\",\n\t\t\t\"title\":\"background-position\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-background-position/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-background.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/oxaaoE6R3ur4T9fAPzVsQ3G2R7p1c9axDm7LLgC3cKw0F/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"y #1\"},\"ios\":{\"2019-02\":\"a\",\"2021-10\":\"y #1\"},\"android\":{\"2019-02\":\"a\",\"2021-10\":\"y #1\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"y\",\"2024-04\":\"y\"},\"ios\":{\"2019-08\":\"y\",\"2024-04\":\"y\"},\"android\":{\"2019-08\":\"y\",\"2024-04\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"n #2\",\"2010\":\"n #2\",\"2013\":\"n #2\",\"2016\":\"n #2\",\"2019\":\"n #2\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"y #1\"},\"ios\":{\"2019-02\":\"y #1\"},\"android\":{\"2019-02\":\"y #1\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"y #1\"},\"ios\":{\"2019-02\":\"y #1\"},\"android\":{\"2019-02\":\"y #1\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\",\"6.0.04.6\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.5.0\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"a #1\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"a #1\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Does not support multiple values. The comma between two values is removed.\",\"2\":\"Background images position can be used in VML with the `position` attribute. See [VML documentation](https://docs.microsoft.com/en-us/windows/win32/vml/position-attribute--fill--vml).\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-background-repeat\",\n\t\t\t\"title\":\"background-repeat\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-background-repeat/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-background.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/oxaaoE6R3ur4T9fAPzVsQ3G2R7p1c9axDm7LLgC3cKw0F/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"a\",\"2021-10\":\"y\"},\"android\":{\"2019-02\":\"a\",\"2021-10\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"y\",\"2024-04\":\"y\"},\"ios\":{\"2019-08\":\"y\",\"2024-04\":\"y\"},\"android\":{\"2019-08\":\"y\",\"2024-04\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"n #2\",\"2010\":\"n #2\",\"2013\":\"n #2\",\"2016\":\"n #2\",\"2019\":\"n #2\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"y #1\"},\"ios\":{\"2019-02\":\"y #1\"},\"android\":{\"2019-02\":\"y #1\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"y #1\"},\"ios\":{\"2019-02\":\"y #1\"},\"android\":{\"2019-02\":\"y #1\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\",\"6.0.04.6\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.5.0\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"a #1\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"a #1\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Does not support multiple values. The comma between two values is removed.\",\"2\":\"Background images repetition can be used in VML with the `type=\\\"tile\\\"` or `type=\\\"frame\\\"` attribute. See [VML documentation](https://docs.microsoft.com/en-us/windows/win32/vml/type-attribute--fill--vml).\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-background-size\",\n\t\t\t\"title\":\"background-size\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-background-size/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-background.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/oxaaoE6R3ur4T9fAPzVsQ3G2R7p1c9axDm7LLgC3cKw0F/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"a #1\"},\"android\":{\"2019-02\":\"a #1\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"n\"},\"ios\":{\"2019-08\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2019-08\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n #3\",\"2010\":\"n #3\",\"2013\":\"n #3\",\"2016\":\"n #3\",\"2019\":\"n #3\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"y #2\"},\"ios\":{\"2019-02\":\"y #2\"},\"android\":{\"2019-02\":\"y #2\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"y #2\"},\"ios\":{\"2019-02\":\"y #2\"},\"android\":{\"2019-02\":\"y #2\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\",\"6.0.04.6\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.5.0\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts. But it can be used in the `background` shorthand property instead.\",\"2\":\"Partial. Does not support multiple values. The comma between two values is removed.\",\"3\":\"Background images size can be used in VML with the `size` attribute. See [VML documentation](https://docs.microsoft.com/en-us/windows/win32/vml/size-attribute--fill--vml).\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-background\",\n\t\t\t\"title\":\"background\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-background/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2023-07-24\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-background.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/04SuPXr8tEGhWRlJ2Us6dA8BzgREpyxHYEmSBeyNuWyWo/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"y\",\"2023-07\":\"a #6 #7\",\"2023-08\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"a #6\"},\"ios\":{\"2019-08\":\"y\",\"2024-04\":\"a #6\"},\"android\":{\"2019-08\":\"y\",\"2024-04\":\"a #6\"}},\"outlook\":{\"windows\":{\"2007\":\"a #3\",\"2010\":\"a #3\",\"2013\":\"a #3\",\"2016\":\"a #3\",\"2019\":\"a #3\"},\"windows-mail\":{\"2019-02\":\"n\",\"2021-10\":\"a #3\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"a #1 #2 #4\",\"2021-10\":\"a #1 #2\"},\"ios\":{\"2019-02\":\"a #1 #2 #4\",\"2021-10\":\"a #1 #2\"},\"android\":{\"2019-02\":\"a #1 #2 #4\",\"2021-10\":\"a #1 #2\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"a #1 #2 #4\",\"2021-10\":\"a #1 #2\"},\"ios\":{\"2019-02\":\"a #1 #2 #4\",\"2021-10\":\"a #1 #2\"},\"android\":{\"2019-02\":\"a #1 #2 #4\",\"2021-10\":\"a #1 #2\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\",\"6.0.04.6\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.5.0\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y #5\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"a #1 #5 #6\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"a #1 #5 #6\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Does not support multiple values. The comma between two values is removed.\",\"2\":\"Partial. Does not support the `/ value` shorthand for `background-size`. But it can be used in the `background-size` property instead.\",\"3\":\"Partial. Only `background-color` values are supported.\",\"4\":\"Partial. Images URL must be between quotes.\",\"5\":\"Partial. Does not support multiple values. The entire property is removed if so.\",\"6\":\"Partial. Does not support the `/ value` shorthand for `background-size`.\",\"7\":\"Partial and buggy. Removes the entire `style` attribute or `<style>` tag when a `url()` function with a valid image URL is present. See [Gmail rolling out changes that strip CSS with background images](https://freshinbox.com/blog/gmail-rolling-out-changes-that-strip-background-image-css/) and [Gmail and background images](https://parcel.io/blog/gmail-and-background-images).\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-block-inline-size\",\n\t\t\t\"title\":\"block-size & inline-size\",\n\t\t\t\"description\":\"Defines the horizontal or vertical size of an element's block, depending on its writing mode.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-block-inline-size/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":\"block-size, inline-size\",\n\t\t\t\"last_test_date\":\"2022-07-14\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-block-size.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/0xeT11rcnx6IzN7f9NsVFlZ\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.13\":\"n\",\"10.15\":\"y\",\"11\":\"y\",\"12\":\"y\"},\"ios\":{\"14\":\"y\",\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"},\"mobile-webmail\":{\"2022-07\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2022\":\"n\"},\"windows-mail\":{\"2022-07\":\"n\"},\"macos\":{\"16.56\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2022-07\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"samsung-email\":{\"android\":{\"10\":\"y\",\"11\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"u\"}},\"thunderbird\":{\"macos\":{\"91.11.0\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2022-07\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-07\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2022-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2022-07\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-border-collapse\",\n\t\t\t\"title\":\"border-collapse\",\n\t\t\t\"description\":\"Sets whether cells inside a `<table>` have shared or separate borders.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-border-collapse/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"table\",\n\t\t\t\"last_test_date\":\"2023-12-20\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-border-collapse.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/4zk4fe7tv86fn4bc6\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"y\"},\"android\":{\"2023-12\":\"y\"},\"mobile-webmail\":{\"2023-12\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2023-12\":\"u\"},\"ios\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}},\"outlook\":{\"windows\":{\"2023-12\":\"y\"},\"windows-mail\":{\"2023-12\":\"y\"},\"macos\":{\"2023-12\":\"y\"},\"outlook-com\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"y\"},\"android\":{\"2023-12\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"y\"},\"android\":{\"2023-12\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"y\"},\"android\":{\"2023-12\":\"y\"}},\"samsung-email\":{\"android\":{\"2023-12\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2024-03\":\"y\"},\"ios\":{\"2024-03\":\"y\"},\"android\":{\"2024-03\":\"y\"}},\"thunderbird\":{\"macos\":{\"2023-12\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"y\"},\"android\":{\"2023-12\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2023-12\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2023-12\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2023-12\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2023-12\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-border-image\",\n\t\t\t\"title\":\"border-image\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-border-image/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-box-model.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/pyPQFHSYLFrhbRShalju0B2fYNwUgLuyKTLx4MLqiw5mE/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2019-08\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2019-08\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.5\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-border-inline-block-individual\",\n\t\t\t\"title\":\"border-inline & border-block individual logical properties\",\n\t\t\t\"description\":\"Support for `border-inline` & `border-block` individual logical properties.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-border-inline-block-individual/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":\"border-inline-color, border-block-color, border-inline-style, border-block-style, border-inline-width, border-block-width, border-inline-start, border-inline-end, border-block-start, border-block-end\",\n\t\t\t\"last_test_date\":\"2022-07-14\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-border-logical-properties.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/1yxFDAGtwrUmbf4tzMTY\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.12\":\"n\",\"10.13\":\"n\",\"10.15\":\"a #1\",\"11\":\"y\",\"12\":\"y\"},\"ios\":{\"11\":\"n\",\"12\":\"a #1\",\"13\":\"a #1\",\"14\":\"a #1\",\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"},\"mobile-webmail\":{\"2022-07\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2022-07\":\"u\"},\"ios\":{\"2022-07\":\"u\"},\"android\":{\"2022-07\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2022-07\":\"n\"},\"macos\":{\"2011\":\"a #1\",\"2016\":\"a #1\",\"16.80\":\"n\"},\"outlook-com\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"samsung-email\":{\"android\":{\"10\":\"a #1\",\"11\":\"a #1\"}},\"sfr\":{\"desktop-webmail\":{\"2024-03\":\"y\"},\"ios\":{\"2024-03\":\"y\"},\"android\":{\"2024-03\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"u\"}},\"aol\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-07\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. `border-<inline/block>-color` and `border-<inline/block>-width` does not work.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-border-inline-block-longhand\",\n\t\t\t\"title\":\"border-inline & border-block longhand properties\",\n\t\t\t\"description\":\"Support for `border-inline` & `border-block` longhand properties.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-border-inline-block-longhand/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":\"border-inline-start-color, border-block-start-color, border-inline-start-style, border-block-start-style, border-inline-start-width, border-block-start-width, border-inline-end-color, border-block-end-color, border-inline-end-style, border-block-end-style, border-inline-end-width, border-block-end-width\",\n\t\t\t\"last_test_date\":\"2022-07-14\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-border-logical-properties.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/1yxFDAGtwrUmbf4tzMTY\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.12\":\"n\",\"10.13\":\"n\",\"10.15\":\"y\",\"11\":\"y\",\"12\":\"y\"},\"ios\":{\"11\":\"n\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\",\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"},\"mobile-webmail\":{\"2022-07\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2022-07\":\"u\"},\"ios\":{\"2022-07\":\"u\"},\"android\":{\"2022-07\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2022-07\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"samsung-email\":{\"android\":{\"10\":\"y\",\"11\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2024-03\":\"y\"},\"ios\":{\"2024-03\":\"y\"},\"android\":{\"2024-03\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"u\"}},\"aol\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-07\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-border-inline-block\",\n\t\t\t\"title\":\"border-inline & border-block\",\n\t\t\t\"description\":\"Support for the `border-inline` and `border-block` shorthand properties.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-border-inline-block/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":\"border-inline, border-block\",\n\t\t\t\"last_test_date\":\"2022-07-13\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-border-logical-properties.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/1yxFDAGtwrUmbf4tzMTY\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.15\":\"n\",\"11\":\"y\",\"12\":\"y\"},\"ios\":{\"13\":\"n\",\"14\":\"n\",\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"},\"mobile-webmail\":{\"2022-07\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2022-07\":\"u\"},\"ios\":{\"2022-07\":\"u\"},\"android\":{\"2022-07\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2022-07\":\"n\"},\"macos\":{\"2011\":\"n\",\"2016\":\"n\",\"16.80\":\"n\"},\"outlook-com\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"samsung-email\":{\"android\":{\"10\":\"n\",\"11\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2024-03\":\"y\"},\"ios\":{\"2024-03\":\"y\"},\"android\":{\"2024-03\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"u\"}},\"aol\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-07\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-border-radius-logical\",\n\t\t\t\"title\":\"border-radius logical properties\",\n\t\t\t\"description\":\"Support for border radius logical properties\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-border-radius-logical/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":\"border-start-start-radius, border-start-end-radius, border-end-start-radius, border-end-end-radius\",\n\t\t\t\"last_test_date\":\"2022-08-16\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-border-logical-properties.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/1yxFDAGtwrUmbf4tzMTY\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.15\":\"n\",\"11\":\"n\",\"12\":\"y\"},\"ios\":{\"13\":\"n\",\"14\":\"n\",\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"n\"},\"android\":{\"2022-08\":\"n\"},\"mobile-webmail\":{\"2022-08\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2022-08\":\"u\"},\"ios\":{\"2022-08\":\"u\"},\"android\":{\"2022-08\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2022-08\":\"n\"},\"macos\":{\"2011\":\"n\",\"2016\":\"n\",\"16.80\":\"n\"},\"outlook-com\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"n\"},\"android\":{\"2022-08\":\"n\"}},\"samsung-email\":{\"android\":{\"10\":\"n\",\"11\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2024-03\":\"y\"},\"ios\":{\"2024-03\":\"y\"},\"android\":{\"2024-03\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"u\"}},\"aol\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"n\"},\"android\":{\"2022-08\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"n\"},\"android\":{\"2022-08\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2022-08\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-08\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2022-08\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2022-08\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-border-radius\",\n\t\t\t\"title\":\"border-radius\",\n\t\t\t\"description\":\"The `border-radius` CSS property rounds the corners of an element's outer border edge.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-border-radius/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"rounded corners\",\n\t\t\t\"last_test_date\":\"2021-03-09\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-border-radius.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/6baogXZwm2BzrRxpjQq0z7QrcfjJQjQa2sLYKhIJSg2sh/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.3\":\"y\"},\"ios\":{\"10.3\":\"y\",\"12.2\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2019-08\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2019-08\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"n #1\",\"2007\":\"n #1\",\"2010\":\"n #1\",\"2013\":\"n #1\",\"2016\":\"n #1\",\"2019\":\"n #1\"},\"windows-mail\":{\"2020-01\":\"n #1\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"samsung-email\":{\"android\":{\"6.1.31.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2021-02\":\"a #2\"},\"ios\":{\"2021-03\":\"a #2\"},\"android\":{\"2021-03\":\"a #2\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-02\":\"a #2\"},\"ios\":{\"2021-03\":\"a #2\"},\"android\":{\"6.18.2.1529859\":\"a #2\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Round corners can be used in VML with the `RoundRect` element. See [buttons.cm](https://buttons.cm/) and [VML documentation](https://docs.microsoft.com/en-us/windows/win32/vml/msdn-online-vml-roundrect-element).\",\"2\":\"Partial support. Shorthand for setting elliptical borders with the slash `/` notation is not supported e.g. `border-radius: 27% 73% 70% 30% / 30% 34% 66% 70%;`.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-border-spacing\",\n\t\t\t\"title\":\"border-spacing\",\n\t\t\t\"description\":\"Sets the distance between the borders of adjacent cells in a `<table>`.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-border-spacing/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"table\",\n\t\t\t\"last_test_date\":\"2023-12-20\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-border-spacing.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/dyodfk8c5dhjanflz\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"y\"},\"android\":{\"2023-12\":\"y\"},\"mobile-webmail\":{\"2023-12\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2023-12\":\"u\"},\"ios\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2023-12\":\"n\"},\"macos\":{\"2023-12\":\"n\"},\"outlook-com\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"y\"},\"android\":{\"2023-12\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"y\"},\"android\":{\"2023-12\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"y\"},\"android\":{\"2023-12\":\"y\"}},\"samsung-email\":{\"android\":{\"2023-12\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2024-03\":\"y\"},\"ios\":{\"2024-03\":\"y\"},\"android\":{\"2024-03\":\"y\"}},\"thunderbird\":{\"macos\":{\"2023-12\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"y\"},\"android\":{\"2023-12\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2023-12\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2023-12\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2023-12\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2023-12\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-border\",\n\t\t\t\"title\":\"border\",\n\t\t\t\"description\":\"The `border` properties set an element's border. This page accounts for all `border` longhands and shorthands.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-border/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"border-width, border-style, border-color, border-top, border-bottom, border-left, border-right, border-top-width, border-top-style, border-top-color, border-bottom-width, border-bottom-style, border-bottom-color, border-left-width, border-left-style, border-left-color, border-right-width, border-right-style, border-right-color\",\n\t\t\t\"last_test_date\":\"2021-07-22\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-border.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/jbuHMEUL4RGhgEHaRBjYJdJuonItGgpBGH9XWbqznvzrD/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"},\"ios\":{\"2021-07\":\"y\"},\"android\":{\"2021-07\":\"y\"},\"mobile-webmail\":{\"2021-07\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2021-07\":\"y\"},\"ios\":{\"2021-07\":\"y\"},\"android\":{\"2021-07\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"a #1 #2\",\"2010\":\"a #1 #2\",\"2013\":\"a #1 #2\",\"2016\":\"a #1 #2\",\"2019\":\"a #1 #2\"},\"windows-mail\":{\"2021-07\":\"a #1 #2\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2021-07\":\"y\"},\"ios\":{\"2021-07\":\"y\"},\"android\":{\"2021-07\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-07\":\"y\"},\"ios\":{\"2021-07\":\"y\"},\"android\":{\"2021-07\":\"y\"}},\"thunderbird\":{\"macos\":{\"78.12\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2021-07\":\"y\"},\"ios\":{\"2021-07\":\"y\"},\"android\":{\"2021-07\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-07\":\"y\"},\"ios\":{\"2021-07\":\"y\"},\"android\":{\"2021-07\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"},\"ios\":{\"2021-07\":\"y\"},\"android\":{\"2021-07\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":\"This pages reports support for all CSS properties related to `border`: `border`, `border-width`, `border-style`, `border-color`, `border-top`, `border-bottom`, `border-left`, `border-right`, `border-top-width`, `border-top-style`, `border-top-color`, `border-bottom-width`, `border-bottom-style`, `border-bottom-color`, `border-left-width`, `border-left-style`, `border-left-color`, `border-right-width`, `border-right-style`, `border-right-color`.\",\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. A border can not be bigger than 8px.\",\"2\":\"Buggy. Unreliable if used on a `<p>` or a `<div>`.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-box-shadow\",\n\t\t\t\"title\":\"box-shadow\",\n\t\t\t\"description\":\"Adds shadow effects around an element's frame.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-box-shadow/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2023-12-20\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-box-shadow.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/TxbmgM1vnD44aLEEOdI06riwAqm3qmfet8jFoYTQ65bRp/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\",\"16.0\":\"y\"},\"ios\":{\"12.1\":\"y\",\"17.2\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"n\",\"2023-01\":\"n\"},\"ios\":{\"2019-02\":\"a #1\",\"2023-01\":\"a #1\"},\"android\":{\"2021-08\":\"a #1\",\"2023-01\":\"a #1\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"n\"},\"ios\":{\"2021-03\":\"n\",\"2023-12\":\"n\"},\"android\":{\"2019-08\":\"y\",\"2023-12\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"n\",\"2023-01\":\"n\",\"2023-12\":\"y\"},\"ios\":{\"2019-02\":\"n\",\"2023-01\":\"n\",\"2023-12\":\"y\"},\"android\":{\"2019-02\":\"n\",\"2023-01\":\"n\",\"2023-12\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"n\",\"2023-01\":\"n\"},\"ios\":{\"2019-02\":\"n\",\"2023-01\":\"n\"},\"android\":{\"2019-02\":\"n\",\"2023-01\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\",\"2023-01\":\"n\"},\"android\":{\"2019-02\":\"n\",\"2023-01\":\"n\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\",\"6.1.50.25\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.5\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Only supported with non Google accounts.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-box-sizing\",\n\t\t\t\"title\":\"box-sizing\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-box-sizing/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"box model\",\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-box-model.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/pyPQFHSYLFrhbRShalju0B2fYNwUgLuyKTLx4MLqiw5mE/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"y\",\"2024-04\":\"n\"},\"ios\":{\"2019-08\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2019-08\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.5\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-caption-side\",\n\t\t\t\"title\":\"caption-side\",\n\t\t\t\"description\":\"The `caption-side` CSS property puts the content of a table's `<caption>` on the specified side.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-caption-side/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"caption,table\",\n\t\t\t\"last_test_date\":\"2021-05-13\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-caption-side.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/JOOcrJZc0YcjDSZQRFP0OTlYXcw\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"11\":\"y\",\"12\":\"y\",\"13\":\"y\"},\"ios\":{\"11\":\"y\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"y\"},\"mobile-webmail\":{\"2021-05\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2021-05\":\"n\"},\"macos\":{\"2021-05\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"6.27\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"y\"}},\"samsung-email\":{\"android\":{\"6.1.42.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"y\"}},\"thunderbird\":{\"macos\":{\"2021-05\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-05\":\"y #1\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-05\":\"y #1\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2021-12\":\"y\"},\"ios\":{\"2021-12\":\"y\"},\"android\":{\"2021-12\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2021-12\":\"y\"},\"ios\":{\"2021-12\":\"y\"},\"android\":{\"2021-12\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2021-12\":\"y\"},\"android\":{\"2021-12\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"The `caption-side` property in CSS is supported but the `<caption>` HTML element is not.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-clear\",\n\t\t\t\"title\":\"clear\",\n\t\t\t\"description\":\"Sets whether an element must be moved below (cleared) floating elements that precede it.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-clear/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2024-09-06\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-box-model.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/gyjkc98dtyzxfd3bhz\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"11\":\"a #1\",\"12\":\"y\"},\"ios\":{\"14\":\"a #1\",\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2024-09\":\"y\"},\"ios\":{\"13\":\"a #2\",\"16\":\"y\"},\"android\":{\"2024-09\":\"a #1\"},\"mobile-webmail\":{\"2024-09\":\"u\"}},\"orange\":{\"desktop-webmail\":{\"2024-09\":\"u\"},\"ios\":{\"2024-09\":\"u\"},\"android\":{\"2024-09\":\"u\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2024-09\":\"n\"},\"macos\":{\"2024-09\":\"u\"},\"outlook-com\":{\"2024-09\":\"y\"},\"ios\":{\"2024-09\":\"y\"},\"android\":{\"2024-09\":\"a #1\"}},\"yahoo\":{\"desktop-webmail\":{\"2024-09\":\"a #1\"},\"ios\":{\"2024-09\":\"a #1\"},\"android\":{\"2024-09\":\"a #1\"}},\"aol\":{\"desktop-webmail\":{\"2024-09\":\"a #1\"},\"ios\":{\"2024-09\":\"a #1\"},\"android\":{\"2024-09\":\"a #1\"}},\"samsung-email\":{\"android\":{\"2024-09\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2024-09\":\"u\"},\"ios\":{\"2024-09\":\"u\"},\"android\":{\"2024-09\":\"u\"}},\"thunderbird\":{\"macos\":{\"2024-09\":\"u\"}},\"protonmail\":{\"desktop-webmail\":{\"2024-09\":\"y\"},\"ios\":{\"2024-09\":\"u\"},\"android\":{\"2024-09\":\"u\"}},\"hey\":{\"desktop-webmail\":{\"2024-09\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-09\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2024-09\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2024-09\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2024-09\":\"a #1\"},\"ios\":{\"2024-09\":\"u\"},\"android\":{\"2024-09\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2024-09\":\"a #1\"},\"ios\":{\"2024-09\":\"u\"},\"android\":{\"2024-09\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2024-09\":\"u\"},\"android\":{\"2024-09\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Logical property values `inline-start` and `inline-end` are not supported.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-clip-path\",\n\t\t\t\"title\":\"clip-path\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-clip-path/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"clip,path,svg,mask\",\n\t\t\t\"last_test_date\":\"2021-03-09\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-clip-path.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/wp9G5kZZ8sY3lne7CeGvMmPIlyBT2Wy0nBMBBAevQNgQO/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"11\":\"n\",\"12\":\"n\",\"13\":\"y\"},\"ios\":{\"11\":\"n\",\"12\":\"n\",\"13\":\"n\",\"14\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-02\":\"n\"},\"ios\":{\"2021-02\":\"n\"},\"android\":{\"2021-02\":\"n\"},\"mobile-webmail\":{\"2021-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2021-02\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2021-03\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2021-03\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2021-02\":\"n\"},\"macos\":{\"2011\":\"n\",\"2016\":\"n\",\"2021-02\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2021-02\":\"n\"},\"ios\":{\"2021-02\":\"n\"},\"android\":{\"4.2101.1\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-02\":\"n\"},\"ios\":{\"2021-03\":\"n\"},\"android\":{\"6.16.2.1525679\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2021-02\":\"n\"},\"ios\":{\"2021-03\":\"n\"},\"android\":{\"2021-03\":\"n\"}},\"samsung-email\":{\"android\":{\"6.1.31.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-03\":\"y\"},\"ios\":{\"2021-03\":\"y\"},\"android\":{\"2021-03\":\"y\"}},\"thunderbird\":{\"macos\":{\"2021-02\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-03\":\"y\"},\"ios\":{\"2021-03\":\"y\"},\"android\":{\"2021-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-03\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-02\":\"a #1 #2\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2021-12\":\"n\"},\"ios\":{\"2021-12\":\"y\"},\"android\":{\"2021-12\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2021-12\":\"n\"},\"ios\":{\"2021-12\":\"y\"},\"android\":{\"2021-12\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2021-12\":\"y\"},\"android\":{\"2021-12\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. `path()` is not supported.\",\"2\":\"Partial. [Embedded SVG](/features/html-svg/) is not supported. Referencing an embedded SVG's `<clipPath>` with `url()` does not work.\",\"3\":\"Partial. Referencing an external SVG's `<clipPath>` with `url()` does not work.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-color-scheme\",\n\t\t\t\"title\":\"color-scheme CSS property\",\n\t\t\t\"description\":\"Changes the default colors of HTML elements. Useful for when you want an email to display only in a dark color scheme or only a light scheme, regardless of user settings\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-color-scheme/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"accessibility\"],\n\t\t\t\"keywords\":\"dark mode, light mode\",\n\t\t\t\"last_test_date\":\"2023-09-18\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-color-scheme.html\",\n\t\t\t\"test_results_url\":\"\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"16.0\":\"y #1\"},\"ios\":{\"16.1\":\"y #1\"}},\"gmail\":{\"desktop-webmail\":{\"2023-09\":\"n\"},\"ios\":{\"2023-09\":\"n\"},\"android\":{\"2023-09\":\"n\"},\"mobile-webmail\":{\"2023-09\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2022-12\":\"u\"},\"ios\":{\"2022-12\":\"u\"},\"android\":{\"2022-12\":\"u\"}},\"outlook\":{\"windows\":{\"2003\":\"u\",\"2007\":\"u\",\"2010\":\"u\",\"2013\":\"u\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-01\":\"u\"},\"macos\":{\"16.73\":\"n\",\"16.80\":\"n\"},\"outlook-com\":{\"2023-09\":\"n\"},\"ios\":{\"2023-09\":\"n\"},\"android\":{\"2023-09\":\"n\"}},\"samsung-email\":{\"android\":{\"6.1\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2024-03\":\"a #2\"},\"ios\":{\"2024-03\":\"n\"},\"android\":{\"2024-03\":\"y\"}},\"thunderbird\":{\"macos\":{\"102.11\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2023-09\":\"n\"},\"ios\":{\"2023-09\":\"n\"},\"android\":{\"2023-09\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2023-09\":\"n\"},\"ios\":{\"2023-09\":\"n\"},\"android\":{\"2023-09\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2023-09\":\"y\"},\"ios\":{\"2023-09\":\"n\"},\"android\":{\"2023-09\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2023-09\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2023-09\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2023-09\":\"n\"}},\"laposte\":{\"desktop-webmail\":{\"2022-12\":\"u\"}},\"free-fr\":{\"desktop-webmail\":{\"2022-12\":\"u\"}},\"t-online-de\":{\"desktop-webmail\":{\"2023-09\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2023-09\":\"n\"},\"ios\":{\"2022-06\":\"u\"},\"android\":{\"2022-06\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2023-09\":\"n\"},\"ios\":{\"2022-06\":\"u\"},\"android\":{\"2022-06\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"u\"},\"android\":{\"2022-06\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Works only the html/root element\",\"2\":\"Buggy. The property is supported but does not work due to a prefix added by the webmail on the rule selector.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-column-count\",\n\t\t\t\"title\":\"column-count\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-column-count/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"columns\",\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-box-model.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/pyPQFHSYLFrhbRShalju0B2fYNwUgLuyKTLx4MLqiw5mE/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.5\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-column-layout-properties\",\n\t\t\t\"title\":\"css column properties\",\n\t\t\t\"description\":\"Support for the `columns` shorthand and longhand properties.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-column-layout-properties/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"columns, column-count, column-fill, column-gap, column-rule, column-rule-color, column-rule-style, column-rule-width, column-span, column-width\",\n\t\t\t\"last_test_date\":\"2022-08-02\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-column-layout.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/E87UgpgtlXxt6Rsx4Ec1pcxm\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"15\":\"y\"},\"ios\":{\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"},\"mobile-webmail\":{\"2022-08\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2022-08\":\"u\"},\"ios\":{\"2022-08\":\"u\"},\"android\":{\"2022-08\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2022-08\":\"n\"},\"macos\":{\"2022-08\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2022-08\":\"y\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"samsung-email\":{\"android\":{\"2022-08\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2024-03\":\"y\"},\"ios\":{\"2024-03\":\"y\"},\"android\":{\"2024-03\":\"y\"}},\"thunderbird\":{\"macos\":{\"102.7\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"n\"},\"android\":{\"2022-08\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-08\":\"n\",\"2023-01\":\"n\"},\"ios\":{\"2022-08\":\"n\",\"2023-01\":\"n\"},\"android\":{\"2022-08\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2023-01\":\"y\"},\"ios\":{\"2023-01\":\"y\"},\"android\":{\"2023-01\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2023-01\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-08\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2023-01\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2022-08\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-comments\",\n\t\t\t\"title\":\"CSS comments\",\n\t\t\t\"description\":\"Adds explanatory notes to the code or to prevent the browser from interpreting specific parts of the style sheet\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-comments/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2024-04-25\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-comments.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/n4ayign05k6cozot6\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2024-04\":\"y\"},\"ios\":{\"2024-04\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2024-04\":\"y\"},\"ios\":{\"2024-04\":\"y\"},\"android\":{\"2024-04\":\"a #2\"},\"mobile-webmail\":{\"2024-04\":\"a #4\"}},\"orange\":{\"desktop-webmail\":{\"2024-08\":\"n #6\"},\"ios\":{\"2024-08\":\"n #6\"},\"android\":{\"2024-08\":\"n #6\"}},\"outlook\":{\"windows\":{\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\",\"2021\":\"y\"},\"windows-mail\":{\"2024-04\":\"y\"},\"macos\":{\"2024-04\":\"y\"},\"outlook-com\":{\"2024-04\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2024-04\":\"y\"},\"android\":{\"2024-04\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2024-04\":\"y\"},\"ios\":{\"2024-04\":\"a #1\"},\"android\":{\"2024-04\":\"a #1\"}},\"aol\":{\"desktop-webmail\":{\"2024-04\":\"y\"},\"ios\":{\"2024-04\":\"a #1\"},\"android\":{\"2024-04\":\"a #1\"}},\"samsung-email\":{\"android\":{\"2024-04\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2024-08\":\"a #5\"},\"ios\":{\"2024-08\":\"a #5\"},\"android\":{\"2024-08\":\"a #5\"}},\"thunderbird\":{\"macos\":{\"2024-04\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2024-04\":\"u\"},\"ios\":{\"2024-04\":\"u\"},\"android\":{\"2024-04\":\"u\"}},\"hey\":{\"desktop-webmail\":{\"2024-04\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-04\":\"a #3\"}},\"fastmail\":{\"desktop-webmail\":{\"2024-04\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2024-08\":\"a #5\"}},\"gmx\":{\"desktop-webmail\":{\"2024-04\":\"n\"},\"ios\":{\"2024-04\":\"u\"},\"android\":{\"2024-04\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2024-04\":\"n\"},\"ios\":{\"2024-04\":\"u\"},\"android\":{\"2024-04\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2024-04\":\"u\"},\"android\":{\"2024-04\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. The first <head> in the HTML is removed, so comment needs to be in the `<style>` tag of a second `<head>` element.\",\"2\":\"Partial. `<style>` tag not supported with non-google account. Comment inside `style:` attribute works.\",\"3\":\"Partial. Comment inside `<style>` tag works. Comment inside `style` attribute strips the whole attribute.\",\"4\":\"Partial. `<style>` tag not supported. Comment inside `style:` attribute works.\",\"5\":\"Partial. Comment inside `style` attribute works.\",\"6\":\"Not supported. The entire rule is removed within a `<style> element. The entire inline `style` attribute is removed.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-conic-gradient\",\n\t\t\t\"title\":\"conic-gradient()\",\n\t\t\t\"description\":\"Creates an image consisting of a gradient with color transitions rotated around a center point (rather than radiating from the center).\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-conic-gradient/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"gradients\",\n\t\t\t\"last_test_date\":\"2023-01-02\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-gradients.html\",\n\t\t\t\"test_results_url\":\"\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12\":\"y\"},\"ios\":{\"14\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2022-12\":\"n\"},\"ios\":{\"2022-12\":\"n\"},\"android\":{\"2022-12\":\"n\"},\"mobile-webmail\":{\"2022-12\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2023-01\":\"n\"},\"ios\":{\"2023-01\":\"n\"},\"android\":{\"2023-01\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2022-12\":\"n\"},\"macos\":{\"2022-12\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2022-12\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2022-12\":\"n\"},\"android\":{\"2022-12\":\"n\"}},\"samsung-email\":{\"android\":{\"6.0\":\"u\",\"6.1.90.16\":\"a #1\"}},\"sfr\":{\"desktop-webmail\":{\"2023-01\":\"y\"},\"ios\":{\"2023-01\":\"y\"},\"android\":{\"2023-01\":\"y\"}},\"thunderbird\":{\"macos\":{\"91.4.1\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2022-12\":\"n\"},\"ios\":{\"2022-12\":\"n\"},\"android\":{\"2022-12\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-12\":\"n\"},\"ios\":{\"2022-12\":\"n\"},\"android\":{\"2022-12\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-12\":\"y\"},\"ios\":{\"2022-12\":\"y\"},\"android\":{\"2022-12\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2022-12\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-12\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2022-12\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2023-01\":\"y\"}},\"t-online-de\":{\"desktop-webmail\":{\"2023-01\":\"y\"}},\"free-fr\":{\"desktop-webmail\":{\"2023-01\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with Hotmail/Outlook accounts.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-cursor\",\n\t\t\t\"title\":\"cursor\",\n\t\t\t\"description\":\"The cursor CSS property sets the mouse cursor, if any, to show when the mouse pointer is over an element.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-cursor/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"accessibility\"],\n\t\t\t\"keywords\":\"pointer, mouse, touch\",\n\t\t\t\"last_test_date\":\"2025-07-02\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-cursor.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/52gbh94rh6lncdl4fge\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"15\":\"a #1\"},\"ios\":{\"15\":\"n #3\"}},\"gmail\":{\"desktop-webmail\":{\"2025-07\":\"n #2\"},\"ios\":{\"2025-07\":\"n #2\"},\"android\":{\"2025-07\":\"n #2\"},\"mobile-webmail\":{\"2025-07\":\"n #2\"}},\"orange\":{\"desktop-webmail\":{\"2025-07\":\"u\"},\"ios\":{\"2025-07\":\"u\"},\"android\":{\"2025-07\":\"u\"}},\"outlook\":{\"windows\":{\"2007\":\"u\",\"2010\":\"u\",\"2013\":\"u\",\"2016\":\"u\",\"2019\":\"u\"},\"windows-mail\":{\"2025-07\":\"u\"},\"macos\":{\"16.98\":\"n\"},\"outlook-com\":{\"2025-07\":\"n\"},\"ios\":{\"2025-07\":\"n #3\"},\"android\":{\"2025-07\":\"n\"}},\"samsung-email\":{\"android\":{\"6.0\":\"u\"}},\"sfr\":{\"desktop-webmail\":{\"2025-07\":\"u\"},\"ios\":{\"2025-07\":\"u\"},\"android\":{\"2025-07\":\"u\"}},\"thunderbird\":{\"macos\":{\"78.14\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2025-07\":\"u\"},\"ios\":{\"2025-07\":\"u\"},\"android\":{\"2025-07\":\"u\"}},\"yahoo\":{\"desktop-webmail\":{\"2025-07\":\"a #4\"},\"ios\":{\"2025-07\":\"n #3\"},\"android\":{\"2025-07\":\"a #4\"}},\"protonmail\":{\"desktop-webmail\":{\"2025-07\":\"a #5\"},\"ios\":{\"2025-07\":\"u\"},\"android\":{\"2025-07\":\"u\"}},\"hey\":{\"desktop-webmail\":{\"2025-07\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2025-07\":\"u\"}},\"fastmail\":{\"desktop-webmail\":{\"2025-07\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2025-07\":\"u\"}},\"free-fr\":{\"desktop-webmail\":{\"2025-07\":\"u\"}},\"t-online-de\":{\"desktop-webmail\":{\"2025-07\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2025-07\":\"a #6\"},\"ios\":{\"2025-07\":\"n #3\"},\"android\":{\"2025-07\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2025-07\":\"u\"},\"ios\":{\"2025-07\":\"u\"},\"android\":{\"2025-07\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2025-07\":\"u\"},\"android\":{\"2025-07\":\"u\"}}},\n\t\t\t\"notes\":\"Cursor appearance on Android mobile devices depends on external pointer device/mouse. Cursor on iOS mobile devices also requires assistive touch to be enabled, and does not appear to respond to content.\",\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. URL is not supported.\",\"2\":\"Cursor property is removed.\",\"3\":\"iOS assistive touch does not respond to cursor.\",\"4\":\"Partial. Only supports: crosshair, help, move, pointer, progress, text, wait, auto, default, and all single-direction resize properties.\",\"5\":\"Partial. URL buggy and only works inline.\",\"6\":\"Partial. Inline CSS only. Only supports: crosshair, help, move, pointer, wait, auto, default, and all single-direction resize properties.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-direction\",\n\t\t\t\"title\":\"direction\",\n\t\t\t\"description\":\"Sets the direction of text or table columns.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-direction/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2021-10-05\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-direction.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/TzLrUhEnTKX7mZ1LTCpvaZ2PNzg4mnsZtoLJi0FVbCSRD/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"14\":\"y\"},\"ios\":{\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-10\":\"y\"},\"ios\":{\"2021-10\":\"y\"},\"android\":{\"2021-10\":\"y\"},\"mobile-webmail\":{\"2021-10\":\"a #1\"}},\"orange\":{\"desktop-webmail\":{\"2021-10\":\"y\"},\"ios\":{\"2021-10\":\"y\"},\"android\":{\"2021-10\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2021-10\":\"y\"},\"macos\":{\"16.55\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2021-10\":\"y\"},\"ios\":{\"2021-10\":\"y\"},\"android\":{\"2021-10\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-10\":\"y\"},\"ios\":{\"2021-10\":\"y\"},\"android\":{\"2021-10\":\"y\"}},\"thunderbird\":{\"macos\":{\"78.14\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2021-10\":\"y\"},\"ios\":{\"2021-10\":\"y\"},\"android\":{\"2021-10\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-10\":\"y\"},\"ios\":{\"2021-10\":\"y\"},\"android\":{\"2021-10\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-10\":\"y\"},\"ios\":{\"2021-10\":\"y\"},\"android\":{\"2021-10\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-10\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-10\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-10\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2021-12\":\"y\"},\"ios\":{\"2021-12\":\"y\"},\"android\":{\"2021-12\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2021-12\":\"y\"},\"ios\":{\"2021-12\":\"y\"},\"android\":{\"2021-12\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2021-12\":\"y\"},\"android\":{\"2021-12\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. The client applies an `rtl` direction to the entire email if it contains text in a `rtl` language.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-display-flex\",\n\t\t\t\"title\":\"display:flex\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-display-flex/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"flexbox\",\n\t\t\t\"last_test_date\":\"2021-11-02\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-flexbox.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/Kw9bvIPLsmmwVoXhbXpIu1FM31v4nV2KXMaEvPQPezSO9/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\",\"2020-11\":\"a #1\"},\"android\":{\"2019-02\":\"y\",\"2020-11\":\"a #1\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2019-08\":\"y\",\"2021-11\":\"n\",\"2024-04\":\"n\"},\"android\":{\"2019-08\":\"y\",\"2021-11\":\"n\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"a\",\"2020-11\":\"y\"},\"android\":{\"2019-02\":\"a\",\"2020-11\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"n\",\"2020-11\":\"y #2\"},\"ios\":{\"2019-02\":\"n\",\"2020-11\":\"y #2\"},\"android\":{\"2019-02\":\"n\",\"2020-11\":\"y #2\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"n\",\"2020-11\":\"y #2\"},\"ios\":{\"2019-02\":\"n\",\"2020-11\":\"y #2\"},\"android\":{\"2019-02\":\"n\",\"2020-11\":\"y #2\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.5\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Not supported with non Google accounts.\",\"2\":\"`display:inline-flex` is not supported.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-display-grid\",\n\t\t\t\"title\":\"display:grid\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-display-grid/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-box-model.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/pyPQFHSYLFrhbRShalju0B2fYNwUgLuyKTLx4MLqiw5mE/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2019-08\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2019-08\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"n\",\"2024-01\":\"y\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.5\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-display-none\",\n\t\t\t\"title\":\"display:none\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-display-none/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-visual-effects.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/DhTRmGsVH6uobU4pHD3CasJywfBL4HnEjA1LOF8f9ctso/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"a #1 #2\",\"2010\":\"a #1 #2\",\"2013\":\"a #1 #2\",\"2016\":\"a #1 #2\",\"2019\":\"a #1 #2\"},\"windows-mail\":{\"2019-09\":\"a #1 #2\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.8\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Does not inherit onto nested `<table>` elements.\",\"2\":\"Partial. Does not work when applied directly to an `<img>` element.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-display\",\n\t\t\t\"title\":\"display\",\n\t\t\t\"description\":\"Sets whether an element is treated as a block or inline element and the layout used for its children.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-display/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"inline, block, inline-block\",\n\t\t\t\"last_test_date\":\"2021-12-01\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-display.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/eyGBFSORjrHd635gw4udynxX8ykC5bzlMUUrF6yi7Kspu/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"15\":\"y\"},\"ios\":{\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-12\":\"y\"},\"ios\":{\"2021-12\":\"a #1\"},\"android\":{\"2021-12\":\"a #1\"},\"mobile-webmail\":{\"2021-12\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2021-12\":\"a #2 #3\"},\"ios\":{\"2021-12\":\"a #2\"},\"android\":{\"2021-12\":\"a #2\"}},\"outlook\":{\"windows\":{\"2007\":\"a #4 #5\",\"2010\":\"a #4 #5\",\"2013\":\"a #4 #5\",\"2016\":\"a #4 #5\",\"2019\":\"a #4 #5\"},\"windows-mail\":{\"2021-12\":\"a #4 #5\"},\"macos\":{\"16.56\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2021-12\":\"y\"},\"ios\":{\"2021-12\":\"y\"},\"android\":{\"2021-12\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-12\":\"y\"},\"ios\":{\"2021-12\":\"y\"},\"android\":{\"2021-12\":\"y\"}},\"thunderbird\":{\"macos\":{\"91.2\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2021-12\":\"a #6\"},\"ios\":{\"2021-12\":\"a #6\"},\"android\":{\"2021-12\":\"a #6\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-12\":\"a #6\"},\"ios\":{\"2021-12\":\"a #6\"},\"android\":{\"2021-12\":\"a #6\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-12\":\"y\"},\"ios\":{\"2021-12\":\"y\"},\"android\":{\"2021-12\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-12\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-12\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-12\":\"a #7\"}},\"laposte\":{\"desktop-webmail\":{\"2021-12\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"a #2\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"a #2\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. `flex`, `grid`, `flow-root`, `contents`, `inline flow-root`, `inline flex`, `inline grid`, `initial`, `revert`, `unset` are not supported with non Google accounts.\",\"2\":\"Partial. `inline-flex`, `inline-grid`, `flex`, `grid`, `flow-root`, `contents`, `inline flow-root`, `inline flex`, `inline grid`, `initial`, `revert`, `unset` values are not supported.\",\"3\":\"Buggy. Only the first value is kept with the two-value syntax.\",\"4\":\"Buggy. `display:none` does not inherit to inner tables.\",\"5\":\"Partial. Only supports `display:none` (but not on `<img>`).\",\"6\":\"Partial. `flow-root`, `inline-flex`, `inline-grid`, `inline flow`, `contents`, `revert` are not supported.\",\"7\":\"Partial. Two-value syntax are combined into a single one with a dash.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-empty-cells\",\n\t\t\t\"title\":\"empty-cells\",\n\t\t\t\"description\":\"Sets whether borders and backgrounds appear around `<table>` cells that have no visible content.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-empty-cells/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"blank\",\n\t\t\t\"last_test_date\":\"2024-08-23\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-empty-cells.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/kgl7t57xs8jxueze0v8\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2024-08\":\"y\"},\"ios\":{\"2024-08\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2024-08\":\"y\"},\"ios\":{\"2024-08\":\"y\"},\"android\":{\"2024-08\":\"y\"},\"mobile-webmail\":{\"2024-08\":\"u\"}},\"orange\":{\"desktop-webmail\":{\"2024-08\":\"u\"},\"ios\":{\"2024-08\":\"u\"},\"android\":{\"2024-08\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2024-08\":\"n\"},\"macos\":{\"2024-08\":\"y\"},\"outlook-com\":{\"2024-08\":\"y\"},\"ios\":{\"2024-08\":\"y\"},\"android\":{\"2024-08\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2024-08\":\"y\"},\"ios\":{\"2024-08\":\"n\"},\"android\":{\"2024-08\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2024-08\":\"y\"},\"ios\":{\"2024-08\":\"n\"},\"android\":{\"2024-08\":\"n\"}},\"samsung-email\":{\"android\":{\"2024-08\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2024-03\":\"u\"},\"ios\":{\"2024-03\":\"u\"},\"android\":{\"2024-03\":\"u\"}},\"protonmail\":{\"desktop-webmail\":{\"2024-08\":\"y\"},\"ios\":{\"2024-08\":\"y\"},\"android\":{\"2024-08\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2024-08\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-08\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2024-08\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-filter\",\n\t\t\t\"title\":\"filter\",\n\t\t\t\"description\":\"Tests with values `blur`, `brightness`, `grayscale`, `hue-rotate`, `invert`, `opacity`, `saturate`, `sepia` and `shadow`.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-filter/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2024-01-03\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-visual-effects.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/73yrcloa1z681dkzu6\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2019-08\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2019-08\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.8\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-01\":\"a #1\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Multiple values don't work.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-flex-direction\",\n\t\t\t\"title\":\"flex-direction:column\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-flex-direction/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"flexbox\",\n\t\t\t\"last_test_date\":\"2023-01-17\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-box-model.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/pyPQFHSYLFrhbRShalju0B2fYNwUgLuyKTLx4MLqiw5mE/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"n\",\"2023-01\":\"n\"},\"ios\":{\"2019-02\":\"n\",\"2023-01\":\"n\"},\"android\":{\"2019-02\":\"n\",\"2023-01\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2019-08\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2019-08\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"n\",\"2023-01\":\"y\"},\"ios\":{\"2019-02\":\"n\",\"2023-01\":\"y\"},\"android\":{\"2019-02\":\"n\",\"2023-01\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.5\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-flex-wrap\",\n\t\t\t\"title\":\"flex-wrap: wrap\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-flex-wrap/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"flexbox\",\n\t\t\t\"last_test_date\":\"2023-01-16\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-box-model.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/pyPQFHSYLFrhbRShalju0B2fYNwUgLuyKTLx4MLqiw5mE/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"n\",\"2023-01\":\"n\"},\"ios\":{\"2019-02\":\"n\",\"2023-01\":\"n\"},\"android\":{\"2019-02\":\"n\",\"2023-01\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\",\"2023-01\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2019-08\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2019-08\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"n\",\"2023-01\":\"y\"},\"ios\":{\"2019-02\":\"n\",\"2023-01\":\"y\"},\"android\":{\"2019-02\":\"n\",\"2023-01\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"n\",\"2023-01\":\"n\"},\"ios\":{\"2019-02\":\"n\",\"2023-01\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.5\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-float\",\n\t\t\t\"title\":\"float\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-float/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2024-05-08\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-box-model.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/gyjkc98dtyzxfd3bhz\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"11.7\":\"a #2\",\"12.4\":\"y\"},\"ios\":{\"14\":\"a #2\",\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\",\"2024-05\":\"a #2\"},\"android\":{\"2019-02\":\"y\",\"2024-05\":\"a #2\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"n #1\",\"2010\":\"n #1\",\"2013\":\"n #1\",\"2016\":\"n #1\",\"2019\":\"n #1\"},\"windows-mail\":{\"2019-02\":\"n #1\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"y\",\"2024-05\":\"a #2\"},\"ios\":{\"2019-02\":\"y\",\"2024-05\":\"a #2\"},\"android\":{\"2019-02\":\"n\",\"2023-01\":\"y\",\"2024-05\":\"a #2\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"y\",\"2024-05\":\"a #2\"},\"ios\":{\"2019-02\":\"y\",\"2024-05\":\"a #2\"},\"android\":{\"2019-02\":\"y\",\"2024-05\":\"a #2\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.5\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y\",\"2024-05\":\"a #2\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y\",\"2024-05\":\"a #2\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Not supported. `table` and `img` elements can use an `align` attribute to get a similar effect.\",\"2\":\"Partial. Logical property values `inline-start` and `inline-end` are not supported.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-font-kerning\",\n\t\t\t\"title\":\"font-kerning\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-font-kerning/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"font,kerning\",\n\t\t\t\"last_test_date\":\"2022-08-01\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-font-kerning.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/RlRYNGDjVNBhofxCNxloUcRbUVWGDhJ2kZ4fy6HXpEatH/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"11\":\"y\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\",\"15\":\"y\"},\"ios\":{\"11\":\"y\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\",\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"},\"mobile-webmail\":{\"2022-08\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"macos\":{\"2022-08\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2022-08\":\"y\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"4.2101.1\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-08\":\"n\",\"2024-03\":\"n\"},\"ios\":{\"2024-03\":\"n\"},\"android\":{\"2024-03\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2022-08\":\"n\"}},\"samsung-email\":{\"android\":{\"6.1.31.2\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-08\":\"n\"}},\"free-fr\":{\"desktop-webmail\":{\"2022-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-08\":\"n\"}},\"t-online-de\":{\"desktop-webmail\":{\"2022-08\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2024-03\":\"y\"},\"ios\":{\"2024-03\":\"u\"},\"android\":{\"2024-03\":\"u\"}},\"thunderbird\":{\"macos\":{\"128.9.0\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"y\"},\"android\":{\"2023-12\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-font-size\",\n\t\t\t\"title\":\"font-size\",\n\t\t\t\"description\":\"Sets the size of the font.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-font-size/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"font,size\",\n\t\t\t\"last_test_date\":\"2024-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-font-size.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/vr3ai85bunngsxjjfd2\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"11\":\"y\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\",\"15\":\"y\"},\"ios\":{\"11\":\"y\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\",\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2024-02\":\"y\"},\"ios\":{\"2024-02\":\"y\"},\"android\":{\"2024-02\":\"y\"},\"mobile-webmail\":{\"2024-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2024-02\":\"u\"},\"ios\":{\"2024-02\":\"u\"},\"android\":{\"2024-02\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"a #1\",\"2016\":\"a #1\",\"2019\":\"a #1\",\"2021\":\"a #1\"},\"windows-mail\":{\"2024-02\":\"a #2\"},\"macos\":{\"2024-02\":\"y\"},\"outlook-com\":{\"2024-02\":\"y\"},\"ios\":{\"2024-02\":\"y\"},\"android\":{\"2024-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2024-02\":\"a #1\"},\"ios\":{\"2024-02\":\"a #1\"},\"android\":{\"2024-02\":\"a #1\"}},\"aol\":{\"desktop-webmail\":{\"2024-02\":\"a #1\"},\"ios\":{\"2024-02\":\"a #1\"},\"android\":{\"2024-02\":\"a #1\"}},\"samsung-email\":{\"android\":{\"2024-02\":\"a #2\"}},\"sfr\":{\"desktop-webmail\":{\"2024-02\":\"u\"},\"ios\":{\"2024-02\":\"u\"},\"android\":{\"2024-02\":\"u\"}},\"thunderbird\":{\"macos\":{\"2024-02\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2024-02\":\"u\"},\"ios\":{\"2024-02\":\"u\"},\"android\":{\"2024-02\":\"u\"}},\"hey\":{\"desktop-webmail\":{\"2024-02\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-02\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2024-02\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2024-02\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2024-02\":\"a #1\"},\"ios\":{\"2024-02\":\"u\"},\"android\":{\"2024-02\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2024-02\":\"a #1\"},\"ios\":{\"2024-02\":\"u\"},\"android\":{\"2024-02\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2024-02\":\"u\"},\"android\":{\"2024-02\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial support. `rem` values are not supported.\",\"2\":\"Partial support. `relative` and `percentage` size values not supported.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-font-stretch\",\n\t\t\t\"title\":\"font-stretch\",\n\t\t\t\"description\":\"Selecting different font widths (e.g. condensed, expanded)\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-font-stretch/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"font\",\n\t\t\t\"last_test_date\":\"2023-09-23\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-font-stretch.html\",\n\t\t\t\"test_results_url\":\"\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"16\":\"y\"},\"ios\":{\"16\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2023-09\":\"y #1\"},\"ios\":{\"2023-09\":\"y\"},\"android\":{\"2024-03\":\"n\"},\"mobile-webmail\":{\"2023-09\":\"y #1\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"macos\":{\"2023-09\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2023-09\":\"y #1\"},\"ios\":{\"2023-09\":\"y\"},\"android\":{\"2024-03\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2023-09\":\"n\"},\"ios\":{\"2023-09\":\"n\"},\"android\":{\"2024-03\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2023-09\":\"n\"},\"ios\":{\"2023-09\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2023-09\":\"y #1\"},\"ios\":{\"2023-09\":\"y\"},\"android\":{\"2022-12\":\"u\"}},\"thunderbird\":{\"macos\":{\"2023-09\":\"y\"}},\"samsung-email\":{\"android\":{\"6.1.31.2\":\"u\",\"6.1.90.16\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-01\":\"y\"}},\"free-fr\":{\"desktop-webmail\":{\"2022-08\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2022-08\":\"u\"}},\"t-online-de\":{\"desktop-webmail\":{\"2022-08\":\"u\"}},\"sfr\":{\"desktop-webmail\":{\"2024-03\":\"y\"},\"ios\":{\"2024-03\":\"y\"},\"android\":{\"2024-03\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Depends on the browser. There is currently a bug in Chrome/Edge where font-stretch does not work for variable fonts.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-font-weight\",\n\t\t\t\"title\":\"font-weight\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-font-weight/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"font,weight\",\n\t\t\t\"last_test_date\":\"2022-06-23\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-font-weight.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/n4Dj7AQAtcFsJEgkLoSucoofVEvWrmqTqsO5pev2ew8XN/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"11\":\"y\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\",\"15\":\"y\"},\"ios\":{\"11\":\"y\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\",\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-01\":\"y\",\"2022-06\":\"y\"},\"ios\":{\"2021-01\":\"y\",\"2022-06\":\"y\"},\"android\":{\"2021-01\":\"y\",\"2022-06\":\"y\"},\"mobile-webmail\":{\"2021-01\":\"y\",\"2022-06\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2021-01\":\"y\",\"2021-03\":\"a #1\",\"2024-04\":\"a #1\"},\"ios\":{\"2021-01\":\"y\",\"2024-04\":\"y\"},\"android\":{\"2021-01\":\"y\",\"2024-04\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"a #2\",\"2010\":\"a #2\",\"2013\":\"a #2\",\"2016\":\"a #2\",\"2019\":\"a #2\"},\"windows-mail\":{\"2021-01\":\"a #2\"},\"macos\":{\"2021-01\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2021-01\":\"y\"},\"ios\":{\"2021-01\":\"y\"},\"android\":{\"4.2101.1\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-01\":\"a #1\"},\"ios\":{\"6.21.1\":\"a #1\"},\"android\":{\"6.16.2.1525679\":\"a #1\"}},\"aol\":{\"desktop-webmail\":{\"2021-01\":\"a #1\",\"2022-06\":\"a #1\"},\"ios\":{\"2021-01\":\"a #1\"},\"android\":{\"2021-01\":\"a #1\"}},\"samsung-email\":{\"android\":{\"6.1.31.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-01\":\"y\"},\"ios\":{\"2021-01\":\"y\"},\"android\":{\"2021-01\":\"y\"}},\"thunderbird\":{\"macos\":{\"2021-01\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-01\":\"y\"},\"ios\":{\"2021-01\":\"y\"},\"android\":{\"2021-01\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-01\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-01\":\"y\",\"2022-06\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial support. `<number>` values are not supported as per CSS Fonts Level 4 where any `<number>` value between 1 and 1000 (inclusive) is a valid value. Only the following numeric values are supported: 100, 200, 300, 400, 500, 600, 700, 800, and 900.\",\"2\":\"Partial support. `<number>` values between 0 and 599 are set as normal font weight. `<number>` values between 600 and 1000 are set as bold font weight.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-font\",\n\t\t\t\"title\":\"font shorthand\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-font/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-text.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/DkqbHs69ek5UnK6uhZ7Uj0n5GVQNTP4Z1FvgXvnKyEoTM/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"a #1\",\"2020-12\":\"y\"},\"android\":{\"2019-02\":\"a #1\",\"2020-12\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-01\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2019-02\":\"y\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"thunderbird\":{\"macos\":{\"68.4\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-function-clamp\",\n\t\t\t\"title\":\"clamp()\",\n\t\t\t\"description\":\"Support for the CSS `clamp()` function.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-function-clamp/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2021-05-07\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-clamp.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/zSEJIfc49LeYUVU5ncqsWBDRRTZlqq01sYRUSICWOs74Y/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"n\",\"14.0\":\"y\"},\"ios\":{\"13\":\"n\",\"14.5\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"a #1\"},\"android\":{\"2021-05\":\"a #1\"},\"mobile-webmail\":{\"2021-05\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2021-05\":\"n\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2021-05\":\"n\"},\"macos\":{\"2011\":\"n\",\"2016\":\"n\",\"16.80\":\"n\"},\"outlook-com\":{\"2021-05\":\"n\",\"2023-12\":\"n\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"}},\"samsung-email\":{\"android\":{\"6.0\":\"n\",\"6.2.06.0\":\"a #2\"}},\"sfr\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"y\"}},\"thunderbird\":{\"macos\":{\"78.10\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2021-05\":\"n\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-05\":\"n\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-05\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-05\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\",\"2\":\"Partial. Not supported with Microsoft accounts.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-function-light-dark\",\n\t\t\t\"title\":\"light-dark()\",\n\t\t\t\"description\":\"Enables setting two colors (one for light and the other for dark mode) for a property.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-function-light-dark/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"accessibility\"],\n\t\t\t\"keywords\":\"dark, light\",\n\t\t\t\"last_test_date\":\"2024-08-14\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-function-light-dark.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/Lai13xyIE95H6jo1BBs6ay0f3RvJdPL344S3j3M7FbeU4/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"16.0\":\"y #1\"},\"ios\":{\"17.5.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2024-08\":\"n\"},\"ios\":{\"2024-08\":\"n\"},\"android\":{\"2024-08\":\"n\"},\"mobile-webmail\":{\"2024-08\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2024-08\":\"n\"},\"ios\":{\"2024-08\":\"n\"},\"android\":{\"2024-08\":\"u\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2024-08\":\"n\"},\"macos\":{\"16.88\":\"n\"},\"outlook-com\":{\"2024-08\":\"n\"},\"ios\":{\"2024-08\":\"n\"},\"android\":{\"2024-08\":\"u\"}},\"samsung-email\":{\"android\":{\"6.2\":\"a #3\"}},\"sfr\":{\"desktop-webmail\":{\"2024-08\":\"a #2\"},\"ios\":{\"2024-08\":\"a #1 #2\"},\"android\":{\"2024-08\":\"u\"}},\"thunderbird\":{\"macos\":{\"115.10.1\":\"n\",\"128.1.0\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2024-08\":\"n\"},\"ios\":{\"2024-08\":\"n\"},\"android\":{\"2024-08\":\"u\"}},\"yahoo\":{\"desktop-webmail\":{\"2024-08\":\"n\"},\"ios\":{\"2024-08\":\"n\"},\"android\":{\"2024-08\":\"u\"}},\"protonmail\":{\"desktop-webmail\":{\"2024-08\":\"a #2\"},\"ios\":{\"2024-08\":\"y #1\"},\"android\":{\"2024-08\":\"u\"}},\"hey\":{\"desktop-webmail\":{\"2024-08\":\"a #2\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-08\":\"a #2\"}},\"fastmail\":{\"desktop-webmail\":{\"2024-08\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2024-08\":\"a #2\"}},\"free-fr\":{\"desktop-webmail\":{\"2024-08\":\"n\"}},\"t-online-de\":{\"desktop-webmail\":{\"2024-08\":\"a #2\"}},\"gmx\":{\"desktop-webmail\":{\"2024-08\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Only supported if you’ve updated your OS with Safari 17.5 or later.\",\"2\":\"Buggy. The function is supported but the color stays light even in dark mode.\",\"3\":\"Partial. Not supported with Hotmail/Outlook accounts.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-function-max\",\n\t\t\t\"title\":\"max()\",\n\t\t\t\"description\":\"Support for the CSS `max()` function.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-function-max/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2021-05-07\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-clamp.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/zSEJIfc49LeYUVU5ncqsWBDRRTZlqq01sYRUSICWOs74Y/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\",\"14.0\":\"y\"},\"ios\":{\"13\":\"y\",\"14.5\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"a #1\"},\"android\":{\"2021-05\":\"a #1\"},\"mobile-webmail\":{\"2021-05\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2021-05\":\"n\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2021-05\":\"n\"},\"macos\":{\"2011\":\"n\",\"2016\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2021-05\":\"n\",\"2023-12\":\"n\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"}},\"samsung-email\":{\"android\":{\"6.0\":\"n\",\"6.2.06.0\":\"a #2\"}},\"sfr\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"y\"}},\"thunderbird\":{\"macos\":{\"78.10\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2021-05\":\"n\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-05\":\"n\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-05\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-05\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\",\"2\":\"Partial. Not supported with Microsoft accounts.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-function-min\",\n\t\t\t\"title\":\"min()\",\n\t\t\t\"description\":\"Support for the CSS `min()` function.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-function-min/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2021-05-07\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-clamp.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/zSEJIfc49LeYUVU5ncqsWBDRRTZlqq01sYRUSICWOs74Y/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\",\"14.0\":\"y\"},\"ios\":{\"13\":\"y\",\"14.5\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"a #1\"},\"android\":{\"2021-05\":\"a #1\"},\"mobile-webmail\":{\"2021-05\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2021-05\":\"n\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2021-05\":\"n\"},\"macos\":{\"2011\":\"n\",\"2016\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2021-05\":\"n\",\"2023-12\":\"n\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"}},\"samsung-email\":{\"android\":{\"6.0\":\"n\",\"6.2.06.0\":\"a #2\"}},\"sfr\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"y\"}},\"thunderbird\":{\"macos\":{\"78.10\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2021-05\":\"n\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-05\":\"n\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-05\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-05\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\",\"2\":\"Partial. Not supported with Microsoft accounts.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-gap\",\n\t\t\t\"title\":\"gap, column-gap, row-gap\",\n\t\t\t\"description\":\"Properties for adding spacing between grid items, flex items and columns\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-gap/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"gap\",\n\t\t\t\"last_test_date\":\"2022-12-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-gap.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/OAnjtSK1hEGcC3V9Q30mzIU8xKXIqwNq0M4lZywGOhQIn/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13.1\":\"y\"},\"ios\":{\"16.2\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2022-12\":\"a #1\"},\"ios\":{\"2022-12\":\"a #1\"},\"android\":{\"2022-12\":\"a #1\"},\"mobile-webmail\":{\"2022-12\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2022-12\":\"n\"},\"ios\":{\"2022-12\":\"n\"},\"android\":{\"2022-12\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2022-12\":\"n\"},\"macos\":{\"2022-12\":\"y\",\"16.80\":\"a #1\"},\"outlook-com\":{\"2022-12\":\"a #1\",\"2023-12\":\"a #1\"},\"ios\":{\"2022-12\":\"a #1\"},\"android\":{\"2022-12\":\"a #1\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-12\":\"n\"},\"ios\":{\"2022-12\":\"n\"},\"android\":{\"2022-12\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2022-12\":\"n\"},\"ios\":{\"2022-12\":\"n\"},\"android\":{\"2022-12\":\"n\"}},\"samsung-email\":{\"android\":{\"6.1\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2022-12\":\"y\"},\"ios\":{\"2022-12\":\"y\"},\"android\":{\"2022-12\":\"y\"}},\"thunderbird\":{\"macos\":{\"102.6.1\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-12\":\"y\"},\"ios\":{\"2022-12\":\"y\"},\"android\":{\"2022-12\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2022-12\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-12\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2022-12\":\"a #2\"}},\"laposte\":{\"desktop-webmail\":{\"2022-12\":\"y\"}},\"free-fr\":{\"desktop-webmail\":{\"2022-12\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-12\":\"n\"}},\"t-online-de\":{\"desktop-webmail\":{\"2022-12\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Supports column-gap for flexbox.\",\"2\":\"Partial. Supports gap and column-gap for flexbox. Supports gap for multi-column layout (i.e. in conjunction with CSS column-count property)\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-grid-template\",\n\t\t\t\"title\":\"grid-template-* properties\",\n\t\t\t\"description\":\"This page reflects support for `grid-template`, `grid-template-areas`, `grid-template-columns`, `grid-template-rows`.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-grid-template/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"grid-template, grid-template-areas, grid-template-columns, grid-template-rows\",\n\t\t\t\"last_test_date\":\"2023-01-09\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-grid-properties.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/MBJ7UfOQ1sVRvPBJNsWByvymNSwrIhi2drpCo4gTw0oM0/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13.1\":\"y\"},\"ios\":{\"16.2\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2023-01\":\"n\"},\"ios\":{\"2023-01\":\"n\"},\"android\":{\"2023-01\":\"n\"},\"mobile-webmail\":{\"2023-01\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2023-01\":\"n\"},\"ios\":{\"2023-01\":\"n\"},\"android\":{\"2023-01\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2023-01\":\"n\"},\"macos\":{\"2023-01\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2023-01\":\"n\",\"2023-12\":\"n\"},\"ios\":{\"2023-01\":\"n\"},\"android\":{\"2023-01\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2023-01\":\"n\"},\"ios\":{\"2023-01\":\"n\"},\"android\":{\"2023-01\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2023-01\":\"n\"},\"ios\":{\"2023-01\":\"n\"},\"android\":{\"2023-01\":\"n\"}},\"samsung-email\":{\"android\":{\"6.1.74.5\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2023-01\":\"y\"},\"ios\":{\"2023-01\":\"y\"},\"android\":{\"2023-01\":\"y\"}},\"thunderbird\":{\"macos\":{\"102.6\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2023-01\":\"y\"},\"ios\":{\"2023-01\":\"y\"},\"android\":{\"2023-01\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2023-01\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2023-01\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2023-01\":\"n\"}},\"laposte\":{\"desktop-webmail\":{\"2023-01\":\"y\"}},\"free-fr\":{\"desktop-webmail\":{\"2023-01\":\"y\"}},\"t-online-de\":{\"desktop-webmail\":{\"2023-01\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2023-01\":\"n\"},\"ios\":{\"2023-01\":\"u\"},\"android\":{\"2023-01\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2023-01\":\"u\"},\"ios\":{\"2023-01\":\"u\"},\"android\":{\"2023-01\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2023-01\":\"u\"},\"android\":{\"2023-01\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-height\",\n\t\t\t\"title\":\"height property\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-height/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-09-27\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-width-height.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/UhsQmS14DHKFfotKEcCTnWaoAiS24FJMiApZ1OtmHR7vs/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2019-09\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"a #2\",\"2010\":\"a #2\",\"2013\":\"a #2\",\"2016\":\"a #2\",\"2019\":\"a #2\"},\"windows-mail\":{\"2019-09\":\"y\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-09\":\"y\",\"2023-12\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-09\":\"n #1\"},\"ios\":{\"2019-09\":\"n #1\"},\"android\":{\"2019-09\":\"n #1\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-09\":\"n #1\"},\"ios\":{\"2019-09\":\"n #1\"},\"android\":{\"2019-09\":\"n #1\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. Replaces `height` by `min-height`.\",\"2\":\"Partial. Not supported on `<body>`, `<span>`, `<div>` or `<p>` elements.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-hyphenate-character\",\n\t\t\t\"title\":\"hyphenate-character\",\n\t\t\t\"description\":\"Sets the character (or string) used at the end of a line before a hyphenation break.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-hyphenate-character/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"hyphens, break\",\n\t\t\t\"last_test_date\":\"2024-06-19\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-hyphenate-character.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/vr3e1e5bikda08oxc2\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"20\":\"n\",\"21\":\"n\",\"22\":\"n\",\"23\":\"y\"},\"ios\":{\"11\":\"n\",\"12\":\"n\",\"13\":\"n\",\"14\":\"n\",\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2024-06\":\"n\"},\"ios\":{\"2024-06\":\"n\"},\"android\":{\"2024-06\":\"n\"},\"mobile-webmail\":{\"2024-06\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2024-06\":\"u\"},\"ios\":{\"2024-06\":\"u\"},\"android\":{\"2024-06\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2024-06\":\"n\"},\"macos\":{\"2024-06\":\"n\"},\"outlook-com\":{\"2024-06\":\"n\"},\"ios\":{\"2024-06\":\"n\"},\"android\":{\"2024-06\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2024-06\":\"n\"},\"ios\":{\"2024-06\":\"n\"},\"android\":{\"2024-06\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2024-06\":\"n\"},\"ios\":{\"2024-06\":\"n\"},\"android\":{\"2024-06\":\"n\"}},\"samsung-email\":{\"android\":{\"2024-06\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2024-03\":\"u\"},\"ios\":{\"2024-03\":\"u\"},\"android\":{\"2024-03\":\"u\"}},\"protonmail\":{\"desktop-webmail\":{\"2024-06\":\"y\"},\"ios\":{\"2024-06\":\"y\"},\"android\":{\"2024-06\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2024-06\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-06\":\"a #1\"}},\"fastmail\":{\"desktop-webmail\":{\"2024-06\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2024-06\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2024-06\":\"n\"},\"ios\":{\"2024-06\":\"u\"},\"android\":{\"2024-06\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2024-06\":\"n\"},\"ios\":{\"2024-06\":\"u\"},\"android\":{\"2024-06\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2024-06\":\"u\"},\"android\":{\"2024-06\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Does not support encoded character values\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-hyphenate-limit-chars\",\n\t\t\t\"title\":\"hyphenate-limit-chars\",\n\t\t\t\"description\":\"Specifies the minimum word length to allow hyphenation of words as well as the minimum number of characters before and after the hyphen.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-hyphenate-limit-chars/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2024-08-08\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-hyphenate-limit-chars.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/kgljcojhdyrfdv5s2\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2024-08\":\"n\"},\"ios\":{\"2024-08\":\"n\"}},\"gmail\":{\"desktop-webmail\":{\"2024-08\":\"n\"},\"ios\":{\"2024-08\":\"n\"},\"android\":{\"2024-08\":\"n\"},\"mobile-webmail\":{\"2024-08\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2024-08\":\"u\"},\"ios\":{\"2024-08\":\"u\"},\"android\":{\"2024-08\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2024-08\":\"n\"},\"macos\":{\"2024-08\":\"n\"},\"outlook-com\":{\"2024-08\":\"n\"},\"ios\":{\"2024-08\":\"n\"},\"android\":{\"2024-08\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2024-08\":\"n\"},\"ios\":{\"2024-08\":\"n\"},\"android\":{\"2024-08\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2024-08\":\"n\"},\"ios\":{\"2024-08\":\"n\"},\"android\":{\"2024-08\":\"n\"}},\"samsung-email\":{\"android\":{\"2024-08\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2024-08\":\"u\"},\"ios\":{\"2024-08\":\"u\"},\"android\":{\"2024-08\":\"u\"}},\"thunderbird\":{\"macos\":{\"2024-08\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2024-08\":\"u\"},\"ios\":{\"2024-08\":\"u\"},\"android\":{\"2024-08\":\"u\"}},\"hey\":{\"desktop-webmail\":{\"2024-08\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-08\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2024-08\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2024-08\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2024-08\":\"n\"},\"ios\":{\"2024-08\":\"u\"},\"android\":{\"2024-08\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2024-08\":\"n\"},\"ios\":{\"2024-08\":\"u\"},\"android\":{\"2024-08\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2024-08\":\"u\"},\"android\":{\"2024-08\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-hyphens\",\n\t\t\t\"title\":\"hyphens\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-hyphens/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"hyphens, break\",\n\t\t\t\"last_test_date\":\"2022-08-03\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-hyphen.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/rvEUZkBsPVNSbRohvoRigyRiM\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"16\":\"y\",\"17\":\"y\",\"18\":\"y\",\"19\":\"y\",\"20\":\"y\",\"21\":\"y\"},\"ios\":{\"11\":\"y\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\",\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"n\"},\"android\":{\"2022-08\":\"n\"},\"mobile-webmail\":{\"2022-08\":\"u\"}},\"orange\":{\"desktop-webmail\":{\"2022-08\":\"u\"},\"ios\":{\"2022-08\":\"u\"},\"android\":{\"2022-08\":\"u\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2022-08\":\"n\"},\"macos\":{\"2022-08\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2022-08\":\"n\",\"2023-12\":\"n\"},\"ios\":{\"2022-08\":\"n\"},\"android\":{\"2022-08\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"n\"},\"android\":{\"2022-08\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"u\"},\"android\":{\"2022-08\":\"u\"}},\"samsung-email\":{\"android\":{\"2022-08\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2024-03\":\"y\"},\"ios\":{\"2024-03\":\"y\"},\"android\":{\"2024-03\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-08\":\"u\"},\"ios\":{\"2022-08\":\"u\"},\"android\":{\"2022-08\":\"u\"}},\"hey\":{\"desktop-webmail\":{\"2022-08\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-08\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2022-08\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-important\",\n\t\t\t\"title\":\"!important keyword\",\n\t\t\t\"description\":\"An `!important` declaration takes precedence over a normal declaration.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-important/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2021-11-30\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-important.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/JEyxyPfKHFZCPKxlgiOugpH4lyNrXX39cd9M8xaW1DojH/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\",\"15\":\"y\"},\"ios\":{\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-11\":\"a #1\"},\"ios\":{\"2021-11\":\"a #1 #2\"},\"android\":{\"2021-11\":\"a #1 #2\"},\"mobile-webmail\":{\"2021-11\":\"a #1 #2\"}},\"orange\":{\"desktop-webmail\":{\"2021-11\":\"n #3\"},\"ios\":{\"2021-11\":\"n #3\"},\"android\":{\"2021-11\":\"n #3\"}},\"outlook\":{\"windows\":{\"2007\":\"a #4 #5\",\"2010\":\"a #4 #5\",\"2013\":\"a #4 #5\",\"2016\":\"a #4 #5\",\"2019\":\"a #4 #5\"},\"windows-mail\":{\"2021-11\":\"a #4\"},\"macos\":{\"16.56\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"}},\"samsung-email\":{\"android\":{\"7.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"a #6\"},\"android\":{\"2021-11\":\"a #6\"}},\"thunderbird\":{\"macos\":{\"91.2.1\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2021-11\":\"a #7\"},\"ios\":{\"2021-11\":\"a #7\"},\"android\":{\"2021-11\":\"a #7\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-11\":\"a #7\"},\"ios\":{\"2021-11\":\"a #7\"},\"android\":{\"2021-11\":\"a #7\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-11\":\"a #8\"},\"ios\":{\"2021-11\":\"a #6\",\"2025-08\":\"a #9\"},\"android\":{\"2021-11\":\"a #6\"}},\"hey\":{\"desktop-webmail\":{\"2021-11\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-11\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-11\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-11\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"wp-pl\":{\"desktop-webmail\":{\"2023-12\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Only supported in lowercase. (See [email-bugs#13](https://github.com/hteumeuleu/email-bugs/issues/13))\",\"2\":\"Partial. Only supported inline when using a Non Gmail Account due to the lack of `<style>` support.\",\"3\":\"Not supported. The entire declaration is removed if there is no space before `!important`.\",\"4\":\"Partial. Only supported with a space before.\",\"5\":\"Partial. Not supported inline. (See [email-bugs#31](https://github.com/hteumeuleu/email-bugs/issues/31))\",\"6\":\"Partial. Only supported inline due to the lack of `<style>` support.\",\"7\":\"Partial. Removed if there is no space before when used with a `background-image` property. (See [email-bugs#16](https://github.com/hteumeuleu/email-bugs/issues/16))\",\"8\":\"Partial. Only supported in lowercase.\",\"9\":\"Partial. Only supported if not written in lowercase.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-inline-size\",\n\t\t\t\"title\":\"inline-size \",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-inline-size/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-box-model.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/pyPQFHSYLFrhbRShalju0B2fYNwUgLuyKTLx4MLqiw5mE/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"n\"},\"ios\":{\"12.1\":\"n\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"n\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"n\",\"16.80\":\"n\"},\"outlook-com\":{\"2019-02\":\"n\",\"2023-12\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.5\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-inset\",\n\t\t\t\"title\":\"inset\",\n\t\t\t\"description\":\"Shorthand that corresponds to the `top`, `right`, `bottom`, and/or `left` properties\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-inset/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":\"inset-block, inset-inline, inset-inline-start, inset-inline-end, inset-block-start, inset-block-end\",\n\t\t\t\"last_test_date\":\"2024-05-29\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-inset.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/rlpdia3k18jytjx8c2\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.15\":\"n\",\"11.7\":\"y\"},\"ios\":{\"14\":\"n\",\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2024-05\":\"n\"},\"ios\":{\"2024-05\":\"n\"},\"android\":{\"2024-05\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2024-05\":\"u\"},\"ios\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}},\"outlook\":{\"windows\":{\"2024-05\":\"n\"},\"windows-mail\":{\"2024-05\":\"n\"},\"macos\":{\"2024-05\":\"n\"},\"outlook-com\":{\"2024-05\":\"n\"},\"ios\":{\"2024-05\":\"n\"},\"android\":{\"2024-05\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2024-05\":\"n\"},\"ios\":{\"2024-05\":\"n\"},\"android\":{\"2024-05\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2024-05\":\"n\"},\"ios\":{\"2024-05\":\"n\"},\"android\":{\"2024-05\":\"n\"}},\"samsung-email\":{\"android\":{\"2024-05\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2024-05\":\"u\"},\"ios\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}},\"thunderbird\":{\"macos\":{\"2024-05\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2024-05\":\"u\"},\"ios\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}},\"hey\":{\"desktop-webmail\":{\"2024-05\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-05\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2024-05\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2024-05\":\"n\"},\"ios\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2024-05\":\"n\"},\"ios\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-intrinsic-size\",\n\t\t\t\"title\":\"fit-content, min-content, max-content\",\n\t\t\t\"description\":\"Sets the height or width relative to its content.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-intrinsic-size/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2022-12-31\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-intrinsic-size.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/tcE2poX3tOf6HlwmIH6GQqveQN6SwMrsqCp4ylpkFVc3W/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y #2\"},\"ios\":{\"12.1\":\"y #2\"}},\"gmail\":{\"desktop-webmail\":{\"2022-12\":\"y\"},\"ios\":{\"2022-12\":\"a #3\"},\"android\":{\"2022-12\":\"y\"},\"mobile-webmail\":{\"2022-12\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2023-01\":\"n\"},\"ios\":{\"2023-01\":\"n\"},\"android\":{\"2022-12\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2022-12\":\"n\"},\"macos\":{\"2022-12\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2022-12\":\"y\",\"2023-12\":\"y\"},\"ios\":{\"2022-12\":\"a #1\"},\"android\":{\"2022-12\":\"a #1\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-12\":\"n\"},\"ios\":{\"2022-12\":\"n\"},\"android\":{\"2022-12\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2022-12\":\"n\"},\"ios\":{\"2022-12\":\"n\"},\"android\":{\"2022-12\":\"n\"}},\"samsung-email\":{\"android\":{\"6.1\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2022-12\":\"y\"},\"ios\":{\"2022-12\":\"y\"},\"android\":{\"2022-12\":\"y\"}},\"thunderbird\":{\"macos\":{\"102.6\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-12\":\"y\"},\"ios\":{\"2022-12\":\"y #2\"},\"android\":{\"2022-12\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2022-12\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-12\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2022-12\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2022-12\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Supports `min-content` and `fit-content` only.\",\"2\":\"Buggy. `width: min-content` acts as if `word-break: break-all` had been set on the element.\",\"3\":\"Partial. Not supported with non Google accounts.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-justify-content\",\n\t\t\t\"title\":\"justify-content\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-justify-content/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"justify,align,flexbox,grid\",\n\t\t\t\"last_test_date\":\"2021-03-09\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-justify-content.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/xzDNNDwodl8rMxtm0irAH2Y4XVYRhz2GOzStUxrTVFgog/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"11\":\"y\",\"12\":\"y\",\"13\":\"y\"},\"ios\":{\"11\":\"y\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-12\":\"n\"},\"ios\":{\"2020-12\":\"n\"},\"android\":{\"2020-12\":\"n\"},\"mobile-webmail\":{\"2020-12\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2021-02\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2021-03\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2021-03\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-12\":\"n\"},\"macos\":{\"2020-12\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-12\":\"n\",\"2023-01\":\"y\",\"2023-12\":\"y\"},\"ios\":{\"2020-12\":\"n\",\"2023-01\":\"y\"},\"android\":{\"2020-12\":\"n\",\"2023-01\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-12\":\"n\"},\"ios\":{\"2021-03\":\"n\"},\"android\":{\"6.16.2.1519779\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2020-12\":\"n\"},\"ios\":{\"2021-03\":\"n\"},\"android\":{\"2021-03\":\"n\"}},\"samsung-email\":{\"android\":{\"6.1.31.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-03\":\"y\"},\"ios\":{\"2021-03\":\"y\"},\"android\":{\"2021-03\":\"y\"}},\"thunderbird\":{\"macos\":{\"2020-12\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-03\":\"y\"},\"ios\":{\"2021-03\":\"y\"},\"android\":{\"2021-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-03\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-12\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-left-right-top-bottom\",\n\t\t\t\"title\":\"left, right, top, bottom\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-left-right-top-bottom/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"absolute, sticky, fixed, relative, position\",\n\t\t\t\"last_test_date\":\"2021-05-16\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-positioning.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/E45AW3a9IiIhUSBpv3dc1qPfMiMN8mLepy5BsvqtpXhhy/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"14\":\"y\"},\"ios\":{\"14.5\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-05\":\"n\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"},\"mobile-webmail\":{\"2021-05\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2021-05\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2021-05\":\"y\",\"2023-12\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\",\"6.2.01.1\":\"a #2\"}},\"sfr\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"y\"}},\"thunderbird\":{\"macos\":{\"78.10\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2021-05\":\"n\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-05\":\"n\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-05\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-05\":\"a #1\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. `left` and `top` are not supported.\",\"2\":\"Partial. Percentages values are not supported.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-letter-spacing\",\n\t\t\t\"title\":\"letter-spacing\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-letter-spacing/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2020-01-19\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-text.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/VfcyPsGJhy1PhKyxvuo0oXGz0tPdMLjc7tI37nGIW8HWu/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.2\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-01\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-01\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"a #1 #2\",\"2010\":\"a #1 #2\",\"2013\":\"a #1 #2\",\"2016\":\"a #1 #2\",\"2019\":\"a #1 #2\"},\"windows-mail\":{\"2020-01\":\"a #1\"},\"macos\":{\"2020-01\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-01\":\"y\",\"2023-12\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-01\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-01\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"thunderbird\":{\"macos\":{\"68.4\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Big negative values are rendered differently from CSS standards.\",\"2\":\"Buggy. Values set in `em` are smaller than the expected render in CSS. See [#192](https://github.com/hteumeuleu/caniemail/issues/192).\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-line-height\",\n\t\t\t\"title\":\"line-height\",\n\t\t\t\"description\":\"Sets the height of a line box. Or, basically, the height of a line of text.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-line-height/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2021-10-02\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-line-height.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/POu1Ixvy9x2XUtzmwlFPA4Lx8DDVhRpGvraSjEmf9DnDG/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"14\":\"y\"},\"ios\":{\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-10\":\"y\"},\"ios\":{\"2021-10\":\"y\"},\"android\":{\"2021-10\":\"y\"},\"mobile-webmail\":{\"2021-10\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2021-10\":\"y\"},\"ios\":{\"2021-10\":\"a #2\"},\"android\":{\"2021-10\":\"a #2\"}},\"outlook\":{\"windows\":{\"2007\":\"a #1\",\"2010\":\"a #1\",\"2013\":\"a #1\",\"2016\":\"a #1\",\"2019\":\"a #1\"},\"windows-mail\":{\"2021-10\":\"a #1\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2021-10\":\"y\"},\"ios\":{\"2021-10\":\"y\"},\"android\":{\"2021-10\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-10\":\"y\"},\"ios\":{\"2021-10\":\"y\"},\"android\":{\"2021-10\":\"y\"}},\"thunderbird\":{\"macos\":{\"78.14\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2021-10\":\"y\"},\"ios\":{\"2021-10\":\"y\"},\"android\":{\"2021-10\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-10\":\"y\"},\"ios\":{\"2021-10\":\"y\"},\"android\":{\"2021-10\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-10\":\"y\"},\"ios\":{\"2021-10\":\"y\"},\"android\":{\"2021-10\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-10\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-10\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-10\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. `em` and `px` units behave weirdly. Use `mso-line-height-rule:exactly`.\",\"2\":\"Partial. `normal` value is not supported.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-linear-gradient\",\n\t\t\t\"title\":\"linear-gradient()\",\n\t\t\t\"description\":\"Creates an image consisting of a progressive transition between two or more colors along a straight line.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-linear-gradient/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"gradients\",\n\t\t\t\"last_test_date\":\"2021-12-29\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-gradients.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/8FCYDYSPXot6jquGzeiqGsfoeCU4tvCeRpnVG0z6luNLr/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"15\":\"y\"},\"ios\":{\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-12\":\"y\"},\"ios\":{\"2021-12\":\"y\"},\"android\":{\"2021-12\":\"y\",\"2025-04\":\"a #3\"},\"mobile-webmail\":{\"2021-12\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2021-12\":\"n\"},\"ios\":{\"2021-12\":\"n\"},\"android\":{\"2021-12\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n #1\",\"2010\":\"n #1\",\"2013\":\"n #1\",\"2016\":\"n #1\",\"2019\":\"n #1\"},\"windows-mail\":{\"2021-12\":\"n\"},\"macos\":{\"16.57\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2021-12\":\"n\",\"2023-12\":\"n\"},\"ios\":{\"2.51.1\":\"y\",\"4.2148.2\":\"n\"},\"android\":{\"4.2147.4\":\"n\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\",\"6.1.90.16\":\"a #2\"}},\"sfr\":{\"desktop-webmail\":{\"2021-12\":\"y\"},\"ios\":{\"2021-12\":\"y\"},\"android\":{\"2021-12\":\"y\"}},\"thunderbird\":{\"macos\":{\"91.4.1\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2021-12\":\"n\"},\"ios\":{\"2021-12\":\"n\"},\"android\":{\"2021-12\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-12\":\"n\"},\"ios\":{\"2021-12\":\"n\"},\"android\":{\"2021-12\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-12\":\"y\"},\"ios\":{\"2021-12\":\"y\"},\"android\":{\"2021-12\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-12\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-12\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-12\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-12\":\"y\"}},\"t-online-de\":{\"desktop-webmail\":{\"2021-12\":\"y\"}},\"free-fr\":{\"desktop-webmail\":{\"2021-12\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Gradients can be created in VML using `type=\\\"gradient\\\"` on a `<v:fill>`. See [VML documentation](https://docs.microsoft.com/en-us/windows/win32/vml/web-workshop---how-to-use-vml-on-web-pages-----fill--element#gradient-fill).\",\"2\":\"Partial. Not supported with Hotmail/Outlook accounts.\",\"3\":\"Buggy. Does not work inline in the `background-image` property. (See [email-bugs#135](https://github.com/hteumeuleu/email-bugs/issues/135))\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-list-style-image\",\n\t\t\t\"title\":\"list-style-image\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-list-style-image/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2020-04-20\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-list.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/ifwlqtEsBCU23xVI7NgjBqvJlcJ4c20Akv3aRW3ugRJsP/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-04\":\"n\"},\"ios\":{\"2020-04\":\"n\"},\"android\":{\"2020-04\":\"n\"},\"mobile-webmail\":{\"2020-04\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2020-04\":\"y\",\"2021-03\":\"n\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"n #1\",\"2010\":\"n #1\",\"2013\":\"n #1\",\"2016\":\"n #1\",\"2019\":\"n #1\"},\"windows-mail\":{\"2020-04\":\"n #1\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2020-04\":\"n #2\",\"2023-12\":\"n #2\"},\"ios\":{\"2020-04\":\"n #2\"},\"android\":{\"2020-04\":\"n #2\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-04\":\"y\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"thunderbird\":{\"macos\":{\"68.7\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-04\":\"n\"},\"ios\":{\"2020-04\":\"n\"},\"android\":{\"2020-04\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-04\":\"n\"},\"ios\":{\"2020-04\":\"n\"},\"android\":{\"2020-04\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-04\":\"n #3\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Not supported, but the proprietary `mso-bullet-image` property can be used instead on `<li>`. Outlook 2007 and 2010 only support `.ico` and `.gif` file formats.\",\"2\":\"Not supported, but image values are supported through the `list-style` shorthand property.\",\"3\":\"`url()` is transformed into `proton-url()`.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-list-style-position\",\n\t\t\t\"title\":\"list-style-position\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-list-style-position/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2020-04-20\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-list.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/ifwlqtEsBCU23xVI7NgjBqvJlcJ4c20Akv3aRW3ugRJsP/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-04\":\"y\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"},\"mobile-webmail\":{\"2020-04\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-04\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"n #1\",\"2010\":\"n #1\",\"2013\":\"n #1\",\"2016\":\"n #1\",\"2019\":\"n #1\"},\"windows-mail\":{\"2020-04\":\"n #1\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-04\":\"y\",\"2023-12\":\"y\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-04\":\"y\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"thunderbird\":{\"macos\":{\"68.7\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-04\":\"y\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-04\":\"y\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-04\":\"y\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Not supported, but the proprietary `mso-text-indent-alt` property can be used instead.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-list-style-type\",\n\t\t\t\"title\":\"list-style-type\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-list-style-type/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2020-04-20\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-list.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/ifwlqtEsBCU23xVI7NgjBqvJlcJ4c20Akv3aRW3ugRJsP/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-04\":\"y\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"},\"mobile-webmail\":{\"2020-04\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-04\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2020-04\":\"y\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-04\":\"y\",\"2023-12\":\"y\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-04\":\"y\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"thunderbird\":{\"macos\":{\"68.7\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-04\":\"y\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-04\":\"y\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-04\":\"y\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":\"Tests and support are based on the CSS Level 2 specification. `<custom-ident>`, `symbols()` and `<string>` values are not tested here.\",\n\t\t\t\"notes_by_num\":{}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-list-style\",\n\t\t\t\"title\":\"list-style\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-list-style/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2020-04-20\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-list.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/ifwlqtEsBCU23xVI7NgjBqvJlcJ4c20Akv3aRW3ugRJsP/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-04\":\"a #1\"},\"ios\":{\"2020-04\":\"a #1\"},\"android\":{\"2020-04\":\"a #1\"},\"mobile-webmail\":{\"2020-04\":\"a #1\"}},\"orange\":{\"desktop-webmail\":{\"2020-04\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"a #2\",\"2010\":\"a #2\",\"2013\":\"a #2\",\"2016\":\"a #2\",\"2019\":\"a #2\"},\"windows-mail\":{\"2020-04\":\"a #2\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"a #3\"},\"outlook-com\":{\"2020-04\":\"y\",\"2023-12\":\"y\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-04\":\"y\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"thunderbird\":{\"macos\":{\"68.7\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-04\":\"a #3\"},\"ios\":{\"2020-04\":\"a #3\"},\"android\":{\"2020-04\":\"a #3\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-04\":\"a #3\"},\"ios\":{\"2020-04\":\"a #3\"},\"android\":{\"2020-04\":\"a #3\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-04\":\"a #4\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":\"Tests and support are based on the CSS Level 2 specification. `<custom-ident>`, `symbols()` and `<string>` values are not tested here.\",\n\t\t\t\"notes_by_num\":{\"1\":\"`list-style-image` values are not supported. The entire `list-style` property is removed if present.\",\"2\":\"Only `list-style-type` values are supported.\",\"3\":\"`list-style-image` values are not supported but the rest of the `list-style` property is kept.\",\"4\":\"`url()` is transformed into `proton-url()` for `list-style-type` values, invalidating the entire property.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-margin-block-start-end\",\n\t\t\t\"title\":\"margin-block-start & margin-block-end\",\n\t\t\t\"description\":\"Support for the `margin-block-start` and `margin-block-end` css properties.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-margin-block-start-end/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":\"margin-block-start, margin-block-end\",\n\t\t\t\"last_test_date\":\"2022-07-13\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-margin-logical-properties.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/qAmuL03Fg51cE6hkbNSVrXik\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.12.6\":\"n\",\"10.13.6\":\"n\",\"10.15.7\":\"y\",\"12.4\":\"y\"},\"ios\":{\"11.4\":\"n\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\",\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"},\"mobile-webmail\":{\"2022-07\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2022-07\":\"u\"},\"ios\":{\"2022-07\":\"u\"},\"android\":{\"2022-07\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2022-07\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"samsung-email\":{\"android\":{\"10\":\"y\",\"11\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2022-07\":\"u\"},\"ios\":{\"2022-07\":\"u\"},\"android\":{\"2022-07\":\"u\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"u\"}},\"aol\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-07\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-margin-inline-block\",\n\t\t\t\"title\":\"margin-inline & margin-block\",\n\t\t\t\"description\":\"Support for the `margin-inline` and `margin-block` shorthand properties.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-margin-inline-block/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":\"margin-inline, margin-block\",\n\t\t\t\"last_test_date\":\"2022-07-12\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-margin-logical-properties.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/qAmuL03Fg51cE6hkbNSVrXik\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.12.6\":\"n\",\"10.13.6\":\"n\",\"10.15.7\":\"n\",\"12.4\":\"y\"},\"ios\":{\"11.4\":\"n\",\"12\":\"n\",\"13\":\"n\",\"14\":\"n\",\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"},\"mobile-webmail\":{\"2022-07\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2022-07\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2022-07\":\"n\"},\"macos\":{\"2011\":\"n\",\"2016\":\"n\",\"16.80\":\"n\"},\"outlook-com\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"samsung-email\":{\"android\":{\"10\":\"n\",\"11\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2022-07\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-07\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2022-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2022-07\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-margin-inline-start-end\",\n\t\t\t\"title\":\"margin-inline-start & margin-inline-end\",\n\t\t\t\"description\":\"Support for the `margin-inline-start` and `margin-inline-end` css properties.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-margin-inline-start-end/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":\"margin-inline-start, margin-inline-end\",\n\t\t\t\"last_test_date\":\"2022-07-13\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-margin-logical-properties.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/qAmuL03Fg51cE6hkbNSVrXik\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.12.6\":\"n\",\"10.13.6\":\"n\",\"10.15.7\":\"y\",\"12.4\":\"y\"},\"ios\":{\"11.4\":\"n\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\",\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"},\"mobile-webmail\":{\"2022-07\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2022-07\":\"u\"},\"ios\":{\"2022-07\":\"u\"},\"android\":{\"2022-07\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2022-07\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"samsung-email\":{\"android\":{\"10\":\"y\",\"11\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2022-07\":\"u\"},\"ios\":{\"2022-07\":\"u\"},\"android\":{\"2022-07\":\"u\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"u\"}},\"aol\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-07\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-margin\",\n\t\t\t\"title\":\"margin\",\n\t\t\t\"description\":\"Support for the `margin` shorthand property and the `margin-left`, `margin-right`, `margin-top`, `margin-bottom` properties.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-margin/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"margin-left, margin-right, margin-top, margin-bottom\",\n\t\t\t\"last_test_date\":\"2019-10-01\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-margin.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/UmR6V6XenYY9bQiABuLGZRRrdP3fj2ZraiJjEyi4WKBho/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-10\":\"a #1\"},\"ios\":{\"2019-10\":\"a #1\"},\"android\":{\"2019-10\":\"a #1\"},\"mobile-webmail\":{\"2020-02\":\"a #1\"}},\"orange\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"a #1 #2 #3 #4\",\"2010\":\"a #1 #2 #3 #4\",\"2013\":\"a #1 #2 #3 #4\",\"2016\":\"a #1 #2 #3 #4\",\"2019\":\"a #1 #2 #3 #4\"},\"windows-mail\":{\"2019-10\":\"a #1 #2 #3\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"a #1\"},\"outlook-com\":{\"2019-10\":\"a #1\",\"2023-12\":\"a #1\"},\"ios\":{\"2.51.1\":\"y\",\"4.3.1\":\"a #1\"},\"android\":{\"2019-10\":\"a #1\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2024-10\":\"a #1\"},\"ios\":{\"2019-10\":\"y\",\"2024-10\":\"a #1\"},\"android\":{\"2019-10\":\"y\",\"2024-10\":\"a #1\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2024-10\":\"a #1\"},\"ios\":{\"2019-10\":\"y\",\"2024-10\":\"a #1\"},\"android\":{\"2019-10\":\"y\",\"2024-10\":\"a #1\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"a #1\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Negative values are not supported.\",\"2\":\"Partial. Not supported on `<span>` and `<body>` elements.\",\"3\":\"Buggy. `background-color` is included inside the `margin`.\",\"4\":\"Partial. `auto` value is not supported.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-mask-image\",\n\t\t\t\"title\":\"mask-image\",\n\t\t\t\"description\":\"Sets the image that is used as mask layer for an element\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-mask-image/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2024-11-27\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-mask-image.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/x9aotv8ysvn805531p\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10\":\"n\",\"11\":\"n\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\",\"15\":\"y\"},\"ios\":{\"11\":\"n\",\"12\":\"n\",\"13\":\"n\",\"14\":\"n\",\"15\":\"y\",\"16\":\"y\",\"17\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2024-11\":\"n\"},\"ios\":{\"2024-11\":\"n\"},\"android\":{\"2024-11\":\"n\"},\"mobile-webmail\":{\"2024-11\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2024-11\":\"u\"},\"ios\":{\"2024-11\":\"u\"},\"android\":{\"2024-11\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\",\"2024\":\"n\"},\"windows-mail\":{\"2024-11\":\"n\"},\"macos\":{\"2024-11\":\"y\"},\"outlook-com\":{\"2024-11\":\"n\"},\"ios\":{\"2024-11\":\"n\"},\"android\":{\"2024-11\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2024-11\":\"n\"},\"ios\":{\"2024-11\":\"n\"},\"android\":{\"2024-11\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2024-11\":\"n\"},\"ios\":{\"2024-11\":\"n\"},\"android\":{\"2024-11\":\"n\"}},\"samsung-email\":{\"android\":{\"2024-11\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2024-03\":\"u\"},\"ios\":{\"2024-03\":\"u\"},\"android\":{\"2024-03\":\"u\"}},\"protonmail\":{\"desktop-webmail\":{\"2024-11\":\"u\"},\"ios\":{\"2024-11\":\"u\"},\"android\":{\"2024-11\":\"u\"}},\"hey\":{\"desktop-webmail\":{\"2024-11\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-11\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2024-11\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2024-11\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2024-11\":\"n\"},\"ios\":{\"2024-11\":\"u\"},\"android\":{\"2024-11\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2024-11\":\"n\"},\"ios\":{\"2024-11\":\"u\"},\"android\":{\"2024-11\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2024-11\":\"u\"},\"android\":{\"2024-11\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-max-block-size\",\n\t\t\t\"title\":\"max-block-size\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-max-block-size/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":\"max, block, size\",\n\t\t\t\"last_test_date\":\"2022-09-01\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-max-block-size.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/1y7SkPH3W7FPg0HwxS9lEiY\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"16\":\"y\",\"17\":\"y\",\"18\":\"y\",\"19\":\"y\",\"20\":\"y\",\"21\":\"y\"},\"ios\":{\"11\":\"y\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\",\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-08\":\"n\"},\"mobile-webmail\":{\"2022-07\":\"u\"}},\"orange\":{\"desktop-webmail\":{\"2022-07\":\"u\"},\"ios\":{\"2022-07\":\"u\"},\"android\":{\"2022-07\":\"u\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2022-07\":\"n\"},\"macos\":{\"2022-07\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2022-07\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"u\"},\"android\":{\"2022-07\":\"u\"}},\"samsung-email\":{\"android\":{\"2022-09\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-07\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2022-07\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-max-height\",\n\t\t\t\"title\":\"max-height property\",\n\t\t\t\"description\":\"Sets the maximum height of an element.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-max-height/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2021-11-29\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-width-height.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/OyakEYuRTOxGB2hvK9C0F3lsjxpwtUJXZJPrixqyF8gEI/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"15\":\"y\"},\"ios\":{\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"},\"mobile-webmail\":{\"2021-11\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2021-11\":\"n\"},\"macos\":{\"16.56\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2021-11\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"}},\"thunderbird\":{\"macos\":{\"78.14\":\"y\",\"91.3.2\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-11\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-11\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-11\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-11\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-max-inline-size\",\n\t\t\t\"title\":\"max-inline-size\",\n\t\t\t\"description\":\"Defines the horizontal or vertical maximum size of an element's block, depending on its writing mode\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-max-inline-size/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":\"max, inline, size\",\n\t\t\t\"last_test_date\":\"2024-05-31\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-max-inline-size.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/8r8g0dn81y8jc72z09\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2024-05\":\"y\"},\"ios\":{\"2024-05\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2024-05\":\"n\"},\"ios\":{\"2024-05\":\"n\"},\"android\":{\"2024-05\":\"n\"},\"mobile-webmail\":{\"2024-05\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2024-05\":\"u\"},\"ios\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2024-05\":\"n\"},\"macos\":{\"2024-05\":\"n\"},\"outlook-com\":{\"2024-05\":\"n\"},\"ios\":{\"2024-05\":\"n\"},\"android\":{\"2024-05\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2024-05\":\"n\"},\"ios\":{\"2024-05\":\"n\"},\"android\":{\"2024-05\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2024-05\":\"n\"},\"ios\":{\"2024-05\":\"n\"},\"android\":{\"2024-05\":\"n\"}},\"samsung-email\":{\"android\":{\"2024-05\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2024-05\":\"u\"},\"ios\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}},\"protonmail\":{\"desktop-webmail\":{\"2024-05\":\"y\"},\"ios\":{\"2024-05\":\"y\"},\"android\":{\"2024-05\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2024-05\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-05\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2024-05\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2024-05\":\"n\"},\"ios\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2024-05\":\"n\"},\"ios\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-max-width\",\n\t\t\t\"title\":\"max-width\",\n\t\t\t\"description\":\"This is the description of the `max-width` property.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-max-width/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"size\",\n\t\t\t\"last_test_date\":\"2019-08-02\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-width-height.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/dP8XNPcCLZGrogYGvFgCRRjJJO2nTWxchQ0WZSu0Pxcyb/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.3\":\"y\"},\"ios\":{\"5.1\":\"a #2\",\"6.1\":\"a #2\",\"10.3\":\"y\",\"12.2\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"a #2\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"a #1\"},\"windows-mail\":{\"2020-01\":\"a #1\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-08\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.7\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-01\":\"y\"},\"ios\":{\"2020-01\":\"a #2\",\"2025-11\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"a #2\",\"2025-11\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"a #2\",\"2025-11\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Only works on `<table>` elements.\",\"2\":\"Partial. Doesn't work on `<table>` elements, as per [CSS 2.1 specification](https://www.w3.org/TR/CSS2/visudet.html#min-max-widths).\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-min-block-size\",\n\t\t\t\"title\":\"min-block-size\",\n\t\t\t\"description\":\"Defines the minimum horizontal or vertical size of an element's block, depending on its writing mode\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-min-block-size/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":\"min, block, size\",\n\t\t\t\"last_test_date\":\"2024-05-31\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-min-block-size.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/73yg05zgtpk3cez6ua5\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2024-05\":\"y\"},\"ios\":{\"2024-05\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2024-05\":\"n\"},\"ios\":{\"2024-05\":\"n\"},\"android\":{\"2024-05\":\"n\"},\"mobile-webmail\":{\"2024-05\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2024-05\":\"u\"},\"ios\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2024-05\":\"n\"},\"macos\":{\"2024-05\":\"n\"},\"outlook-com\":{\"2024-05\":\"n\"},\"ios\":{\"2024-05\":\"n\"},\"android\":{\"2024-05\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2024-05\":\"n\"},\"ios\":{\"2024-05\":\"n\"},\"android\":{\"2024-05\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2024-05\":\"n\"},\"ios\":{\"2024-05\":\"n\"},\"android\":{\"2024-05\":\"n\"}},\"samsung-email\":{\"android\":{\"2024-05\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2024-05\":\"u\"},\"ios\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}},\"protonmail\":{\"desktop-webmail\":{\"2024-05\":\"y\"},\"ios\":{\"2024-05\":\"y\"},\"android\":{\"2024-05\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2024-05\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-05\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2024-05\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2024-05\":\"n\"},\"ios\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2024-05\":\"n\"},\"ios\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-min-height\",\n\t\t\t\"title\":\"min-height property\",\n\t\t\t\"description\":\"Sets the minimum height of an element.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-min-height/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2021-11-29\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-width-height.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/OyakEYuRTOxGB2hvK9C0F3lsjxpwtUJXZJPrixqyF8gEI/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"15\":\"y\"},\"ios\":{\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"},\"mobile-webmail\":{\"2021-11\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2021-11\":\"n\"},\"macos\":{\"16.56\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2021-11\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"n\"},\"android\":{\"2021-11\":\"y\"}},\"thunderbird\":{\"macos\":{\"78.14\":\"y\",\"91.3.2\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"n\"},\"android\":{\"2021-11\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-11\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-11\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-11\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-11\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-min-inline-size\",\n\t\t\t\"title\":\"min-inline-size\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-min-inline-size/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":\"min, inline, size\",\n\t\t\t\"last_test_date\":\"2022-08-30\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-min-inline-size.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/6m0cx5puENPh8pLi9rpSPzJSB\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"16\":\"y\",\"17\":\"y\",\"18\":\"y\",\"19\":\"y\",\"20\":\"y\",\"21\":\"y\"},\"ios\":{\"11\":\"y\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\",\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-08\":\"n\"},\"mobile-webmail\":{\"2022-07\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2022-07\":\"u\"},\"ios\":{\"2022-07\":\"u\"},\"android\":{\"2022-07\":\"u\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2022-07\":\"n\"},\"macos\":{\"2022-07\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2022-07\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"u\"},\"android\":{\"2022-07\":\"u\"}},\"samsung-email\":{\"android\":{\"2022-07\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2022-07\":\"u\"},\"ios\":{\"2022-07\":\"u\"},\"android\":{\"2022-07\":\"u\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-07\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2022-07\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-min-width\",\n\t\t\t\"title\":\"min-width property\",\n\t\t\t\"description\":\"Sets the minimum width of an element.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-min-width/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2021-11-29\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-width-height.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/OyakEYuRTOxGB2hvK9C0F3lsjxpwtUJXZJPrixqyF8gEI/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"15\":\"y\"},\"ios\":{\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"},\"mobile-webmail\":{\"2021-11\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2021-11\":\"n\"},\"macos\":{\"16.56\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2021-11\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"}},\"thunderbird\":{\"macos\":{\"78.14\":\"y\",\"91.3.2\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-11\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-11\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-11\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-11\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-mix-blend-mode\",\n\t\t\t\"title\":\"mix-blend-mode\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-mix-blend-mode/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"mix-blend-mode,blend,filter\",\n\t\t\t\"last_test_date\":\"2020-12-12\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-mix-blend-mode.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/Yh6M44osu9gXxAcqLb2TBazoUxeQYOXHgdiEWg2wYbEhj/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"11\":\"y\",\"12\":\"y\",\"13\":\"y\"},\"ios\":{\"11\":\"y\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-12\":\"y\"},\"ios\":{\"2020-12\":\"a #1\"},\"android\":{\"2020-12\":\"a #1\"},\"mobile-webmail\":{\"2020-12\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2020-12\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2020-12\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2020-12\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-12\":\"n\"},\"macos\":{\"2020-12\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-12\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-12\":\"y\"},\"android\":{\"2020-12\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-12\":\"n\"},\"ios\":{\"2020-12\":\"n\"},\"android\":{\"2020-12\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2020-12\":\"n\"},\"ios\":{\"2020-12\":\"n\"},\"android\":{\"2020-12\":\"n\"}},\"samsung-email\":{\"android\":{\"6.1.30.30\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-12\":\"y\"},\"ios\":{\"2020-12\":\"y\"},\"android\":{\"2020-12\":\"y\"}},\"thunderbird\":{\"macos\":{\"78.5\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-12\":\"y\"},\"ios\":{\"2020-12\":\"y\"},\"android\":{\"2020-12\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-12\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-12\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-modern-color\",\n\t\t\t\"title\":\"lch(), oklch(), lab(), oklab()\",\n\t\t\t\"description\":\"Modern color values: lch, lab, oklch and oklab\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-modern-color/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"color\",\n\t\t\t\"last_test_date\":\"2023-01-18\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-modern-color.html\",\n\t\t\t\"test_results_url\":\"\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13.1\":\"y\"},\"ios\":{\"16.2\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2023-01\":\"n #2\"},\"ios\":{\"2023-01\":\"n #2\"},\"android\":{\"2023-01\":\"n #2\"},\"mobile-webmail\":{\"2023-01\":\"n #2\"}},\"orange\":{\"desktop-webmail\":{\"2023-01\":\"u\"},\"ios\":{\"2023-01\":\"u\"},\"android\":{\"2023-01\":\"u\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2023-01\":\"u\"},\"macos\":{\"2023-01\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2023-01\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2023-01\":\"n\"},\"android\":{\"2023-01\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2023-01\":\"n\"},\"ios\":{\"2023-01\":\"n\"},\"android\":{\"2023-01\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2023-01\":\"n\"},\"ios\":{\"2023-01\":\"n\"},\"android\":{\"2023-01\":\"n\"}},\"samsung-email\":{\"android\":{\"6.1.74.5\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2020-12\":\"u\"},\"ios\":{\"2020-12\":\"u\"},\"android\":{\"2020-12\":\"u\"}},\"thunderbird\":{\"macos\":{\"102.6\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2023-01\":\"y #1\"},\"ios\":{\"2023-01\":\"y\"},\"android\":{\"2023-01\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2023-01\":\"y #1\"}},\"mail-ru\":{\"desktop-webmail\":{\"2023-01\":\"y #1\"}},\"fastmail\":{\"desktop-webmail\":{\"2023-01\":\"y #1\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Depends on browser support.\",\"2\":\"Using this syntax for an inline style will remove all inline styles applied to that element.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-nesting\",\n\t\t\t\"title\":\"CSS Nesting\",\n\t\t\t\"description\":\"A syntax for nesting selectors, providing the ability to nest one style rule inside another.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-nesting/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2023-08-31\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-nesting.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/8z9ecWkyaSHebmYl0r6dlWFfcia0VNfeKu6s01l5Fw3M0/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"16.0\":\"a #1\"},\"ios\":{\"16.6\":\"a #1\",\"17.2\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2023-08\":\"n\"},\"ios\":{\"2023-08\":\"n\"},\"android\":{\"2023-08\":\"n\"},\"mobile-webmail\":{\"2023-08\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2023-08\":\"a #2\"},\"ios\":{\"2023-08\":\"a #2\"},\"android\":{\"2023-08\":\"u\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2023-08\":\"n\"},\"macos\":{\"16.78\":\"a #1\",\"16.80\":\"n\"},\"outlook-com\":{\"2023-08\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2023-08\":\"n\"},\"android\":{\"2024-03\":\"n\"}},\"samsung-email\":{\"android\":{\"6.0\":\"u\",\"6.1.90.16\":\"a #4\"}},\"sfr\":{\"desktop-webmail\":{\"2023-08\":\"a #1 #2\"},\"ios\":{\"2023-08\":\"n\"},\"android\":{\"2023-08\":\"u\"}},\"thunderbird\":{\"macos\":{\"102.15\":\"n\",\"137.0b3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2024-01\":\"n #3\"},\"ios\":{\"2023-08\":\"u\"},\"android\":{\"2023-08\":\"u\"}},\"yahoo\":{\"desktop-webmail\":{\"2023-08\":\"n #3\"},\"ios\":{\"2023-08\":\"n #3\"},\"android\":{\"2024-03\":\"n #3\"}},\"protonmail\":{\"desktop-webmail\":{\"2023-08\":\"y\"},\"ios\":{\"2023-08\":\"a #5\"},\"android\":{\"2023-08\":\"a #5\"}},\"hey\":{\"desktop-webmail\":{\"2023-08\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-01\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2023-08\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2023-08\":\"u\"}},\"free-fr\":{\"desktop-webmail\":{\"2023-08\":\"u\"}},\"t-online-de\":{\"desktop-webmail\":{\"2023-08\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2021-12\":\"u\"},\"ios\":{\"2021-12\":\"u\"},\"android\":{\"2021-12\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2021-12\":\"u\"},\"ios\":{\"2021-12\":\"u\"},\"android\":{\"2021-12\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2021-12\":\"u\"},\"android\":{\"2021-12\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. `E { F {}}` doesn’t work, but `E { & F {}}` does. Full support was added in macOS 14.2.\",\"2\":\"Buggy. The syntax is supported, but nested selectors are prefixed by the webmail, which might invalidate the selector.\",\"3\":\"Not supported. The nested selectors are removed, making the nested properties apply to the parent selector.\",\"4\":\"Partial. Not supported with Hotmail/Outlook accounts.\",\"5\":\"Partial. `@media` is not fully supported, and `& & &` syntax not supported\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-object-fit\",\n\t\t\t\"title\":\"object-fit\",\n\t\t\t\"description\":\"The `object-fit` CSS property sets how the content of a replaced element, such as an `<img>` or `<video>`, should be resized to fit its container.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-object-fit/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2021-07-15\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-object.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/Rg26n7zpfSw6bcxjGdDU9eF0aieX8XR7QoXfSfjbOEKXt/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\",\"14\":\"y\"},\"ios\":{\"13\":\"y\",\"14.6\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"},\"ios\":{\"2021-07\":\"a #1\"},\"android\":{\"2021-07\":\"a #1\"},\"mobile-webmail\":{\"2021-07\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2021-07\":\"n\"},\"ios\":{\"2021-07\":\"n\"},\"android\":{\"2021-07\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2021-07\":\"n\"},\"macos\":{\"2016\":\"y\",\"2019\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2021-07\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2021-07\":\"y\"},\"android\":{\"2021-07\":\"y\"}},\"samsung-email\":{\"android\":{\"7.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-07\":\"y\"},\"ios\":{\"2021-07\":\"y\"},\"android\":{\"2021-07\":\"y\"}},\"thunderbird\":{\"macos\":{\"78.10\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2021-07\":\"n\"},\"ios\":{\"2021-07\":\"n\"},\"android\":{\"2021-07\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-07\":\"n\"},\"ios\":{\"2021-07\":\"n\"},\"android\":{\"2021-07\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"},\"ios\":{\"2021-07\":\"y\"},\"android\":{\"2021-07\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-object-position\",\n\t\t\t\"title\":\"object-position\",\n\t\t\t\"description\":\"The `object-position` CSS property specifies the alignment of the selected replaced element's contents within the element's box. Areas of the box which aren't covered by the replaced element's object will show the element's background.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-object-position/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2021-07-15\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-object.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/Rg26n7zpfSw6bcxjGdDU9eF0aieX8XR7QoXfSfjbOEKXt/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\",\"14\":\"y\"},\"ios\":{\"13\":\"y\",\"14.6\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"},\"ios\":{\"2021-07\":\"a #1\"},\"android\":{\"2021-07\":\"a #1\"},\"mobile-webmail\":{\"2021-07\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2021-07\":\"n\"},\"ios\":{\"2021-07\":\"n\"},\"android\":{\"2021-07\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2021-07\":\"n\"},\"macos\":{\"2016\":\"n\",\"2019\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2021-07\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2021-07\":\"y\"},\"android\":{\"2021-07\":\"y\"}},\"samsung-email\":{\"android\":{\"7.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-07\":\"y\"},\"ios\":{\"2021-07\":\"y\"},\"android\":{\"2021-07\":\"y\"}},\"thunderbird\":{\"macos\":{\"78.10\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2021-07\":\"n\"},\"ios\":{\"2021-07\":\"n\"},\"android\":{\"2021-07\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-07\":\"n\"},\"ios\":{\"2021-07\":\"n\"},\"android\":{\"2021-07\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"},\"ios\":{\"2021-07\":\"y\"},\"android\":{\"2021-07\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-opacity\",\n\t\t\t\"title\":\"opacity\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-opacity/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-visual-effects.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/DhTRmGsVH6uobU4pHD3CasJywfBL4HnEjA1LOF8f9ctso/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2019-08\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2019-08\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.8\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"n\",\"2025-06\":\"y\"},\"ios\":{\"2019-02\":\"n\",\"2025-06\":\"y\"},\"android\":{\"2019-02\":\"n\",\"2025-06\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-orphans\",\n\t\t\t\"title\":\"orphans\",\n\t\t\t\"description\":\"Sets the minimum number of lines in a block container split on an old page, region or column.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-orphans/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"columns\",\n\t\t\t\"last_test_date\":\"2024-06-13\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-widows.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/6vd8udzx1b5l1vrnsr\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2024-05\":\"y\"},\"ios\":{\"2024-05\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2024-05\":\"n #4\"},\"ios\":{\"2024-05\":\"n\"},\"android\":{\"2024-05\":\"n #3\"},\"mobile-webmail\":{\"2024-05\":\"n #1 #2\"}},\"orange\":{\"desktop-webmail\":{\"2024-05\":\"u\"},\"ios\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2024-05\":\"n\"},\"macos\":{\"2024-05\":\"y\"},\"outlook-com\":{\"2024-05\":\"y\"},\"ios\":{\"2024-05\":\"y\"},\"android\":{\"2024-05\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2024-05\":\"n\"},\"ios\":{\"2024-05\":\"n\"},\"android\":{\"2024-05\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2024-05\":\"n\"},\"ios\":{\"2024-05\":\"n\"},\"android\":{\"2024-05\":\"n\"}},\"samsung-email\":{\"android\":{\"2024-05\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2024-05\":\"u\"},\"ios\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}},\"protonmail\":{\"desktop-webmail\":{\"2024-05\":\"y #4\"},\"ios\":{\"2024-05\":\"y #4\"},\"android\":{\"2024-05\":\"y #4\"}},\"hey\":{\"desktop-webmail\":{\"2024-05\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-05\":\"y #4\"}},\"fastmail\":{\"desktop-webmail\":{\"2024-05\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2024-05\":\"n #1 #4\"},\"ios\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2024-05\":\"n #1 #4\"},\"ios\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. `columns` property is stripped which is required for `orphans` to work\",\"2\":\"Buggy. `orphans` property value is replaced by `auto`\",\"3\":\"Not supported, but Gmail's default styles on the email message container includes the `orphans` property. These values are inherited by children elements\",\"4\":\"Webmail rendering depends on browser support\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-outline-offset\",\n\t\t\t\"title\":\"outline-offset\",\n\t\t\t\"description\":\"Controls the position of an outline.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-outline-offset/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2022-12-26\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-outline-offset.html\",\n\t\t\t\"test_results_url\":\"\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"16.0\":\"y\"},\"ios\":{\"16.2\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2022-12\":\"n\"},\"ios\":{\"2022-12\":\"n\"},\"android\":{\"2022-12\":\"n\"},\"mobile-webmail\":{\"2022-12\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2022-12\":\"n\"},\"ios\":{\"2022-12\":\"n\"},\"android\":{\"2022-12\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2022-12\":\"n\"},\"macos\":{\"16.68\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2022-12\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2022-12\":\"n\"},\"android\":{\"2022-12\":\"n\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2022-12\":\"y\"},\"ios\":{\"2022-12\":\"y\"},\"android\":{\"2022-12\":\"y\"}},\"thunderbird\":{\"macos\":{\"102.6.1\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2022-12\":\"n\"},\"ios\":{\"2022-12\":\"n\"},\"android\":{\"2022-12\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-12\":\"n\"},\"ios\":{\"2022-12\":\"n\"},\"android\":{\"2022-12\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-12\":\"y\"},\"ios\":{\"2022-12\":\"y\"},\"android\":{\"2022-12\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2022-12\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-12\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2022-12\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2022-12\":\"y\"}},\"free-fr\":{\"desktop-webmail\":{\"2022-12\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-12\":\"n\"}},\"t-online-de\":{\"desktop-webmail\":{\"2022-12\":\"y\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-outline\",\n\t\t\t\"title\":\"outline\",\n\t\t\t\"description\":\"Sets a line outside of the element's border.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-outline/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2022-03-17\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-outline.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/lNFpqGgdxFrmgevoZciqnebVLZKXIWKyHp5HCLRCDt9GB/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"16.0\":\"y\"},\"ios\":{\"15.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2022-03\":\"y\"},\"ios\":{\"2022-03\":\"y\"},\"android\":{\"2022-03\":\"y\"},\"mobile-webmail\":{\"2022-03\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2022-03\":\"n\"},\"ios\":{\"2022-03\":\"n\"},\"android\":{\"2022-03\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2022-03\":\"n\"},\"macos\":{\"16.60\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2022-03\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2022-03\":\"y\"},\"android\":{\"2022-03\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2022-03\":\"y\"},\"ios\":{\"2022-03\":\"y\"},\"android\":{\"2022-03\":\"y\"}},\"thunderbird\":{\"macos\":{\"78.14\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2022-03\":\"y\"},\"ios\":{\"2022-03\":\"y\"},\"android\":{\"2022-03\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-03\":\"y\"},\"ios\":{\"2022-03\":\"y\"},\"android\":{\"2022-03\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-03\":\"y\"},\"ios\":{\"2022-03\":\"y\"},\"android\":{\"2022-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2022-03\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-03\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2022-03\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2022-03\":\"y\"}},\"free-fr\":{\"desktop-webmail\":{\"2022-12\":\"y\"}},\"t-online-de\":{\"desktop-webmail\":{\"2022-12\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":\"WebKit renders square outline even when using `border-radius`.\",\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-overflow-wrap\",\n\t\t\t\"title\":\"overflow-wrap\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-overflow-wrap/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2022-08-03\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-overflow-wrap.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/zxOsWrYsJqztvWC7JYF8xrUgn\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"16.0\":\"a #1\"},\"ios\":{\"18.3.2\":\"a #1\"}},\"gmail\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"n\"},\"android\":{\"2022-08\":\"n\"},\"mobile-webmail\":{\"2022-08\":\"u\"}},\"orange\":{\"desktop-webmail\":{\"2022-08\":\"u\"},\"ios\":{\"2022-08\":\"u\"},\"android\":{\"2022-08\":\"u\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2022-08\":\"n\"},\"macos\":{\"2022-08\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2022-08\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2022-08\":\"n\"},\"android\":{\"2022-08\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"n\"},\"android\":{\"2022-08\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"u\"},\"android\":{\"2022-08\":\"u\"}},\"samsung-email\":{\"android\":{\"2022-08\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2022-08\":\"u\"},\"ios\":{\"2022-08\":\"u\"},\"android\":{\"2022-08\":\"u\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-08\":\"u\"},\"ios\":{\"2022-08\":\"u\"},\"android\":{\"2022-08\":\"u\"}},\"hey\":{\"desktop-webmail\":{\"2022-08\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-08\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2022-08\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. Requires `word-break:normal` to reset Apple Mail default style (See [issue#394](https://github.com/hteumeuleu/caniemail/issues/394).)\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-overflow\",\n\t\t\t\"title\":\"overflow\",\n\t\t\t\"description\":\"Sets the desired behavior when content does not fit in the element's padding box\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-overflow/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"overflow-block, overflow-inline\",\n\t\t\t\"last_test_date\":\"2025-05-27\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-box-model.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/278gsp8afyl8f6nbu4\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"a #1\",\"2024-10\":\"a #4\"},\"ios\":{\"12.1\":\"y\",\"2024-10\":\"a #4\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"y\",\"2024-10\":\"a #4\"},\"ios\":{\"2019-02\":\"y\",\"2024-10\":\"a #4\"},\"android\":{\"2019-02\":\"y\",\"2024-10\":\"a #4\",\"2025-07\":\"a #1 #4\"},\"mobile-webmail\":{\"2020-02\":\"y\",\"2024-10\":\"a #4\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"n #2\",\"2021-03\":\"y\",\"2024-10\":\"a #3\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\",\"2024\":\"n\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\",\"2024-10\":\"a #4\"},\"outlook-com\":{\"2019-02\":\"y\",\"2024-01\":\"y\",\"2024-10\":\"a #3\",\"2025-05\":\"a #4\"},\"ios\":{\"2019-02\":\"y\",\"2024-10\":\"a #4\"},\"android\":{\"2019-02\":\"n\",\"2025-05\":\"a #1 #4\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"y\",\"2025-05\":\"a #4\"},\"ios\":{\"2019-02\":\"y\",\"2024-10\":\"a #4\"},\"android\":{\"2019-02\":\"n\",\"2025-05\":\"a #1 #4\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"y\",\"2024-10\":\"a #4\"},\"ios\":{\"2019-02\":\"y\",\"2024-10\":\"a #4\"},\"android\":{\"2019-02\":\"n\",\"2025-07\":\"a #1 #4\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\",\"2024-10\":\"a #4\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.5\":\"y\",\"138\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\",\"2025-07\":\"a #3\"},\"ios\":{\"2020-03\":\"y\",\"2025-07\":\"a #3\"},\"android\":{\"2020-03\":\"y\",\"2025-07\":\"a #1 #3\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\",\"2024-10\":\"a #3\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y\",\"2024-10\":\"a #3\"},\"ios\":{\"2022-06\":\"y\",\"2024-10\":\"a #3\"},\"android\":{\"2022-06\":\"y\",\"2024-10\":\"a #3\",\"2025-07\":\"a #1 #3\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y\",\"2024-10\":\"a #3\"},\"ios\":{\"2022-06\":\"y\",\"2024-10\":\"a #3\"},\"android\":{\"2022-06\":\"y\",\"2024-10\":\"a #3\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. Cannot scroll through to hidden content.\",\"2\":\"Not supported. `overflow` is replaced by `java-script`.\",\"3\":\"Partial. Support for `overflow-block` & `overflow-inline` depends on browser support.\",\"4\":\"Partial. `overflow-block` & `overflow-inline` not supported.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-padding-block-start-end\",\n\t\t\t\"title\":\"padding-block-start & padding-block-end\",\n\t\t\t\"description\":\"Support for the `padding-block-start` and `padding-block-end` css properties.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-padding-block-start-end/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":\"padding-block-start, padding-block-end\",\n\t\t\t\"last_test_date\":\"2022-07-13\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-padding-logical-properties.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/Y91uDXJIDLc6M0FkVXipW\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.12.6\":\"n\",\"10.13.6\":\"n\",\"10.15.7\":\"y\",\"12.4\":\"y\"},\"ios\":{\"11.4\":\"n\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\",\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"},\"mobile-webmail\":{\"2022-07\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2022-07\":\"u\"},\"ios\":{\"2022-07\":\"u\"},\"android\":{\"2022-07\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2022-07\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2022-07\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"samsung-email\":{\"android\":{\"10\":\"y\",\"11\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2022-07\":\"u\"},\"ios\":{\"2022-07\":\"u\"},\"android\":{\"2022-07\":\"u\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"u\"}},\"aol\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-07\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-padding-inline-block\",\n\t\t\t\"title\":\"padding-inline & padding-block\",\n\t\t\t\"description\":\"Support for the `padding-inline` and `padding-block` shorthand properties.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-padding-inline-block/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":\"padding-inline, padding-block\",\n\t\t\t\"last_test_date\":\"2022-07-13\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-padding-logical-properties.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/Y91uDXJIDLc6M0FkVXipW\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.12.6\":\"n\",\"10.13.6\":\"n\",\"10.15.7\":\"n\",\"12.4\":\"y\"},\"ios\":{\"11.4\":\"n\",\"12\":\"n\",\"13\":\"n\",\"14\":\"n\",\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"},\"mobile-webmail\":{\"2022-07\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2022-07\":\"u\"},\"ios\":{\"2022-07\":\"u\"},\"android\":{\"2022-07\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2022-07\":\"n\"},\"macos\":{\"2011\":\"n\",\"2016\":\"n\",\"16.80\":\"n\"},\"outlook-com\":{\"2022-07\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"samsung-email\":{\"android\":{\"10\":\"n\",\"11\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2022-07\":\"u\"},\"ios\":{\"2022-07\":\"u\"},\"android\":{\"2022-07\":\"u\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"u\"}},\"aol\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-07\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-padding-inline-start-end\",\n\t\t\t\"title\":\"padding-inline-start & padding-inline-end\",\n\t\t\t\"description\":\"Support for the `padding-inline-start` and `padding-inline-end` css properties.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-padding-inline-start-end/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":\"padding-inline-start, padding-inline-end\",\n\t\t\t\"last_test_date\":\"2022-07-13\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-padding-logical-properties.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/Y91uDXJIDLc6M0FkVXipW\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.12.6\":\"n\",\"10.13.6\":\"n\",\"10.15.7\":\"y\",\"12.4\":\"y\"},\"ios\":{\"11.4\":\"n\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\",\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"},\"mobile-webmail\":{\"2022-07\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2022-07\":\"u\"},\"ios\":{\"2022-07\":\"u\"},\"android\":{\"2022-07\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2022-07\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2022-07\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"samsung-email\":{\"android\":{\"10\":\"y\",\"11\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2022-07\":\"u\"},\"ios\":{\"2022-07\":\"u\"},\"android\":{\"2022-07\":\"u\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"u\"}},\"aol\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-07\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-padding\",\n\t\t\t\"title\":\"padding\",\n\t\t\t\"description\":\"This test includes support for the `padding` shorthand property as well as for `padding-left`, `padding-right`, `padding-top` and `padding-bottom`.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-padding/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-07-25\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-padding.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/bT9ZMtvO5cZkTTbfskZRDbdCOMlSpCTSVUd4lMkrGU68b/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.3\":\"y\"},\"ios\":{\"10.3\":\"y\",\"12.2\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-05\":\"y\"},\"ios\":{\"2019-05\":\"y\"},\"android\":{\"2019-05\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2019-05\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-05\":\"y\"},\"android\":{\"2019-05\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"a #1 #2\",\"2010\":\"a #1 #2\",\"2013\":\"a #1 #2\",\"2016\":\"a #1 #2\",\"2019\":\"a #1 #2\"},\"windows-mail\":{\"2020-01\":\"a #1 #2\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-05\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-05\":\"y\"},\"android\":{\"2019-05\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-05\":\"y\"},\"ios\":{\"2019-05\":\"y\"},\"android\":{\"2019-05\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-01\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-05\":\"y\"},\"ios\":{\"2019-05\":\"y\"},\"android\":{\"2019-05\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Only supported on table cells.\",\"2\":\"Buggy. Vertical padding will be the same for all cells of a same row, adopting the biggest value.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-position\",\n\t\t\t\"title\":\"position\",\n\t\t\t\"description\":\"Tests for CSS positioning include `relative`, `absolute`, `fixed` and `sticky`.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-position/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"absolute, sticky, fixed, relative, static\",\n\t\t\t\"last_test_date\":\"2021-05-16\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-positioning.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/E45AW3a9IiIhUSBpv3dc1qPfMiMN8mLepy5BsvqtpXhhy/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"14\":\"a #1\"},\"ios\":{\"14.5\":\"a #1\"}},\"gmail\":{\"desktop-webmail\":{\"2021-05\":\"n\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"},\"mobile-webmail\":{\"2021-05\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2021-05\":\"a #1\"},\"ios\":{\"2021-05\":\"a #1\"},\"android\":{\"2021-05\":\"a #1\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2021-05\":\"n\"},\"macos\":{\"2016\":\"y\",\"16.50\":\"y\",\"16.80\":\"a #2\"},\"outlook-com\":{\"2021-05\":\"a #2\",\"2023-12\":\"a #2\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"a #2\"}},\"samsung-email\":{\"android\":{\"6.0\":\"a #1\"}},\"sfr\":{\"desktop-webmail\":{\"2021-05\":\"a #4\"},\"ios\":{\"2021-05\":\"a #4\"},\"android\":{\"2021-05\":\"a #4\"}},\"thunderbird\":{\"macos\":{\"78.10\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2021-05\":\"a #3\"},\"ios\":{\"2021-05\":\"a #3\"},\"android\":{\"2021-05\":\"a #3\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-05\":\"a #3\"},\"ios\":{\"2021-05\":\"a #3\"},\"android\":{\"2021-05\":\"a #3\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-05\":\"a #4\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-05\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"a #1 #6\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"a #4\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y #2\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y #5\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y #2\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y #5\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y #5\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Supports `relative`, `absolute` but not `sticky` and `fixed`.\",\"2\":\"Partial. Supports `sticky` but not `relative`, `absolute` and `fixed`.\",\"3\":\"Partial. Supports `relative` but not  `absolute`, `sticky` and `fixed`.\",\"4\":\"Partial. Supports `relative` and `sticky` but not `absolute` and `fixed`.\",\"5\":\"Buggy. `fixed` elements scroll with page.\",\"6\":\"Buggy. `fixed` is replaced by `absolute`.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-pseudo-class-active\",\n\t\t\t\"title\":\":active\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-pseudo-class-active/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"accessibility\"],\n\t\t\t\"keywords\":\"pseudo-class\",\n\t\t\t\"last_test_date\":\"2019-10-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-selectors-pseudo-classes.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/cl8ZYgIGE372fkVVuJkwNJDd7B4JUpo23Nz6qANcSlRUA/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"n\",\"16\":\"a #2\"},\"ios\":{\"13.1\":\"n\"}},\"gmail\":{\"desktop-webmail\":{\"2019-10\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-10\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"a #1\"},\"outlook-com\":{\"2019-10\":\"a #1\",\"2024-01\":\"a #1\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.8\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0.04.6\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"y\",\"2025-07\":\"n\"},\"ios\":{\"2020-01\":\"n\"},\"android\":{\"2020-01\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Only supported on type selectors.\",\"2\":\"Partial. Supported with mouse clicks. Not supported with keyboard input.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-pseudo-class-checked\",\n\t\t\t\"title\":\":checked\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-pseudo-class-checked/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"check, interactive, pseudo-class\",\n\t\t\t\"last_test_date\":\"2020-03-10\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-selectors-pseudo-classes.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/jRZRnTXxdqCwNM60saxYK4Ee7783SAj1qQKytJlOoB7dO/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.3\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"n\"},\"mobile-webmail\":{\"2020-03\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2020-03\":\"n #2\",\"2021-03\":\"n #2\"},\"ios\":{\"2020-03\":\"n #2\"},\"android\":{\"2020-03\":\"n #2\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-03\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"a #1\"},\"outlook-com\":{\"2020-03\":\"a #1\",\"2024-01\":\"a #1\"},\"ios\":{\"2020-03\":\"a #1\"},\"android\":{\"2020-03\":\"a #1\"}},\"samsung-email\":{\"android\":{\"7.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-03\":\"n #2\",\"2025-07\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"n\"}},\"thunderbird\":{\"macos\":{\"68.5\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"n #2\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Only supported on type selectors.\",\"2\":\"Not supported. `<input>` elements are transformed into `<noinput>`.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-pseudo-class-default\",\n\t\t\t\"title\":\":default\",\n\t\t\t\"description\":\"Selects form elements that are the default in a group of related elements.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-pseudo-class-default/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"accessibility\"],\n\t\t\t\"keywords\":\"pseudo-class, form\",\n\t\t\t\"last_test_date\":\"2026-02-05\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-pseudo-class-default.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/7ov4sbxz1krv07gkf5\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12\":\"y\",\"26\":\"y\"},\"ios\":{\"11\":\"y\",\"26\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2026-02\":\"n\"},\"ios\":{\"2026-02\":\"n\"},\"android\":{\"2026-02\":\"n\"},\"mobile-webmail\":{\"2026-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2026-02\":\"u\"},\"ios\":{\"2026-02\":\"u\"},\"android\":{\"2026-02\":\"u\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2024\":\"n\"},\"windows-mail\":{\"2026-02\":\"n\"},\"macos\":{\"16.105.2\":\"y\"},\"outlook-com\":{\"2026-02\":\"a #1\"},\"ios\":{\"2026-02\":\"y\"},\"android\":{\"2026-02\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"u\"}},\"sfr\":{\"desktop-webmail\":{\"2026-02\":\"u\"},\"ios\":{\"2026-02\":\"u\"},\"android\":{\"2026-02\":\"u\"}},\"thunderbird\":{\"macos\":{\"147.0.1\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2026-02\":\"y\"},\"ios\":{\"2026-02\":\"y\"},\"android\":{\"2026-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2026-02\":\"y\"},\"ios\":{\"2026-02\":\"y\"},\"android\":{\"2026-02\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2026-02\":\"n\"},\"ios\":{\"2026-02\":\"n\"},\"android\":{\"2026-02\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2026-02\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2026-02\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2026-02\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2026-02\":\"u\"}},\"free-fr\":{\"desktop-webmail\":{\"2026-02\":\"u\"}},\"t-online-de\":{\"desktop-webmail\":{\"2026-02\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2026-02\":\"n\"},\"ios\":{\"2026-02\":\"y\"},\"android\":{\"2026-02\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2026-02\":\"n\"},\"ios\":{\"2026-02\":\"u\"},\"android\":{\"2026-02\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2026-02\":\"u\"},\"android\":{\"2026-02\":\"u\"}}},\n\t\t\t\"notes\":\"Depends on device/browser support. Some devices/browsers do not support styling on &lt;option&gt;.\",\n\t\t\t\"notes_by_num\":{\"1\":\"Works on input[radio] and input[checkbox] only.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-pseudo-class-first-child\",\n\t\t\t\"title\":\":first-child\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-pseudo-class-first-child/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-10-16\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-selectors-pseudo-classes.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/cl8ZYgIGE372fkVVuJkwNJDd7B4JUpo23Nz6qANcSlRUA/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-10\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-10\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"a #1\"},\"outlook-com\":{\"2019-10\":\"a #1\",\"2024-01\":\"a #1\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2025-07\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Only supported on type selectors.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-pseudo-class-first-of-type\",\n\t\t\t\"title\":\":first-of-type\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-pseudo-class-first-of-type/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-10-16\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-selectors-pseudo-classes.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/cl8ZYgIGE372fkVVuJkwNJDd7B4JUpo23Nz6qANcSlRUA/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-10\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-10\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"a #1\"},\"outlook-com\":{\"2019-10\":\"a #1\",\"2024-01\":\"a #1\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2025-07\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Only supported on type selectors.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-pseudo-class-focus\",\n\t\t\t\"title\":\":focus\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-pseudo-class-focus/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"accessibility\"],\n\t\t\t\"keywords\":\"pseudo-class\",\n\t\t\t\"last_test_date\":\"2019-10-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-selectors-pseudo-classes.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/cl8ZYgIGE372fkVVuJkwNJDd7B4JUpo23Nz6qANcSlRUA/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"n\",\"16\":\"y\"},\"ios\":{\"13.1\":\"n\"}},\"gmail\":{\"desktop-webmail\":{\"2019-10\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-10\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"a #1\"},\"outlook-com\":{\"2019-10\":\"a #1\",\"2024-01\":\"a #1\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.8\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0.04.6\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"y\",\"2025-07\":\"n\"},\"ios\":{\"2020-01\":\"n\"},\"android\":{\"2020-01\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Only supported on type selectors.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-pseudo-class-has\",\n\t\t\t\"title\":\":has()\",\n\t\t\t\"description\":\"Represents an element if any of the selectors passed as parameters match at least one element.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-pseudo-class-has/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2022-03-15\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-has.html\",\n\t\t\t\"test_results_url\":\"\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"15.0\":\"n\",\"16.0\":\"y\"},\"ios\":{\"15.1\":\"n\",\"15.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-12\":\"n\",\"2023-09\":\"n\"},\"ios\":{\"2021-12\":\"n\",\"2023-09\":\"n\"},\"android\":{\"2021-12\":\"n\",\"2023-09\":\"n\"},\"mobile-webmail\":{\"2021-12\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2021-12\":\"y\"},\"ios\":{\"2021-12\":\"n\"},\"android\":{\"2021-12\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2021-12\":\"n\"},\"macos\":{\"16.56\":\"n\",\"16.73\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2021-12\":\"n\",\"2023-09\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2021-12\":\"n\",\"2023-09\":\"n\"},\"android\":{\"2021-12\":\"n\"}},\"samsung-email\":{\"android\":{\"6.0\":\"n\",\"6.1.82\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-12\":\"y\",\"2025-07\":\"n\"},\"ios\":{\"2021-12\":\"n\"},\"android\":{\"2021-12\":\"n\"}},\"thunderbird\":{\"macos\":{\"78.14\":\"n\",\"115.2\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2021-12\":\"n #1\"},\"ios\":{\"2021-12\":\"n\"},\"android\":{\"2021-12\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-12\":\"n #1\",\"2023-09\":\"n #1\"},\"ios\":{\"2021-12\":\"n\",\"2023-09\":\"n\"},\"android\":{\"2021-12\":\"n\",\"2023-09\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-12\":\"n #2\",\"2023-09\":\"y\"},\"ios\":{\"2021-12\":\"n\",\"2023-09\":\"y\"},\"android\":{\"2021-12\":\"n\",\"2023-09\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2021-12\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-12\":\"n\",\"2023-09\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-12\":\"n\",\"2023-09\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-12\":\"y\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"n\",\"2023-09\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}}},\n\t\t\t\"notes\":\"As of december 2021, `:has()` is only supported in [Safari Technology Preview 137](https://webkit.org/blog/12156/release-notes-for-safari-technology-preview-137/). As of march 2022, it is supported in Safari 15.4.\",\n\t\t\t\"notes_by_num\":{\"1\":\"Not supported. `:has(…)` is replaced by `:has`.\",\"2\":\"Not supported. But the pseudo-class seems interpreted and computed server side.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-pseudo-class-hover\",\n\t\t\t\"title\":\":hover\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-pseudo-class-hover/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"accessibility\"],\n\t\t\t\"keywords\":\"rollover, pseudo-class\",\n\t\t\t\"last_test_date\":\"2019-10-23\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-selectors-pseudo-classes.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/cl8ZYgIGE372fkVVuJkwNJDd7B4JUpo23Nz6qANcSlRUA/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\",\"13.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"a #1\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-10\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"a #2\"},\"outlook-com\":{\"2019-10\":\"a #2\",\"2024-01\":\"a #2\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.8\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"y\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\",\"6.0.04.6\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"y\",\"2025-07\":\"n\"},\"ios\":{\"2020-01\":\"n\"},\"android\":{\"2020-01\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\",\"2\":\"Partial. Only supported on type selectors.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-pseudo-class-lang\",\n\t\t\t\"title\":\"lang()\",\n\t\t\t\"description\":\"Matches elements based on the language they are determined to be in.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-pseudo-class-lang/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2022-07-12\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-pseudo-selectors-lang.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/gGZHWn1FNVBHweeCPYAFL4MSXy\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"15\":\"y\"},\"ios\":{\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"},\"mobile-webmail\":{\"2022-07\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2022-07\":\"u\"},\"ios\":{\"2022-07\":\"u\"},\"android\":{\"2022-07\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2022-07\":\"n\"},\"macos\":{\"16.56\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2022-07\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"samsung-email\":{\"android\":{\"10\":\"y\",\"11\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2022-07\":\"u\",\"2025-07\":\"n\"},\"ios\":{\"2022-07\":\"u\"},\"android\":{\"2022-07\":\"u\"}},\"thunderbird\":{\"macos\":{\"78.14\":\"u\"}},\"aol\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-07\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2022-07\":\"u\",\"2025-07\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-pseudo-class-last-child\",\n\t\t\t\"title\":\":last-child\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-pseudo-class-last-child/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-10-19\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-selectors-pseudo-classes.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/cl8ZYgIGE372fkVVuJkwNJDd7B4JUpo23Nz6qANcSlRUA/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-10\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-10\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"a #1\"},\"outlook-com\":{\"2019-10\":\"a #1\",\"2024-01\":\"a #1\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2025-07\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Only supported on type selectors.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-pseudo-class-last-of-type\",\n\t\t\t\"title\":\":last-of-type\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-pseudo-class-last-of-type/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-10-19\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-selectors-pseudo-classes.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/cl8ZYgIGE372fkVVuJkwNJDd7B4JUpo23Nz6qANcSlRUA/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-10\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-10\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"a #1\"},\"outlook-com\":{\"2019-10\":\"a #1\",\"2024-01\":\"a #1\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2025-07\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Only supported on type selectors.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-pseudo-class-link\",\n\t\t\t\"title\":\":link\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-pseudo-class-link/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"pseudo-class\",\n\t\t\t\"last_test_date\":\"2019-10-23\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-selectors-pseudo-classes.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/cl8ZYgIGE372fkVVuJkwNJDd7B4JUpo23Nz6qANcSlRUA/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-10\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2019-10\":\"y\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"a #1\"},\"outlook-com\":{\"2019-10\":\"y\",\"2024-01\":\"a #1\"},\"ios\":{\"2.51.1\":\"y\",\"4.7.1\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2025-07\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-pseudo-class-not\",\n\t\t\t\"title\":\":not\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-pseudo-class-not/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"pseudo-class\",\n\t\t\t\"last_test_date\":\"2019-10-23\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-selectors-pseudo-classes.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/cl8ZYgIGE372fkVVuJkwNJDd7B4JUpo23Nz6qANcSlRUA/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-10\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-10\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2019-10\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2.51.1\":\"y\",\"4.7.1\":\"n\"},\"android\":{\"2019-10\":\"n\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2025-07\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-10\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-10\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-pseudo-class-nth-child\",\n\t\t\t\"title\":\":nth-child\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-pseudo-class-nth-child/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-10-19\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-selectors-pseudo-classes.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/cl8ZYgIGE372fkVVuJkwNJDd7B4JUpo23Nz6qANcSlRUA/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-10\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-10\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2019-10\":\"a #1\",\"2024-01\":\"n\"},\"ios\":{\"2.51.1\":\"y\",\"4.7.1\":\"n\"},\"android\":{\"2019-10\":\"n\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2025-07\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-10\":\"n #2\"},\"ios\":{\"2019-10\":\"n #2\"},\"android\":{\"2019-10\":\"n #2\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-10\":\"n #2\"},\"ios\":{\"2019-10\":\"n #2\"},\"android\":{\"2019-10\":\"n #2\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Only supported on type selectors.\",\"2\":\"Not supported. Removes the parenthesis part.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-pseudo-class-nth-last-child\",\n\t\t\t\"title\":\":nth-last-child\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-pseudo-class-nth-last-child/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-10-19\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-selectors-pseudo-classes.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/cl8ZYgIGE372fkVVuJkwNJDd7B4JUpo23Nz6qANcSlRUA/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-10\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-10\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2019-10\":\"a #1\",\"2024-01\":\"n\"},\"ios\":{\"2.51.1\":\"y\",\"4.7.1\":\"n\"},\"android\":{\"2019-10\":\"n\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2025-07\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-10\":\"n #2\"},\"ios\":{\"2019-10\":\"n #2\"},\"android\":{\"2019-10\":\"n #2\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-10\":\"n #2\"},\"ios\":{\"2019-10\":\"n #2\"},\"android\":{\"2019-10\":\"n #2\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Only supported on type selectors.\",\"2\":\"Not supported. Removes the parenthesis part.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-pseudo-class-nth-last-of-type\",\n\t\t\t\"title\":\":nth-last-of-type\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-pseudo-class-nth-last-of-type/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-10-19\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-selectors-pseudo-classes.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/cl8ZYgIGE372fkVVuJkwNJDd7B4JUpo23Nz6qANcSlRUA/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-10\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-10\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2019-10\":\"a #1\",\"2024-01\":\"n\"},\"ios\":{\"2.51.1\":\"y\",\"4.7.1\":\"n\"},\"android\":{\"2019-10\":\"n\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2025-07\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-10\":\"n #2\"},\"ios\":{\"2019-10\":\"n #2\"},\"android\":{\"2019-10\":\"n #2\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-10\":\"n #2\"},\"ios\":{\"2019-10\":\"n #2\"},\"android\":{\"2019-10\":\"n #2\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Only supported on type selectors.\",\"2\":\"Not supported. Removes the parenthesis part.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-pseudo-class-nth-of-type\",\n\t\t\t\"title\":\":nth-of-type\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-pseudo-class-nth-of-type/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-10-19\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-selectors-pseudo-classes.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/cl8ZYgIGE372fkVVuJkwNJDd7B4JUpo23Nz6qANcSlRUA/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-10\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-10\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2019-10\":\"a #1\",\"2024-01\":\"n\"},\"ios\":{\"2.51.1\":\"y\",\"4.7.1\":\"n\"},\"android\":{\"2019-10\":\"n\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2025-07\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-10\":\"n #2\"},\"ios\":{\"2019-10\":\"n #2\"},\"android\":{\"2019-10\":\"n #2\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-10\":\"n #2\"},\"ios\":{\"2019-10\":\"n #2\"},\"android\":{\"2019-10\":\"n #2\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Only supported on type selectors.\",\"2\":\"Not supported. Removes the parenthesis part.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-pseudo-class-only-child\",\n\t\t\t\"title\":\":only-child\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-pseudo-class-only-child/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-10-19\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-selectors-pseudo-classes.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/cl8ZYgIGE372fkVVuJkwNJDd7B4JUpo23Nz6qANcSlRUA/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-10\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-10\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"a #1\"},\"outlook-com\":{\"2019-10\":\"a #1\",\"2024-01\":\"a #1\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2025-07\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Only supported on type selectors.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-pseudo-class-only-of-type\",\n\t\t\t\"title\":\":only-of-type\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-pseudo-class-only-of-type/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-10-19\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-selectors-pseudo-classes.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/cl8ZYgIGE372fkVVuJkwNJDd7B4JUpo23Nz6qANcSlRUA/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-10\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-10\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"a #1\"},\"outlook-com\":{\"2019-10\":\"a #1\",\"2024-01\":\"a #1\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2025-07\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Only supported on type selectors.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-pseudo-class-target\",\n\t\t\t\"title\":\":target\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-pseudo-class-target/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"pseudo-class\",\n\t\t\t\"last_test_date\":\"2019-10-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-selectors-pseudo-classes.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/cl8ZYgIGE372fkVVuJkwNJDd7B4JUpo23Nz6qANcSlRUA/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"n\"},\"ios\":{\"13.1\":\"n\"}},\"gmail\":{\"desktop-webmail\":{\"2019-10\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2021-03\":\"n #2\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-10\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"a #1 #2\"},\"outlook-com\":{\"2019-10\":\"a #1 #2\",\"2024-01\":\"a #1 #2\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.8\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0.04.6\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"y\",\"2025-07\":\"n\"},\"ios\":{\"2020-01\":\"n\"},\"android\":{\"2020-01\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n #2\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"n #2\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Only supported on type selectors.\",\"2\":\"Buggy. Not filtered but doesn't work.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-pseudo-class-visited\",\n\t\t\t\"title\":\":visited\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-pseudo-class-visited/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"pseudo-class\",\n\t\t\t\"last_test_date\":\"2019-10-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-selectors-pseudo-classes.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/cl8ZYgIGE372fkVVuJkwNJDd7B4JUpo23Nz6qANcSlRUA/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"n\"},\"ios\":{\"13.1\":\"n\"}},\"gmail\":{\"desktop-webmail\":{\"2019-10\":\"n\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-10\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"a #1\"},\"outlook-com\":{\"2019-10\":\"a #1\",\"2024-01\":\"a #1\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.8\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0.04.6\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"y\",\"2025-07\":\"n\"},\"ios\":{\"2020-01\":\"n\"},\"android\":{\"2020-01\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Only supported on type selectors.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-pseudo-element-after\",\n\t\t\t\"title\":\"::after\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-pseudo-element-after/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"pseudo-element\",\n\t\t\t\"last_test_date\":\"2020-05-21\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-pseudo-elements.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/qXO9EwD1KSCSCrmUEccVDxVOl3Cyc4LPAbb83ElW2OFge/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-05\":\"n\"},\"ios\":{\"2020-05\":\"n\"},\"android\":{\"2020-05\":\"n\"},\"mobile-webmail\":{\"2020-05\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2020-05\":\"y\",\"2021-03\":\"n #2\",\"2024-04\":\"n #2\"},\"ios\":{\"2020-05\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2020-05\":\"n\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-05\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"2019\":\"a #1\",\"16.80\":\"a #1 #3\"},\"outlook-com\":{\"2020-05\":\"n\",\"2024-01\":\"a #3\"},\"ios\":{\"2.51.1\":\"y\",\"4.38.0\":\"n\",\"4.2352.0\":\"a #3\"},\"android\":{\"2020-05\":\"n\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-05\":\"y\",\"2025-07\":\"n\"},\"ios\":{\"2020-05\":\"n\"},\"android\":{\"2020-05\":\"n\"}},\"thunderbird\":{\"macos\":{\"68.8\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-05\":\"n\"},\"ios\":{\"2020-05\":\"n\"},\"android\":{\"2020-05\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-05\":\"n\"},\"ios\":{\"2020-05\":\"n\"},\"android\":{\"2020-05\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-05\":\"n\"},\"ios\":{\"2020-05\":\"n\"},\"android\":{\"2020-05\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Not supported with non Outlook accounts.\",\"2\":\"The pseudo-element is supported but the `content` property is not.\",\"3\":\"Partial. Only supported on type selectors.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-pseudo-element-before\",\n\t\t\t\"title\":\"::before\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-pseudo-element-before/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"pseudo-element\",\n\t\t\t\"last_test_date\":\"2020-05-21\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-pseudo-elements.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/qXO9EwD1KSCSCrmUEccVDxVOl3Cyc4LPAbb83ElW2OFge/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-05\":\"n\"},\"ios\":{\"2020-05\":\"n\"},\"android\":{\"2020-05\":\"n\"},\"mobile-webmail\":{\"2020-05\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2020-05\":\"y\",\"2021-03\":\"n #2\",\"2024-04\":\"n #2\"},\"ios\":{\"2020-05\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2020-05\":\"n\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-05\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"2019\":\"a #1\",\"16.80\":\"a #1 #3\"},\"outlook-com\":{\"2020-05\":\"n\",\"2024-01\":\"a #3\"},\"ios\":{\"2.51.1\":\"y\",\"4.38.0\":\"n\",\"4.2352.0\":\"a #3\"},\"android\":{\"2020-05\":\"n\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-05\":\"y\",\"2025-07\":\"n\"},\"ios\":{\"2020-05\":\"n\"},\"android\":{\"2020-05\":\"n\"}},\"thunderbird\":{\"macos\":{\"68.8\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-05\":\"n\"},\"ios\":{\"2020-05\":\"n\"},\"android\":{\"2020-05\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-05\":\"n\"},\"ios\":{\"2020-05\":\"n\"},\"android\":{\"2020-05\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-05\":\"n\"},\"ios\":{\"2020-05\":\"n\"},\"android\":{\"2020-05\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Not supported with non Outlook accounts.\",\"2\":\"The pseudo-element is supported but the `content` property is not.\",\"3\":\"Partial. Only supported on type selectors.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-pseudo-element-first-letter\",\n\t\t\t\"title\":\"::first-letter\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-pseudo-element-first-letter/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"pseudo-element\",\n\t\t\t\"last_test_date\":\"2020-05-21\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-pseudo-elements.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/qXO9EwD1KSCSCrmUEccVDxVOl3Cyc4LPAbb83ElW2OFge/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-05\":\"n\"},\"ios\":{\"2020-05\":\"n\"},\"android\":{\"2020-05\":\"n\"},\"mobile-webmail\":{\"2020-05\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2020-05\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-05\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"2019\":\"a #1\",\"16.80\":\"a #1 #2\"},\"outlook-com\":{\"2020-05\":\"n\",\"2024-01\":\"a #2\"},\"ios\":{\"2.51.1\":\"y\",\"4.38.0\":\"n\",\"4.2352.0\":\"a #2\"},\"android\":{\"2020-05\":\"n\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-05\":\"y\",\"2025-07\":\"n\"},\"ios\":{\"2020-05\":\"n\"},\"android\":{\"2020-05\":\"n\"}},\"thunderbird\":{\"macos\":{\"68.8\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-05\":\"n\"},\"ios\":{\"2020-05\":\"n\"},\"android\":{\"2020-05\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Not supported with non Outlook accounts.\",\"2\":\"Partial. Only supported on type selectors.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-pseudo-element-first-line\",\n\t\t\t\"title\":\"::first-line\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-pseudo-element-first-line/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"pseudo-element\",\n\t\t\t\"last_test_date\":\"2020-05-21\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-pseudo-elements.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/qXO9EwD1KSCSCrmUEccVDxVOl3Cyc4LPAbb83ElW2OFge/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-05\":\"n\"},\"ios\":{\"2020-05\":\"n\"},\"android\":{\"2020-05\":\"n\"},\"mobile-webmail\":{\"2020-05\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2020-05\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-05\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"2019\":\"a #1\",\"16.80\":\"a #1 #2\"},\"outlook-com\":{\"2020-05\":\"n\",\"2024-01\":\"a #2\"},\"ios\":{\"2.51.1\":\"y\",\"4.38.0\":\"n\",\"4.2352.0\":\"a #2\"},\"android\":{\"2020-05\":\"n\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-05\":\"y\",\"2025-07\":\"n\"},\"ios\":{\"2020-05\":\"n\"},\"android\":{\"2020-05\":\"n\"}},\"thunderbird\":{\"macos\":{\"68.8\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-05\":\"n\"},\"ios\":{\"2020-05\":\"n\"},\"android\":{\"2020-05\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Not supported with non Outlook accounts.\",\"2\":\"Partial. Only supported on type selectors.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-pseudo-element-marker\",\n\t\t\t\"title\":\"::marker\",\n\t\t\t\"description\":\"Selects the marker box of a list item (typically a bullet or number).\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-pseudo-element-marker/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"pseudo-element\",\n\t\t\t\"last_test_date\":\"2022-12-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-marker.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/7ebtlnke6f0814MElvJWzdRU5lTKcZULpbP9ef9OCuxv3/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"16.2\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2022-12\":\"n\"},\"ios\":{\"2022-12\":\"n\"},\"android\":{\"2022-12\":\"n\"},\"mobile-webmail\":{\"2022-12\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2022-12\":\"y\"},\"ios\":{\"2022-12\":\"y\"},\"android\":{\"2022-12\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n #1\"},\"windows-mail\":{\"2022-12\":\"n\"},\"macos\":{\"2022-12\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2022-12\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2022-12\":\"n\",\"2024-01\":\"n\"},\"android\":{\"2022-12\":\"n\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2022-12\":\"y\",\"2025-07\":\"n\"},\"ios\":{\"2022-12\":\"n\"},\"android\":{\"2022-12\":\"n\"}},\"thunderbird\":{\"macos\":{\"102.6\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2022-12\":\"y\"},\"ios\":{\"2022-12\":\"y\"},\"android\":{\"2022-12\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-12\":\"y\"},\"ios\":{\"2022-12\":\"y\"},\"android\":{\"2022-12\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-12\":\"y\"},\"ios\":{\"2022-12\":\"y\"},\"android\":{\"2022-12\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2022-12\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-12\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2022-12\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2022-12\":\"y\",\"2025-07\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Not supported. But `@list` declarations can achieve the same thing.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-pseudo-element-placeholder\",\n\t\t\t\"title\":\"::placeholder\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-pseudo-element-placeholder/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"pseudo-element\",\n\t\t\t\"last_test_date\":\"2020-05-21\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-pseudo-elements.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/qXO9EwD1KSCSCrmUEccVDxVOl3Cyc4LPAbb83ElW2OFge/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-05\":\"n\"},\"ios\":{\"2020-05\":\"n\"},\"android\":{\"2020-05\":\"n\"},\"mobile-webmail\":{\"2020-05\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2020-05\":\"n\",\"2021-03\":\"n #2\"},\"ios\":{\"2020-05\":\"n\"},\"android\":{\"2020-05\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-05\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"2019\":\"a #1\",\"16.80\":\"a #1 #2\"},\"outlook-com\":{\"2020-05\":\"n\",\"2024-01\":\"a #2\"},\"ios\":{\"2.51.1\":\"y\",\"4.38.0\":\"n\",\"4.2352.0\":\"a #2\"},\"android\":{\"2020-05\":\"n\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-05\":\"n\",\"2025-07\":\"n\"},\"ios\":{\"2020-05\":\"n\"},\"android\":{\"2020-05\":\"n\"}},\"thunderbird\":{\"macos\":{\"68.8\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-05\":\"n\"},\"ios\":{\"2020-05\":\"n\"},\"android\":{\"2020-05\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"n #2\",\"2025-07\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Not supported with non Outlook accounts.\",\"2\":\"The pseudo-element is supported but `<input>` elements are not.\",\"3\":\"Partial. Only supported on type selectors.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-radial-gradient\",\n\t\t\t\"title\":\"radial-gradient()\",\n\t\t\t\"description\":\"Creates an image consisting of a progressive transition between two or more colors that radiate from an origin.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-radial-gradient/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"gradients\",\n\t\t\t\"last_test_date\":\"2021-12-29\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-gradients.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/8FCYDYSPXot6jquGzeiqGsfoeCU4tvCeRpnVG0z6luNLr/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"15\":\"y\"},\"ios\":{\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-12\":\"y\"},\"ios\":{\"2021-12\":\"y\"},\"android\":{\"2021-12\":\"y\",\"2025-04\":\"a #2\"},\"mobile-webmail\":{\"2021-12\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2021-12\":\"y\"},\"ios\":{\"2021-12\":\"y\"},\"android\":{\"2021-12\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"n #1\",\"2010\":\"n #1\",\"2013\":\"n #1\",\"2016\":\"n #1\",\"2019\":\"n #1\"},\"windows-mail\":{\"2021-12\":\"n\"},\"macos\":{\"16.57\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2021-12\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2.51.1\":\"y\",\"4.2148.2\":\"n\"},\"android\":{\"4.2147.4\":\"n\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-12\":\"y\"},\"ios\":{\"2021-12\":\"y\"},\"android\":{\"2021-12\":\"y\"}},\"thunderbird\":{\"macos\":{\"91.4.1\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2021-12\":\"n\"},\"ios\":{\"2021-12\":\"n\"},\"android\":{\"2021-12\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-12\":\"n\"},\"ios\":{\"2021-12\":\"n\"},\"android\":{\"2021-12\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-12\":\"y\"},\"ios\":{\"2021-12\":\"y\"},\"android\":{\"2021-12\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-12\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-12\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-12\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-12\":\"y\"}},\"t-online-de\":{\"desktop-webmail\":{\"2021-12\":\"y\"}},\"free-fr\":{\"desktop-webmail\":{\"2021-12\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Gradients can be created in VML using `type=\\\"gradientRadial\\\"` on a `<v:fill>`. See [VML documentation](https://docs.microsoft.com/en-us/windows/win32/vml/web-workshop---how-to-use-vml-on-web-pages-----fill--element#gradient-fill).\",\"2\":\"Buggy. Does not work inline in the `background-image` property. (See [email-bugs#135](https://github.com/hteumeuleu/email-bugs/issues/135))\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-resize\",\n\t\t\t\"title\":\"resize\",\n\t\t\t\"description\":\"Sets whether an element is resizable, and in which directions.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-resize/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"textarea\",\n\t\t\t\"last_test_date\":\"2024-01-17\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-resize.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/6vd212zjibljibxkt9\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2024-01\":\"a #2\"},\"ios\":{\"2024-01\":\"n\"}},\"gmail\":{\"desktop-webmail\":{\"2024-01\":\"n #1\"},\"ios\":{\"2024-01\":\"n #1\"},\"android\":{\"2024-01\":\"n\"},\"mobile-webmail\":{\"2024-01\":\"u\"}},\"orange\":{\"desktop-webmail\":{\"2024-01\":\"u\"},\"ios\":{\"2024-01\":\"n\"},\"android\":{\"2024-01\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2024-01\":\"n\"},\"macos\":{\"2024-01\":\"n #1\"},\"outlook-com\":{\"2024-01\":\"n #1\"},\"ios\":{\"2024-01\":\"n #1\"},\"android\":{\"2024-01\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2024-01\":\"n #1\"},\"ios\":{\"2024-01\":\"n\"},\"android\":{\"2024-01\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2024-01\":\"n #1\"},\"ios\":{\"2024-01\":\"n\"},\"android\":{\"2024-01\":\"n\"}},\"samsung-email\":{\"android\":{\"2024-01\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2024-01\":\"u\"},\"ios\":{\"2024-01\":\"n\"},\"android\":{\"2024-01\":\"u\"}},\"thunderbird\":{\"macos\":{\"2024-01\":\"u\"}},\"protonmail\":{\"desktop-webmail\":{\"2024-01\":\"u\"},\"ios\":{\"2024-01\":\"n\"},\"android\":{\"2024-01\":\"u\"}},\"hey\":{\"desktop-webmail\":{\"2024-01\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-01\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2024-01\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2024-01\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2024-01\":\"n #1\"},\"ios\":{\"2024-01\":\"n\"},\"android\":{\"2024-01\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2024-01\":\"n #1\"},\"ios\":{\"2024-01\":\"n\"},\"android\":{\"2024-01\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2024-01\":\"u\"},\"android\":{\"2024-01\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"`resize` property is stripped from style tag\",\"2\":\"Does not support `inline` and `block` values\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-rgb\",\n\t\t\t\"title\":\"rgb()\",\n\t\t\t\"description\":\"RGB functional notation (`rgb()`)\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-rgb/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"rgb,color\",\n\t\t\t\"last_test_date\":\"2021-05-14\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-colors.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/w7XizGjCyAYCZgDFP1OSGekFm\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"11\":\"y\",\"12\":\"y\",\"13\":\"y\"},\"ios\":{\"11\":\"a #1\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-08\":\"a #1 #3\"},\"ios\":{\"2021-08\":\"a #1 #3\"},\"android\":{\"2021-08\":\"a #1 #3\"},\"mobile-webmail\":{\"2021-08\":\"a #1 #3\"}},\"orange\":{\"desktop-webmail\":{\"2021-05\":\"a #1 #2\"},\"ios\":{\"2021-05\":\"a #1 #2\"},\"android\":{\"2021-05\":\"a #1 #2\"}},\"outlook\":{\"windows\":{\"2007\":\"a #1 #2\",\"2010\":\"a #1 #2\",\"2013\":\"a #1 #2\",\"2016\":\"a #1 #2\",\"2019\":\"a #1 #2\"},\"windows-mail\":{\"2021-01\":\"a #1 #2\"},\"macos\":{\"2011\":\"a #1 #2\",\"2016\":\"a #1 #2\",\"2021-01\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2021-01\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2021-01\":\"y\"},\"android\":{\"4.2101.0\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-01\":\"a #1\"},\"ios\":{\"2021-05\":\"a #1\"},\"android\":{\"6.16.2.1519779\":\"a #1\"}},\"aol\":{\"desktop-webmail\":{\"2021-01\":\"a #1\"},\"ios\":{\"2021-05\":\"a #1\"},\"android\":{\"2021-05\":\"a #1\"}},\"samsung-email\":{\"android\":{\"6.1.31.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"y\"}},\"thunderbird\":{\"macos\":{\"78.10\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-05\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-05\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"a #1 #2\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"a #1 #2\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Whitespace syntax is not supported (`rgb(0 128 0 / 1)`).\",\"2\":\"Alpha value is not supported (`rgb(0, 128, 0, 0.5)`).\",\"3\":\"whitespace syntax (`rgb(0 128 0)`): when used in the `style` attribute of an element, the whole attribute is stripped. When used inside `<style>`, the whole `<style>` block is stripped.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-rgba\",\n\t\t\t\"title\":\"rgba()\",\n\t\t\t\"description\":\"RGB functional notation with alpha-channel transparency value (`rgba()`)\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-rgba/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"rgb,rgba,color,alpha\",\n\t\t\t\"last_test_date\":\"2021-05-14\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-colors.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/w7XizGjCyAYCZgDFP1OSGekFm\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"11\":\"y\",\"12\":\"y\",\"13\":\"y\"},\"ios\":{\"11\":\"a #1\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-08\":\"a #1 #2\"},\"ios\":{\"2021-08\":\"a #1 #2\"},\"android\":{\"2021-08\":\"a #1 #2\"},\"mobile-webmail\":{\"2021-08\":\"a #1 #2\"}},\"orange\":{\"desktop-webmail\":{\"2021-05\":\"n\"},\"ios\":{\"2021-01\":\"n\"},\"android\":{\"2021-01\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2021-01\":\"n\"},\"macos\":{\"2021-01\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2021-01\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2021-01\":\"y\"},\"android\":{\"4.2101.0\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-01\":\"a #1\"},\"ios\":{\"2021-01\":\"a #1\"},\"android\":{\"6.16.2.1519779\":\"a #1\"}},\"aol\":{\"desktop-webmail\":{\"2021-05\":\"a #1\"},\"ios\":{\"2021-05\":\"a #1\"},\"android\":{\"2021-05\":\"a #1\"}},\"samsung-email\":{\"android\":{\"6.1.31.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"y\"}},\"thunderbird\":{\"macos\":{\"78.10\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-05\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-05\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Whitespace syntax is not supported (`rgb(0 128 0 / 1)`).\",\"2\":\"whitespace syntax (`rgba(0 128 0 / 1)`): when used in the `style` attribute of an element, the whole attribute is stripped. When used inside `<style>`, the whole `<style>` block is stripped.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-scroll-snap\",\n\t\t\t\"title\":\"scroll-snap\",\n\t\t\t\"description\":\"Controls panning and scrolling behaviour with snap positions.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-scroll-snap/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2023-02-27\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-scroll-snap.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/O5rtNMDHo58i8YirVtvyUvG\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"16\":\"y\"},\"ios\":{\"16\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2023-02\":\"n\"},\"ios\":{\"2023-02\":\"n\"},\"android\":{\"2023-02\":\"n\"},\"mobile-webmail\":{\"2023-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2023-04\":\"n\"},\"ios\":{\"2023-04\":\"n\"},\"android\":{\"2023-05\":\"n\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2023-02\":\"n\"},\"macos\":{\"16.73\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2023-02\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2023-02\":\"n\"},\"android\":{\"2023-05\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2023-02\":\"n\"},\"ios\":{\"2023-02\":\"n\"},\"android\":{\"2023-02\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2023-02\":\"n\"},\"ios\":{\"2023-02\":\"n\"},\"android\":{\"2023-02\":\"n\"}},\"samsung-email\":{\"android\":{\"2023-02\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2023-02\":\"y\"},\"ios\":{\"2023-02\":\"y\"},\"android\":{\"2023-05\":\"y\"}},\"thunderbird\":{\"macos\":{\"102.10.1\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2023-04\":\"y\"},\"ios\":{\"2023-04\":\"y\"},\"android\":{\"2023-05\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2023-04\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2023-02\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2023-04\":\"n\"}},\"laposte\":{\"desktop-webmail\":{\"2023-04\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-selector-adjacent-sibling\",\n\t\t\t\"title\":\"Adjacent sibling combinator\",\n\t\t\t\"description\":\"The adjacent sibling combinator (`h1 + p`) allows to target an element that is directly after another.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-selector-adjacent-sibling/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2020-03-11\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-selectors.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/ZOGHmMjaZIUfa2M44xDIdv9lwqol3UQN00PDO7G5kK21Y/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.3\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"a #1\"},\"android\":{\"2020-03\":\"a #1\"},\"mobile-webmail\":{\"2020-03\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2020-03\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-03\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-03\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"samsung-email\":{\"android\":{\"7.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"n\"}},\"thunderbird\":{\"macos\":{\"68.5\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"a #2\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"a #2\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\",\"2\":\"Partial. Only when used as class or ID selectors\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-selector-attribute\",\n\t\t\t\"title\":\"Attribute selector\",\n\t\t\t\"description\":\"The attribute selector (`[attr]`) targets elements with this specific attribute.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-selector-attribute/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2020-12-15\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-selectors-attribute.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/xHppIhPmgvxZQPvA3geS9WGCicLxVuR87NTZu70eWaAF8/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"14.0\":\"y\"},\"ios\":{\"14.3\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-12\":\"a #5\",\"2023-02\":\"a #5\"},\"ios\":{\"2020-12\":\"a #5 #6\",\"2023-02\":\"a #5 #6\"},\"android\":{\"2020-12\":\"a #5 #6\",\"2023-02\":\"a #5 #6\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2020-12\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-12\":\"y\"},\"android\":{\"2020-12\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-12\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"a #1 #2 #3\"},\"outlook-com\":{\"2020-12\":\"a #1 #2 #3\",\"2024-01\":\"a #1 #2 #3\"},\"ios\":{\"2020-12\":\"a #1 #2\"},\"android\":{\"2020-12\":\"a #1 #2\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-12\":\"y\"},\"ios\":{\"2020-12\":\"n\"},\"android\":{\"2020-12\":\"n\"}},\"thunderbird\":{\"macos\":{\"78.5\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-12\":\"a #4\"},\"ios\":{\"2020-12\":\"a #4\"},\"android\":{\"2020-12\":\"a #4\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-12\":\"a #4\"},\"ios\":{\"2020-12\":\"a #4\"},\"android\":{\"2020-12\":\"a #4\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"a #4\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":\"This page reflects support for different syntaxes: `[attr]`, `[attr=\\\"value\\\"]`, `[attr~=\\\"value\\\"]`, `[attr|=\\\"value\\\"]`, `[attr^=\\\"value\\\"]`, `[attr$=\\\"value\\\"]`, `[attr*=\\\"value\\\"]`.\",\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Can not be used with a class selector (`.test[class]`). Must be used solo (`[class]`) or with an element selector (`td[class]`).\",\"2\":\"Partial. Only supports `[attr]`, `[attr=value]`, `[attr~=value]`, `[attr|=value]` syntaxes.\",\"3\":\"Buggy. A `class=\\\"test\\\"` in the HTML is prefixed `class=\\\"x_test\\\"`, but an attribute selector stays unprefixed `[class=\\\"test\\\"]`.\",\"4\":\"Partial. Only supports `[attr=value]` syntax.\",\"5\":\"Partial. Only supports `[attr~=value]` syntax. Only `class` as an attribute name is known to be supported.\",\"6\":\"Partial. Doesn't work with non Google accounts.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-selector-chaining\",\n\t\t\t\"title\":\"Chaining selectors\",\n\t\t\t\"description\":\"Chaining selectors (`.foo.bar`) allows to apply styles to elements matching all the corresponding selectors.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-selector-chaining/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-10-13\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-selectors.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/kQbnzGXMSxMg2NDGmrcxIXOHrROwokTB29RcSssnkoPlS/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"a #1\"},\"android\":{\"2019-10\":\"a #1\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"y\"},\"ios\":{\"2019-10\":\"y\",\"2024-04\":\"y\"},\"android\":{\"2019-10\":\"y\",\"2024-04\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"n #3\",\"2010\":\"n #3\",\"2013\":\"n #3\",\"2016\":\"n #3\",\"2019\":\"n #3\"},\"windows-mail\":{\"2019-10\":\"n #3\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-10\":\"n #4\",\"2024-01\":\"n #4\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"a #2\",\"2025-06\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"a #5\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"a #5\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\",\"2\":\"Buggy. The first `<head>` in the HTML is removed, so `<style>` elements need to be in a second `<head>` element.\",\"3\":\"Buggy. Styles will be applied to the first selector of the chain.\",\"4\":\"Buggy. Only the first selector of the chain is prefixed in the styles, but all classes are prefixed in the HTML.\",\"5\":\"Partial. Only when used as class or ID selectors\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-selector-child\",\n\t\t\t\"title\":\"Child combinator\",\n\t\t\t\"description\":\"The child combinator is represented by a superior sign (`>`) between two selectors and matches the second selector if it is a direct child of the first selector.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-selector-child/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-10-13\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-selectors.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/kQbnzGXMSxMg2NDGmrcxIXOHrROwokTB29RcSssnkoPlS/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"a #1\"},\"android\":{\"2019-10\":\"a #1\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2021-03\":\"n\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2023-06\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-10\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"a #2\",\"2025-06\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\",\"2\":\"Buggy. The first `<head>` in the HTML is removed, so `<style>` elements need to be in a second `<head>` element.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-selector-class\",\n\t\t\t\"title\":\"Class selector\",\n\t\t\t\"description\":\"The class selector (`.className`) allows to apply styles to a group of elements with the corresponding `class` attribute.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-selector-class/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-10-13\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-selectors.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/kQbnzGXMSxMg2NDGmrcxIXOHrROwokTB29RcSssnkoPlS/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"a #1\"},\"android\":{\"2019-10\":\"a #1\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2019-10\":\"y\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-10\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"a #2\",\"2025-06\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\",\"2\":\"Buggy. The first `<head>` in the HTML is removed, so `<style>` elements need to be in a second `<head>` element.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-selector-descendant\",\n\t\t\t\"title\":\"Descendant combinator\",\n\t\t\t\"description\":\"The descendant combinator is represented by a space (` `) between two selectors and matches the second selector if it has ancestor matching the first selector.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-selector-descendant/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-10-13\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-selectors.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/kQbnzGXMSxMg2NDGmrcxIXOHrROwokTB29RcSssnkoPlS/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"a #1\"},\"android\":{\"2019-10\":\"a #1\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2019-10\":\"y\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-10\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"a #2\",\"2025-06\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"a #3\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"a #3\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\",\"2\":\"Buggy. The first `<head>` in the HTML is removed, so `<style>` elements need to be in a second `<head>` element.\",\"3\":\"Partial. Only when used as class or ID selectors\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-selector-general-sibling\",\n\t\t\t\"title\":\"General sibling combinator\",\n\t\t\t\"description\":\"The general sibling combinator (`img ~ p`) allows to target any element that after another (directly or indirectly).\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-selector-general-sibling/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2020-03-11\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-selectors.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/ZOGHmMjaZIUfa2M44xDIdv9lwqol3UQN00PDO7G5kK21Y/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.3\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"a #1\"},\"android\":{\"2020-03\":\"a #1\"},\"mobile-webmail\":{\"2020-03\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2020-03\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-03\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-03\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"samsung-email\":{\"android\":{\"7.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"n\"}},\"thunderbird\":{\"macos\":{\"68.5\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-selector-grouping\",\n\t\t\t\"title\":\"Grouping selectors\",\n\t\t\t\"description\":\"Grouping selectors (`.foo, .bar`) allows to apply the same styles to the different corresponding elements.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-selector-grouping/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-10-13\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-selectors.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/kQbnzGXMSxMg2NDGmrcxIXOHrROwokTB29RcSssnkoPlS/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"a #1\"},\"android\":{\"2019-10\":\"a #1\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2019-10\":\"y\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-10\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"a #2\",\"2025-06\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"a #3\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"a #3\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\",\"2\":\"Buggy. The first `<head>` in the HTML is removed, so `<style>` elements need to be in a second `<head>` element.\",\"3\":\"Partial. Only when used as class or ID selectors\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-selector-id\",\n\t\t\t\"title\":\"ID selector\",\n\t\t\t\"description\":\"The ID selector (`#id`) allows to apply styles to an element with the corresponding `id` attribute.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-selector-id/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-10-13\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-selectors.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/kQbnzGXMSxMg2NDGmrcxIXOHrROwokTB29RcSssnkoPlS/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"a #1\"},\"android\":{\"2019-10\":\"a #1\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2019-10\":\"y\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-10\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"a #2\",\"2025-06\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\",\"2\":\"Buggy. The first `<head>` in the HTML is removed, so `<style>` elements need to be in a second `<head>` element.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-selector-type\",\n\t\t\t\"title\":\"Type selector\",\n\t\t\t\"description\":\"Type selector or element selectors allow to apply styles by HTML element names.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-selector-type/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"element\",\n\t\t\t\"last_test_date\":\"2019-10-13\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-selectors.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/kQbnzGXMSxMg2NDGmrcxIXOHrROwokTB29RcSssnkoPlS/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"a #1\"},\"android\":{\"2019-10\":\"a #1\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2019-10\":\"y\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-10\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"a #2\",\"2025-06\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\",\"2\":\"Buggy. The first `<head>` in the HTML is removed, so `<style>` elements need to be in a second `<head>` element.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-selector-universal\",\n\t\t\t\"title\":\"Universal selector *\",\n\t\t\t\"description\":\"The universal selector (`*`) allows to apply styles to every elements.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-selector-universal/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-10-13\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-selectors.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/kQbnzGXMSxMg2NDGmrcxIXOHrROwokTB29RcSssnkoPlS/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"a #1\"},\"android\":{\"2019-10\":\"a #1\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-10\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-10\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-10\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"n\"},\"android\":{\"2019-10\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-10\":\"y\"},\"ios\":{\"2019-10\":\"y\"},\"android\":{\"2019-10\":\"a #2\",\"2025-06\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n #3\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\",\"2\":\"Buggy. The first `<head>` in the HTML is removed, so `<style>` elements need to be in a second `<head>` element.\",\"3\":\"Not supported. The selector is removed and left prefixed with the outer most parent element.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-shape-margin\",\n\t\t\t\"title\":\"shape-margin\",\n\t\t\t\"description\":\"Sets a margin for a CSS shape created using `shape-outside`\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-shape-margin/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"shape, margin\",\n\t\t\t\"last_test_date\":\"2024-03-13\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-shape-margin.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/l2xyho58cl2yfnknhb\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2024-03\":\"y\"},\"ios\":{\"2024-03\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2024-03\":\"n\"},\"ios\":{\"2024-03\":\"n\"},\"android\":{\"2024-03\":\"n\"},\"mobile-webmail\":{\"2024-03\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2024-03\":\"u\"},\"ios\":{\"2024-03\":\"u\"},\"android\":{\"2024-03\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2024-03\":\"n\"},\"macos\":{\"16.56\":\"y\"},\"outlook-com\":{\"2024-03\":\"n\"},\"ios\":{\"2024-03\":\"n\"},\"android\":{\"2024-03\":\"n\"}},\"samsung-email\":{\"android\":{\"2024-03\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2024-03\":\"u\"},\"ios\":{\"2024-03\":\"u\"},\"android\":{\"2024-03\":\"u\"}},\"thunderbird\":{\"macos\":{\"2024-03\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2024-03\":\"n\"},\"ios\":{\"2024-03\":\"n\"},\"android\":{\"2024-03\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2024-03\":\"n\"},\"ios\":{\"2024-03\":\"n\"},\"android\":{\"2024-03\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2024-03\":\"u\"},\"ios\":{\"2024-03\":\"u\"},\"android\":{\"2024-03\":\"u\"}},\"hey\":{\"desktop-webmail\":{\"2024-03\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-03\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2024-03\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2024-03\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-shape-outside\",\n\t\t\t\"title\":\"shape-outside\",\n\t\t\t\"description\":\"Defines a shape, which may be non-rectangular, around which adjacent inline content should wrap.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-shape-outside/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"shape\",\n\t\t\t\"last_test_date\":\"2024-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-shape-outside.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/6vdjc5l6ungvfjgu94\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"15\":\"y\"},\"ios\":{\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2024-02\":\"n\"},\"ios\":{\"2024-02\":\"n\"},\"android\":{\"2024-02\":\"n\"},\"mobile-webmail\":{\"2024-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2024-02\":\"u\"},\"ios\":{\"2024-02\":\"u\"},\"android\":{\"2024-02\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2024-02\":\"n\"},\"macos\":{\"16.56\":\"y\"},\"outlook-com\":{\"2024-02\":\"n\"},\"ios\":{\"2024-02\":\"n\"},\"android\":{\"2024-02\":\"n\"}},\"samsung-email\":{\"android\":{\"2024-02\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2024-02\":\"u\"},\"ios\":{\"2024-02\":\"u\"},\"android\":{\"2024-02\":\"u\"}},\"thunderbird\":{\"macos\":{\"2024-02\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2024-02\":\"n\"},\"ios\":{\"2024-02\":\"n\"},\"android\":{\"2024-02\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2024-02\":\"n\"},\"ios\":{\"2024-02\":\"n\"},\"android\":{\"2024-02\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2024-02\":\"u\"},\"ios\":{\"2024-02\":\"u\"},\"android\":{\"2024-02\":\"u\"}},\"hey\":{\"desktop-webmail\":{\"2024-02\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-02\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2024-02\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2024-02\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-sytem-ui\",\n\t\t\t\"title\":\"system-ui, ui-serif, ui-sans-serif, ui-rounded, ui-monospace\",\n\t\t\t\"description\":\"CSS keywords for specifying system fonts for `font-family`.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-sytem-ui/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2022-12-30\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-system-fonts.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/cylY3QfQE3JBZdvEGzDrCa9D78HmSgry19cYKLZGKqWpA/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13.1\":\"y\"},\"ios\":{\"16.2\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2022-12\":\"y #2\"},\"ios\":{\"2022-12\":\"y\"},\"android\":{\"2022-12\":\"a #1\"},\"mobile-webmail\":{\"2022-12\":\"y #2\"}},\"orange\":{\"desktop-webmail\":{\"2022-12\":\"y\"},\"ios\":{\"2022-12\":\"y\"},\"android\":{\"2022-12\":\"a #1\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2022-12\":\"n\"},\"macos\":{\"2022-12\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2022-12\":\"y #2\",\"2024-01\":\"y #2\"},\"ios\":{\"2022-12\":\"y\"},\"android\":{\"2022-12\":\"a #1\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-12\":\"y #2\"},\"ios\":{\"2022-12\":\"y\"},\"android\":{\"2022-12\":\"a #1\"}},\"aol\":{\"desktop-webmail\":{\"2022-12\":\"y #2\"},\"ios\":{\"2022-12\":\"y\"},\"android\":{\"2022-12\":\"a #1\"}},\"samsung-email\":{\"android\":{\"6.1.73.1\":\"a #1\"}},\"sfr\":{\"desktop-webmail\":{\"2022-12\":\"y #2\"},\"ios\":{\"2022-12\":\"y\"},\"android\":{\"2022-12\":\"a #1\"}},\"thunderbird\":{\"macos\":{\"102.6\":\"a #1\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-12\":\"y #2\"},\"ios\":{\"2022-12\":\"y\"},\"android\":{\"2022-12\":\"a #1\"}},\"hey\":{\"desktop-webmail\":{\"2022-12\":\"y #2\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-12\":\"y #2\"}},\"fastmail\":{\"desktop-webmail\":{\"2022-12\":\"y #2\"}},\"laposte\":{\"desktop-webmail\":{\"2022-12\":\"y #2\"}},\"t-online-de\":{\"desktop-webmail\":{\"2023-01\":\"y #2\"}},\"gmx\":{\"desktop-webmail\":{\"2021-12\":\"y #2\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Supports `system-ui` only.\",\"2\":\"Partial. Depends on browser support. `system-ui` works in all browsers. `ui-serif`, `ui-sans-serif`, `ui-rounded` and `ui-monospace` are supported on all browsers on iOS and Safari on MacOS.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-tab-size\",\n\t\t\t\"title\":\"tab-size\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-tab-size/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"tab,size\",\n\t\t\t\"last_test_date\":\"2022-07-21\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-tab-size.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/Rk9H1m9ubAYH1DwUqZu8G\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"16\":\"y\",\"17\":\"y\",\"18\":\"y\",\"19\":\"y\",\"20\":\"y\",\"21\":\"y\"},\"ios\":{\"11\":\"y\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\",\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"},\"mobile-webmail\":{\"2022-07\":\"u\"}},\"orange\":{\"desktop-webmail\":{\"2022-07\":\"u\"},\"ios\":{\"2022-07\":\"u\"},\"android\":{\"2022-07\":\"u\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2022-07\":\"n\"},\"macos\":{\"2022-07\":\"y\",\"16.80\":\"y #2\"},\"outlook-com\":{\"2022-07\":\"y #2\",\"2024-01\":\"y #2\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"u\"},\"android\":{\"2022-07\":\"u\"}},\"samsung-email\":{\"android\":{\"2022-07\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2022-07\":\"u\"},\"ios\":{\"2022-07\":\"u\"},\"android\":{\"2022-07\":\"u\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-07\":\"n #1\"}},\"fastmail\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Supports `tab-size` but doesn't support `white-space`. Therefore, `tab-size` is not effectively visible\",\"2\":\"Supports `tab-size` but strips the tab character `&#0009;`\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-table-layout\",\n\t\t\t\"title\":\"table-layout\",\n\t\t\t\"description\":\"The table-layout property defines the algorithm used to lay out table cells, rows, and columns.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-table-layout/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"table,layout\",\n\t\t\t\"last_test_date\":\"2022-07-20\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-table-layout.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/G4buV6sBBxUr6quykrtVA3sk\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"16\":\"y\",\"17\":\"y\",\"18\":\"y\",\"19\":\"y\",\"20\":\"y\",\"21\":\"y\"},\"ios\":{\"11\":\"y\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\",\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"},\"mobile-webmail\":{\"2022-07\":\"u\"}},\"orange\":{\"desktop-webmail\":{\"2022-07\":\"u\"},\"ios\":{\"2022-07\":\"u\"},\"android\":{\"2022-07\":\"u\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2022-07\":\"n\"},\"macos\":{\"2022-07\":\"y\",\"16.80\":\"n #1\"},\"outlook-com\":{\"2022-07\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"u\"},\"android\":{\"2022-07\":\"u\"}},\"samsung-email\":{\"android\":{\"2022-07\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2022-07\":\"u\"},\"ios\":{\"2022-07\":\"u\"},\"android\":{\"2022-07\":\"u\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-07\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2022-07\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Not supported. All tables are forced to `table-layout:fixed`.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-text-align-last\",\n\t\t\t\"title\":\"text-align-last\",\n\t\t\t\"description\":\"The `text-align-last` CSS property sets how the last line of a block or a line right before a forced line break is aligned.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-text-align-last/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":\"align, align-last\",\n\t\t\t\"last_test_date\":\"2022-08-31\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-text-align-last.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/LxplTmJT9Ilq9GUyn8Aq8MVK6EO427qmx1Ic4A7jc7bOJ/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2022-10\":\"y\"},\"ios\":{\"11\":\"n\",\"12\":\"n\",\"13\":\"n\",\"14\":\"n\",\"15\":\"n\",\"16.0\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"},\"mobile-webmail\":{\"2021-08\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"macos\":{\"2022-08\":\"n\",\"16.80\":\"n\"},\"outlook-com\":{\"2022-08\":\"y\",\"2024-01\":\"n\"},\"ios\":{\"2022-08\":\"n\"},\"android\":{\"2022-08\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-08\":\"n\",\"2024-03\":\"n\"},\"ios\":{\"2024-03\":\"n\"},\"android\":{\"2024-03\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2022-08\":\"n\"}},\"samsung-email\":{\"android\":{\"6.1.51.1\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"free-fr\":{\"desktop-webmail\":{\"2022-08\":\"y\"}},\"t-online-de\":{\"desktop-webmail\":{\"2022-08\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-08\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-text-align\",\n\t\t\t\"title\":\"text-align\",\n\t\t\t\"description\":\"Sets the horizontal alignment of the content.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-text-align/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":\"align\",\n\t\t\t\"last_test_date\":\"2021-09-24\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-text-align.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/G4YtBn8fBxEsLx6uybqcxD\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2021-09\":\"y\"},\"ios\":{\"11\":\"y\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-09\":\"y\"},\"ios\":{\"2021-09\":\"a #2\"},\"android\":{\"2021-09\":\"a #2\"},\"mobile-webmail\":{\"2021-09\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2021-09\":\"a #1\"},\"ios\":{\"2021-09\":\"a #1\"},\"android\":{\"2021-09\":\"a #1\"}},\"outlook\":{\"windows\":{\"2007\":\"a #1\",\"2010\":\"a #1\",\"2013\":\"a #1\",\"2016\":\"a #1\",\"2019\":\"a #1\"},\"windows-mail\":{\"2021-09\":\"a #1\"},\"macos\":{\"2021-09\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2021-09\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2021-09\":\"y\"},\"android\":{\"2021-09\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-09\":\"a #1\"},\"ios\":{\"2021-09\":\"a #1\"},\"android\":{\"6.37\":\"a #1\"}},\"aol\":{\"desktop-webmail\":{\"2021-09\":\"a #1\"},\"ios\":{\"2021-09\":\"a #1\"},\"android\":{\"2021-09\":\"a #1\"}},\"samsung-email\":{\"android\":{\"6.1.51.1\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-09\":\"y\"},\"ios\":{\"2021-09\":\"y\"},\"android\":{\"2021-09\":\"y\"}},\"thunderbird\":{\"macos\":{\"2021-09\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-09\":\"y\"},\"ios\":{\"2021-09\":\"y\"},\"android\":{\"2021-09\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-09\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-09\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-09\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"y #1\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"y #1\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Flow-relative values `start` and `end` are not supported.\",\"2\":\"Partial. Flow-relative values `start` and `end` are not supported with non Gmail account.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-text-decoration-color\",\n\t\t\t\"title\":\"text-decoration-color\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-text-decoration-color/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"underline\",\n\t\t\t\"last_test_date\":\"2020-04-30\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-text-decoration.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/VYmPi84Nw2pMoQLeljigICaH0QudjS2xc2CgpvPbEW7FZ/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12\":\"a #1\",\"13.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-04\":\"y\"},\"ios\":{\"2020-04\":\"a #3\"},\"android\":{\"2020-04\":\"a #3\"},\"mobile-webmail\":{\"2020-04\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2020-04\":\"y\",\"2021-03\":\"n\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n #2\",\"2010\":\"n #2\",\"2013\":\"n #2\",\"2016\":\"n #2\",\"2019\":\"n #2\"},\"windows-mail\":{\"2020-04\":\"n #2\"},\"macos\":{\"2011\":\"a #1\",\"2016\":\"a #1\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-04\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"samsung-email\":{\"android\":{\"7.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-04\":\"y\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"thunderbird\":{\"macos\":{\"68.7\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-04\":\"y\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-04\":\"y\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-04\":\"y\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}}},\n\t\t\t\"notes\":\"`text-decoration-color` is not supported in Internet Explorer.\",\n\t\t\t\"notes_by_num\":{\"1\":\"Supported with prefix `-webkit-`.\",\"2\":\"Not supported, but the proprietary `text-underline-color` property can be used instead.\",\"3\":\"Partial. Not supported with non Google accounts.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-text-decoration-line\",\n\t\t\t\"title\":\"text-decoration-line\",\n\t\t\t\"description\":\"Sets the kind of decoration that is used on text in an element, such as an underline or overline.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-text-decoration-line/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":\"underline, overline, line-through\",\n\t\t\t\"last_test_date\":\"2023-12-06\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-text-decoration-line.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/kg6y1a4eiynkf8z7t4\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2023-12\":\"y\"},\"ios\":{\"11\":\"n\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"y\"},\"android\":{\"6\":\"n\",\"7\":\"n\",\"8\":\"y\",\"9\":\"y\",\"10\":\"y\",\"11\":\"y\",\"12\":\"y\",\"13\":\"y\"},\"mobile-webmail\":{\"2023-12\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2023-12\":\"u\"},\"ios\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2023-12\":\"n\"},\"macos\":{\"2023-12\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2023-12\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2023-12\":\"y\"},\"android\":{\"2023-12\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"y\"},\"android\":{\"2023-12\":\"n\",\"2025-06\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"y\"},\"android\":{\"2023-12\":\"n\"}},\"samsung-email\":{\"android\":{\"2023-12\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2023-12\":\"u\"},\"ios\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}},\"thunderbird\":{\"macos\":{\"2023-12\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"y\"},\"android\":{\"2023-12\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2023-12\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2023-12\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2023-12\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2023-12\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2023-12\":\"n\"},\"ios\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2023-12\":\"n\"},\"ios\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-text-decoration-skip-ink\",\n\t\t\t\"title\":\"text-decoration-skip-ink\",\n\t\t\t\"description\":\"Specifies how overlines and underlines are drawn when they pass over glyph ascenders and descenders.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-text-decoration-skip-ink/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":\"underline\",\n\t\t\t\"last_test_date\":\"2023-12-08\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-text-decoration-skip-ink.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/n477u94ixnz0kbrh6\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.15\":\"n\",\"11.7\":\"n\",\"12.7\":\"y\",\"13.6\":\"y\"},\"ios\":{\"10\":\"n\",\"11\":\"n\",\"12\":\"n\",\"13\":\"y\",\"14\":\"y\",\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2023-12\":\"n\"},\"ios\":{\"2023-12\":\"n\"},\"android\":{\"2023-12\":\"n\"},\"mobile-webmail\":{\"2023-12\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2023-12\":\"n\"},\"ios\":{\"2023-12\":\"n\"},\"android\":{\"2023-12\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2023-12\":\"n\"},\"macos\":{\"2023-12\":\"n\",\"16.80\":\"n\"},\"outlook-com\":{\"2023-12\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2023-12\":\"n\"},\"android\":{\"2023-12\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2023-12\":\"n\"},\"ios\":{\"2023-12\":\"n\"},\"android\":{\"2023-12\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2023-12\":\"n\"},\"ios\":{\"2023-12\":\"n\"},\"android\":{\"2023-12\":\"n\"}},\"samsung-email\":{\"android\":{\"2023-12\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}},\"thunderbird\":{\"macos\":{\"2023-12\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"y\"},\"android\":{\"2023-12\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2023-12\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2023-12\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2023-12\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2023-12\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2023-12\":\"n\"},\"ios\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2023-12\":\"n\"},\"ios\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-text-decoration-style\",\n\t\t\t\"title\":\"text-decoration-style\",\n\t\t\t\"description\":\"Sets the style of the lines specified by text-decoration-line.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-text-decoration-style/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"underline\",\n\t\t\t\"last_test_date\":\"2023-12-06\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-text-decoration-style.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/jalr04oy0yrxfd7kuo\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2023-12\":\"y\"},\"ios\":{\"11\":\"n\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"y\"},\"android\":{\"6\":\"n\",\"7\":\"n\",\"8\":\"y\",\"9\":\"y\",\"10\":\"y\",\"11\":\"y\",\"12\":\"y\",\"13\":\"y\"},\"mobile-webmail\":{\"2023-12\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2023-12\":\"u\"},\"ios\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2023-12\":\"n\"},\"macos\":{\"2023-12\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2023-12\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2023-12\":\"y\"},\"android\":{\"2023-12\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"y\"},\"android\":{\"2023-12\":\"n\",\"2025-06\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"y\"},\"android\":{\"2023-12\":\"n\"}},\"samsung-email\":{\"android\":{\"2023-12\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2023-12\":\"u\"},\"ios\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}},\"thunderbird\":{\"macos\":{\"2023-12\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"y\"},\"android\":{\"2023-12\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2023-12\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2023-12\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2023-12\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2023-12\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2023-12\":\"n\"},\"ios\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2023-12\":\"n\"},\"ios\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-text-decoration-thickness\",\n\t\t\t\"title\":\"text-decoration-thickness\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-text-decoration-thickness/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"underline\",\n\t\t\t\"last_test_date\":\"2023-01-16\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-text-decoration.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/VYmPi84Nw2pMoQLeljigICaH0QudjS2xc2CgpvPbEW7FZ/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12\":\"a #1\",\"13.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-04\":\"y\",\"2023-01\":\"n\"},\"ios\":{\"2020-04\":\"n\",\"2023-01\":\"n\"},\"android\":{\"2020-04\":\"n\",\"2023-01\":\"n\"},\"mobile-webmail\":{\"2020-04\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2020-04\":\"y\",\"2021-03\":\"n\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n #2\",\"2010\":\"n #2\",\"2013\":\"n #2\",\"2016\":\"n #2\",\"2019\":\"n #2\"},\"windows-mail\":{\"2020-04\":\"n #2\"},\"macos\":{\"2011\":\"n\",\"2016\":\"n\",\"2023-01\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2020-04\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2020-04\":\"n\",\"2023-01\":\"n\"},\"android\":{\"2020-04\":\"n\",\"2023-01\":\"n\"}},\"samsung-email\":{\"android\":{\"7.0\":\"n\",\"10\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-04\":\"y\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"n\"}},\"thunderbird\":{\"macos\":{\"68.7\":\"n\",\"102.6\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-04\":\"n\"},\"ios\":{\"2020-04\":\"n\"},\"android\":{\"2020-04\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-04\":\"n\"},\"ios\":{\"2020-04\":\"n\",\"2023-01\":\"n\"},\"android\":{\"2020-04\":\"n\",\"2023-01\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-04\":\"y\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"n\",\"2023-01\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Supported with prefix `-webkit-`.\",\"2\":\"Not supported, but the proprietary `text-underline-style` property can be used instead with the keyword value `thick`.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-text-decoration\",\n\t\t\t\"title\":\"text-decoration\",\n\t\t\t\"description\":\"Tested with the values `overline`, `underline` and `line-through`.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-text-decoration/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":\"underline\",\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-text-decoration.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/3r2uYHjW7RohepVjh05qVkSQ9t7gJVJd6O5ABI8grFvqQ/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"a #1\"},\"android\":{\"2019-02\":\"a #1\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-01\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-01\":\"n\"},\"android\":{\"2020-01\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"a #2 #3\",\"2010\":\"a #2 #3\",\"2013\":\"a #2 #3\",\"2016\":\"a #2 #3\",\"2019\":\"a #2 #3\"},\"windows-mail\":{\"2019-02\":\"a #2 #3\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"y\"},\"ios\":{\"2020-01\":\"n\"},\"android\":{\"2020-01\":\"y\"}},\"thunderbird\":{\"macos\":{\"68.4\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"a #4\"},\"ios\":{\"2022-07\":\"a #5\"},\"android\":{\"2022-07\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"a #4\"},\"ios\":{\"2022-07\":\"a #5\"},\"android\":{\"2022-07\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\",\"2\":\"Partial. Not supported with multiple values.\",\"3\":\"Partial. `overline` is not supported.\",\"4\":\"Partial. Only supports the line property, not style, color or thickness.\",\"5\":\"Partial. Only supports style, color or thickness when written with long hand selectors.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-text-emphasis-position\",\n\t\t\t\"title\":\"text-emphasis-position\",\n\t\t\t\"description\":\"Determines the position of the emphasis mark.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-text-emphasis-position/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2022-07-07\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-text-emphasis.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/O0yCP6t6l3sZ1xFrLZuEx6I5O\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2022-07\":\"y\"},\"ios\":{\"11\":\"y\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\",\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"},\"mobile-webmail\":{\"2022-07\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2022-07\":\"u\"},\"ios\":{\"2022-07\":\"u\"},\"android\":{\"2022-07\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2022-07\":\"n\"},\"macos\":{\"2022-07\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2022-07\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"samsung-email\":{\"android\":{\"10\":\"n\",\"11\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2022-07\":\"u\"},\"ios\":{\"2022-07\":\"u\"},\"android\":{\"2022-07\":\"u\"}},\"thunderbird\":{\"macos\":{\"78.14\":\"u\"}},\"aol\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-07\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-text-emphasis\",\n\t\t\t\"title\":\"text-emphasis\",\n\t\t\t\"description\":\"Applies emphasis marks to text commonly used in East Asian languages. Tests with text-emphasis, text-emphasis-color & text-emphasis-style properties.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-text-emphasis/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2022-07-07\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-text-emphasis.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/O0yCP6t6l3sZ1xFrLZuEx6I5O\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2022-07\":\"y\"},\"ios\":{\"11\":\"y\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\",\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"n\"},\"mobile-webmail\":{\"2022-07\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2022-07\":\"u\"},\"ios\":{\"2022-07\":\"u\"},\"android\":{\"2022-07\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2022-07\":\"n\"},\"macos\":{\"2022-07\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2022-07\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"n\"}},\"samsung-email\":{\"android\":{\"10\":\"n\",\"11\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2022-07\":\"u\"},\"ios\":{\"2022-07\":\"u\"},\"android\":{\"2022-07\":\"u\"}},\"thunderbird\":{\"macos\":{\"78.14\":\"u\"}},\"aol\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-07\":\"n\"},\"ios\":{\"2022-07\":\"n\"},\"android\":{\"2022-07\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-07\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2022-07\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-text-indent\",\n\t\t\t\"title\":\"text-indent\",\n\t\t\t\"description\":\"Support below refers to supporting a `<length>` value only. This does not include the new `each-line` or `hanging` keywords.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-text-indent/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"indent\",\n\t\t\t\"last_test_date\":\"2021-01-31\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-text-indent.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/Ew5f99Cy8NuRM0iPMVFoyYI8\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"11\":\"y\",\"12\":\"y\",\"13\":\"y\"},\"ios\":{\"11\":\"y\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-01\":\"a #1\"},\"ios\":{\"2021-01\":\"a #1\"},\"android\":{\"2021-01\":\"a #1\"},\"mobile-webmail\":{\"2021-01\":\"a #1\"}},\"orange\":{\"desktop-webmail\":{\"2021-01\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2021-01\":\"y\"},\"android\":{\"2021-01\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2021-01\":\"y\"},\"macos\":{\"2021-01\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2021-01\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2021-01\":\"y\"},\"android\":{\"4.2101.1\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-01\":\"a #1\"},\"ios\":{\"6.21.1\":\"a #1\"},\"android\":{\"6.16.2.1525679\":\"a #1\"}},\"aol\":{\"desktop-webmail\":{\"2021-01\":\"a #1\"},\"ios\":{\"6.0.0\":\"a #1\"},\"android\":{\"5.15.0\":\"a #1\"}},\"samsung-email\":{\"android\":{\"6.1.31.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-01\":\"y\"},\"ios\":{\"2021-01\":\"y\"},\"android\":{\"2021-01\":\"y\"}},\"thunderbird\":{\"macos\":{\"2021-01\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-01\":\"y\"},\"ios\":{\"2021-01\":\"y\"},\"android\":{\"2021-01\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-01\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-01\":\"a #2\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"ios\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-07\":\"y\"},\"android\":{\"2022-07\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Negative values are not supported.\",\"2\":\"Partial. Hard-coded negative values are not supported, but negative values as a result of the `calc()` function are supported.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-text-justify\",\n\t\t\t\"title\":\"text-justify\",\n\t\t\t\"description\":\"Sets what type of justification should be applied to text when `text-align: justify;` is set on an element.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-text-justify/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2024-04-17\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-text-justify.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/z7b61px4fel2ivk9sb2\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2024-04\":\"n #1\"},\"ios\":{\"2024-04\":\"n #1\"}},\"gmail\":{\"desktop-webmail\":{\"2024-04\":\"a #2\"},\"ios\":{\"2024-04\":\"n #1\"},\"android\":{\"2024-04\":\"n #1\"},\"mobile-webmail\":{\"2024-04\":\"a #2\"}},\"orange\":{\"desktop-webmail\":{\"2024-04\":\"u\"},\"ios\":{\"2024-04\":\"u\"},\"android\":{\"2024-04\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"a #3\",\"2016\":\"a #3\",\"2019\":\"a #3\",\"2021\":\"a #3\"},\"windows-mail\":{\"2024-04\":\"n #5\"},\"macos\":{\"2024-04\":\"n #1\"},\"outlook-com\":{\"2024-04\":\"a #2\"},\"ios\":{\"2024-04\":\"n #1\"},\"android\":{\"2024-04\":\"n #1\"}},\"yahoo\":{\"desktop-webmail\":{\"2024-04\":\"a #2 #4\"},\"ios\":{\"2024-04\":\"n #1\"},\"android\":{\"2024-04\":\"n #1\"}},\"aol\":{\"desktop-webmail\":{\"2024-04\":\"n #2 #4\"},\"ios\":{\"2024-04\":\"n #1\"},\"android\":{\"2024-04\":\"n #1\"}},\"samsung-email\":{\"android\":{\"2024-04\":\"n #1\"}},\"sfr\":{\"desktop-webmail\":{\"2024-04\":\"u\"},\"ios\":{\"2024-04\":\"u\"},\"android\":{\"2024-04\":\"u\"}},\"thunderbird\":{\"macos\":{\"2024-04\":\"u\"}},\"protonmail\":{\"desktop-webmail\":{\"2024-04\":\"u\"},\"ios\":{\"2024-04\":\"u\"},\"android\":{\"2024-04\":\"u\"}},\"hey\":{\"desktop-webmail\":{\"2024-04\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-04\":\"a #2\"}},\"fastmail\":{\"desktop-webmail\":{\"2024-04\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2024-04\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2024-04\":\"a #2\"},\"ios\":{\"2024-04\":\"u\"},\"android\":{\"2024-04\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2024-04\":\"n #1\"},\"ios\":{\"2024-04\":\"u\"},\"android\":{\"2024-04\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2024-04\":\"u\"},\"android\":{\"2024-04\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. `text-justify` is stripped\",\"2\":\"Partial. Depends on browser support\",\"3\":\"Partial. `text-justify` is stripped except when the value is `inter-character`\",\"4\":\"Partial. `text-justify` is stripped except when the value is `inter-word` or `distribute`\",\"5\":\"Buggy. `text-justify` values `none`, `inter-word` and `distribute` are replaced with `inter-ideograph`\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-text-orientation\",\n\t\t\t\"title\":\"text-orientation\",\n\t\t\t\"description\":\"Sets the orientation of the text characters in vertical mode.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-text-orientation/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":\"vertical orientation\",\n\t\t\t\"last_test_date\":\"2023-12-08\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-text-orientation.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/6vppidbfjxnczrxh4\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.15\":\"a #1\",\"11.7\":\"y\",\"12.7\":\"y\",\"13.6\":\"y\",\"14.1\":\"y\"},\"ios\":{\"10\":\"n\",\"11\":\"n\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\",\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2023-12\":\"y\"},\"ios\":{\"10\":\"n\",\"11\":\"n\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\",\"15\":\"y\"},\"android\":{\"2023-12\":\"y\"},\"mobile-webmail\":{\"2023-12\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2023-12\":\"u\"},\"ios\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2023-12\":\"n\"},\"macos\":{\"2023-12\":\"n\",\"16.80\":\"y\"},\"outlook-com\":{\"2023-12\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"10\":\"n\",\"11\":\"n\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\",\"15\":\"y\"},\"android\":{\"2023-12\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2023-12\":\"n\"},\"ios\":{\"2023-12\":\"n\"},\"android\":{\"2023-12\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2023-12\":\"n\"},\"ios\":{\"2023-12\":\"n\"},\"android\":{\"2023-12\":\"n\"}},\"samsung-email\":{\"android\":{\"2023-12\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2023-12\":\"u\"},\"ios\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}},\"thunderbird\":{\"macos\":{\"2023-12\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"y\"},\"android\":{\"2023-12\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2023-12\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2023-12\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2023-12\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2023-12\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2023-12\":\"n\"},\"ios\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2023-12\":\"n\"},\"ios\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. `sideways` is not supported\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-text-overflow\",\n\t\t\t\"title\":\"text-overflow\",\n\t\t\t\"description\":\"Tested with the value `ellipsis`.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-text-overflow/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-text.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/DkqbHs69ek5UnK6uhZ7Uj0n5GVQNTP4Z1FvgXvnKyEoTM/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"a #1\"},\"android\":{\"2019-02\":\"a #1\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-01\":\"n #2\",\"2021-03\":\"n\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"thunderbird\":{\"macos\":{\"68.4\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\",\"2\":\"Not supported. `overflow` is replaced by `java-script`.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-text-shadow\",\n\t\t\t\"title\":\"text-shadow\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-text-shadow/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-box-model.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/pyPQFHSYLFrhbRShalju0B2fYNwUgLuyKTLx4MLqiw5mE/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2019-02\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"n\",\"2025-06\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.5\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-text-transform\",\n\t\t\t\"title\":\"text-transform\",\n\t\t\t\"description\":\"Each of the six `text-transform` values defined by MDN (`capitalize`, `uppercase`, `lowercase`, `none`, `full-width`, `full-size-kana`).\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-text-transform/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2021-09-19\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-text-transform.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/shared-preview/R6niSqR1SM\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-08\":\"y\"},\"ios\":{\"2021-08\":\"y\"},\"android\":{\"2021-08\":\"y\"},\"mobile-webmail\":{\"2021-08\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"a #1\",\"2010\":\"a #1\",\"2013\":\"a #1\",\"2016\":\"a #1\",\"2019\":\"a #1\"},\"windows-mail\":{\"2021-09\":\"a #1\"},\"macos\":{\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2021-08\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2021-09\":\"y\"},\"android\":{\"2021-09\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-08\":\"y\"},\"ios\":{\"2021-09\":\"y\"},\"android\":{\"2021-09\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2021-09\":\"y\"},\"ios\":{\"2021-09\":\"y\"},\"android\":{\"2021-09\":\"y\"}},\"samsung-email\":{\"android\":{\"7.0\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"thunderbird\":{\"macos\":{\"78.12\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-09\":\"y\"},\"ios\":{\"2021-09\":\"y\"},\"android\":{\"2021-09\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-09\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2021-09\":\"y\"},\"ios\":{\"2021-09\":\"y\"},\"android\":{\"2021-09\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-09\":\"y\"},\"ios\":{\"2021-09\":\"y\"},\"android\":{\"2021-09\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-09\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-09\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}}},\n\t\t\t\"notes\":\"Currently, only Firefox supports the `full-width` and `full-size-kana` property values. Web clients on Firefox will support these properties.\",\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. `lowercase` value is not supported.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-text-underline-offset\",\n\t\t\t\"title\":\"text-underline-offset\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-text-underline-offset/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":\"underline\",\n\t\t\t\"last_test_date\":\"2023-01-16\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-text-decoration.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/Zo8XyakhcacSbta8lYvU5vSTAWnaTLi7XIcWtQ7B218Cj/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"14.0\":\"y\"},\"ios\":{\"14.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-01\":\"n\",\"2023-01\":\"n\"},\"ios\":{\"2021-01\":\"n\",\"2023-01\":\"n\"},\"android\":{\"2021-01\":\"n\",\"2023-01\":\"n\"},\"mobile-webmail\":{\"2021-01\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2021-01\":\"y\",\"2021-03\":\"n\"},\"ios\":{\"2021-01\":\"y\"},\"android\":{\"2021-01\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2021-01\":\"n\"},\"macos\":{\"2016\":\"n\",\"16.46\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2021-01\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2021-01\":\"n\",\"2023-01\":\"n\"},\"android\":{\"2021-01\":\"n\",\"2023-01\":\"n\"}},\"samsung-email\":{\"android\":{\"7.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-01\":\"y\"},\"ios\":{\"2021-01\":\"y\"},\"android\":{\"2021-01\":\"n\"}},\"thunderbird\":{\"macos\":{\"78.6\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2021-01\":\"n\"},\"ios\":{\"2021-01\":\"n\"},\"android\":{\"2021-01\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-01\":\"n\"},\"ios\":{\"2021-01\":\"n\",\"2023-01\":\"n\"},\"android\":{\"2021-01\":\"n\",\"2023-01\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-01\":\"y\"},\"ios\":{\"2021-01\":\"y\"},\"android\":{\"2021-01\":\"n\",\"2023-01\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-01\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-01\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-text-underline-position\",\n\t\t\t\"title\":\"text-underline-position\",\n\t\t\t\"description\":\"Specifies the position of the underline which is set using the `text-decoration property`'s underline value.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-text-underline-position/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":\"underline\",\n\t\t\t\"last_test_date\":\"2023-12-08\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-text-underline-position.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/yd99u8e12nvh97jcv\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2023-12\":\"a #1\"},\"ios\":{\"2023-12\":\"a #1\"}},\"gmail\":{\"desktop-webmail\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"a #1\"},\"android\":{\"2023-12\":\"y\"},\"mobile-webmail\":{\"2023-12\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2023-12\":\"u\"},\"ios\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2023-12\":\"n\"},\"macos\":{\"2023-12\":\"n\",\"16.80\":\"n\"},\"outlook-com\":{\"2023-12\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2023-12\":\"a #1\"},\"android\":{\"2023-12\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2023-12\":\"n\"},\"ios\":{\"2023-12\":\"n\"},\"android\":{\"2023-12\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2023-12\":\"n\"},\"ios\":{\"2023-12\":\"n\"},\"android\":{\"2023-12\":\"n\"}},\"samsung-email\":{\"android\":{\"2023-12\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2023-12\":\"u\"},\"ios\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}},\"thunderbird\":{\"macos\":{\"2023-12\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"y\"},\"android\":{\"2023-12\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2023-12\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2023-12\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2023-12\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2023-12\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2023-12\":\"n\"},\"ios\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2023-12\":\"n\"},\"ios\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2023-12\":\"u\"},\"android\":{\"2023-12\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Supports `under` but not `right` and `left`\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-text-wrap\",\n\t\t\t\"title\":\"text-wrap\",\n\t\t\t\"description\":\"Controls how text inside an element is wrapped\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-text-wrap/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2024-04-03\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-text-wrap.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/xle5u5a5i9eh9opi7a\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2024-04\":\"y\"},\"ios\":{\"15\":\"y\",\"14\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2024-04\":\"n\"},\"ios\":{\"2024-04\":\"n\"},\"android\":{\"2024-04\":\"n\"},\"mobile-webmail\":{\"2024-04\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2024-04\":\"u\"},\"ios\":{\"2024-04\":\"u\"},\"android\":{\"2024-04\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2024-04\":\"n\"},\"macos\":{\"2024-04\":\"n\"},\"outlook-com\":{\"2024-04\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2024-04\":\"n\"},\"android\":{\"2024-04\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2024-04\":\"n\"},\"ios\":{\"2024-04\":\"n\"},\"android\":{\"2024-04\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2024-04\":\"n\"},\"ios\":{\"2024-04\":\"n\"},\"android\":{\"2024-04\":\"n\"}},\"samsung-email\":{\"android\":{\"2024-04\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2024-04\":\"u\"},\"ios\":{\"2024-04\":\"u\"},\"android\":{\"2024-04\":\"u\"}},\"thunderbird\":{\"macos\":{\"2024-04\":\"u\"}},\"protonmail\":{\"desktop-webmail\":{\"2024-04\":\"u\"},\"ios\":{\"2024-04\":\"u\"},\"android\":{\"2024-04\":\"u\"}},\"hey\":{\"desktop-webmail\":{\"2024-04\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-04\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2024-04\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2024-04\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2024-04\":\"n\"},\"ios\":{\"2024-04\":\"u\"},\"android\":{\"2024-04\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2024-04\":\"n\"},\"ios\":{\"2024-04\":\"u\"},\"android\":{\"2024-04\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2024-04\":\"u\"},\"android\":{\"2024-04\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-transform\",\n\t\t\t\"title\":\"transform\",\n\t\t\t\"description\":\"Tested with values `matrix`, `translate`, `scale`, `rotate`, `skew`, `scale translate`.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-transform/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-visual-effects.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/DhTRmGsVH6uobU4pHD3CasJywfBL4HnEjA1LOF8f9ctso/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2019-08\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2019-08\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2019-02\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.8\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. `scale translate` is not supported.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-transition\",\n\t\t\t\"title\":\"transition\",\n\t\t\t\"description\":\"Creates a visual transition when changing CSS properties.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-transition/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"animation\",\n\t\t\t\"last_test_date\":\"2024-03-29\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-transition.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/04RtAsXcEt68kJzhGogIDE8xcmwE7xuKdGPlz2b07ZfJY/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"16.0\":\"y\"},\"ios\":{\"17.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2024-03\":\"n\"},\"ios\":{\"2024-03\":\"n\"},\"android\":{\"2024-03\":\"n\"},\"mobile-webmail\":{\"2024-03\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2024-03\":\"n\"},\"ios\":{\"2024-03\":\"n\"},\"android\":{\"2024-03\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2024-03\":\"n\"},\"macos\":{\"16.80\":\"n\"},\"outlook-com\":{\"2024-03\":\"n\"},\"ios\":{\"2024-03\":\"n\"},\"android\":{\"2024-03\":\"n\"}},\"samsung-email\":{\"android\":{\"6.1.90.16\":\"a #5\"}},\"sfr\":{\"desktop-webmail\":{\"2024-03\":\"y\"},\"ios\":{\"2024-03\":\"n\"},\"android\":{\"2024-03\":\"n\"}},\"thunderbird\":{\"macos\":{\"115.9.0\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2024-03\":\"a #1 #2\"},\"ios\":{\"2024-03\":\"a #1 #2\"},\"android\":{\"2024-03\":\"a #1 #2\"}},\"yahoo\":{\"desktop-webmail\":{\"2024-03\":\"a #1 #2\"},\"ios\":{\"2024-03\":\"a #1 #2\"},\"android\":{\"2024-03\":\"a #1 #2\"}},\"protonmail\":{\"desktop-webmail\":{\"2024-03\":\"y\"},\"ios\":{\"2024-03\":\"n\"},\"android\":{\"2024-03\":\"u\"}},\"hey\":{\"desktop-webmail\":{\"2024-03\":\"a #3\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-03\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2024-03\":\"y #4\"}},\"laposte\":{\"desktop-webmail\":{\"2024-03\":\"y\"}},\"free-fr\":{\"desktop-webmail\":{\"2024-03\":\"y\"}},\"t-online-de\":{\"desktop-webmail\":{\"2024-03\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2024-03\":\"u\"},\"ios\":{\"2024-03\":\"u\"},\"android\":{\"2024-03\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2024-03\":\"u\"},\"ios\":{\"2024-03\":\"u\"},\"android\":{\"2024-03\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2024-03\":\"u\"},\"android\":{\"2024-03\":\"u\"}}},\n\t\t\t\"notes\":\"This page accounts for the shorthand `transition` property and the longhand properties `transition-delay`, `transition-duration`, `transition-property` and `transition-timing-function`.\",\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Longhand properties are not supported.\",\"2\":\"Buggy. The `all` keyword is not supported.\",\"3\":\"Buggy. `transition-duration` is forced to `0` on a global reset style.\",\"4\":\"Transition properties are supported but pseudo-classes like `:hover` are not.\",\"5\":\"Partial. Not supported with Outlook accounts.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-unit-calc\",\n\t\t\t\"title\":\"CSS calc() function\",\n\t\t\t\"description\":\"Support for CSS calc function.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-unit-calc/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"unit, calc\",\n\t\t\t\"last_test_date\":\"2020-02-25\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-units.html\",\n\t\t\t\"test_results_url\":\"\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"a #1\"},\"android\":{\"2020-02\":\"a #1\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-02\":\"n\"},\"macos\":{\"2016\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2020-02\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2020-02\":\"n\"},\"android\":{\"2020-02\":\"n\"}},\"samsung-email\":{\"android\":{\"9.0\":\"y\"}},\"thunderbird\":{\"windows\":{\"2020-02\":\"y\"},\"macos\":{\"68.4\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-02\":\"n\"},\"ios\":{\"2020-02\":\"n\"},\"android\":{\"2020-02\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-02\":\"n\"},\"ios\":{\"2020-02\":\"n\"},\"android\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2020-02\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2020-02\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2020-02\":\"y\",\"2024-04\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-unit-ch\",\n\t\t\t\"title\":\"ch unit\",\n\t\t\t\"description\":\"Support for ch unit, relative to the width of a '0'.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-unit-ch/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"unit, ch\",\n\t\t\t\"last_test_date\":\"2020-02-25\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-units.html\",\n\t\t\t\"test_results_url\":\"\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-02\":\"n\"},\"macos\":{\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"samsung-email\":{\"android\":{\"9.0\":\"y\"}},\"thunderbird\":{\"windows\":{\"2020-02\":\"y\"},\"macos\":{\"68.4\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-02\":\"n\"},\"ios\":{\"2020-02\":\"n\"},\"android\":{\"2020-02\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-02\":\"n\"},\"ios\":{\"2020-02\":\"n\"},\"android\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2020-02\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2020-02\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2020-02\":\"y\",\"2024-04\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-unit-cm\",\n\t\t\t\"title\":\"cm unit\",\n\t\t\t\"description\":\"Support for centimeters unit\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-unit-cm/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"unit, cm\",\n\t\t\t\"last_test_date\":\"2020-02-25\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-units.html\",\n\t\t\t\"test_results_url\":\"\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2020-02\":\"y\"},\"macos\":{\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"samsung-email\":{\"android\":{\"9.0\":\"y\"}},\"thunderbird\":{\"windows\":{\"2020-02\":\"y\"},\"macos\":{\"68.4\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-02\":\"y\",\"2021-03\":\"y\",\"2024-04\":\"y\"},\"ios\":{\"2020-02\":\"y\",\"2024-04\":\"y\"},\"android\":{\"2020-02\":\"y\",\"2024-04\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-unit-em\",\n\t\t\t\"title\":\"em unit\",\n\t\t\t\"description\":\"Support for em unit, relative to the current font-size.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-unit-em/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"unit, em\",\n\t\t\t\"last_test_date\":\"2020-02-25\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-units.html\",\n\t\t\t\"test_results_url\":\"\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2020-02\":\"y\"},\"macos\":{\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"samsung-email\":{\"android\":{\"9.0\":\"y\"}},\"thunderbird\":{\"windows\":{\"2020-02\":\"y\"},\"macos\":{\"68.4\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-02\":\"y\",\"2021-03\":\"y\",\"2024-04\":\"y\"},\"ios\":{\"2020-02\":\"y\",\"2024-04\":\"y\"},\"android\":{\"2020-02\":\"y\",\"2024-04\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-unit-ex\",\n\t\t\t\"title\":\"ex unit\",\n\t\t\t\"description\":\"Support for ex unit, relative to the height of an 'x'.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-unit-ex/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"unit, ex\",\n\t\t\t\"last_test_date\":\"2020-02-25\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-units.html\",\n\t\t\t\"test_results_url\":\"\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2020-02\":\"y\"},\"macos\":{\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"samsung-email\":{\"android\":{\"9.0\":\"y\"}},\"thunderbird\":{\"windows\":{\"2020-02\":\"y\"},\"macos\":{\"68.4\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-02\":\"y\",\"2021-03\":\"y\",\"2024-04\":\"y\"},\"ios\":{\"2020-02\":\"y\",\"2024-04\":\"y\"},\"android\":{\"2020-02\":\"y\",\"2024-04\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-unit-in\",\n\t\t\t\"title\":\"in unit\",\n\t\t\t\"description\":\"Support for inches unit\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-unit-in/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"unit, in\",\n\t\t\t\"last_test_date\":\"2020-02-25\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-units.html\",\n\t\t\t\"test_results_url\":\"\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2020-02\":\"y\"},\"macos\":{\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"samsung-email\":{\"android\":{\"9.0\":\"y\"}},\"thunderbird\":{\"windows\":{\"2020-02\":\"y\"},\"macos\":{\"68.4\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-02\":\"y\",\"2021-03\":\"y\",\"2024-04\":\"y\"},\"ios\":{\"2020-02\":\"y\",\"2024-04\":\"y\"},\"android\":{\"2020-02\":\"y\",\"2024-04\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-unit-initial\",\n\t\t\t\"title\":\"initial unit\",\n\t\t\t\"description\":\"Support for initial keyword value.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-unit-initial/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"unit, initial\",\n\t\t\t\"last_test_date\":\"2020-02-25\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-units.html\",\n\t\t\t\"test_results_url\":\"\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-02\":\"a #1\"},\"ios\":{\"2020-02\":\"a #2\"},\"android\":{\"2020-02\":\"a #2\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-02\":\"n\"},\"macos\":{\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-02\":\"a #1\",\"2024-01\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"samsung-email\":{\"android\":{\"9.0\":\"y\"}},\"thunderbird\":{\"windows\":{\"2020-02\":\"y\"},\"macos\":{\"68.4\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-02\":\"n\"},\"ios\":{\"2020-02\":\"n\"},\"android\":{\"2020-02\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-02\":\"n\"},\"ios\":{\"2020-02\":\"n\"},\"android\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2020-02\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2020-02\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2020-02\":\"y\",\"2024-04\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Webmail rendering doesn't work in IE.\",\"2\":\"Partial. Not supported with non Google accounts.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-unit-mm\",\n\t\t\t\"title\":\"mm unit\",\n\t\t\t\"description\":\"Support for millimeters unit\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-unit-mm/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"unit, mm\",\n\t\t\t\"last_test_date\":\"2020-02-25\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-units.html\",\n\t\t\t\"test_results_url\":\"\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2020-02\":\"y\"},\"macos\":{\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"samsung-email\":{\"android\":{\"9.0\":\"y\"}},\"thunderbird\":{\"windows\":{\"2020-02\":\"y\"},\"macos\":{\"68.4\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-02\":\"y\",\"2021-03\":\"y\",\"2024-04\":\"y\"},\"ios\":{\"2020-02\":\"y\",\"2024-04\":\"y\"},\"android\":{\"2020-02\":\"y\",\"2024-04\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-unit-pc\",\n\t\t\t\"title\":\"pc unit\",\n\t\t\t\"description\":\"Support for picas unit\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-unit-pc/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"unit, pc\",\n\t\t\t\"last_test_date\":\"2020-02-25\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-units.html\",\n\t\t\t\"test_results_url\":\"\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2020-02\":\"y\"},\"macos\":{\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"samsung-email\":{\"android\":{\"9.0\":\"y\"}},\"thunderbird\":{\"windows\":{\"2020-02\":\"y\"},\"macos\":{\"68.4\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-02\":\"y\",\"2021-03\":\"y\",\"2024-04\":\"y\"},\"ios\":{\"2020-02\":\"y\",\"2024-04\":\"y\"},\"android\":{\"2020-02\":\"y\",\"2024-04\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-unit-percent\",\n\t\t\t\"title\":\"% unit\",\n\t\t\t\"description\":\"Support for percentage unit\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-unit-percent/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"unit, %\",\n\t\t\t\"last_test_date\":\"2020-02-25\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-units.html\",\n\t\t\t\"test_results_url\":\"\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2020-02\":\"y\"},\"macos\":{\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"samsung-email\":{\"android\":{\"9.0\":\"y\"}},\"thunderbird\":{\"windows\":{\"2020-02\":\"y\"},\"macos\":{\"68.4\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-02\":\"y\",\"2021-03\":\"y\",\"2024-04\":\"y\"},\"ios\":{\"2020-02\":\"y\",\"2024-04\":\"y\"},\"android\":{\"2020-02\":\"y\",\"2024-04\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-unit-pt\",\n\t\t\t\"title\":\"pt unit\",\n\t\t\t\"description\":\"Support for points unit\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-unit-pt/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"unit, pt\",\n\t\t\t\"last_test_date\":\"2020-02-25\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-units.html\",\n\t\t\t\"test_results_url\":\"\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2020-02\":\"y\"},\"macos\":{\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"samsung-email\":{\"android\":{\"9.0\":\"y\"}},\"thunderbird\":{\"windows\":{\"2020-02\":\"y\"},\"macos\":{\"68.4\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-02\":\"y\",\"2024-04\":\"y\"},\"ios\":{\"2020-02\":\"y\",\"2024-04\":\"y\"},\"android\":{\"2020-02\":\"y\",\"2024-04\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-unit-px\",\n\t\t\t\"title\":\"px unit\",\n\t\t\t\"description\":\"Support for pixels unit\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-unit-px/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"unit, px\",\n\t\t\t\"last_test_date\":\"2020-02-25\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-units.html\",\n\t\t\t\"test_results_url\":\"\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2020-02\":\"y\"},\"macos\":{\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"samsung-email\":{\"android\":{\"9.0\":\"y\"}},\"thunderbird\":{\"windows\":{\"2020-02\":\"y\"},\"macos\":{\"68.4\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-02\":\"y\",\"2021-03\":\"y\",\"2024-04\":\"y\"},\"ios\":{\"2020-02\":\"y\",\"2024-04\":\"y\"},\"android\":{\"2020-02\":\"y\",\"2024-04\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-unit-rem\",\n\t\t\t\"title\":\"rem unit\",\n\t\t\t\"description\":\"Support for rem unit, relative to the root font-size.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-unit-rem/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"unit, rem\",\n\t\t\t\"last_test_date\":\"2020-02-25\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-units.html\",\n\t\t\t\"test_results_url\":\"\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-02\":\"n\"},\"macos\":{\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"samsung-email\":{\"android\":{\"9.0\":\"y\"}},\"thunderbird\":{\"windows\":{\"2020-02\":\"y\"},\"macos\":{\"68.4\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-02\":\"n\"},\"ios\":{\"2020-02\":\"n\"},\"android\":{\"2020-02\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-02\":\"n\"},\"ios\":{\"2020-02\":\"n\"},\"android\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2020-02\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2020-02\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2020-02\":\"y\",\"2024-04\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-unit-vh\",\n\t\t\t\"title\":\"vh unit\",\n\t\t\t\"description\":\"Support for viewport height unit.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-unit-vh/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"unit, vh\",\n\t\t\t\"last_test_date\":\"2023-10-01\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-units.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/AhzTJnsoWULAInwe2B8h7uzlsa6vGOgAkVK1VA6BbuKaW/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\",\"15\":\"a #2\",\"17\":\"a #2\"}},\"gmail\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-02\":\"n\"},\"macos\":{\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"samsung-email\":{\"android\":{\"9.0\":\"y\"}},\"thunderbird\":{\"windows\":{\"2020-02\":\"y\"},\"macos\":{\"68.4\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-02\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2020-02\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2020-02\":\"y\",\"2024-04\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"a #2\"}},\"web-de\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"a #2\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-08\":\"y #1\"},\"android\":{\"2022-08\":\"y\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. Can affect the preview window size, meaning content can get lost.\",\"2\":\"Buggy. Value resolves to zero\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-unit-vmax\",\n\t\t\t\"title\":\"vmax unit\",\n\t\t\t\"description\":\"Support for viewport maximum unit.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-unit-vmax/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"unit, vmax\",\n\t\t\t\"last_test_date\":\"2020-02-25\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-units.html\",\n\t\t\t\"test_results_url\":\"\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-02\":\"n\"},\"macos\":{\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"samsung-email\":{\"android\":{\"9.0\":\"y\"}},\"thunderbird\":{\"windows\":{\"2020-02\":\"y\"},\"macos\":{\"68.4\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-02\":\"n\"},\"ios\":{\"2020-02\":\"n\"},\"android\":{\"2020-02\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-02\":\"n\"},\"ios\":{\"2020-02\":\"n\"},\"android\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2020-02\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2020-02\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2020-02\":\"y\",\"2024-04\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"a #2\"}},\"web-de\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"a #2\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-08\":\"y #1\"},\"android\":{\"2022-08\":\"y\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. VH values can affect the preview window size, meaning content can get lost.\",\"2\":\"Buggy. VH values resolves to zero\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-unit-vmin\",\n\t\t\t\"title\":\"vmin unit\",\n\t\t\t\"description\":\"Support for viewport minimum unit.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-unit-vmin/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"unit, vmin\",\n\t\t\t\"last_test_date\":\"2020-02-25\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-units.html\",\n\t\t\t\"test_results_url\":\"\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-02\":\"n\"},\"macos\":{\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"samsung-email\":{\"android\":{\"9.0\":\"y\"}},\"thunderbird\":{\"windows\":{\"2020-02\":\"y\"},\"macos\":{\"68.4\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-02\":\"n\"},\"ios\":{\"2020-02\":\"n\"},\"android\":{\"2020-02\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-02\":\"n\"},\"ios\":{\"2020-02\":\"n\"},\"android\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2020-02\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2020-02\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2020-02\":\"y\",\"2024-04\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"a #2\"}},\"web-de\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"a #2\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-08\":\"y #1\"},\"android\":{\"2022-08\":\"y\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. VH values can affect the preview window size, meaning content can get lost.\",\"2\":\"Buggy. VH values resolves to zero\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-unit-vw\",\n\t\t\t\"title\":\"vw unit\",\n\t\t\t\"description\":\"Support for viewport width unit.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-unit-vw/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"unit, vw\",\n\t\t\t\"last_test_date\":\"2020-02-25\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-units.html\",\n\t\t\t\"test_results_url\":\"\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-02\":\"y #1\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-02\":\"n\"},\"macos\":{\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-02\":\"y #1\",\"2024-01\":\"y #1\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"samsung-email\":{\"android\":{\"9.0\":\"y\"}},\"thunderbird\":{\"windows\":{\"2020-02\":\"y\"},\"macos\":{\"68.4\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-02\":\"y #1\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-02\":\"y #1\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-02\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2020-02\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2020-02\":\"y\",\"2024-04\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y #1\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-08\":\"n\"},\"ios\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-08\":\"y\"},\"android\":{\"2022-08\":\"y\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{\"1\":\"The HTML of the email message is embedded directly on the webmail (not in an <iframe>) and may not fill the full viewport's width. In this case, the vw values are relevant to the viewport (browser window) not the email message.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-user-select\",\n\t\t\t\"title\":\"user-select\",\n\t\t\t\"description\":\"Controls whether the user can select text.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-user-select/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"select, copy\",\n\t\t\t\"last_test_date\":\"2024-02-14\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-user-select.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/9zjptajgcxyzc74ockp\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2024-02\":\"y #1\"},\"ios\":{\"2024-02\":\"a #1 #2\"}},\"gmail\":{\"desktop-webmail\":{\"2024-02\":\"n #3\"},\"ios\":{\"2024-02\":\"n #3\"},\"android\":{\"2024-02\":\"n\"},\"mobile-webmail\":{\"2024-02\":\"u\"}},\"orange\":{\"desktop-webmail\":{\"2024-02\":\"u\"},\"ios\":{\"2024-02\":\"u\"},\"android\":{\"2024-02\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2024-02\":\"n\"},\"macos\":{\"2024-02\":\"n #3\"},\"outlook-com\":{\"2024-02\":\"n #3\"},\"ios\":{\"2024-02\":\"n #3\"},\"android\":{\"2024-02\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2024-02\":\"n #3\"},\"ios\":{\"2024-02\":\"n\"},\"android\":{\"2024-02\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2024-02\":\"n #3\"},\"ios\":{\"2024-02\":\"n\"},\"android\":{\"2024-02\":\"n\"}},\"samsung-email\":{\"android\":{\"2024-02\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2024-02\":\"u\"},\"ios\":{\"2024-02\":\"u\"},\"android\":{\"2024-02\":\"u\"}},\"thunderbird\":{\"macos\":{\"2024-02\":\"u\"}},\"protonmail\":{\"desktop-webmail\":{\"2024-02\":\"a #2 #4\"},\"ios\":{\"2024-02\":\"a #2 #4\"},\"android\":{\"2024-02\":\"a #2 #4\"}},\"hey\":{\"desktop-webmail\":{\"2024-02\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-02\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2024-02\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2024-02\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2024-02\":\"n #3\"},\"ios\":{\"2024-02\":\"u\"},\"android\":{\"2024-02\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2024-02\":\"n #3\"},\"ios\":{\"2024-02\":\"n\"},\"android\":{\"2024-02\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2024-02\":\"u\"},\"android\":{\"2024-02\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Works with `-webkit` prefix\",\"2\":\"`all` value does not work\",\"3\":\"Property is stripped from style tag\",\"4\":\"`none` value does not work, client allow to select text anyway\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-variables\",\n\t\t\t\"title\":\"CSS Variables (Custom Properties)\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-variables/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2020-02-25\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-variables.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/3viCexhHsrjaP9YS8RwzNwikbf4C0akxPFhK8xgyltxpe/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.3\":\"y\"},\"ios\":{\"10.3\":\"y\",\"12.2\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-08\":\"n #1\"},\"ios\":{\"2019-08\":\"n #1 #2\"},\"android\":{\"2019-08\":\"n #1 #2\"},\"mobile-webmail\":{\"2020-02\":\"n #1\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2019-08\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2019-08\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-01\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2019-08\":\"n\",\"2020-02\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2019-08\":\"n\",\"2020-02\":\"n\"},\"android\":{\"2019-08\":\"n\",\"2020-02\":\"n\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\",\"9.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"thunderbird\":{\"windows\":{\"2020-02\":\"y\"},\"macos\":{\"60.3\":\"y\",\"68.4\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-01\":\"n\"},\"ios\":{\"2020-01\":\"n\"},\"android\":{\"2020-01\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-08\":\"n\",\"2020-02\":\"n\"},\"ios\":{\"2019-08\":\"n\",\"2020-02\":\"n\"},\"android\":{\"2019-08\":\"n\",\"2020-02\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n #1\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n #1\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. The `var()` function is supported, but not the variable declaration.\",\"2\":\"Partial. Not supported with non Google accounts.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-vertical-align\",\n\t\t\t\"title\":\"vertical-align\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-vertical-align/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2020-12-21\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-vertical-align-html-valign.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/XDUBIjG7AOXLUwfUUDYDO68OO1POjklmaeeqkOeSylkJL/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-12\":\"y\"},\"ios\":{\"2020-12\":\"y\"},\"android\":{\"2020-12\":\"y\"},\"mobile-webmail\":{\"2020-12\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-12\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-12\":\"y\"},\"android\":{\"2020-12\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2020-12\":\"y\"},\"macos\":{\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-12\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-12\":\"y\"},\"android\":{\"2020-12\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-12\":\"y\"},\"ios\":{\"2020-12\":\"y\"},\"android\":{\"2020-12\":\"y\"}},\"thunderbird\":{\"macos\":{\"78.6\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-12\":\"y\"},\"ios\":{\"2020-12\":\"y\"},\"android\":{\"2020-12\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-12\":\"y\"},\"ios\":{\"2020-12\":\"y\"},\"android\":{\"2020-12\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-12\":\"y\"},\"ios\":{\"2020-12\":\"y\"},\"android\":{\"2020-12\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-12\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-12\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-visibility\",\n\t\t\t\"title\":\"visibility\",\n\t\t\t\"description\":\"Tested for values `hidden` and `collapse`.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-visibility/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-visual-effects.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/DhTRmGsVH6uobU4pHD3CasJywfBL4HnEjA1LOF8f9ctso/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y #1\"},\"ios\":{\"12.1\":\"y #1\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"a #2\",\"2024-04\":\"n\"},\"ios\":{\"2019-08\":\"a #1\",\"2024-04\":\"n\"},\"android\":{\"2019-08\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y #1\",\"16.80\":\"y #1\"},\"outlook-com\":{\"2019-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-02\":\"y #1\"},\"android\":{\"2019-02\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.8\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y #1\"},\"android\":{\"2019-02\":\"n\",\"2025-06\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"y #1\"},\"android\":{\"2019-02\":\"y\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y #1\"},\"android\":{\"2019-08\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y #1\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. `visibility:collapse` applied to a `<tr>` only hides content and does not \\\"remove\\\" it from layout.\",\"2\":\"Partially supported. `visibility:collapse` is not supported.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-white-space-collapse\",\n\t\t\t\"title\":\"white-space-collapse\",\n\t\t\t\"description\":\"Controls how white space inside an element is collapsed.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-white-space-collapse/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"break, space, collapse, hide\",\n\t\t\t\"last_test_date\":\"2024-09-04\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-white-space-collapse.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/e6y4s3zytp5kty7kcg\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10\":\"n\",\"11\":\"n\",\"12\":\"n\",\"13\":\"n\",\"14\":\"y #1\"},\"ios\":{\"15\":\"y #1\"}},\"gmail\":{\"desktop-webmail\":{\"2024-09\":\"n\"},\"ios\":{\"2024-09\":\"n\"},\"android\":{\"2024-09\":\"n\"},\"mobile-webmail\":{\"2024-09\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2024-09\":\"u\"},\"ios\":{\"2024-09\":\"u\"},\"android\":{\"2024-09\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2024-09\":\"n\"},\"macos\":{\"2024-09\":\"n\"},\"outlook-com\":{\"2024-09\":\"n\"},\"ios\":{\"2024-09\":\"n\"},\"android\":{\"2024-09\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2024-09\":\"n\"},\"ios\":{\"2024-09\":\"n\"},\"android\":{\"2024-09\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2024-09\":\"n\"},\"ios\":{\"2024-09\":\"n\"},\"android\":{\"2024-09\":\"n\"}},\"samsung-email\":{\"android\":{\"2024-09\":\"y #1\"}},\"sfr\":{\"desktop-webmail\":{\"2024-03\":\"u\"},\"ios\":{\"2024-03\":\"u\"},\"android\":{\"2024-03\":\"u\"}},\"protonmail\":{\"desktop-webmail\":{\"2024-09\":\"y\"},\"ios\":{\"2024-09\":\"y\"},\"android\":{\"2024-09\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2024-09\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-09\":\"y #1\"}},\"fastmail\":{\"desktop-webmail\":{\"2024-09\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. `preserve-spaces` value works only on Firefox.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-white-space\",\n\t\t\t\"title\":\"white-space\",\n\t\t\t\"description\":\"Tested with the values `nowrap` and `pre`.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-white-space/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-text.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/DkqbHs69ek5UnK6uhZ7Uj0n5GVQNTP4Z1FvgXvnKyEoTM/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"a #1 #2\"},\"android\":{\"2019-02\":\"a #1 #2\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-01\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y #2\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"y #2\",\"2024-01\":\"y\"},\"ios\":{\"2019-02\":\"y #2\"},\"android\":{\"2019-02\":\"y #2\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"a #2\"},\"android\":{\"2019-02\":\"a #2\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"a #2\"}},\"thunderbird\":{\"macos\":{\"68.4\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"a #2\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"a #2\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"a #2\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\",\"2\":\"Partial. `pre` value is not supported.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-widows\",\n\t\t\t\"title\":\"widows\",\n\t\t\t\"description\":\"Sets the minimum number of lines in a block container split on a new page, region or column.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-widows/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"columns\",\n\t\t\t\"last_test_date\":\"2024-05-03\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-widows.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/6vd8udzx1b5l1vrnsr\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2024-05\":\"y\"},\"ios\":{\"2024-05\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2024-05\":\"n #4\"},\"ios\":{\"2024-05\":\"n\"},\"android\":{\"2024-05\":\"n #3\"},\"mobile-webmail\":{\"2024-05\":\"n #1 #2\"}},\"orange\":{\"desktop-webmail\":{\"2024-05\":\"u\"},\"ios\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2024-05\":\"n\"},\"macos\":{\"2024-05\":\"y\"},\"outlook-com\":{\"2024-05\":\"y\"},\"ios\":{\"2024-05\":\"y\"},\"android\":{\"2024-05\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2024-05\":\"n\"},\"ios\":{\"2024-05\":\"n\"},\"android\":{\"2024-05\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2024-05\":\"n\"},\"ios\":{\"2024-05\":\"n\"},\"android\":{\"2024-05\":\"n\"}},\"samsung-email\":{\"android\":{\"2024-05\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2024-05\":\"u\"},\"ios\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}},\"protonmail\":{\"desktop-webmail\":{\"2024-05\":\"y #4\"},\"ios\":{\"2024-05\":\"y #4\"},\"android\":{\"2024-05\":\"y #4\"}},\"hey\":{\"desktop-webmail\":{\"2024-05\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-05\":\"y #4\"}},\"fastmail\":{\"desktop-webmail\":{\"2024-05\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2024-05\":\"n #1 #4\"},\"ios\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2024-05\":\"n #1 #4\"},\"ios\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. `columns` property is stripped which is required for `widows` to work\",\"2\":\"Buggy. `widows` property value is replaced by `auto`\",\"3\":\"Not supported, but Gmail's default styles on the email message container includes the `widows` property. These values are inherited by children elements\",\"4\":\"Webmail rendering depends on browser support\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-width\",\n\t\t\t\"title\":\"width property\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-width/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-09-27\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-width-height.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/N3bgM8CXDd1TNWZzO65F0RkiJwugaAuNYr8mvcYt1C3Da/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2019-09\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"a #1\",\"2010\":\"a #1\",\"2013\":\"a #1\",\"2016\":\"a #1\",\"2019\":\"a #1\"},\"windows-mail\":{\"2019-09\":\"y\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-09\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"a #2\"}},\"gmx\":{\"desktop-webmail\":{\"2021-12\":\"y\"},\"ios\":{\"2021-12\":\"y\"},\"android\":{\"2021-12\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2021-12\":\"y\"},\"ios\":{\"2021-12\":\"y\"},\"android\":{\"2021-12\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2021-12\":\"y\"},\"android\":{\"2021-12\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported on `<body>`, `<span>`, `<div>`, `<p>` or `<img>` elements.\",\"2\":\"Buggy. The webmail has a generic style that sets `table { width:inherit; }`.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-word-break\",\n\t\t\t\"title\":\"word-break\",\n\t\t\t\"description\":\"Prevents or allows words to be broken over multiple lines.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-word-break/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"a, b, c\",\n\t\t\t\"last_test_date\":\"2022-02-15\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-word-break.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/9NBw8gAf2zJZOD57zwPqHjhsp7cfEOJLANCTkq3OFbZNR/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"15\":\"a #2\"},\"ios\":{\"15\":\"a #2\"}},\"gmail\":{\"desktop-webmail\":{\"2022-02\":\"y #1\"},\"ios\":{\"2022-02\":\"a #2\"},\"android\":{\"2022-02\":\"a #2\"},\"mobile-webmail\":{\"2022-02\":\"a #2\"}},\"orange\":{\"desktop-webmail\":{\"2022-02\":\"a #3\"},\"ios\":{\"2022-02\":\"n\"},\"android\":{\"2022-02\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"u\",\"2010\":\"u\",\"2013\":\"u\",\"2016\":\"u\",\"2019\":\"y\"},\"windows-mail\":{\"2022-02\":\"n\"},\"macos\":{\"16.59\":\"y\",\"16.80\":\"a #2\"},\"outlook-com\":{\"2022-02\":\"a #2\",\"2024-01\":\"a #2\"},\"ios\":{\"2022-02\":\"a #2\"},\"android\":{\"2022-02\":\"a #2\"}},\"samsung-email\":{\"android\":{\"6.0\":\"a #2\"}},\"sfr\":{\"desktop-webmail\":{\"2022-02\":\"y\"},\"ios\":{\"2022-02\":\"y\"},\"android\":{\"2022-02\":\"y\"}},\"thunderbird\":{\"macos\":{\"78.14\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2022-02\":\"n\"},\"ios\":{\"2022-02\":\"n\"},\"android\":{\"2022-02\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-02\":\"n\"},\"ios\":{\"2022-02\":\"n\"},\"android\":{\"2022-02\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-02\":\"y\"},\"ios\":{\"2022-02\":\"a #2\"},\"android\":{\"2022-02\":\"a #2\"}},\"hey\":{\"desktop-webmail\":{\"2022-02\":\"a #4\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-02\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2022-02\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2022-02\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Supported. But Gmail adds `<wbr>` every 30 characters.\",\"2\":\"Buggy. Supported but a `word-wrap:break-word` is applied, making it look like `break-all`.\",\"3\":\"Partially supported. Only `word-break:break-all` works.\",\"4\":\"Buggy. Every value is replaced by `break-word`.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-word-spacing\",\n\t\t\t\"title\":\"word-spacing\",\n\t\t\t\"description\":\"Sets the length of space between words and between tags.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-word-spacing/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"word\",\n\t\t\t\"last_test_date\":\"2024-03-13\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-word-spacing.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/8r8pfrlvcxklca32ij\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2024-03\":\"y\"},\"ios\":{\"2024-03\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2024-03\":\"y\"},\"ios\":{\"2024-03\":\"y\"},\"android\":{\"2024-03\":\"y\"},\"mobile-webmail\":{\"2024-03\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2024-03\":\"u\"},\"ios\":{\"2024-03\":\"u\"},\"android\":{\"2024-03\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"2021\":\"n\"},\"windows-mail\":{\"2024-03\":\"n\"},\"macos\":{\"2024-03\":\"y\"},\"outlook-com\":{\"2024-03\":\"y\"},\"ios\":{\"2024-03\":\"y\"},\"android\":{\"2024-03\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2024-03\":\"a #1\"},\"ios\":{\"2024-03\":\"a #1\"},\"android\":{\"2024-03\":\"n\",\"2025-06\":\"a #1\"}},\"aol\":{\"desktop-webmail\":{\"2024-03\":\"a #1\"},\"ios\":{\"2024-03\":\"a #1\"},\"android\":{\"2024-03\":\"n\"}},\"samsung-email\":{\"android\":{\"2024-03\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2024-03\":\"u\"},\"ios\":{\"2024-03\":\"u\"},\"android\":{\"2024-03\":\"u\"}},\"thunderbird\":{\"macos\":{\"2024-03\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2024-03\":\"y\"},\"ios\":{\"2024-03\":\"y\"},\"android\":{\"2024-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2024-03\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-03\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2024-03\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2024-03\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2024-03\":\"a #1\"},\"ios\":{\"2024-03\":\"u\"},\"android\":{\"2024-03\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2024-03\":\"a #1\"},\"ios\":{\"2024-03\":\"u\"},\"android\":{\"2024-03\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2024-03\":\"u\"},\"android\":{\"2024-03\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Does not support `rem` and `ch` values.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-word-wrap\",\n\t\t\t\"title\":\"word-wrap\",\n\t\t\t\"description\":\"The word-wrap CSS property (now standardized as overflow-wrap) controls whether the browser should break words to prevent them from overflowing their container. When set to break-word, it forces otherwise unbreakable strings to wrap onto the next line to avoid layout issues.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-word-wrap/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"word-wrap, word-break, overflow-wrap\",\n\t\t\t\"last_test_date\":\"2025-05-08\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-word-wrap.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/vCaPUwbCjmYsdRJi38QdpqJSetV5X7wBQh5iymp8t1Sty/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"16\":\"y\"},\"ios\":{\"16\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2025-05\":\"a #1\"},\"android\":{\"2025-05\":\"a #1\"}},\"outlook\":{\"windows\":{\"2016\":\"n #2\",\"2019\":\"n #2\",\"2021\":\"n #2\"},\"windows-mail\":{\"2025-05\":\"n\"},\"outlook-com\":{\"2022-05\":\"y #3\"},\"ios\":{\"2025-05\":\"y #3\"},\"android\":{\"2025-05\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2025-05\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Applies `word-wrap: break-word` to all items.\",\"2\":\"Does not honor any wrapping, including `word-wrap`, `overflow-wrap`, and `word-break`. Using an equivalent MSO breakdown (`-ms-word-break: break-all` and `mso-hyphenate: none` together) does not work.\",\"3\":\"Supports both `word-wrap` and equivalent MSO breakdown (`-ms-word-break: break-all` and `mso-hyphenate: none` together)\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-writing-mode\",\n\t\t\t\"title\":\"writing-mode\",\n\t\t\t\"description\":\"Sets whether lines of text are laid out horizontally or vertically.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-writing-mode/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2022-05-04\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-writing-mode.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/Ingjv4scPnWSgh0u0Fr7EctmGksq4DyF7Pw9PQcENfZ37/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\",\"15\":\"y\"},\"ios\":{\"14\":\"y\",\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2022-05\":\"y\"},\"ios\":{\"2022-05\":\"a #1\"},\"android\":{\"2022-05\":\"a #1\"},\"mobile-webmail\":{\"2022-05\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2022-05\":\"n\"},\"ios\":{\"2022-05\":\"n\"},\"android\":{\"2022-05\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2022-05\":\"n\"},\"macos\":{\"16.62\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2022-05\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2022-05\":\"y\"},\"android\":{\"2022-05\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2022-05\":\"y\"},\"ios\":{\"2022-05\":\"y\"},\"android\":{\"2022-05\":\"y\"}},\"thunderbird\":{\"macos\":{\"91.5.0\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2022-05\":\"n\"},\"ios\":{\"2022-05\":\"n\"},\"android\":{\"2022-05\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2022-05\":\"n\"},\"ios\":{\"2022-05\":\"n\"},\"android\":{\"2022-05\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2022-05\":\"y\"},\"ios\":{\"2022-05\":\"y\"},\"android\":{\"2022-05\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2022-05\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2022-05\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2022-05\":\"n\"}},\"laposte\":{\"desktop-webmail\":{\"2022-05\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-05\":\"n\",\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"css-z-index\",\n\t\t\t\"title\":\"z-index\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/css-z-index/\",\n\t\t\t\"category\":\"css\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"absolute, sticky, fixed, relative, position\",\n\t\t\t\"last_test_date\":\"2021-05-16\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-positioning.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/E45AW3a9IiIhUSBpv3dc1qPfMiMN8mLepy5BsvqtpXhhy/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"14\":\"y\"},\"ios\":{\"14.5\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-05\":\"n\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"},\"mobile-webmail\":{\"2021-05\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2021-05\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2021-05\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"y\"}},\"thunderbird\":{\"macos\":{\"78.10\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-05\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-05\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-abbr\",\n\t\t\t\"title\":\"<abbr> element\",\n\t\t\t\"description\":\"Represents an abbreviation or acronym.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-abbr/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[\"accessibility\"],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2023-09-13\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-abbr.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/xlp4una8f926u48oco8\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2023-09\":\"y\"},\"ios\":{\"2023-09\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2023-09\":\"y\"},\"ios\":{\"2023-09\":\"y\"},\"android\":{\"2023-09\":\"y\"},\"mobile-webmail\":{\"2023-09\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2023-10\":\"n\"},\"ios\":{\"2023-10\":\"n\"},\"android\":{\"2023-09\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n #1\",\"2016\":\"n #1\",\"2019\":\"n #1\",\"2021\":\"n #1\"},\"windows-mail\":{\"2023-09\":\"n #1\"},\"macos\":{\"2023-09\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2023-09\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2023-09\":\"y\"},\"android\":{\"2023-09\":\"y\"}},\"samsung-email\":{\"android\":{\"2023-09\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2023-10\":\"y\"},\"ios\":{\"2023-10\":\"y\"},\"android\":{\"2023-09\":\"u\"}},\"thunderbird\":{\"macos\":{\"2023-09\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2023-09\":\"n\"},\"ios\":{\"2023-09\":\"n\"},\"android\":{\"2023-09\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2023-09\":\"n\"},\"ios\":{\"2023-09\":\"n\"},\"android\":{\"2023-09\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2023-10\":\"y\"},\"ios\":{\"2023-10\":\"y\"},\"android\":{\"2023-09\":\"u\"}},\"hey\":{\"desktop-webmail\":{\"2023-10\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2023-09\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2023-10\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2023-10\":\"y\"}},\"free-fr\":{\"desktop-webmail\":{\"2023-10\":\"y\"}},\"t-online-de\":{\"desktop-webmail\":{\"2023-09\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2023-09\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2023-09\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. Element's content are still kept.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-acronym\",\n\t\t\t\"title\":\"<acronym> element\",\n\t\t\t\"description\":\"Represents an abbreviation or acronym.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-acronym/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2024-01-03\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-acronym.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/ayebhgpxu58yce2bhd\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2024-01\":\"y\"},\"ios\":{\"2024-01\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2024-01\":\"y\"},\"ios\":{\"2024-01\":\"y\"},\"android\":{\"2024-01\":\"y\"},\"mobile-webmail\":{\"2024-01\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2024-01\":\"u\"},\"ios\":{\"2024-01\":\"u\"},\"android\":{\"2024-01\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"n #1\",\"2016\":\"n #1\",\"2019\":\"n #1\",\"2021\":\"n #1\"},\"windows-mail\":{\"2024-01\":\"n #1\"},\"macos\":{\"2024-01\":\"y\"},\"outlook-com\":{\"2024-01\":\"y\"},\"ios\":{\"2024-01\":\"y\"},\"android\":{\"2024-01\":\"y\"}},\"samsung-email\":{\"android\":{\"2024-01\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2024-03\":\"y\"},\"ios\":{\"2024-03\":\"y\"},\"android\":{\"2024-03\":\"y\"}},\"thunderbird\":{\"macos\":{\"2024-01\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2024-01\":\"n\"},\"ios\":{\"2024-01\":\"n\"},\"android\":{\"2024-01\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2024-01\":\"n\"},\"ios\":{\"2024-01\":\"n\"},\"android\":{\"2024-01\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2024-01\":\"y\"},\"ios\":{\"2024-01\":\"y\"},\"android\":{\"2024-01\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2024-01\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-01\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2024-01\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2024-01\":\"u\"}},\"free-fr\":{\"desktop-webmail\":{\"2024-01\":\"u\"}},\"t-online-de\":{\"desktop-webmail\":{\"2024-01\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2024-01\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2024-01\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. `title` attribute is removed but keeps `<acronym>` tag.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-address\",\n\t\t\t\"title\":\"address\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-address/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"address\",\n\t\t\t\"last_test_date\":\"2021-01-17\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-semantics.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/uAHFDBWoBklNKrU9rSxERL50IkROvrLzZwLsnNWV5V2tn/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"11\":\"y\",\"12\":\"y\",\"13\":\"y\"},\"ios\":{\"11\":\"y\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-12\":\"y\"},\"ios\":{\"2020-12\":\"y\"},\"android\":{\"2020-12\":\"y\"},\"mobile-webmail\":{\"2020-12\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-12\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2020-12\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2020-12\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2020-12\":\"y\"},\"macos\":{\"2020-12\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-12\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-12\":\"y\"},\"android\":{\"4.2048.4\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-12\":\"y\"},\"ios\":{\"2020-12\":\"y\"},\"android\":{\"6.16.2.1519779\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-12\":\"y\"},\"ios\":{\"2020-12\":\"y\"},\"android\":{\"2020-12\":\"y\"}},\"samsung-email\":{\"android\":{\"6.1.31.\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-12\":\"y\"},\"ios\":{\"2020-12\":\"y\"},\"android\":{\"2020-12\":\"y\"}},\"thunderbird\":{\"macos\":{\"78.5\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-12\":\"y\"},\"ios\":{\"2020-12\":\"y\"},\"android\":{\"2020-12\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-12\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-12\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n #1\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n #1\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"n #1\"},\"android\":{\"2022-11\":\"n #1\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Unsupported opening and closing tags are stripped.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-align\",\n\t\t\t\"title\":\"align attribute\",\n\t\t\t\"description\":\"This deprecated HTML attribute indicates how an element can be aligned, either horizontally (`left`, `center` or `right`) or vertically for elements like images (`top`, `middle`, `bottom`).\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-align/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"left, center, right\",\n\t\t\t\"last_test_date\":\"2021-11-10\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-align.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/00FrYZOGg0JvRT2Q0A5jI1jJFZ1cOzDvhWFMdE5883gj3/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"15\":\"y\"},\"ios\":{\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"},\"mobile-webmail\":{\"2021-11\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2021-11\":\"y\"},\"macos\":{\"16.56\":\"y\",\"16.80\":\"a #3\"},\"outlook-com\":{\"2021-11\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"}},\"thunderbird\":{\"macos\":{\"78.14\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-11\":\"a #1\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-11\":\"a #2\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-11\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-11\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-11\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. `<img>` elements are wrapped in a `<span>` so `left` and `right` values have no effect.\",\"2\":\"Partial. Not supported on `<img>` elements.\",\"3\":\"Partial. `left` and `right` do not work on `<table>` elements. Use `float:left` or `float:right` styles instead.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-anchor-links\",\n\t\t\t\"title\":\"Local anchors\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-anchor-links/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"hash, fragment identifiers, URLs, URIs, document fragments, local, named, anchor, anchors, links\",\n\t\t\t\"last_test_date\":\"2019-08-08\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-anchor-links.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/lvP3Vdg0qtue1RAuGTjzEXl19nfCJu3TVV4lLdzwdqQk5/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y #6\"},\"ios\":{\"12.4\":\"n #3\",\"15.0\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-08\":\"y #7\"},\"ios\":{\"2019-08\":\"n #3\"},\"android\":{\"2019-08\":\"y #6\"},\"mobile-webmail\":{\"2020-02\":\"y #7\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"a #1\",\"2021-03\":\"n\",\"2024-04\":\"n #8\"},\"ios\":{\"2019-08\":\"n #3\",\"2024-04\":\"n\"},\"android\":{\"2019-08\":\"y\",\"2024-04\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"u\",\"2007\":\"y #7\",\"2010\":\"y #7\",\"2013\":\"y #7\",\"2016\":\"y #7\",\"2019\":\"y #7\"},\"windows-mail\":{\"2020-01\":\"y #7\"},\"macos\":{\"2019\":\"n\",\"2023\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-08\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-08\":\"n #3\"},\"android\":{\"2019-08\":\"n #3\"}},\"samsung-email\":{\"android\":{\"6.0\":\"n #3\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"n #2\"},\"ios\":{\"2019-08\":\"n #4\"},\"android\":{\"2019-08\":\"n #3\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-01\":\"a #7\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-08\":\"a #7\"},\"ios\":{\"2019-08\":\"n #5\"},\"android\":{\"2019-08\":\"n #3\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y #7 #9\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. `target=_blank` is added on links so anchors open in a new window.\",\"2\":\"Buggy. Anchor links go back to the homepage of the webmail because it also uses anchor links for navigation.\",\"3\":\"Buggy. Clicking an anchor link does nothing.\",\"4\":\"Buggy. Opens a new browser window with the anchor as a URL.\",\"5\":\"Buggy. Opens a new in-app browser window on yahoo.com with the anchor appended to the URL.\",\"6\":\"Buggy. Targeted content is partially hidden by the application UI on top.\",\"7\":\"Partial. Works when targeting an empty anchor with the corresponding `name` attribute, but not with `id` attributes.\",\"8\":\"Not supported. Opens a new window with the same email.\",\"9\":\"The `name` and `href` attributes are prefixed by a specific `mailruanchor_` prefix.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-aria-describedby\",\n\t\t\t\"title\":\"aria-describedby attribute\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-aria-describedby/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[\"accessibility\"],\n\t\t\t\"keywords\":\"accessibility, a11y, wai-aria\",\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-ARIA.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/7YEJc8itgQA23oSz9yaDB2rg6MHiItQgtKbflcOSjNx48/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"n #1\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n #1\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2020-01\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2020-01\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-01\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"n\",\"2024-01\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.8\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n #1\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n #1\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. `id` prefix mismatched. The `id` value is prefixed but not the `aria-describedby` value.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-aria-hidden\",\n\t\t\t\"title\":\"aria-hidden attribute\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-aria-hidden/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[\"accessibility\"],\n\t\t\t\"keywords\":\"accessibility, a11y, wai-aria\",\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-ARIA.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/7YEJc8itgQA23oSz9yaDB2rg6MHiItQgtKbflcOSjNx48/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2020-01\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2020-01\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2020-01\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.8\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-aria-label\",\n\t\t\t\"title\":\"aria-label attribute\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-aria-label/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[\"accessibility\"],\n\t\t\t\"keywords\":\"accessibility, a11y, wai-aria\",\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-ARIA.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/7YEJc8itgQA23oSz9yaDB2rg6MHiItQgtKbflcOSjNx48/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2020-01\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2020-01\":\"y\",\"2024-04\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.8\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2020-01\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-aria-labelledby\",\n\t\t\t\"title\":\"aria-labelledby attribute\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-aria-labelledby/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[\"accessibility\"],\n\t\t\t\"keywords\":\"accessibility, a11y, wai-aria\",\n\t\t\t\"last_test_date\":\"2022-04-08\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-ARIA.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/zxzI0MZSJwpF46bHbO6C1gOiVn\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"n\",\"2022-04\":\"n #2\"},\"ios\":{\"2020-01\":\"y\",\"2022-04\":\"n #2\"},\"android\":{\"2020-01\":\"y\",\"2022-04\":\"n #2\"},\"mobile-webmail\":{\"2020-02\":\"y\",\"2022-04\":\"n #1\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2020-01\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2020-01\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n #1\",\"2010\":\"n #1\",\"2013\":\"n #1\",\"2016\":\"n #1\",\"2019\":\"n #1\"},\"windows-mail\":{\"2020-01\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"2024-01\":\"y\"},\"outlook-com\":{\"2019-02\":\"n #2\",\"2024-02\":\"n #2\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.8\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n #2\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. `id` is removed.\",\"2\":\"Buggy. `id` prefix mismatched. The `id` value is prefixed but not the `aria-labelledby` value.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-aria-live\",\n\t\t\t\"title\":\"aria-live attribute\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-aria-live/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[\"accessibility\"],\n\t\t\t\"keywords\":\"accessibility, a11y, wai-aria\",\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-ARIA.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/7YEJc8itgQA23oSz9yaDB2rg6MHiItQgtKbflcOSjNx48/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2019-02\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2020-01\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2020-01\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"a #2\",\"2010\":\"a #2\",\"2013\":\"a #2\",\"2016\":\"a #2\",\"2019\":\"a #2\"},\"windows-mail\":{\"2020-01\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2019-02\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.8\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Unable to trigger live change to test.\",\"2\":\"Code isn't stripped but can't trigger a live change.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-audio\",\n\t\t\t\"title\":\"<audio> element\",\n\t\t\t\"description\":\"This is the description of the `<audio>` element.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-audio/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"audio\",\n\t\t\t\"last_test_date\":\"2020-04-21\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-audio.html\",\n\t\t\t\"test_results_url\":null,\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.15\":\"y\"},\"ios\":{\"11.3.1\":\"y\",\"12\":\"y\",\"13.1\":\"y\",\"13.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-04\":\"n\"},\"ios\":{\"2020-04\":\"n\"},\"android\":{\"2020-04\":\"n\"},\"mobile-webmail\":{\"2020-04\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2020-04\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2020-04\":\"n #3\",\"2024-04\":\"n\"},\"android\":{\"2020-04\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-04\":\"n\"},\"macos\":{\"2016\":\"y #1\",\"2019\":\"n\",\"16.80\":\"n\"},\"outlook-com\":{\"2020-04\":\"n\",\"2021-05\":\"n #4\",\"2024-01\":\"n #4\"},\"ios\":{\"2020-04\":\"n\",\"2021-05\":\"n #4\"},\"android\":{\"2020-04\":\"n\",\"2021-05\":\"n #4\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\",\"7.0\":\"y\",\"9.0\":\"y\",\"10.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-04\":\"y\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"thunderbird\":{\"macos\":{\"68.7\":\"y\"},\"windows\":{\"60\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-04\":\"n\"},\"ios\":{\"2020-04\":\"n\"},\"android\":{\"2020-04\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-04\":\"n\"},\"ios\":{\"2020-04\":\"n\"},\"android\":{\"2020-04\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-04\":\"n #2\"},\"ios\":{\"2020-04\":\"n\"},\"android\":{\"2020-04\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n #5\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"n #6\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"n #3\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"n #3\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"n #5\"},\"android\":{\"2022-11\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Does not play.\",\"2\":\"The `src` attribute is transformed into `proton-src`.\",\"3\":\"The `<audio>` element is supported but can not be played.\",\"4\":\"The `<audio>` element is supported, but the video can not play due to a strict Content Security Policy.\",\"5\":\"Not supported. The `<audio>` element and all its content is removed.\",\"6\":\"Not supported. The `src` attribute is replaced by `data-src`.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-background\",\n\t\t\t\"title\":\"background attribute\",\n\t\t\t\"description\":\"The HTML `background` attribute can only be used on `<body>`, `<table>`, `<td>` and `<th>` elements.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-background/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-background.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/oxaaoE6R3ur4T9fAPzVsQ3G2R7p1c9axDm7LLgC3cKw0F/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"a #1\"},\"android\":{\"2019-02\":\"a #1\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"a #2\",\"2010\":\"a #2\",\"2013\":\"a #2\",\"2016\":\"a #2\",\"2019\":\"a #2\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\",\"6.0.04.6\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-08\":\"y\"},\"ios\":{\"2019-08\":\"y\"},\"android\":{\"2019-08\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.5.0\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-09\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"t-online-de\":{\"desktop-webmail\":{\"2021-11\":\"y\"}},\"free-fr\":{\"desktop-webmail\":{\"2021-11\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\",\"2\":\"Partial. Only supported on the `<body>` element.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-base\",\n\t\t\t\"title\":\"<base>\",\n\t\t\t\"description\":\"Specifies the base URL to use for all relative URLs in a document.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-base/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2021-12-01\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-base.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/4H7Qbzi9dS5UOKS1db7w5SIbkPVfIlbAM3UCFi8FOnhA8/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"15\":\"y\"},\"ios\":{\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-12\":\"a #1\"},\"ios\":{\"2021-12\":\"a #1\"},\"android\":{\"2021-12\":\"a #1\"},\"mobile-webmail\":{\"2021-12\":\"a #1\"}},\"orange\":{\"desktop-webmail\":{\"2021-12\":\"a #1\",\"2024-04\":\"n #1\"},\"ios\":{\"2021-12\":\"n\",\"2024-04\":\"n\"},\"android\":{\"2021-12\":\"n\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"u\",\"2007\":\"u\",\"2010\":\"u\",\"2013\":\"u\",\"2016\":\"u\",\"2019\":\"y\"},\"windows-mail\":{\"2021-12\":\"n\"},\"macos\":{\"16.56\":\"y\",\"16.80\":\"a #1\"},\"outlook-com\":{\"2021-12\":\"a #1\",\"2024-01\":\"a #1\"},\"ios\":{\"2021-12\":\"a #1 #2\"},\"android\":{\"2021-12\":\"n\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-12\":\"n\"},\"ios\":{\"2021-12\":\"n\"},\"android\":{\"2021-12\":\"n\"}},\"thunderbird\":{\"macos\":{\"91.2\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2021-12\":\"a #1 #3 #4\"},\"ios\":{\"2021-12\":\"a #1 #3 #4\"},\"android\":{\"2021-12\":\"a #1 #3 #4\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-12\":\"a #1 #3 #4\"},\"ios\":{\"2021-12\":\"a #1 #3 #4\"},\"android\":{\"2021-12\":\"a #1 #3 #4\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-12\":\"a\"},\"ios\":{\"2021-12\":\"n\"},\"android\":{\"2021-12\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2021-12\":\"a #1\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-12\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-12\":\"a #1 #4\"}},\"laposte\":{\"desktop-webmail\":{\"2021-12\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. The element is removed but URLs are turned into links with `href` attribute.\",\"2\":\"Buggy. URLs are computed based on the first `<base>` element found in the `<body>`.\",\"3\":\"Buggy. URLs are computed based on the last `<base>` element found in the `<body>`.\",\"4\":\"Buggy. Not supported for local anchor links.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-bdi\",\n\t\t\t\"title\":\"<bdi> element\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-bdi/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/HTML5.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/Bzyzx8Z5Kvlfib1Fw9Ted8xtPE26RcjPSdUobdUywgJVm/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2019-08\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2020-01\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2019-02\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.8\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"android\":{\"2022-11\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-blockquote\",\n\t\t\t\"title\":\"<blockquote> element\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-blockquote/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[\"accessibility\"],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2020-05-08\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-semantics.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/IulqGoKCPriLhe6DbI1dWmF2AjH535vSIujVufxhenXVC/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"},\"mobile-webmail\":{\"2020-05\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-05\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2020-05\":\"y\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-05\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"thunderbird\":{\"macos\":{\"68.7\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"a #1\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. Blockquote elements are nested in a `<details>` element.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-body\",\n\t\t\t\"title\":\"<body> element\",\n\t\t\t\"description\":\"Represents the content of an HTML document.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-body/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2021-11-30\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-body.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/M1w9fKYqtXsrlJ2mlElp9b2RoSd7lDcWwftkDazPgy4hm/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"15\":\"y\"},\"ios\":{\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-11\":\"a #1\"},\"ios\":{\"2021-11\":\"a #1\"},\"android\":{\"2021-11\":\"a #1\"},\"mobile-webmail\":{\"2021-11\":\"a #1\"}},\"orange\":{\"desktop-webmail\":{\"2021-11\":\"a #1\"},\"ios\":{\"2021-11\":\"a #1\"},\"android\":{\"2021-11\":\"a #1\"}},\"outlook\":{\"windows\":{\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2021-11\":\"y\"},\"macos\":{\"16.56\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2021-11\":\"a #1\",\"2024-01\":\"a #1\"},\"ios\":{\"2021-11\":\"n\",\"2025-04\":\"a #1\"},\"android\":{\"2021-11\":\"n\",\"2025-04\":\"a #1\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-11\":\"n\"},\"ios\":{\"2021-11\":\"n\"},\"android\":{\"2021-11\":\"n\"}},\"thunderbird\":{\"macos\":{\"78.14\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2021-11\":\"a #1\"},\"ios\":{\"2021-11\":\"n\"},\"android\":{\"2021-11\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-11\":\"a #1\"},\"ios\":{\"2021-11\":\"n\"},\"android\":{\"2021-11\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-11\":\"n\"},\"ios\":{\"2021-11\":\"n\"},\"android\":{\"2021-11\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2021-11\":\"a #1\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-11\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-11\":\"n\"}},\"laposte\":{\"desktop-webmail\":{\"2021-11\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Replaced by a `<div>` with supported attributes.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-button-reset\",\n\t\t\t\"title\":\"<button type=\\\"reset\\\"> element\",\n\t\t\t\"description\":\"HTML button type reset\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-button-reset/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"form, reset, button\",\n\t\t\t\"last_test_date\":\"2019-09-10\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-forms.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/MOk8g8TWwCTL4vLGrdMIgu3Vncqdxif6KlK4g8HfUV1mB/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.3\":\"y\",\"12.4\":\"y\"},\"ios\":{\"10.3\":\"y\",\"12.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"a #1\"},\"android\":{\"2019-09\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-01\":\"y\",\"2021-03\":\"y\",\"2024-04\":\"y\"},\"ios\":{\"2020-01\":\"y\",\"2024-04\":\"y\"},\"android\":{\"2020-01\":\"y\",\"2024-04\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-01\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"2019\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-09\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-06\":\"n\"},\"android\":{\"2019-06\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\",\"9.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"a #3\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"thunderbird\":{\"macos\":{\"68.4\":\"a #2\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-09\":\"n\"},\"ios\":{\"2019-09\":\"n\"},\"android\":{\"2019-09\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2019-09\":\"n\"},\"ios\":{\"2019-09\":\"n\"},\"android\":{\"2019-09\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"a #2\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"a #3\"},\"android\":{\"2022-11\":\"n\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\",\"2\":\"Buggy. Interacting with the element submits the form it belongs in.\",\"3\":\"Partial. The element is present but is not interactive.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-button-submit\",\n\t\t\t\"title\":\"<button type=\\\"submit\\\"> element\",\n\t\t\t\"description\":\"HTML button type submit\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-button-submit/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"form, submit, button\",\n\t\t\t\"last_test_date\":\"2019-09-10\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-forms.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/MOk8g8TWwCTL4vLGrdMIgu3Vncqdxif6KlK4g8HfUV1mB/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.3\":\"y\",\"12.4\":\"y\"},\"ios\":{\"10.3\":\"y\",\"12.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"a #1\"},\"android\":{\"2019-09\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-01\":\"y\",\"2021-03\":\"y\",\"2024-04\":\"y\"},\"ios\":{\"2020-01\":\"y\",\"2024-04\":\"y\"},\"android\":{\"2020-01\":\"y\",\"2024-04\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-01\":\"n\"},\"macos\":{\"2011\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"16.80\":\"n\"},\"outlook-com\":{\"2019-09\":\"y\",\"2024-01\":\"n\"},\"ios\":{\"2019-06\":\"n\"},\"android\":{\"2019-06\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\",\"9.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"a #2\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"thunderbird\":{\"macos\":{\"68.4\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"n\"},\"android\":{\"2019-09\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"a #2\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"a #2\"},\"android\":{\"2022-11\":\"n\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\",\"2\":\"Partial. The element is present but is not interactive.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-cellpadding\",\n\t\t\t\"title\":\"cellpadding attribute\",\n\t\t\t\"description\":\"Represents the padding around the individual cells of the table\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-cellpadding/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2024-05-01\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-cellspacing.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/rlpatnzxf3eyce9oc3\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2024-05\":\"y\"},\"ios\":{\"2024-05\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2024-05\":\"y\"},\"ios\":{\"2024-05\":\"y\"},\"android\":{\"2024-05\":\"y\"},\"mobile-webmail\":{\"2024-05\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2024-05\":\"u\"},\"ios\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\",\"2021\":\"y\"},\"windows-mail\":{\"2024-05\":\"y\"},\"macos\":{\"2024-05\":\"y\"},\"outlook-com\":{\"2024-05\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2024-05\":\"y\"},\"android\":{\"2024-05\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2024-05\":\"y\"},\"ios\":{\"2024-05\":\"y\"},\"android\":{\"2024-05\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2024-05\":\"y\"},\"ios\":{\"2024-05\":\"y\"},\"android\":{\"2024-05\":\"y\"}},\"samsung-email\":{\"android\":{\"2024-05\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2024-05\":\"u\"},\"ios\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}},\"thunderbird\":{\"macos\":{\"2024-05\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2024-05\":\"y\"},\"ios\":{\"2024-05\":\"y\"},\"android\":{\"2024-05\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2024-05\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-05\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2024-05\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2024-05\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2024-05\":\"y\"},\"ios\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2024-05\":\"y\"},\"ios\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-cellspacing\",\n\t\t\t\"title\":\"cellspacing attribute\",\n\t\t\t\"description\":\"Represents the spacing around the individual `<th>` and `<td>` elements\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-cellspacing/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2024-05-01\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-cellspacing.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/rlpatnzxf3eyce9oc3\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2024-05\":\"y\"},\"ios\":{\"2024-05\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2024-05\":\"y\"},\"ios\":{\"2024-05\":\"y\"},\"android\":{\"2024-05\":\"y\"},\"mobile-webmail\":{\"2024-05\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2024-05\":\"u\"},\"ios\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\",\"2021\":\"y\"},\"windows-mail\":{\"2024-05\":\"y\"},\"macos\":{\"2024-05\":\"y\"},\"outlook-com\":{\"2024-05\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2024-05\":\"y\"},\"android\":{\"2024-05\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2024-05\":\"y\"},\"ios\":{\"2024-05\":\"y\"},\"android\":{\"2024-05\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2024-05\":\"y\"},\"ios\":{\"2024-05\":\"y\"},\"android\":{\"2024-05\":\"y\"}},\"samsung-email\":{\"android\":{\"2024-05\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2024-05\":\"u\"},\"ios\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}},\"thunderbird\":{\"macos\":{\"2024-05\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2024-05\":\"y\"},\"ios\":{\"2024-05\":\"y\"},\"android\":{\"2024-05\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2024-05\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-05\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2024-05\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2024-05\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2024-05\":\"y\"},\"ios\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2024-05\":\"y\"},\"ios\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2024-05\":\"u\"},\"android\":{\"2024-05\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-code\",\n\t\t\t\"title\":\"<code> element\",\n\t\t\t\"description\":\"A short fragment of computer code.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-code/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[\"accessibility\"],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2023-04-25\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-code.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/VpMij9qijz7SbpecQE7ubxbhQl\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2023-04\":\"y\"},\"ios\":{\"2023-04\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2023-04\":\"y\"},\"ios\":{\"2023-04\":\"y\"},\"android\":{\"2023-04\":\"y\"},\"mobile-webmail\":{\"2023-04\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2023-05\":\"n #1\"},\"ios\":{\"2023-05\":\"n #1\"},\"android\":{\"2023-05\":\"n #1\"}},\"outlook\":{\"windows\":{\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2023-04\":\"y\"},\"macos\":{\"16.56\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2023-04\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2023-04\":\"y\"},\"android\":{\"2023-04\":\"y\"}},\"samsung-email\":{\"android\":{\"2023-04\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2023-05\":\"y\"},\"ios\":{\"2023-05\":\"y\"},\"android\":{\"2023-05\":\"y\"}},\"thunderbird\":{\"macos\":{\"102.10.1\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2023-05\":\"y\"},\"ios\":{\"2023-05\":\"y\"},\"android\":{\"2023-05\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2023-04\":\"y\"},\"ios\":{\"2023-04\":\"y\"},\"android\":{\"2023-04\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2023-05\":\"y\"},\"ios\":{\"2023-05\":\"y\"},\"android\":{\"2023-05\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2023-05\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2023-04\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2023-05\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2023-05\":\"y\"}},\"free-fr\":{\"desktop-webmail\":{\"2023-05\":\"y\"}},\"t-online-de\":{\"desktop-webmail\":{\"2023-05\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2023-04\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2023-04\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Not supported. The tags are removed but the content is kept.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-comments\",\n\t\t\t\"title\":\"HTML comments\",\n\t\t\t\"description\":\"Adds explanatory notes to the code or to prevent the browser from interpreting specific parts of the style sheet\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-comments/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2024-05-1\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-comments.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/n4ayign05k6cozot6\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2024-04\":\"y\"},\"ios\":{\"2024-04\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2024-04\":\"y\"},\"ios\":{\"2024-04\":\"y\"},\"android\":{\"2024-04\":\"y\"},\"mobile-webmail\":{\"2024-04\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2024-04\":\"u\"},\"ios\":{\"2024-04\":\"u\"},\"android\":{\"2024-04\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\",\"2021\":\"y\"},\"windows-mail\":{\"2024-04\":\"y\"},\"macos\":{\"2024-04\":\"y\"},\"outlook-com\":{\"2024-04\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2024-04\":\"y\"},\"android\":{\"2024-04\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2024-04\":\"y\"},\"ios\":{\"2024-04\":\"y\"},\"android\":{\"2024-04\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2024-04\":\"y\"},\"ios\":{\"2024-04\":\"y\"},\"android\":{\"2024-04\":\"y\"}},\"samsung-email\":{\"android\":{\"2024-04\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2024-04\":\"u\"},\"ios\":{\"2024-04\":\"u\"},\"android\":{\"2024-04\":\"u\"}},\"thunderbird\":{\"macos\":{\"2024-04\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2024-04\":\"u\"},\"ios\":{\"2024-04\":\"u\"},\"android\":{\"2024-04\":\"u\"}},\"hey\":{\"desktop-webmail\":{\"2024-04\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-04\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2024-04\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2024-04\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2024-04\":\"y\"},\"ios\":{\"2024-04\":\"u\"},\"android\":{\"2024-04\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2024-04\":\"y\"},\"ios\":{\"2024-04\":\"u\"},\"android\":{\"2024-04\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2024-04\":\"u\"},\"android\":{\"2024-04\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-del\",\n\t\t\t\"title\":\"<del> element\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-del/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-text.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/DkqbHs69ek5UnK6uhZ7Uj0n5GVQNTP4Z1FvgXvnKyEoTM/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"a #1\",\"2023-03\":\"y\"},\"android\":{\"2019-02\":\"a #1\",\"2023-03\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-01\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2019-02\":\"y\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"thunderbird\":{\"macos\":{\"68.4\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-dfn\",\n\t\t\t\"title\":\"<dfn> element\",\n\t\t\t\"description\":\"It is used to identify a term that is going to be described within the content.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-dfn/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[\"accessibility\"],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2023-09-11\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-dfn.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/gy2dfo4j19d4176d08y\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2023-09\":\"y\"},\"ios\":{\"2023-09\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2023-09\":\"y\"},\"ios\":{\"2023-09\":\"y\"},\"android\":{\"2023-09\":\"y\"},\"mobile-webmail\":{\"2023-09\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2023-09\":\"u\"},\"ios\":{\"2023-09\":\"u\"},\"android\":{\"2023-09\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"a #1\",\"2016\":\"a #1\",\"2019\":\"a #1\",\"2021\":\"a #1\"},\"windows-mail\":{\"2023-09\":\"a #1\"},\"macos\":{\"16.56\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2023-09\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2023-09\":\"y\"},\"android\":{\"2023-09\":\"y\"}},\"samsung-email\":{\"android\":{\"2023-09\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2024-03\":\"y\"},\"ios\":{\"2024-03\":\"y\"},\"android\":{\"2024-03\":\"y\"}},\"thunderbird\":{\"macos\":{\"2023-09\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2023-09\":\"y\"},\"ios\":{\"2023-09\":\"y\"},\"android\":{\"2023-09\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2023-09\":\"y\"},\"ios\":{\"2023-09\":\"y\"},\"android\":{\"2023-09\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2023-09\":\"y\"},\"ios\":{\"2023-09\":\"y\"},\"android\":{\"2023-09\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2023-09\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2023-09\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2023-09\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2023-09\":\"u\"}},\"free-fr\":{\"desktop-webmail\":{\"2023-09\":\"u\"}},\"t-online-de\":{\"desktop-webmail\":{\"2023-09\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2023-09\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2023-09\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. The `title` attribute is removed.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-dialog\",\n\t\t\t\"title\":\"<dialog> element\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-dialog/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/HTML5.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/Bzyzx8Z5Kvlfib1Fw9Ted8xtPE26RcjPSdUobdUywgJVm/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2020-01\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2019-08\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2020-01\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2019-02\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.8\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"android\":{\"2022-11\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-dir\",\n\t\t\t\"title\":\"dir attribute\",\n\t\t\t\"description\":\"Indicates the directionality of the element's text.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-dir/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[\"i18n\",\"accessibility\"],\n\t\t\t\"keywords\":\"direction, ltr, rtl\",\n\t\t\t\"last_test_date\":\"2021-11-01\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-direction.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/onwCpJ1Hp7mc8XYhbvUVbvHoM\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"14\":\"y\"},\"ios\":{\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-10\":\"y\"},\"ios\":{\"2021-10\":\"y\"},\"android\":{\"2021-10\":\"y\"},\"mobile-webmail\":{\"2021-10\":\"a #1\"}},\"orange\":{\"desktop-webmail\":{\"2021-10\":\"a #2\",\"2024-04\":\"a #2\"},\"ios\":{\"2021-10\":\"y\",\"2024-04\":\"a #2\"},\"android\":{\"2021-10\":\"y\",\"2024-04\":\"a #2\"}},\"outlook\":{\"windows\":{\"2007\":\"a #3 #4\",\"2010\":\"a #3\",\"2013\":\"a #3\",\"2016\":\"a #3\",\"2019\":\"a #3\"},\"windows-mail\":{\"2021-10\":\"a #3\"},\"macos\":{\"16.55\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2021-10\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2021-10\":\"y\"},\"android\":{\"2021-10\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-10\":\"y\"},\"ios\":{\"2021-10\":\"y\"},\"android\":{\"2021-10\":\"y\"}},\"thunderbird\":{\"macos\":{\"78.14\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2021-10\":\"y\"},\"ios\":{\"2021-10\":\"y\"},\"android\":{\"2021-10\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-10\":\"y\"},\"ios\":{\"2021-10\":\"y\"},\"android\":{\"2021-10\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-10\":\"y\"},\"ios\":{\"2021-10\":\"y\"},\"android\":{\"2021-10\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-10\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-10\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-10\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. The client applies an `rtl` direction to the entire email if it contains text in a `rtl` language.\",\"2\":\"Partial. Removed from `<table>` and `<td>` elements.\",\"3\":\"Buggy on anchor `<a>` tags.\",\"4\":\"Buggy. LTR text in `[dir=rtl]` (and vice versa) is rendered with reversed word order.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-div\",\n\t\t\t\"title\":\"<div> element\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-div/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2020-05-08\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-semantics.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/IulqGoKCPriLhe6DbI1dWmF2AjH535vSIujVufxhenXVC/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"},\"mobile-webmail\":{\"2020-05\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-05\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2020-05\":\"y\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-05\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"thunderbird\":{\"macos\":{\"68.7\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":\"This page reflects support for the `<div>` semantic HTML element. But in order to support _the Outlooks_ (2007-2019) on Windows, it is still recommended to use tables.\",\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-doctype\",\n\t\t\t\"title\":\"HTML5 doctype\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-doctype/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-07-25\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-doctype.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/aBmEH3dY9eBopWg9Qzf14ZZYy3Wmllacb9lbenpbCRhth/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.3\":\"y\"},\"ios\":{\"10.3\":\"y\",\"12.2\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-07\":\"y\"},\"ios\":{\"2019-07\":\"y\"},\"android\":{\"2019-07\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2019-07\":\"y\",\"2021-03\":\"y\",\"2024-04\":\"y\"},\"ios\":{\"2019-07\":\"n\",\"2024-04\":\"n\"},\"android\":{\"2019-07\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"a #3\",\"2007\":\"n #2\",\"2010\":\"n #2\",\"2013\":\"n #2\",\"2016\":\"n #2\",\"2019\":\"n #2\"},\"windows-mail\":{\"2020-01\":\"y\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-07\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-07\":\"a #1\",\"2021-11\":\"y\"},\"android\":{\"2019-07\":\"a #1\",\"2021-11\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2019-07\":\"y\"},\"ios\":{\"2019-07\":\"n\"},\"android\":{\"2019-07\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"n #1\"}},\"aol\":{\"desktop-webmail\":{\"2020-01\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-07\":\"y\"},\"ios\":{\"2019-07\":\"y\"},\"android\":{\"2019-07\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"android\":{\"2022-11\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. Renders as if there was no doctype with an Outlook email, and in HTML5 otherwise.\",\"2\":\"Not supported. The HTML5 doctype has no impact here.\",\"3\":\"Partial support. Depends on the version of Internet Explorer installed.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-form\",\n\t\t\t\"title\":\"<form> element\",\n\t\t\t\"description\":\"The ability to submit an HTML form via email, this requires support of at least one form element, at least one form method and either input submit or button submit to pass data to an end point.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-form/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"form\",\n\t\t\t\"last_test_date\":\"2019-09-10\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-forms.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/MOk8g8TWwCTL4vLGrdMIgu3Vncqdxif6KlK4g8HfUV1mB/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.3\":\"y\",\"12.4\":\"y\"},\"ios\":{\"10.3\":\"y\",\"12.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"a #1\"},\"android\":{\"2019-09\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-01\":\"n #4\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2020-01\":\"n #4\",\"2024-04\":\"n\"},\"android\":{\"2020-01\":\"n #4\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-01\":\"n\"},\"macos\":{\"2011\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"16.80\":\"a #6\"},\"outlook-com\":{\"2019-02\":\"y #2\",\"2019-10\":\"a #2 #3\",\"2024-01\":\"a #6\"},\"ios\":{\"2019-06\":\"y\"},\"android\":{\"2019-06\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\",\"9.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"n #4\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"thunderbird\":{\"macos\":{\"68.4\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"n\"},\"android\":{\"2019-09\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"n #5\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n #6\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"n #4\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"android\":{\"2022-11\":\"n\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{\"1\":\"Does not submit in iOS with non Google accounts.\",\"2\":\"On submit name values are prefixed with `x_`.\",\"3\":\"Does not submit in preview pane, but does when the email is opened in a new window.\",\"4\":\"Not supported. `<form>` is transformed into `<noform>`.\",\"5\":\"Not supported. The `<form>` and its entire content is removed.\",\"6\":\"Not supported. The `<form>` is there but values are not submitted.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-h1-h6\",\n\t\t\t\"title\":\"<h1> to <h6> elements\",\n\t\t\t\"description\":\"Support for headings elements in HTML: `<h1>`, `<h2>`, `<h3>`, `<h4>`, `<h5>`, `<h6>`.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-h1-h6/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[\"accessibility\"],\n\t\t\t\"keywords\":\"headings, h1, h2, h3, h4, h5, h6\",\n\t\t\t\"last_test_date\":\"2020-05-08\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-semantics.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/IulqGoKCPriLhe6DbI1dWmF2AjH535vSIujVufxhenXVC/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"},\"mobile-webmail\":{\"2020-05\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-05\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2020-05\":\"y\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-05\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"thunderbird\":{\"macos\":{\"68.7\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y #1\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"The webmail sets `<h1>` to `color:#fff` and `<h2>` to `background:#354963`.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-height\",\n\t\t\t\"title\":\"height attribute\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-height/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-09-27\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-width-height.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/UhsQmS14DHKFfotKEcCTnWaoAiS24FJMiApZ1OtmHR7vs/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2019-09\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"a #1 #2\",\"2010\":\"a #1 #2\",\"2013\":\"a #1 #2\",\"2016\":\"a #1 #2\",\"2019\":\"a #1 #2\"},\"windows-mail\":{\"2019-09\":\"y\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-09\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. Percentage width on `<img>` elements are based on the physical file's width, not on the parent element's width.\",\"2\":\"Buggy. Sizes set in attributes don't scale in 120 dpi mode.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-hidden\",\n\t\t\t\"title\":\"hidden attribute\",\n\t\t\t\"description\":\"The global HTML `hidden` attribute\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-hidden/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[\"accessibility\"],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2024-01-26\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-hidden-attribute.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/rlpli9r9trvs62rt7p\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"20\":\"y\",\"21\":\"y\",\"22\":\"y\",\"23\":\"y\"},\"ios\":{\"14\":\"y\",\"15\":\"y\",\"16\":\"y\",\"17\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2024-01\":\"n\"},\"ios\":{\"2024-01\":\"n\"},\"android\":{\"2024-01\":\"n\"},\"mobile-webmail\":{\"2024-01\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2024-01\":\"u\"},\"ios\":{\"2024-01\":\"u\"},\"android\":{\"2024-01\":\"u\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2024-01\":\"n\"},\"macos\":{\"2016\":\"y\",\"2024-01\":\"n\"},\"outlook-com\":{\"2024-01\":\"n\"},\"ios\":{\"2024-01\":\"n\"},\"android\":{\"2024-01\":\"n\"}},\"samsung-email\":{\"android\":{\"6.1\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2024-03\":\"y\"},\"ios\":{\"2024-03\":\"y\"},\"android\":{\"2024-03\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2024-01\":\"n\"},\"ios\":{\"2024-01\":\"u\"},\"android\":{\"2024-01\":\"u\"}},\"yahoo\":{\"desktop-webmail\":{\"2024-01\":\"n\"},\"ios\":{\"2024-01\":\"n\"},\"android\":{\"2024-01\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2024-01\":\"y\"},\"ios\":{\"2024-01\":\"y\"},\"android\":{\"2024-01\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2024-01\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-01\":\"a #1\"}},\"fastmail\":{\"desktop-webmail\":{\"2024-01\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2024-01\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2024-01\":\"n\"},\"ios\":{\"2024-01\":\"u\"},\"android\":{\"2024-01\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2024-01\":\"n\"},\"ios\":{\"2024-01\":\"u\"},\"android\":{\"2024-01\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2024-01\":\"u\"},\"android\":{\"2024-01\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Does not support the unquoted attribute value syntax `<div hidden></div>`\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-hr\",\n\t\t\t\"title\":\"<hr> element\",\n\t\t\t\"description\":\"HTML horizontal rule\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-hr/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[\"accessibility\"],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2023-09-08\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-hr.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/e6ndurbxtpz9hz95hp\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2023-09\":\"y\"},\"ios\":{\"2023-09\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2023-09\":\"y\"},\"ios\":{\"2023-09\":\"y\"},\"android\":{\"2023-09\":\"y\"},\"mobile-webmail\":{\"2023-09\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2023-09\":\"u\"},\"ios\":{\"2023-09\":\"u\"},\"android\":{\"2023-09\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\",\"2021\":\"y\"},\"windows-mail\":{\"2023-09\":\"y\"},\"macos\":{\"16.56\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2023-09\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2023-09\":\"y\"},\"android\":{\"2023-09\":\"y\"}},\"samsung-email\":{\"android\":{\"2023-09\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2023-09\":\"y\"},\"ios\":{\"2023-09\":\"y\"},\"android\":{\"2023-09\":\"y\"}},\"thunderbird\":{\"macos\":{\"102.10.1\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2023-09\":\"y\"},\"ios\":{\"2023-09\":\"y\"},\"android\":{\"2023-09\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2023-09\":\"y\"},\"ios\":{\"2023-09\":\"y\"},\"android\":{\"2023-09\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2023-09\":\"y\"},\"ios\":{\"2023-09\":\"y\"},\"android\":{\"2023-09\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2023-09\":\"u\"}},\"mail-ru\":{\"desktop-webmail\":{\"2023-09\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2023-09\":\"u\"}},\"laposte\":{\"desktop-webmail\":{\"2023-09\":\"u\"}},\"free-fr\":{\"desktop-webmail\":{\"2023-09\":\"u\"}},\"t-online-de\":{\"desktop-webmail\":{\"2023-09\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2023-09\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2023-09\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-image-maps\",\n\t\t\t\"title\":\"Image maps\",\n\t\t\t\"description\":\"`<img>` with a `usemap` attribute and associated `<map>` with a `name` attribute and descendant `<area>` elements define a client-side image map.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-image-maps/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"image maps, map element, area element, usemap attribute\",\n\t\t\t\"last_test_date\":\"2021-09-29\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-image-maps.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/ic6X67PzJrt0xc61WP8l4A2VLFGcXIA9JaBjhI9nM7JAm/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"14.0\":\"y\"},\"ios\":{\"15.0\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-09\":\"y\"},\"ios\":{\"2021-09\":\"y\"},\"android\":{\"2021-09\":\"y\"},\"mobile-webmail\":{\"2021-09\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2021-09\":\"y\"},\"ios\":{\"2021-09\":\"y\"},\"android\":{\"2021-09\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"u\",\"2007\":\"u\",\"2010\":\"u\",\"2013\":\"u\",\"2016\":\"u\",\"2019\":\"y\"},\"windows-mail\":{\"2021-09\":\"n\"},\"macos\":{\"16.54\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2021-09\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2021-09\":\"y\"},\"android\":{\"2021-09\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-09\":\"y\"},\"ios\":{\"2021-09\":\"y\"},\"android\":{\"2021-09\":\"y\"}},\"thunderbird\":{\"macos\":{\"78.10\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2021-10\":\"y\"},\"ios\":{\"2021-10\":\"n\"},\"android\":{\"2021-10\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-09\":\"y\"},\"ios\":{\"2021-09\":\"y\"},\"android\":{\"2021-10\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-09\":\"y\"},\"ios\":{\"2021-09\":\"y\"},\"android\":{\"2021-09\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2021-09\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-09\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-09\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-img\",\n\t\t\t\"title\":\"<img> element\",\n\t\t\t\"description\":\"Displays an image into the document\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-img/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[\"performance\",\"accessibility\"],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2023-12-16\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-img.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/dyogfa8504y2cljofa2\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2023-09\":\"y\"},\"ios\":{\"2023-09\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2023-09\":\"y\"},\"ios\":{\"2023-09\":\"y\"},\"android\":{\"2023-09\":\"y\"},\"mobile-webmail\":{\"2023-09\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"y\"},\"android\":{\"2023-09\":\"u\"}},\"outlook\":{\"windows\":{\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\",\"2021\":\"y\"},\"windows-mail\":{\"2023-09\":\"y\"},\"macos\":{\"16.56\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2023-09\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2023-09\":\"y\"},\"android\":{\"2023-09\":\"y\"}},\"samsung-email\":{\"android\":{\"2023-09\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"y\"},\"android\":{\"2023-09\":\"u\"}},\"thunderbird\":{\"macos\":{\"102.10.1\":\"u\"}},\"aol\":{\"desktop-webmail\":{\"2023-09\":\"y\"},\"ios\":{\"2023-09\":\"y\"},\"android\":{\"2023-09\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2023-09\":\"y\"},\"ios\":{\"2023-09\":\"y\"},\"android\":{\"2023-09\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2023-12\":\"y\"},\"ios\":{\"2023-12\":\"y\"},\"android\":{\"2023-09\":\"u\"}},\"hey\":{\"desktop-webmail\":{\"2023-12\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2023-09\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2023-12\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2023-09\":\"y\"}},\"free-fr\":{\"desktop-webmail\":{\"2023-12\":\"y\"}},\"t-online-de\":{\"desktop-webmail\":{\"2023-09\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2023-09\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2023-09\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-input-checkbox\",\n\t\t\t\"title\":\"<input type=\\\"checkbox\\\"> element\",\n\t\t\t\"description\":\"HTML input type checkbox\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-input-checkbox/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"form, checkbox\",\n\t\t\t\"last_test_date\":\"2019-09-10\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-forms.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/MOk8g8TWwCTL4vLGrdMIgu3Vncqdxif6KlK4g8HfUV1mB/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.3\":\"y\",\"12.4\":\"y\"},\"ios\":{\"10.3\":\"y\",\"12.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-01\":\"n #1\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2020-01\":\"n #1\",\"2024-04\":\"n\"},\"android\":{\"2020-01\":\"n #1\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-01\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"2019\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-09\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-06\":\"y\"},\"android\":{\"2019-06\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\",\"9.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"n #1\"},\"ios\":{\"2020-01\":\"y\",\"2021-06\":\"n #1\"},\"android\":{\"2020-01\":\"y\",\"2021-06\":\"n #1\"}},\"thunderbird\":{\"macos\":{\"68.4\":\"a #2\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"n #1\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"android\":{\"2022-11\":\"n\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{\"1\":\"Not supported. `<input>` is transformed into `<noinput>`.\",\"2\":\"Buggy. Interacting with the element submits the form it belongs in.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-input-hidden\",\n\t\t\t\"title\":\"<input type=\\\"hidden\\\"> element\",\n\t\t\t\"description\":\"HTML input type hidden\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-input-hidden/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"form, hidden\",\n\t\t\t\"last_test_date\":\"2019-09-10\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-forms.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/MOk8g8TWwCTL4vLGrdMIgu3Vncqdxif6KlK4g8HfUV1mB/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.3\":\"y\",\"12.4\":\"y\"},\"ios\":{\"10.3\":\"y\",\"12.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-01\":\"n #1\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2020-01\":\"n #1\",\"2024-04\":\"n\"},\"android\":{\"2020-01\":\"n #1\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-01\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"2019\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-09\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-06\":\"y\"},\"android\":{\"2019-06\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\",\"9.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"n #1\"},\"ios\":{\"2020-01\":\"y\",\"2021-06\":\"n #1\"},\"android\":{\"2020-01\":\"y\",\"2021-06\":\"n #1\"}},\"thunderbird\":{\"macos\":{\"68.4\":\"a #2\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"n #1\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"android\":{\"2022-11\":\"n\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{\"1\":\"Not supported. `<input>` is transformed into `<noinput>`.\",\"2\":\"Buggy. Interacting with the element submits the form it belongs in.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-input-radio\",\n\t\t\t\"title\":\"<input type=\\\"radio\\\"> element\",\n\t\t\t\"description\":\"HTML input type radio\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-input-radio/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"form, radio\",\n\t\t\t\"last_test_date\":\"2019-09-10\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-forms.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/MOk8g8TWwCTL4vLGrdMIgu3Vncqdxif6KlK4g8HfUV1mB/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.3\":\"y\",\"12.4\":\"y\"},\"ios\":{\"10.3\":\"y\",\"12.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-01\":\"n #1\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2020-01\":\"n #1\",\"2024-04\":\"n\"},\"android\":{\"2020-01\":\"n #1\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-01\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"2019\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-09\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-06\":\"y\"},\"android\":{\"2019-06\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\",\"9.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"n #1\"},\"ios\":{\"2020-01\":\"y\",\"2021-06\":\"n #1\"},\"android\":{\"2020-01\":\"y\",\"2021-06\":\"n #1\"}},\"thunderbird\":{\"macos\":{\"68.4\":\"a #2\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"n #1\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"android\":{\"2022-11\":\"n\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{\"1\":\"Not supported. `<input>` is transformed into `<noinput>`.\",\"2\":\"Buggy. Interacting with the element submits the form it belongs in.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-input-reset\",\n\t\t\t\"title\":\"<input type=\\\"reset\\\"> element\",\n\t\t\t\"description\":\"HTML input type reset\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-input-reset/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"form, reset\",\n\t\t\t\"last_test_date\":\"2019-09-10\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-forms.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/MOk8g8TWwCTL4vLGrdMIgu3Vncqdxif6KlK4g8HfUV1mB/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.3\":\"y\",\"12.4\":\"y\"},\"ios\":{\"10.3\":\"y\",\"12.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"a #1\"},\"android\":{\"2019-09\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-01\":\"n #2\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2020-01\":\"n #2\",\"2024-04\":\"n\"},\"android\":{\"2020-01\":\"n #2\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-01\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"2019\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-09\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-06\":\"y\"},\"android\":{\"2019-06\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\",\"9.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"n #2\"},\"ios\":{\"2020-01\":\"y\",\"2021-06\":\"n #1\"},\"android\":{\"2020-01\":\"y\",\"2021-06\":\"n #1\"}},\"thunderbird\":{\"macos\":{\"68.4\":\"a #3\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"n #1\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"android\":{\"2022-11\":\"n\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\",\"2\":\"Not supported. `<input>` is transformed into `<noinput>`.\",\"3\":\"Buggy. Interacting with the element submits the form it belongs in.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-input-submit\",\n\t\t\t\"title\":\"<input type=\\\"submit\\\"> element\",\n\t\t\t\"description\":\"HTML input type submit\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-input-submit/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"form, submit\",\n\t\t\t\"last_test_date\":\"2019-09-10\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-forms.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/MOk8g8TWwCTL4vLGrdMIgu3Vncqdxif6KlK4g8HfUV1mB/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.3\":\"y\",\"12.4\":\"y\"},\"ios\":{\"10.3\":\"y\",\"12.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"a #1\"},\"android\":{\"2019-09\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-01\":\"n #2\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2020-01\":\"n #2\",\"2024-04\":\"n\"},\"android\":{\"2020-01\":\"n #2\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-01\":\"n\"},\"macos\":{\"2011\":\"n\",\"2016\":\"n\",\"2019\":\"n\",\"16.80\":\"n\"},\"outlook-com\":{\"2019-09\":\"y\",\"2024-01\":\"n\"},\"ios\":{\"2019-06\":\"y\"},\"android\":{\"2019-06\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\",\"9.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"n #2\"},\"ios\":{\"2020-01\":\"y\",\"2021-06\":\"n #1\"},\"android\":{\"2020-01\":\"y\",\"2021-06\":\"n #1\"}},\"thunderbird\":{\"macos\":{\"68.4\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"n\"},\"android\":{\"2019-09\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"n #2\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"android\":{\"2022-11\":\"n\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\",\"2\":\"Not supported. `<input>` is transformed into `<noinput>`.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-input-text\",\n\t\t\t\"title\":\"<input type=\\\"text\\\"> element\",\n\t\t\t\"description\":\"HTML input type text\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-input-text/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"form, text\",\n\t\t\t\"last_test_date\":\"2019-09-10\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-forms.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/MOk8g8TWwCTL4vLGrdMIgu3Vncqdxif6KlK4g8HfUV1mB/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.3\":\"y #1\",\"12.4\":\"y #1\",\"13\":\"y\"},\"ios\":{\"10.3\":\"y #2\",\"12.4\":\"y #2\"}},\"gmail\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-01\":\"n #4\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2020-01\":\"n #4\",\"2024-04\":\"n\"},\"android\":{\"2020-01\":\"n #4\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-01\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"2019\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-09\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-06\":\"y\"},\"android\":{\"2019-06\":\"n #3\"}},\"samsung-email\":{\"android\":{\"6.0\":\"n #3\",\"9.0\":\"n #3\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"n #4\"},\"ios\":{\"2020-01\":\"y\",\"2021-06\":\"n #1\"},\"android\":{\"2020-01\":\"y\",\"2021-06\":\"n #1\"}},\"thunderbird\":{\"macos\":{\"68.4\":\"a #5\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"n #3\"}},\"aol\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"n #4\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"android\":{\"2022-11\":\"n\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. Email scrolls to the end when space bar is pressed.  This can be fixed by wrapping the `<input>` in `<ul role=\\\"presentation\\\">`.\",\"2\":\"Buggy. Screen jumps when input is in focus.\",\"3\":\"Buggy. A number of Android clients will not show the keyboard when the input is clicked. Copy and pasting text works.\",\"4\":\"Not supported. `<input>` is transformed into `<noinput>`.\",\"5\":\"Buggy. Interacting with the element submits the form it belongs in.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-lang\",\n\t\t\t\"title\":\"lang attribute\",\n\t\t\t\"description\":\"The `lang` attribute lets you declare the language of the content of an HTML element.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-lang/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[\"i18n\",\"accessibility\"],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2020-10-25\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-lang.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/Cw4skDHvaihUbRGS21mmXSnyxfjIQQNAMGfRwXlpf6zR7/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"14.0\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-10\":\"y\"},\"ios\":{\"2020-10\":\"y\"},\"android\":{\"2020-10\":\"y\"},\"mobile-webmail\":{\"2020-10\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-10\":\"y\",\"2021-03\":\"n\"},\"ios\":{\"2020-10\":\"y\"},\"android\":{\"2020-10\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"u\",\"2007\":\"u\",\"2010\":\"u\",\"2013\":\"u\",\"2016\":\"u\",\"2019\":\"y\"},\"windows-mail\":{\"2020-10\":\"y\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-10\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-10\":\"y\"},\"android\":{\"2020-10\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-10\":\"y\"},\"ios\":{\"2020-10\":\"y\"},\"android\":{\"2020-10\":\"n\",\"2021-10\":\"y\"}},\"thunderbird\":{\"macos\":{\"78.4\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-10\":\"a #1\"},\"ios\":{\"2020-10\":\"y\"},\"android\":{\"2020-10\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-10\":\"a #1\"},\"ios\":{\"2020-10\":\"a #1\"},\"android\":{\"2020-10\":\"a #1\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-10\":\"y\"},\"ios\":{\"2020-10\":\"y\"},\"android\":{\"2020-10\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"android\":{\"2022-06\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Not supported on `<td>` elements.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-link\",\n\t\t\t\"title\":\"<link> element\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-link/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"css, style\",\n\t\t\t\"last_test_date\":\"2023-12-21\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-placement.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/4eAS6hlxtHuqL7tea72awaBTYF24iTYG36GsEOWVnrBJ4/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\",\"16.0\":\"y\"},\"ios\":{\"12.1\":\"y\",\"17.2\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"n\",\"2023-12\":\"n\"},\"ios\":{\"2019-08\":\"y\",\"2023-12\":\"n\"},\"android\":{\"2019-10\":\"y\",\"2023-12\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2019-02\":\"y\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2019-02\":\"n\",\"2023-12\":\"n\"},\"ios\":{\"2019-02\":\"n\",\"2023-12\":\"n\"},\"android\":{\"2019-02\":\"n\",\"2023-12\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.8\":\"y\",\"78.5\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2020-01\":\"n\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"n\"},\"ios\":{\"2020-01\":\"n\"},\"android\":{\"2020-01\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-09\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"android\":{\"2022-11\":\"n\"}},\"wp-pl\":{\"desktop-webmail\":{\"2023-12\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-lists\",\n\t\t\t\"title\":\"<ul>, <ol> and <dl>\",\n\t\t\t\"description\":\"Support for lists in HTML: `<ul>`, `<ol>`, `<li>`, `<dl>`, `<dt>` and `<dd>` elements.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-lists/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[\"accessibility\"],\n\t\t\t\"keywords\":\"ul, ol, li, dl, dt, dd\",\n\t\t\t\"last_test_date\":\"2024-02-17\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-list.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/5jbpc3r2cknp15ayua\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-04\":\"y\",\"2024-02\":\"a #1\"},\"ios\":{\"2020-04\":\"y\",\"2024-02\":\"a #1\"},\"android\":{\"2020-04\":\"y\",\"2024-02\":\"a #1\"},\"mobile-webmail\":{\"2020-04\":\"y\",\"2024-02\":\"a #1\"}},\"orange\":{\"desktop-webmail\":{\"2020-04\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"a #1 #2\",\"2007\":\"a #1 #2\",\"2010\":\"a #1 #2\",\"2013\":\"a #1 #2\",\"2016\":\"a #1 #2\",\"2019\":\"a #1 #2\"},\"windows-mail\":{\"2020-04\":\"y\",\"2024-02\":\"a #1 #2\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\",\"2024-02\":\"a #1\"},\"outlook-com\":{\"2020-04\":\"y\",\"2024-01\":\"y\",\"2024-02\":\"a #1\"},\"ios\":{\"2020-04\":\"y\",\"2024-02\":\"a #1\"},\"android\":{\"2020-04\":\"y\",\"2024-02\":\"a #1\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-04\":\"y\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"thunderbird\":{\"macos\":{\"68.7\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-04\":\"y\",\"2024-02\":\"a #1\"},\"ios\":{\"2020-04\":\"y\",\"2024-02\":\"a #1\"},\"android\":{\"2020-04\":\"y\",\"2024-02\":\"a #1\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-04\":\"y\",\"2024-02\":\"a #1\"},\"ios\":{\"2020-04\":\"y\",\"2024-02\":\"a #1\"},\"android\":{\"2020-04\":\"y\",\"2024-02\":\"a #1\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-04\":\"y\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\",\"2024-02\":\"a #1\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. The `reversed` attribute on `<ol>` is not supported.\",\"2\":\"Partial. Setting the `value` attribute to an `<li>` within an `<ol>` results in a different behaviour in comparison to browsers. The `<ol>` tag is closed before the `<li value=\\\"\\\">`. A new `<ol>` is added with the `start` attribute on it set to the value of the `value` attribute of the `<li>`.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-loading-attribute\",\n\t\t\t\"title\":\"loading attribute\",\n\t\t\t\"description\":\"The `loading` attribute on `img` indicates how the browser should load the image.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-loading-attribute/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"img,image,loading\",\n\t\t\t\"last_test_date\":\"2021-10-27\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-loading-attribute.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/BG3iM7ZhOjpi8Qlf4E9IQxg\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2021-10\":\"y #1\"},\"ios\":{\"11\":\"y #1\",\"12\":\"y #1\",\"13\":\"y #1\",\"14\":\"y #1\"}},\"gmail\":{\"desktop-webmail\":{\"2021-10\":\"n\"},\"ios\":{\"2021-10\":\"n\"},\"android\":{\"2021-10\":\"n\"},\"mobile-webmail\":{\"2021-10\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2021-10\":\"n\"},\"ios\":{\"2021-10\":\"n\"},\"android\":{\"2021-10\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2021-10\":\"n\"},\"macos\":{\"2021-10\":\"y #1\",\"16.80\":\"n\"},\"outlook-com\":{\"2021-10\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2021-10\":\"n\"},\"android\":{\"2021-10\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-10\":\"n\"},\"ios\":{\"2021-10\":\"n\"},\"android\":{\"2021-10\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2021-10\":\"n\"},\"ios\":{\"2021-10\":\"n\"},\"android\":{\"2021-10\":\"n\"}},\"samsung-email\":{\"android\":{\"2021-10\":\"y #1\"}},\"sfr\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"n\"},\"android\":{\"2021-11\":\"n\"}},\"thunderbird\":{\"macos\":{\"2021-10\":\"y #1\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-10\":\"n\"},\"android\":{\"2021-10\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2021-10\":\"a #3\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-10\":\"y #1\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n\"}},\"laposte\":{\"desktop-webmail\":{\"2021-10\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"android\":{\"2022-11\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"The `loading` attribute is supported, but not confirmed whether it works according to spec.\",\"2\":\"The `loading` attribute is supported by the email client, but not by the browser engine.\",\"3\":\"Not supported. The webmail adds `loading=\\\"lazy\\\"` to all images.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-mailto-links\",\n\t\t\t\"title\":\"mailto: links\",\n\t\t\t\"description\":\"The `mailto:` URL scheme allows to create links that open a new outgoing email message. It supports different parameters corresponding to header fields, like `cc`, `bcc`, `subject` and `body`.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-mailto-links/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2021-05-20\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-mailto-links.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/7EAQ7RkA45N8kFDAlsnXHBhUjwfbH0cOKsJix4P4zHgqu/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"14\":\"y\"},\"ios\":{\"14.5\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"y\"},\"mobile-webmail\":{\"2021-05\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2021-05\":\"a #1\"},\"ios\":{\"2021-05\":\"n #2\"},\"android\":{\"2021-05\":\"a #3\"}},\"outlook\":{\"windows\":{\"2003\":\"u\",\"2007\":\"u\",\"2010\":\"u\",\"2013\":\"u\",\"2016\":\"u\",\"2019\":\"y\"},\"windows-mail\":{\"2021-05\":\"y\"},\"macos\":{\"16.50\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2021-05\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"y\"}},\"samsung-email\":{\"android\":{\"6.1\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"y\"}},\"thunderbird\":{\"macos\":{\"78.10\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"a #3\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"a #3\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-05\":\"y\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"a #3\"}},\"hey\":{\"desktop-webmail\":{\"2021-05\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-05\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"a #1\"},\"android\":{\"2022-11\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. `cc` and `bcc` are not supported.\",\"2\":\"Not supported. The link opens a contact card inside the app.\",\"3\":\"Partial. `bcc` is not supported.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-marquee\",\n\t\t\t\"title\":\"<marquee> element\",\n\t\t\t\"description\":\"This is the description of the `<marquee>` element.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-marquee/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"marquee\",\n\t\t\t\"last_test_date\":\"2020-04-21\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-marquee.html\",\n\t\t\t\"test_results_url\":null,\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.15\":\"y\"},\"ios\":{\"11.3.1\":\"y\",\"12\":\"y\",\"13.1\":\"y\",\"13.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-04\":\"n\"},\"ios\":{\"2020-04\":\"n\"},\"android\":{\"2020-04\":\"n\"},\"mobile-webmail\":{\"2020-04\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2020-04\":\"y\",\"2021-03\":\"n\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-03\":\"n\"},\"macos\":{\"2016\":\"y\",\"16.80\":\"a #3\"},\"outlook-com\":{\"2020-04\":\"y\",\"2024-01\":\"a #3\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\",\"7.0\":\"y\",\"9.0\":\"y\",\"10.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-04\":\"y\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"thunderbird\":{\"macos\":{\"68.7\":\"y\"},\"windows\":{\"60\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-04\":\"a #1\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-04\":\"a #1\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-04\":\"a #2\"},\"ios\":{\"2020-04\":\"y\"},\"android\":{\"2020-04\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"a #1\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"android\":{\"2022-11\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"The `<marquee>` element is supported but the animation doesn't play in Internet Explorer or Firefox.\",\"2\":\"The `behavior` attribute is not supported.\",\"3\":\"The `<marquee>` element is supported but not the `behavior` or `direction` attributes.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-meta-color-scheme\",\n\t\t\t\"title\":\"color-scheme meta tag\",\n\t\t\t\"description\":\"Changes the default colors of HTML elements. Useful for when you want an email to display only in a dark color scheme or only a light scheme, regardless of user settings. Equivalent to setting the color-scheme CSS property on the root element\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-meta-color-scheme/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[\"accessibility\"],\n\t\t\t\"keywords\":\"dark-mode\",\n\t\t\t\"last_test_date\":\"2023-09-18\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-meta-color-scheme.html\",\n\t\t\t\"test_results_url\":\"\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"16\":\"y\"},\"ios\":{\"12.4\":\"n\"}},\"gmail\":{\"desktop-webmail\":{\"2023-09\":\"n\"},\"ios\":{\"2019-09\":\"n\"},\"android\":{\"2023-09\":\"n\"},\"mobile-webmail\":{\"2023-09\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2021-03\":\"u\"},\"ios\":{\"2020-01\":\"u\"},\"android\":{\"2020-01\":\"u\"}},\"outlook\":{\"windows\":{\"2003\":\"u\",\"2007\":\"u\",\"2010\":\"u\",\"2013\":\"u\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2023-09\":\"n\"},\"macos\":{\"2023\":\"n\",\"16.80\":\"n\"},\"outlook-com\":{\"2023-09\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2023-09\":\"n\"},\"android\":{\"2023-09\":\"n\"}},\"samsung-email\":{\"android\":{\"9.1\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2024-03\":\"n\"},\"ios\":{\"2024-03\":\"n\"},\"android\":{\"2024-03\":\"n\"}},\"thunderbird\":{\"macos\":{\"102.11\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2023-09\":\"n\"},\"ios\":{\"2023-09\":\"n\"},\"android\":{\"2023-09\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2023-09\":\"n\"},\"ios\":{\"2023-09\":\"n\"},\"android\":{\"2023-09\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2023-09\":\"n\"},\"ios\":{\"2023-09\":\"n\"},\"android\":{\"2023-09\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2023-09\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2023-09\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2023-09\":\"n\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2023-09\":\"n\"},\"ios\":{\"2022-11\":\"u\"},\"android\":{\"2022-11\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2023-09\":\"n\"},\"ios\":{\"2022-11\":\"u\"},\"android\":{\"2022-11\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"u\"},\"android\":{\"2022-11\":\"u\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-meter\",\n\t\t\t\"title\":\"<meter> element\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-meter/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/HTML5.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/Bzyzx8Z5Kvlfib1Fw9Ted8xtPE26RcjPSdUobdUywgJVm/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"a #1\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2019-08\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2020-01\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.8\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"android\":{\"2022-11\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Supported with non Google accounts only.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-object\",\n\t\t\t\"title\":\"<object> element\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-object/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"object,embed,video,image,pdf\",\n\t\t\t\"last_test_date\":\"2021-02-20\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-object.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/IExyoVbvCeJfhRfY6W30e6k4MCsSprSAk58zyNlPlms39/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"14.0\":\"a #1\"},\"ios\":{\"14.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-10\":\"n\"},\"ios\":{\"2020-10\":\"n\"},\"android\":{\"2020-10\":\"n\"},\"mobile-webmail\":{\"2020-10\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2020-10\":\"n #3\",\"2021-03\":\"n\"},\"ios\":{\"2020-10\":\"n\"},\"android\":{\"2020-10\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-10\":\"n\"},\"macos\":{\"2011\":\"a #1\",\"2016\":\"a #1\",\"16.80\":\"n\"},\"outlook-com\":{\"2020-10\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2020-10\":\"n\"},\"android\":{\"2020-10\":\"n\"}},\"samsung-email\":{\"android\":{\"6.1\":\"a #2\"}},\"sfr\":{\"desktop-webmail\":{\"2020-10\":\"n #3\"},\"ios\":{\"2020-10\":\"n\"},\"android\":{\"2020-10\":\"n\"}},\"thunderbird\":{\"macos\":{\"78.7\":\"a #2\"}},\"aol\":{\"desktop-webmail\":{\"2021-02\":\"n\"},\"ios\":{\"2020-10\":\"n\"},\"android\":{\"2020-10\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-10\":\"n\"},\"ios\":{\"2020-10\":\"n\"},\"android\":{\"2020-10\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-10\":\"n\"},\"ios\":{\"2020-10\":\"n\"},\"android\":{\"2020-10\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n #4\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"n #3\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"a #1 #2\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"a #1 #2\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"android\":{\"2022-11\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial support. Does not support Videos resources.\",\"2\":\"Partial support. Does not support PDF resources.\",\"3\":\"Not supported. `<object>` is turned into `<noobject>`.\",\"4\":\"Not supported. The `<object>` element and all its content is removed.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-p\",\n\t\t\t\"title\":\"<p> element\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-p/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[\"accessibility\"],\n\t\t\t\"keywords\":\"paragraph\",\n\t\t\t\"last_test_date\":\"2020-05-08\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-semantics.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/IulqGoKCPriLhe6DbI1dWmF2AjH535vSIujVufxhenXVC/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"},\"mobile-webmail\":{\"2020-05\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-05\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2020-05\":\"y\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-05\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"thunderbird\":{\"macos\":{\"68.7\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-picture\",\n\t\t\t\"title\":\"<picture> element\",\n\t\t\t\"description\":\"This is the description of the `<picture>` element.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-picture/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[\"accessibility\",\"performance\"],\n\t\t\t\"keywords\":\"picture, responsive image\",\n\t\t\t\"last_test_date\":\"2024-04-15\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-picture.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/vr32cxxk1exntxrjfdp\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.3\":\"y\",\"10.15\":\"a #2\",\"11.7\":\"a #2\",\"12.7\":\"a #2\",\"13.6\":\"a #2\",\"14.4\":\"a #2\"},\"ios\":{\"10.3\":\"y\",\"12.2\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-05\":\"n #1\"},\"ios\":{\"2019-05\":\"n #1\"},\"android\":{\"2019-05\":\"n #1\"},\"mobile-webmail\":{\"2020-02\":\"n #1\"}},\"orange\":{\"desktop-webmail\":{\"2019-05\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2019-05\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2019-05\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-01\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.62\":\"n\",\"16.80\":\"n\"},\"outlook-com\":{\"2019-05\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2019-05\":\"n\"},\"android\":{\"2019-05\":\"n\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-05\":\"y\"},\"ios\":{\"2019-05\":\"y\"},\"android\":{\"2019-05\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-01\":\"n\"},\"ios\":{\"2020-01\":\"n\"},\"android\":{\"2020-01\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-05\":\"n\"},\"ios\":{\"2019-05\":\"n\"},\"android\":{\"2019-05\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"android\":{\"2022-11\":\"n\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{\"1\":\"`<picture>` and `<source>` tags are replaced by `<u></u>` tags.\",\"2\":\"`<picture>` tag is stripped in some cases (like having too few content or no background-color).\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-popover\",\n\t\t\t\"title\":\"popover attribute\",\n\t\t\t\"description\":\"Show and hide things\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-popover/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2023-09-25\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-popover.html\",\n\t\t\t\"test_results_url\":\"\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"16\":\"y\"},\"ios\":{\"16\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2023-09\":\"n\"},\"ios\":{\"2023-09\":\"n\"},\"android\":{\"2024-03\":\"n\"},\"mobile-webmail\":{\"2024-03\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"u\",\"2021-03\":\"u\"},\"ios\":{\"2019-08\":\"u\"},\"android\":{\"2019-08\":\"u\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2023-09\":\"n\"},\"macos\":{\"2023-09\":\"n\",\"16.80\":\"n\"},\"outlook-com\":{\"2023-09\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2023-09\":\"n\"},\"android\":{\"2024-03\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2023-09\":\"n\"},\"ios\":{\"2023-09\":\"n\"},\"android\":{\"2024-03\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2023-09\":\"n\"},\"ios\":{\"2023-09\":\"n\"},\"android\":{\"2023-09\":\"u\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"u\",\"6.1.90.16\":\"a #1\"}},\"sfr\":{\"desktop-webmail\":{\"2024-03\":\"y\"},\"ios\":{\"2024-03\":\"n\"},\"android\":{\"2024-03\":\"y\"}},\"thunderbird\":{\"macos\":{\"115.2\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2023-09\":\"n\"},\"ios\":{\"2023-09\":\"n\"},\"android\":{\"2023-09\":\"u\"}},\"hey\":{\"desktop-webmail\":{\"2023-09\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2024-01\":\"a\"}},\"fastmail\":{\"desktop-webmail\":{\"2023-09\":\"n\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"u\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"u\"},\"ios\":{\"2022-06\":\"u\"},\"android\":{\"2022-06\":\"u\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"u\"},\"ios\":{\"2022-06\":\"u\"},\"android\":{\"2022-06\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"u\"},\"android\":{\"2022-06\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with Hotmail/Outlook accounts.\",\"2\":\"Partial. The `popovertarget` attribute is supported, but the `popover` attribute and the `dialog` element are not.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-pre\",\n\t\t\t\"title\":\"<pre> element\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-pre/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2023-04-25\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-pre.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/0pJiPjxSL0gh01YC9wFAAkfq\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2023-04\":\"y\"},\"ios\":{\"2023-04\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2023-04\":\"y\"},\"ios\":{\"2023-04\":\"y\"},\"android\":{\"2023-04\":\"y\"},\"mobile-webmail\":{\"2023-04\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2023-05\":\"y\"},\"ios\":{\"2023-05\":\"y\"},\"android\":{\"2023-05\":\"y\"}},\"outlook\":{\"windows\":{\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2023-04\":\"y\"},\"macos\":{\"16.56\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2023-04\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2023-04\":\"y\"},\"android\":{\"2023-04\":\"y\"}},\"samsung-email\":{\"android\":{\"2023-04\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2023-05\":\"y\"},\"ios\":{\"2023-05\":\"y\"},\"android\":{\"2023-05\":\"y\"}},\"thunderbird\":{\"macos\":{\"102.10.1\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2023-05\":\"y\"},\"ios\":{\"2023-05\":\"y\"},\"android\":{\"2023-05\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2023-04\":\"y\"},\"ios\":{\"2023-04\":\"y\"},\"android\":{\"2023-04\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2023-05\":\"y\"},\"ios\":{\"2023-05\":\"y\"},\"android\":{\"2023-05\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2023-05\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2023-04\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2023-04\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2023-05\":\"y\"}},\"free-fr\":{\"desktop-webmail\":{\"2023-05\":\"y\"}},\"t-online-de\":{\"desktop-webmail\":{\"2023-05\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2023-04\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2023-04\":\"u\"},\"ios\":{\"2023-04\":\"u\"},\"android\":{\"2023-04\":\"u\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2023-04\":\"u\"},\"android\":{\"2023-04\":\"u\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-progress\",\n\t\t\t\"title\":\"<progress> element\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-progress/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/HTML5.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/Bzyzx8Z5Kvlfib1Fw9Ted8xtPE26RcjPSdUobdUywgJVm/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"a #1\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2019-08\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2020-01\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2019-02\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.8\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"n\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"android\":{\"2022-11\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Supported with non Google accounts only.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-required\",\n\t\t\t\"title\":\"required attribute\",\n\t\t\t\"description\":\"Required attribute on form elements\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-required/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"form, required\",\n\t\t\t\"last_test_date\":\"2019-09-10\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-forms.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/MOk8g8TWwCTL4vLGrdMIgu3Vncqdxif6KlK4g8HfUV1mB/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.3\":\"y\",\"12.4\":\"y\"},\"ios\":{\"10.3\":\"y\",\"12.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-09\":\"n\"},\"ios\":{\"2019-09\":\"n\"},\"android\":{\"2019-09\":\"a #1\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2020-01\":\"a #2\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2020-01\":\"a #2\",\"2024-04\":\"n\"},\"android\":{\"2020-01\":\"a #2\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-01\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"2019\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2019-09\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2019-06\":\"y\"},\"android\":{\"2019-06\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\",\"9.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"a #2\"},\"ios\":{\"2020-01\":\"n\"},\"android\":{\"2020-01\":\"y\"}},\"thunderbird\":{\"macos\":{\"68.4\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-09\":\"n\"},\"ios\":{\"2019-09\":\"n\"},\"android\":{\"2019-09\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2019-09\":\"n\"},\"ios\":{\"2019-09\":\"n\"},\"android\":{\"2019-09\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"a #2\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"a #2\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"android\":{\"2022-11\":\"n\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Only supported with non Google accounts.\",\"2\":\"Partial. The `required` attribute is supported, but form elements are not.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-role\",\n\t\t\t\"title\":\"role attribute\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-role/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[\"accessibility\"],\n\t\t\t\"keywords\":\"accessibility, a11y, wai-aria\",\n\t\t\t\"last_test_date\":\"2020-02-04\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-role.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/5UaXELRV2jCpTG9wg7EEDfjuPE0Elj08cJQGTxK5H4guu/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.3\":\"y\"},\"ios\":{\"10.3\":\"y\",\"12.2\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-06\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2020-01\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2019-06\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2019-08\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2020-01\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"y #1\",\"2007\":\"y #1\",\"2010\":\"y #1\",\"2013\":\"y #1\",\"2016\":\"y #1\",\"2019\":\"y #1\"},\"windows-mail\":{\"2020-01\":\"n\"},\"macos\":{\"2019\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-06\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-06\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-06\":\"n\",\"2020-02\":\"a #2\"},\"ios\":{\"2019-02\":\"n\",\"2020-02\":\"a #2\"},\"android\":{\"2019-02\":\"n\",\"2020-02\":\"a #2\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"n\",\"2020-02\":\"a #2\"},\"ios\":{\"2019-02\":\"n\",\"2020-02\":\"a #2\"},\"android\":{\"2019-02\":\"n\",\"2020-02\":\"a #2\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"ios\":{\"2022-06\":\"y\"},\"android\":{\"2022-06\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-06\":\"n\"},\"android\":{\"2022-06\":\"n\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{\"1\":\"Not live tested, only looked at processed code on Litmus.\",\"2\":\"Partial. Only works on the `<table>` tag.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-rp\",\n\t\t\t\"title\":\"<rp> element\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-rp/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/HTML5.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/Bzyzx8Z5Kvlfib1Fw9Ted8xtPE26RcjPSdUobdUywgJVm/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2020-02\":\"a #1\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2019-08\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2020-01\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.8\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"android\":{\"2022-11\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Supported with non Google accounts only.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-rt\",\n\t\t\t\"title\":\"<rt> element\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-rt/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[\"i18n\"],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/HTML5.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/Bzyzx8Z5Kvlfib1Fw9Ted8xtPE26RcjPSdUobdUywgJVm/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"a #1\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2019-08\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2020-01\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.8\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"android\":{\"2022-11\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Supported with non Google accounts only.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-ruby\",\n\t\t\t\"title\":\"<ruby> element\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-ruby/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[\"i18n\",\"accessibility\"],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/HTML5.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/Bzyzx8Z5Kvlfib1Fw9Ted8xtPE26RcjPSdUobdUywgJVm/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"n\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"a #1\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2019-08\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2020-01\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.8\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"android\":{\"2022-11\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Supported with non Google accounts only.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-select\",\n\t\t\t\"title\":\"<select> element\",\n\t\t\t\"description\":\"HTML select menu\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-select/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"form, select\",\n\t\t\t\"last_test_date\":\"2019-09-10\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-forms.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/MOk8g8TWwCTL4vLGrdMIgu3Vncqdxif6KlK4g8HfUV1mB/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.3\":\"y\",\"12.4\":\"y\"},\"ios\":{\"10.3\":\"a #1\",\"12.4\":\"a #1\"}},\"gmail\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"a #1\"},\"android\":{\"2019-09\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-01\":\"n #2\",\"2021-03\":\"y\",\"2024-04\":\"y\"},\"ios\":{\"2020-01\":\"n #2\",\"2024-04\":\"y\"},\"android\":{\"2020-01\":\"n #2\",\"2024-04\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-01\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"2019\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-09\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-06\":\"y\"},\"android\":{\"2019-06\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\",\"9.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"n #2\"},\"ios\":{\"2020-01\":\"y\",\"2021-06\":\"n #2\"},\"android\":{\"2020-01\":\"y\",\"2021-06\":\"n #2\"}},\"thunderbird\":{\"macos\":{\"68.4\":\"a #3\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"n\"},\"android\":{\"2019-09\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"n #2\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. Works if the email is shorter than the viewport. Otherwise the screen goes blank when the `<select>` is in focus.\",\"2\":\"Not supported. `<select>` is transformed into `<noselect>`.\",\"3\":\"Buggy. Interacting with the element submits the form it belongs in.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-semantics\",\n\t\t\t\"title\":\"HTML5 semantics\",\n\t\t\t\"description\":\"This includes support for `<article>`, `<aside>`, `<details>`, `<figcaption>`, `<figure>`, `<footer>`, `<header>`, `<main>`, `<mark>`, `<nav>`, `<section>`, `<summary>`, `<time>` elements.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-semantics/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[\"accessibility\"],\n\t\t\t\"keywords\":\"article, aside, details, figcaption, figure, footer, header, main, mark, nav, section, summary, time\",\n\t\t\t\"last_test_date\":\"2019-07-29\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-semantics.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/fel5GW8SquYS9SWxQHu5Z9s0IeTpLZcnf5ghDEqQFf5Je/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.3\":\"y\"},\"ios\":{\"10.3\":\"y\",\"12.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-07\":\"a #2\"},\"ios\":{\"2019-07\":\"a #2\"},\"android\":{\"2019-07\":\"a #2\"},\"mobile-webmail\":{\"2020-02\":\"a #2\"}},\"orange\":{\"desktop-webmail\":{\"2019-07\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"a #1\"},\"ios\":{\"2019-07\":\"y\",\"2024-04\":\"a #1\"},\"android\":{\"2019-07\":\"a #3\",\"2024-04\":\"a #1\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-01\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"a #1 #4\"},\"outlook-com\":{\"2019-07\":\"a #1 #4\",\"2024-01\":\"a #1 #4\"},\"ios\":{\"2019-07\":\"a #1 #4\"},\"android\":{\"2019-07\":\"a #1 #4\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-07\":\"y\"},\"ios\":{\"2019-07\":\"y\"},\"android\":{\"2019-07\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-01\":\"n #4\"},\"ios\":{\"2020-01\":\"n #4\"},\"android\":{\"2020-01\":\"n #4\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-07\":\"n #4\"},\"ios\":{\"2019-07\":\"n #4\"},\"android\":{\"2019-07\":\"n #4\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"a #5\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"a #1\"},\"android\":{\"2022-11\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Only `<time>` is supported.\",\"2\":\"Partial. `<details>`, `<main>`, `<nav>` and `<summary>` are not supported. Opening and closing tags are replaced by `<u></u>`.\",\"3\":\"Partial. `<article>` is not supported.\",\"4\":\"Unsupported opening and closing tags are stripped.\",\"5\":\"Partial. `<article>`, `<aside>`, `<header>`, `<main>` `<nav>`, `<section>`, `<summary>` are not supported.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-small\",\n\t\t\t\"title\":\"<small> element\",\n\t\t\t\"description\":\"The `<small>` HTML element represents side-comments and small print, like copyright and legal text, independent of its styled presentation.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-small/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2021-11-13\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-small.html\",\n\t\t\t\"test_results_url\":\"https://testi.at/proj/QZ5ik13cllxUNqjfEBQf9JrI5E\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"2021-11\":\"y\"},\"ios\":{\"11\":\"y\",\"12\":\"y\",\"13\":\"y\",\"14\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2021-11\":\"n\"},\"ios\":{\"2021-11\":\"n\"},\"android\":{\"2021-11\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2021-11\":\"y\"},\"macos\":{\"2021-11\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2021-11\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"}},\"samsung-email\":{\"android\":{\"2021-11\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"}},\"thunderbird\":{\"macos\":{\"2021-11\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-11\":\"y\"},\"ios\":{\"2021-11\":\"y\"},\"android\":{\"2021-11\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2021-11\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-11\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-11\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-11\":\"y\"}},\"t-online-de\":{\"desktop-webmail\":{\"2021-11\":\"y\"}},\"free-fr\":{\"desktop-webmail\":{\"2021-11\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"android\":{\"2022-11\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-span\",\n\t\t\t\"title\":\"<span> element\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-span/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2020-05-08\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-semantics.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/IulqGoKCPriLhe6DbI1dWmF2AjH535vSIujVufxhenXVC/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"},\"mobile-webmail\":{\"2020-05\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-05\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2020-05\":\"y\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-05\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"thunderbird\":{\"macos\":{\"68.7\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-srcset\",\n\t\t\t\"title\":\"srcset and sizes attributes\",\n\t\t\t\"description\":\"This is the description of the `srcset` and `sizes` attributes.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-srcset/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[\"performance\"],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-05-29\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-srcset.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/1Bs8nKCQe7qRRBaKa2CdUjcn5j2RBqTSPtf6FRSTh2nQg/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.3\":\"y\"},\"ios\":{\"10.3\":\"y\",\"12.2\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-05\":\"n\"},\"ios\":{\"2019-05\":\"n\"},\"android\":{\"2019-05\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-05\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2019-05\":\"y\",\"2024-04\":\"n\"},\"android\":{\"2019-05\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-01\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2019-05\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2019-05\":\"n\"},\"android\":{\"2019-05\":\"n\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-05\":\"y\"},\"ios\":{\"2019-05\":\"y\"},\"android\":{\"2019-05\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-01\":\"n\"},\"ios\":{\"2020-01\":\"n\"},\"android\":{\"2020-01\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-05\":\"n\"},\"ios\":{\"2019-05\":\"n\"},\"android\":{\"2019-05\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n #2\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n #2\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n #2\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"android\":{\"2022-11\":\"n\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{\"1\":\"`<picture>` and `<source>` tags are replaced by `<u></u>` tags.\",\"2\":\"The `sizes` attribute is supported but not `srcset`.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-strike\",\n\t\t\t\"title\":\"<strike> element\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-strike/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-text.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/DkqbHs69ek5UnK6uhZ7Uj0n5GVQNTP4Z1FvgXvnKyEoTM/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"a #1\"},\"android\":{\"2019-02\":\"a #1\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-01\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2019-02\":\"y\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"thunderbird\":{\"macos\":{\"68.4\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported with non Google accounts.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-strong\",\n\t\t\t\"title\":\"<strong> element\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-strong/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2020-05-08\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-semantics.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/IulqGoKCPriLhe6DbI1dWmF2AjH535vSIujVufxhenXVC/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"13.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"},\"mobile-webmail\":{\"2020-05\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-05\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2020-05\":\"y\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-05\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"thunderbird\":{\"macos\":{\"68.7\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-05\":\"y\"},\"ios\":{\"2020-05\":\"y\"},\"android\":{\"2020-05\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-style\",\n\t\t\t\"title\":\"<style> element\",\n\t\t\t\"description\":\"An HTML element to contain style information, in CSS, for a document or part of a document.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-style/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2023-07-27\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-style.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/CAMb612bxbVwRWPhM4wZKNhhdcdkNxj0Rj6dtRRw6LQUO/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.3\":\"y\",\"16.0\":\"y\"},\"ios\":{\"10.3\":\"y\",\"12.3\":\"y\",\"16.2\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-06\":\"a #1\",\"2023-01\":\"a #1 #6\"},\"ios\":{\"2019-06\":\"a #1 #2\",\"2023-01\":\"a #1 #2\"},\"android\":{\"2019-06\":\"a #1 #2\"},\"mobile-webmail\":{\"2020-02\":\"n\",\"2023-01\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2019-06\":\"y\",\"2021-03\":\"y\",\"2023-02\":\"y\"},\"ios\":{\"2019-06\":\"y\",\"2023-02\":\"y\"},\"android\":{\"2019-06\":\"n\",\"2019-08\":\"y\",\"2023-02\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"a #4\",\"2010\":\"a #4\",\"2013\":\"a #4\",\"2016\":\"a #4\",\"2019\":\"a #4\"},\"windows-mail\":{\"2020-01\":\"a #4\",\"2023-01\":\"a #4\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"2019\":\"y\",\"2021\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-06\":\"y\",\"2023-01\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-06\":\"y\",\"2023-01\":\"y\"},\"android\":{\"2019-06\":\"y\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\",\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-06\":\"y\",\"2023-02\":\"y\",\"2025-07\":\"n\"},\"ios\":{\"2019-06\":\"n\",\"2023-02\":\"n\",\"2025-07\":\"n\"},\"android\":{\"2019-06\":\"n\",\"2023-02\":\"n\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\",\"102.7\":\"y\"},\"windows\":{\"102.7\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-01\":\"y\",\"2023-01\":\"y\"},\"ios\":{\"2020-01\":\"y\",\"2023-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-06\":\"y #5\",\"2023-01\":\"y\"},\"ios\":{\"2019-06\":\"y\",\"2023-01\":\"y\"},\"android\":{\"2019-06\":\"a #3\",\"2023-01\":\"a #3\",\"2025-06\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\",\"2022-02\":\"y\",\"2023-01\":\"y\"},\"ios\":{\"2020-03\":\"n\",\"2023-01\":\"a #1\"},\"android\":{\"2020-03\":\"a #1\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-09\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\",\"2025-07\":\"n\"},\"ios\":{\"2023-02\":\"n\",\"2025-07\":\"n\"},\"android\":{\"2023-02\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"y\",\"2023-07\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"y\",\"2025-04\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"rainloop\":{\"desktop-webmail\":{\"2023-02\":\"n\"}},\"wp-pl\":{\"desktop-webmail\":{\"2023-12\":\"y\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Not supported inside the `<body>`.\",\"2\":\"Partial. Not supported with non Google accounts.\",\"3\":\"Buggy. The first `<head>` in the HTML is removed, so `<style>` elements need to be in a second `<head>` element.\",\"4\":\"Buggy. `<style>` elements need to be declared before their rules are used.\",\"5\":\"A CSS rule following a CSS comment is ignored. (See [email-bugs#25](https://github.com/hteumeuleu/email-bugs/issues/25).)\",\"6\":\"The size of the `<style>` tag [is limited to 16 KB](https://github.com/hteumeuleu/email-bugs/issues/90)\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-svg\",\n\t\t\t\"title\":\"Embedded <svg> image\",\n\t\t\t\"description\":\"Support for embedded `<svg>` images. See [SVG html](/features/image-svg) for linked external SVG support.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-svg/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"image, SVG\",\n\t\t\t\"last_test_date\":\"2020-02-06\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/images.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/xm1T5nQ1MKtHpVSJidhagmt3Z53CjqbkMhorlvuM0Gz57/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"a #1\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-02\":\"n\"},\"ios\":{\"2020-02\":\"n\"},\"android\":{\"2020-02\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-02\":\"n\"},\"macos\":{\"2016\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2020-02\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2020-02\":\"n\"},\"android\":{\"2020-02\":\"n\"}},\"samsung-email\":{\"android\":{\"9.0\":\"y\"}},\"thunderbird\":{\"windows\":{\"2020-02\":\"y\"},\"macos\":{\"68.4\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-02\":\"n\"},\"ios\":{\"2020-02\":\"n\"},\"android\":{\"2020-02\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-02\":\"n\"},\"ios\":{\"2020-02\":\"n\"},\"android\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2020-02\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"y\"},\"ios\":{\"2020-02\":\"y\",\"2024-04\":\"y\"},\"android\":{\"2020-02\":\"y\",\"2024-04\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"n #2\"},\"android\":{\"2022-11\":\"n\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. Requires a background on the `<body>`. Can be any type of background, image or a color, can be set as attribute, inline style or in `<style>` block\",\"2\":\"Not supported. Element is removed along with any fallback content.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-table\",\n\t\t\t\"title\":\"<table> element\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-table/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[\"accessibility\"],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-09-15\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-table.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/wwEgKadK3WVORrysJAcIrYgLlqhSw7z0aVvEQziMSHo7n/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2019-09\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2019-09\":\"y\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-09\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-target\",\n\t\t\t\"title\":\"target attribute\",\n\t\t\t\"description\":\"Where to display the linked URL on `<a>` elements.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-target/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2021-12-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-target.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/JU58WeEpop755UWcHt3uqkXW8btkk44WIkzhtL1UU3p46/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"15\":\"a #3\"},\"ios\":{\"15\":\"a #3\"}},\"gmail\":{\"desktop-webmail\":{\"2021-12\":\"n #1\"},\"ios\":{\"2021-12\":\"n #1\"},\"android\":{\"2021-12\":\"n #1\"},\"mobile-webmail\":{\"2021-12\":\"n #1\"}},\"orange\":{\"desktop-webmail\":{\"2021-12\":\"n #1\"},\"ios\":{\"2021-12\":\"n #1\"},\"android\":{\"2021-12\":\"n #1\"}},\"outlook\":{\"windows\":{\"2007\":\"u\",\"2010\":\"u\",\"2013\":\"u\",\"2016\":\"u\",\"2019\":\"a #3\"},\"windows-mail\":{\"2021-12\":\"a #3\"},\"macos\":{\"16.57\":\"n #1\",\"16.80\":\"a #3\"},\"outlook-com\":{\"2021-12\":\"n #1\",\"2024-01\":\"n #1\"},\"ios\":{\"2021-12\":\"a #3\"},\"android\":{\"2021-12\":\"a #3\"}},\"samsung-email\":{\"android\":{\"6.0\":\"a #3\"}},\"sfr\":{\"desktop-webmail\":{\"2021-12\":\"y #2\"},\"ios\":{\"2021-12\":\"n #1\"},\"android\":{\"2021-12\":\"n #1\"}},\"thunderbird\":{\"macos\":{\"78.14\":\"a #3\"}},\"aol\":{\"desktop-webmail\":{\"2021-12\":\"n #1\"},\"ios\":{\"2021-12\":\"n #1\"},\"android\":{\"2021-12\":\"n #1\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-12\":\"n #1\"},\"ios\":{\"2021-12\":\"n #1\"},\"android\":{\"2021-12\":\"n #1\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-12\":\"n #1\"},\"ios\":{\"2021-12\":\"n #1\"},\"android\":{\"2021-12\":\"n #1\"}},\"hey\":{\"desktop-webmail\":{\"2021-12\":\"n #1\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-12\":\"n #1\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-12\":\"n #1\"}},\"laposte\":{\"desktop-webmail\":{\"2021-12\":\"y #2\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n #1\"},\"ios\":{\"2022-11\":\"a #3\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n #1\"},\"ios\":{\"2022-11\":\"a #3\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"n #1\"},\"android\":{\"2022-11\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Not supported. `target=\\\"_blank\\\"` is forced on all links.\",\"2\":\"Supported. But `target=\\\"_blank\\\"` is forced if the attribute is missing.\",\"3\":\"The attribute is supported but links always open in a new window anyway.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-textarea\",\n\t\t\t\"title\":\"<textarea> element\",\n\t\t\t\"description\":\"HTML input type text\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-textarea/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"form, text\",\n\t\t\t\"last_test_date\":\"2019-09-10\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-forms.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/MOk8g8TWwCTL4vLGrdMIgu3Vncqdxif6KlK4g8HfUV1mB/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"10.3\":\"y #1\",\"12.4\":\"y #1\",\"13\":\"y\"},\"ios\":{\"10.3\":\"y #2\",\"12.4\":\"y #2\"}},\"gmail\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-01\":\"n #4\",\"2021-03\":\"y\",\"2024-04\":\"y\"},\"ios\":{\"2020-01\":\"n #4\",\"2024-04\":\"y\"},\"android\":{\"2020-01\":\"n #4\",\"2024-04\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"n\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-01\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"2019\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-09\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-06\":\"y\"},\"android\":{\"2019-06\":\"n #3\"}},\"samsung-email\":{\"android\":{\"6.0\":\"n #3\",\"9.0\":\"n #3\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"n #4\"},\"ios\":{\"2020-01\":\"y\",\"2021-06\":\"n #4\"},\"android\":{\"2020-01\":\"y\",\"2021-06\":\"n #4\"}},\"thunderbird\":{\"macos\":{\"68.4\":\"a #5\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"n #3\"}},\"aol\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"a #3\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"n #4\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}}},\n\t\t\t\"notes\":\"\",\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. Email scrolls to the end when space bar is pressed.  This can be fixed by wrapping the `<input>` in `<ul role=\\\"presentation\\\">`.\",\"2\":\"Buggy. Screen jumps when input is in focus.\",\"3\":\"Buggy. A number of android clients will not show the keyboard when the input is clicked.  Copy and pasting text works.\",\"4\":\"Not supported. `<textarea>` is transformed into `<notextarea>`.\",\"5\":\"Buggy. Interacting with the element submits the form it belongs in.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-valign\",\n\t\t\t\"title\":\"valign attribute\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-valign/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2020-12-21\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-vertical-align-html-valign.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/XDUBIjG7AOXLUwfUUDYDO68OO1POjklmaeeqkOeSylkJL/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-12\":\"y\"},\"ios\":{\"2020-12\":\"y\"},\"android\":{\"2020-12\":\"y\"},\"mobile-webmail\":{\"2020-12\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-12\":\"a #1\",\"2021-03\":\"y\"},\"ios\":{\"2020-12\":\"y\"},\"android\":{\"2020-12\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2020-12\":\"y\"},\"macos\":{\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-12\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-12\":\"y\"},\"android\":{\"2020-12\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-12\":\"y\"},\"ios\":{\"2020-12\":\"y\"},\"android\":{\"2020-12\":\"y\"}},\"thunderbird\":{\"macos\":{\"78.6\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-12\":\"y\"},\"ios\":{\"2020-12\":\"y\"},\"android\":{\"2020-12\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-12\":\"y\"},\"ios\":{\"2020-12\":\"y\"},\"android\":{\"2020-12\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-12\":\"y\"},\"ios\":{\"2020-12\":\"y\"},\"android\":{\"2020-12\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-12\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-12\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. The attribute is supported, but the webmail has default styles setting all `<td>` to `vertical-align:top` in CSS.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-video\",\n\t\t\t\"title\":\"<video> element\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-video/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"mp4\",\n\t\t\t\"last_test_date\":\"2019-09-16\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/html-video.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/C1W5YvmzphKLen2MeUNiYUoXRfk5w8WHKlhnXU7zWqJ7H/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.4\":\"a #5\",\"13.3\":\"a #5 #6\"}},\"gmail\":{\"desktop-webmail\":{\"2019-09\":\"n #2\"},\"ios\":{\"2019-09\":\"n #2\"},\"android\":{\"2019-09\":\"n #2\"},\"mobile-webmail\":{\"2020-02\":\"n #2\"}},\"orange\":{\"desktop-webmail\":{\"2019-09\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2019-09\":\"n #1\",\"2024-04\":\"n\"},\"android\":{\"2019-09\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"y #3\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-09\":\"n\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y #11\"},\"outlook-com\":{\"2019-09\":\"n #4\",\"2021-05\":\"n #8\",\"2024-01\":\"n #8\"},\"ios\":{\"2019-09\":\"n #4\",\"2021-05\":\"n #8\"},\"android\":{\"2019-09\":\"n #4\",\"2021-05\":\"n #8\"}},\"samsung-email\":{\"android\":{\"6.0\":\"a #5\"}},\"sfr\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.9\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-09\":\"n #4\"},\"ios\":{\"2019-09\":\"n #4\"},\"android\":{\"2019-09\":\"n #4\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-09\":\"n #4\"},\"ios\":{\"2019-09\":\"n #4\"},\"android\":{\"2019-09\":\"n #4\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n #7\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n #9\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"n #10\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y #5 #6\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"y #5 #6\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"n #9\"},\"android\":{\"2022-11\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"The `<video>` element is supported, but not the inner `<source>` element so no video can play.\",\"2\":\"`<video>`, `<source>` and `<track>` tags are replaced by `<u></u>` tags.\",\"3\":\"Buggy. Support depends on the version of Internet Explorer installed.\",\"4\":\"`<video>`, `<source>` and `<track>` tags are stripped. The fallback content is shown instead.\",\"5\":\"Partial support. Requires the `controls` attribute to play.\",\"6\":\"Partial support. `autoplay` is not supported.\",\"7\":\"The `<video>` element is supported and the `poster` image is visible, but the video can not play due to a strict Content Security Policy.\",\"8\":\"The `<video>` element is supported, but the video can not play due to a strict Content Security Policy.\",\"9\":\"Not supported. The `<video>` element and all its content is removed.\",\"10\":\"Not supported. The `src` attribute is replaced by `data-src`.\",\"11\":\"Partial support. The video can be played by doing a right click and play. But `autoplay` and `controls` are not supported.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-wbr\",\n\t\t\t\"title\":\"<wbr> element\",\n\t\t\t\"description\":\"\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-wbr/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-02-28\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/HTML5.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/Bzyzx8Z5Kvlfib1Fw9Ted8xtPE26RcjPSdUobdUywgJVm/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y\"},\"ios\":{\"12.1\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-02\":\"n\",\"2020-02\":\"y\"},\"ios\":{\"2019-02\":\"n\"},\"android\":{\"2019-02\":\"a #1\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2019-08\":\"y\",\"2021-03\":\"n\",\"2024-04\":\"n\"},\"ios\":{\"2019-08\":\"y\",\"2024-04\":\"y\"},\"android\":{\"2020-01\":\"y\",\"2024-04\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2019-02\":\"n\"},\"macos\":{\"2019-02\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.8\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-02\":\"y\"},\"ios\":{\"2019-02\":\"y\"},\"android\":{\"2019-02\":\"y\"}},\"samsung-email\":{\"android\":{\"5.0.10.2\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-01\":\"y\"},\"ios\":{\"2020-01\":\"y\"},\"android\":{\"2020-01\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"n\"},\"android\":{\"2022-11\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"n\"},\"ios\":{\"2022-11\":\"n\"},\"android\":{\"2022-11\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial. Supported with non Google accounts only.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"html-width\",\n\t\t\t\"title\":\"width attribute\",\n\t\t\t\"description\":null,\n\t\t\t\"url\":\"https://www.caniemail.com/features/html-width/\",\n\t\t\t\"category\":\"html\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":null,\n\t\t\t\"last_test_date\":\"2019-09-27\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/css-width-height.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/UhsQmS14DHKFfotKEcCTnWaoAiS24FJMiApZ1OtmHR7vs/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"12.4\":\"y #3\"},\"ios\":{\"12.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2019-09\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"a #1 #2\",\"2010\":\"a #1 #2\",\"2013\":\"a #1 #2\",\"2016\":\"a #1 #2\",\"2019\":\"a #1 #2\"},\"windows-mail\":{\"2019-09\":\"y\"},\"macos\":{\"2011\":\"y\",\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2019-09\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"samsung-email\":{\"android\":{\"6.0\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"thunderbird\":{\"macos\":{\"60.3\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2019-09\":\"y\"},\"ios\":{\"2019-09\":\"y\"},\"android\":{\"2019-09\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"ios\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-11\":\"y\"},\"android\":{\"2022-11\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. Percentage width on `<img>` elements are based on the physical file's width, not on the parent element's width.\",\"2\":\"Buggy. Sizes set in attributes don't scale in 120 dpi mode.\",\"3\":\"The `width` attribute is ignored on CID embedded images. See [#171](https://github.com/hteumeuleu/caniemail/issues/171).\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"image-apng\",\n\t\t\t\"title\":\"Animated PNG image format\",\n\t\t\t\"description\":\"Support for animated PNG image format\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/image-apng/\",\n\t\t\t\"category\":\"image\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"image, apng, png, animation\",\n\t\t\t\"last_test_date\":\"2020-02-06\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/images.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/xm1T5nQ1MKtHpVSJidhagmt3Z53CjqbkMhorlvuM0Gz57/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-02\":\"n\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-02\":\"n\"},\"macos\":{\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"n\"}},\"samsung-email\":{\"android\":{\"9.0\":\"y\"}},\"thunderbird\":{\"windows\":{\"2020-02\":\"y\"},\"macos\":{\"68.4\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-02\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}}},\n\t\t\t\"notes\":\"In all cases where the Animated PNG fails, it will fallback to show the first frame of the animation.\",\n\t\t\t\"notes_by_num\":{}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"image-avif\",\n\t\t\t\"title\":\"AVIF image format\",\n\t\t\t\"description\":\"A modern image format based on the AV1 video format.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/image-avif/\",\n\t\t\t\"category\":\"image\",\n\t\t\t\"tags\":[\"performance\"],\n\t\t\t\"keywords\":\"image,img,AVIF\",\n\t\t\t\"last_test_date\":\"2023-01-14\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/images.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/d69Dg0BGvV5zAiHI1fjKX6BDulsODM8eYdKO9QVxlNuUL/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"11\":\"n\",\"12\":\"n\",\"13\":\"n\",\"13.1\":\"a #2\",\"13.3\":\"y\"},\"ios\":{\"11\":\"n\",\"12\":\"n\",\"13\":\"n\",\"14\":\"n\",\"16.2\":\"a #2\",\"16.4\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-01\":\"n\",\"2023-01\":\"a #3\"},\"ios\":{\"2021-01\":\"n\",\"2023-01\":\"a #3\"},\"android\":{\"2021-01\":\"n\",\"2023-01\":\"a #3\"},\"mobile-webmail\":{\"2021-01\":\"n\",\"2023-01\":\"a #3\"}},\"orange\":{\"desktop-webmail\":{\"2021-01\":\"y #1\"},\"ios\":{\"2021-01\":\"n\"},\"android\":{\"2021-05\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2021-01\":\"n\"},\"macos\":{\"2021-01\":\"n\",\"2023-01\":\"a #2\",\"2023-04\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2021-01\":\"y #1\",\"2024-01\":\"y #1\"},\"ios\":{\"2021-01\":\"n\",\"2023-01\":\"a #2\",\"2023-04\":\"y\"},\"android\":{\"4.2116.0\":\"y #1\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-01\":\"y #1\"},\"ios\":{\"2021-01\":\"n\",\"2023-01\":\"a #2\",\"2023-04\":\"y\"},\"android\":{\"6.27\":\"y #1\"}},\"aol\":{\"desktop-webmail\":{\"2021-01\":\"y #1\"},\"ios\":{\"2021-01\":\"n\",\"2023-01\":\"a #2\",\"2023-04\":\"y\"},\"android\":{\"2021-05\":\"y #1\"}},\"samsung-email\":{\"android\":{\"6.1.31.2\":\"n\",\"6.1.74.5\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2021-01\":\"y #1\"},\"ios\":{\"2021-01\":\"n\"},\"android\":{\"2021-05\":\"y #1\"}},\"thunderbird\":{\"macos\":{\"78.10\":\"n\",\"102.6\":\"y #2\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-01\":\"y #1\"},\"ios\":{\"2021-05\":\"n\",\"2023-01\":\"n\",\"2023-04\":\"y\"},\"android\":{\"2021-01\":\"y #1\"}},\"hey\":{\"desktop-webmail\":{\"2021-05\":\"n\",\"2023-01\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-01\":\"n\",\"2023-01\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n\",\"2023-01\":\"n\",\"2023-04\":\"n\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"n\"},\"android\":{\"2022-09\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"n\"},\"android\":{\"2022-09\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"android\":{\"2022-09\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Support depends on browser and operating system support.\",\"2\":\"Does not support animated AVIF.\",\"3\":\"Converts still AVIF into a jpg and animated AVIF into a gif.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"image-base64\",\n\t\t\t\"title\":\"Base 64 image format\",\n\t\t\t\"description\":\"Support for Base 64 image format\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/image-base64/\",\n\t\t\t\"category\":\"image\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"image, Base 64\",\n\t\t\t\"last_test_date\":\"2020-02-06\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/images.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/xm1T5nQ1MKtHpVSJidhagmt3Z53CjqbkMhorlvuM0Gz57/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-02\":\"n\",\"2024-05\":\"n\"},\"ios\":{\"2020-02\":\"n\"},\"android\":{\"2020-02\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"a #1\"},\"windows-mail\":{\"2020-02\":\"n\"},\"macos\":{\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"samsung-email\":{\"android\":{\"9.0\":\"y\"}},\"thunderbird\":{\"windows\":{\"2020-02\":\"y\"},\"macos\":{\"68.4\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-02\":\"n #2\",\"2021-03\":\"y\",\"2024-04\":\"y\"},\"ios\":{\"2020-02\":\"n\",\"2024-04\":\"y\"},\"android\":{\"2020-02\":\"n\",\"2024-04\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-02\":\"n #2\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"n #2\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"a #3\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"a #3\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial: Does not render Base 64 gif format.\",\"2\":\"Not supported. The `src` is turned into a `nosrc` attribute.\",\"3\":\"Partial: Only supports Base 64 png format.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"image-bmp\",\n\t\t\t\"title\":\"BMP image format\",\n\t\t\t\"description\":\"Support for BMP image format\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/image-bmp/\",\n\t\t\t\"category\":\"image\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"image, BMP\",\n\t\t\t\"last_test_date\":\"2020-02-06\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/images.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/xm1T5nQ1MKtHpVSJidhagmt3Z53CjqbkMhorlvuM0Gz57/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-02\":\"a #1\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"a #1\"},\"mobile-webmail\":{\"2020-02\":\"a #1\"}},\"outlook\":{\"windows\":{\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2020-02\":\"y\"},\"macos\":{\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"samsung-email\":{\"android\":{\"9.0\":\"y\"}},\"thunderbird\":{\"windows\":{\"2020-02\":\"y\"},\"macos\":{\"68.4\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-02\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"ios\":{\"2022-09\":\"n\"},\"android\":{\"2022-09\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"ios\":{\"2022-09\":\"n\"},\"android\":{\"2022-09\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. An image renders but the colours are incorrect.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"image-gif\",\n\t\t\t\"title\":\"GIF image format\",\n\t\t\t\"description\":\"Support for GIF image format\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/image-gif/\",\n\t\t\t\"category\":\"image\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"image, GIF\",\n\t\t\t\"last_test_date\":\"2020-02-06\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/images.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/xm1T5nQ1MKtHpVSJidhagmt3Z53CjqbkMhorlvuM0Gz57/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"a #1\",\"2010\":\"a #1\",\"2013\":\"a #1\",\"2016\":\"a #1\",\"2019\":\"a #1 #2\"},\"windows-mail\":{\"2020-02\":\"y\"},\"macos\":{\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"samsung-email\":{\"android\":{\"9.0\":\"y\"}},\"thunderbird\":{\"windows\":{\"2020-02\":\"y\"},\"macos\":{\"68.4\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-02\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial: Does not support animated gif images.\",\"2\":\"Partial: With an Office 365 account, it plays the animation 3 times then stops and shows a play button to reactivate the animation.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"image-hdr\",\n\t\t\t\"title\":\"HDR image format\",\n\t\t\t\"description\":\"Support for HDR image format\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/image-hdr/\",\n\t\t\t\"category\":\"image\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"image, HDR\",\n\t\t\t\"last_test_date\":\"2020-02-06\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/images.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/xm1T5nQ1MKtHpVSJidhagmt3Z53CjqbkMhorlvuM0Gz57/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"n\"},\"ios\":{\"13\":\"n\"}},\"gmail\":{\"desktop-webmail\":{\"2020-02\":\"n\"},\"ios\":{\"2020-02\":\"n\"},\"android\":{\"2020-02\":\"n\"},\"mobile-webmail\":{\"2020-02\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-02\":\"n\"},\"macos\":{\"2016\":\"y\",\"2019\":\"n\",\"16.80\":\"n\"},\"outlook-com\":{\"2020-02\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2020-02\":\"n\"},\"android\":{\"2020-02\":\"n\"}},\"samsung-email\":{\"android\":{\"9.0\":\"n\"}},\"thunderbird\":{\"windows\":{\"2020-02\":\"n\"},\"macos\":{\"68.4\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2020-02\":\"n\"},\"ios\":{\"2020-02\":\"n\"},\"android\":{\"2020-02\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-02\":\"n\"},\"ios\":{\"2020-02\":\"n\"},\"android\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2020-02\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\"},\"ios\":{\"2020-03\":\"n\"},\"android\":{\"2020-03\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"n\"},\"android\":{\"2022-09\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"n\"},\"android\":{\"2022-09\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"android\":{\"2022-09\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"image-heif\",\n\t\t\t\"title\":\"HEIF image format\",\n\t\t\t\"description\":\"Support for HEIF image format\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/image-heif/\",\n\t\t\t\"category\":\"image\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"image, HEIF\",\n\t\t\t\"last_test_date\":\"2020-02-06\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/images.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/xm1T5nQ1MKtHpVSJidhagmt3Z53CjqbkMhorlvuM0Gz57/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"n\",\"16\":\"y #2\"},\"ios\":{\"13\":\"n\",\"17\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-02\":\"a #1\",\"2024-02\":\"a #1\"},\"ios\":{\"2020-02\":\"n\",\"2024-02\":\"a #1\"},\"android\":{\"2020-02\":\"a #1\",\"2024-02\":\"a #1\"},\"mobile-webmail\":{\"2020-02\":\"a #1\",\"2024-02\":\"a #1\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-02\":\"n\",\"2024-02\":\"n\"},\"macos\":{\"2016\":\"n\",\"2019\":\"n\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-02\":\"n\",\"2024-02\":\"n\"},\"ios\":{\"2020-02\":\"n\",\"2024-02\":\"y\"},\"android\":{\"2020-02\":\"n\",\"2024-02\":\"n\"}},\"samsung-email\":{\"android\":{\"9.0\":\"n\"}},\"thunderbird\":{\"windows\":{\"2020-02\":\"n\",\"115.7\":\"n\"},\"macos\":{\"68.4\":\"n\",\"115.7\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2020-02\":\"n\",\"2024-02\":\"a #3\"},\"ios\":{\"2020-02\":\"n\",\"2024-02\":\"y\"},\"android\":{\"2020-02\":\"n\",\"2024-02\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-02\":\"n\",\"2024-02\":\"a #3\"},\"ios\":{\"2020-02\":\"n\",\"2024-02\":\"y\"},\"android\":{\"2020-02\":\"n\",\"2024-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2020-02\":\"n\",\"2021-03\":\"n\"},\"ios\":{\"2020-02\":\"n\"},\"android\":{\"2020-02\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2020-02\":\"n\"},\"ios\":{\"2020-02\":\"n\"},\"android\":{\"2020-02\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"n\",\"2024-02\":\"a #3\"},\"ios\":{\"2020-03\":\"n\",\"2024-02\":\"y\"},\"android\":{\"2020-03\":\"n\",\"2024-02\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"n\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"n\"},\"android\":{\"2022-09\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"n\"},\"android\":{\"2022-09\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"android\":{\"2022-09\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial: Converts file to jpg.\",\"2\":\"Supported on macOS 14 Sonoma.\",\"3\":\"Partial. Webmail rendering depends on browser support.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"image-ico\",\n\t\t\t\"title\":\"ICO image format\",\n\t\t\t\"description\":\"Support for ICO image format\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/image-ico/\",\n\t\t\t\"category\":\"image\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"image, ICO\",\n\t\t\t\"last_test_date\":\"2020-02-06\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/images.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/xm1T5nQ1MKtHpVSJidhagmt3Z53CjqbkMhorlvuM0Gz57/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-02\":\"y\"},\"macos\":{\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"samsung-email\":{\"android\":{\"9.0\":\"y\"}},\"thunderbird\":{\"windows\":{\"2020-02\":\"y\"},\"macos\":{\"68.4\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-02\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"image-jpg\",\n\t\t\t\"title\":\"JPG image format\",\n\t\t\t\"description\":\"Support for JPG image format\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/image-jpg/\",\n\t\t\t\"category\":\"image\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"image, JPG, jpeg\",\n\t\t\t\"last_test_date\":\"2020-02-06\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/images.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/xm1T5nQ1MKtHpVSJidhagmt3Z53CjqbkMhorlvuM0Gz57/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2020-02\":\"y\"},\"macos\":{\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"samsung-email\":{\"android\":{\"9.0\":\"y\"}},\"thunderbird\":{\"windows\":{\"2020-02\":\"y\"},\"macos\":{\"68.4\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-02\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"image-mp4\",\n\t\t\t\"title\":\"Video as Image Assets\",\n\t\t\t\"description\":\"WebKit in Safari supports loading H.264 encoded MP4 video with an HTML tag or as a CSS background.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/image-mp4/\",\n\t\t\t\"category\":\"image\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"mp4\",\n\t\t\t\"last_test_date\":\"2021-05-13\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/video-as-img.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/zvrwvdXQmUlQraqSQK6MRTbapeBTBX0VBWZ0h2KMVCwk6/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"11\":\"y\",\"14\":\"y\"},\"ios\":{\"11\":\"y\",\"14.5\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2021-05\":\"n\"},\"ios\":{\"2021-05\":\"n\"},\"android\":{\"2021-05\":\"n\"},\"mobile-webmail\":{\"2021-05\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2021-05\":\"y #1\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"n\"}},\"outlook\":{\"windows\":{\"2003\":\"n #2\",\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2021-05\":\"n\"},\"macos\":{\"2011\":\"n\",\"2016\":\"y\",\"16.80\":\"n\"},\"outlook-com\":{\"2021-05\":\"y #1\",\"2024-01\":\"y #1\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"n\"}},\"samsung-email\":{\"android\":{\"6.0\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2021-05\":\"y #1\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"n\"}},\"thunderbird\":{\"macos\":{\"78.19\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2021-05\":\"y #1\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2021-05\":\"y #1\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2021-05\":\"y #1\"},\"ios\":{\"2021-05\":\"y\"},\"android\":{\"2021-05\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2021-05\":\"n\"}},\"mail-ru\":{\"desktop-webmail\":{\"2021-05\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y #1\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"android\":{\"2022-09\":\"n\"}}},\n\t\t\t\"notes\":\"As of may 2021, using Video as Image Assets is only supported in WebKit and Safari.\",\n\t\t\t\"notes_by_num\":{\"1\":\"Only supported in Safari.\",\"2\":\"A similar can be achieved using the [`dynsrc` attribute](https://docs.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/ms533742(v=vs.85)).\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"image-png\",\n\t\t\t\"title\":\"PNG image format\",\n\t\t\t\"description\":\"Support for PNG image format\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/image-png/\",\n\t\t\t\"category\":\"image\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"image, PNG\",\n\t\t\t\"last_test_date\":\"2020-02-06\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/images.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/xm1T5nQ1MKtHpVSJidhagmt3Z53CjqbkMhorlvuM0Gz57/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"},\"mobile-webmail\":{\"2020-02\":\"y\"}},\"outlook\":{\"windows\":{\"2003\":\"y\",\"2007\":\"y\",\"2010\":\"y\",\"2013\":\"y\",\"2016\":\"y\",\"2019\":\"y\"},\"windows-mail\":{\"2020-02\":\"y\"},\"macos\":{\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"samsung-email\":{\"android\":{\"9.0\":\"y\"}},\"thunderbird\":{\"windows\":{\"2020-02\":\"y\"},\"macos\":{\"68.4\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-02\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":null\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"image-svg\",\n\t\t\t\"title\":\"SVG image format\",\n\t\t\t\"description\":\"Support for SVG image format, as a linked file. See [SVG html](/features/html-svg) for embedded SVG support.\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/image-svg/\",\n\t\t\t\"category\":\"image\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"image, SVG\",\n\t\t\t\"last_test_date\":\"2023-01-15\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/images.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/xm1T5nQ1MKtHpVSJidhagmt3Z53CjqbkMhorlvuM0Gz57/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"n\",\"14\":\"y\"},\"ios\":{\"13\":\"n\",\"14\":\"n\",\"15\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-02\":\"n\",\"2023-01\":\"n\",\"2024-07\":\"n\"},\"ios\":{\"2020-02\":\"a #1\",\"2023-01\":\"a #1\"},\"android\":{\"2020-02\":\"a #1\",\"2023-01\":\"a #1\"},\"mobile-webmail\":{\"2020-02\":\"n\",\"2023-01\":\"n\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"y\"},\"windows-mail\":{\"2020-02\":\"y\"},\"macos\":{\"2016\":\"n\",\"13.1\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-02\":\"y\",\"2024-01\":\"y\"},\"ios\":{\"2020-02\":\"n\",\"2023-01\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"samsung-email\":{\"android\":{\"9.0\":\"y\"}},\"thunderbird\":{\"windows\":{\"2020-02\":\"y\"},\"macos\":{\"68.4\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-02\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\",\"2023-01\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partially supported. Only works with non Google accounts.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"image-tiff\",\n\t\t\t\"title\":\"TIFF image format\",\n\t\t\t\"description\":\"Support for TIFF image format\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/image-tiff/\",\n\t\t\t\"category\":\"image\",\n\t\t\t\"tags\":[],\n\t\t\t\"keywords\":\"image, TIFF\",\n\t\t\t\"last_test_date\":\"2020-02-06\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/images.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/xm1T5nQ1MKtHpVSJidhagmt3Z53CjqbkMhorlvuM0Gz57/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"y\"},\"ios\":{\"13\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-02\":\"a #1\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"a #1 #2\"},\"mobile-webmail\":{\"2020-02\":\"a #1\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-02\":\"y\"},\"macos\":{\"2016\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-02\":\"n\",\"2024-01\":\"n\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"n\"}},\"samsung-email\":{\"android\":{\"9.0\":\"n\"}},\"thunderbird\":{\"windows\":{\"2020-02\":\"n\"},\"macos\":{\"68.4\":\"n\"}},\"aol\":{\"desktop-webmail\":{\"2020-02\":\"n\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"n\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-02\":\"n\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"n\"}},\"orange\":{\"desktop-webmail\":{\"2020-02\":\"y\",\"2021-03\":\"n\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"n\"}},\"sfr\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"y\"},\"android\":{\"2020-02\":\"n\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"y\"},\"android\":{\"2020-03\":\"n\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"n\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"n\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"n\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"n\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"n\"},\"android\":{\"2022-09\":\"n\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Buggy. An image renders but the colours are incorrect.\",\"2\":\"Partial. Does not render with non Google accounts.\"}\n\t\t},\n\t\n\t\t{\n\t\t\t\"slug\":\"image-webp\",\n\t\t\t\"title\":\"webP image format\",\n\t\t\t\"description\":\"Support for webP image format\",\n\t\t\t\"url\":\"https://www.caniemail.com/features/image-webp/\",\n\t\t\t\"category\":\"image\",\n\t\t\t\"tags\":[\"performance\"],\n\t\t\t\"keywords\":\"image, webP\",\n\t\t\t\"last_test_date\":\"2021-02-06\",\n\t\t\t\"test_url\":\"https://www.caniemail.com/tests/images.html\",\n\t\t\t\"test_results_url\":\"https://app.emailonacid.com/app/acidtest/Fykm4EjEiDat8FSTWcKYdh26kFWklJuyERBKIsasMB2VH/list\",\n\t\t\t\"stats\":{\"apple-mail\":{\"macos\":{\"13\":\"n\",\"14\":\"y\"},\"ios\":{\"13\":\"n\",\"14\":\"y\"}},\"gmail\":{\"desktop-webmail\":{\"2020-02\":\"a #1 #3\"},\"ios\":{\"2020-02\":\"n\",\"2021-02\":\"a #1 #3\"},\"android\":{\"2020-02\":\"a #1 #3\"},\"mobile-webmail\":{\"2020-02\":\"a #1 #3\"}},\"outlook\":{\"windows\":{\"2007\":\"n\",\"2010\":\"n\",\"2013\":\"n\",\"2016\":\"n\",\"2019\":\"n\"},\"windows-mail\":{\"2020-02\":\"y #3\"},\"macos\":{\"2016\":\"n\",\"16.47\":\"y\",\"16.80\":\"y\"},\"outlook-com\":{\"2020-02\":\"y #2\",\"2024-01\":\"y #2\"},\"ios\":{\"2020-02\":\"n\",\"2021-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"samsung-email\":{\"android\":{\"9.0\":\"y\"}},\"thunderbird\":{\"windows\":{\"2020-02\":\"y\"},\"macos\":{\"68.4\":\"y\"}},\"aol\":{\"desktop-webmail\":{\"2020-02\":\"y #2\"},\"ios\":{\"2020-02\":\"n\",\"2021-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"yahoo\":{\"desktop-webmail\":{\"2020-02\":\"y #2\"},\"ios\":{\"2020-02\":\"n\",\"2021-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"orange\":{\"desktop-webmail\":{\"2020-02\":\"y\",\"2021-03\":\"y\"},\"ios\":{\"2020-02\":\"n\",\"2021-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"sfr\":{\"desktop-webmail\":{\"2020-02\":\"y\"},\"ios\":{\"2020-02\":\"n\",\"2021-02\":\"y\"},\"android\":{\"2020-02\":\"y\"}},\"protonmail\":{\"desktop-webmail\":{\"2020-03\":\"y\"},\"ios\":{\"2020-03\":\"n\",\"2021-02\":\"y\"},\"android\":{\"2020-03\":\"y\"}},\"hey\":{\"desktop-webmail\":{\"2020-06\":\"y\"}},\"mail-ru\":{\"desktop-webmail\":{\"2020-10\":\"y\"}},\"fastmail\":{\"desktop-webmail\":{\"2021-07\":\"y\"}},\"laposte\":{\"desktop-webmail\":{\"2021-08\":\"y\"}},\"gmx\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"web-de\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"ios\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}},\"ionos-1and1\":{\"desktop-webmail\":{\"2022-09\":\"y\"},\"android\":{\"2022-09\":\"y\"}}},\n\t\t\t\"notes\":null,\n\t\t\t\"notes_by_num\":{\"1\":\"Partial: Converts file to jpg.\",\"2\":\"Partial. Webmail rendering depends on browser support.\",\"3\":\"Partial. Does not support animation.\"}\n\t\t}\n\t]\n}"
  },
  {
    "path": "internal/htmlcheck/caniemail.go",
    "content": "// Package htmlcheck is used for parsing HTML and returning\n// HTML compatibility errors and warnings\npackage htmlcheck\n\nimport (\n\t\"embed\"\n\t\"encoding/json\"\n\t\"regexp\"\n)\n\n//go:embed caniemail-data.json\nvar embeddedFS embed.FS\n\nvar (\n\tcie = CanIEmail{}\n\n\tnoteMatch = regexp.MustCompile(` #(\\d)+$`)\n\n\t// LimitFamilies will limit results to families if set\n\tLimitFamilies = []string{}\n\n\t// LimitPlatforms will limit results to platforms if set\n\tLimitPlatforms = []string{}\n\n\t// LimitClients will limit results to clients if set\n\tLimitClients = []string{}\n)\n\n// CanIEmail struct for JSON data\ntype CanIEmail struct {\n\tAPIVersion     string `json:\"api_version\"`\n\tLastUpdateDate string `json:\"last_update_date\"`\n\t// NiceNames map[string]string `json:\"last_update_date\"`\n\tNiceNames struct {\n\t\tFamily   map[string]string `json:\"family\"`\n\t\tPlatform map[string]string `json:\"platform\"`\n\t\tSupport  map[string]string `json:\"support\"`\n\t\tCategory map[string]string `json:\"category\"`\n\t} `json:\"nicenames\"`\n\tData []JSONResult `json:\"data\"`\n}\n\n// JSONResult struct for CanIEmail Data\ntype JSONResult struct {\n\tSlug           string            `json:\"slug\"`\n\tTitle          string            `json:\"title\"`\n\tDescription    string            `json:\"description\"`\n\tURL            string            `json:\"url\"`\n\tCategory       string            `json:\"category\"`\n\tTags           []string          `json:\"tags\"`\n\tKeywords       string            `json:\"keywords\"`\n\tLastTestDate   string            `json:\"last_test_date\"`\n\tTestURL        string            `json:\"test_url\"`\n\tTestResultsURL string            `json:\"test_results_url\"`\n\tStats          map[string]any    `json:\"stats\"`\n\tNotes          string            `json:\"notes\"`\n\tNotesByNumber  map[string]string `json:\"notes_by_num\"`\n}\n\n// Load the JSON data\nfunc loadJSONData() error {\n\tif cie.APIVersion != \"\" {\n\t\treturn nil\n\t}\n\n\tb, err := embeddedFS.ReadFile(\"caniemail-data.json\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tcie = CanIEmail{}\n\n\treturn json.Unmarshal(b, &cie)\n}\n"
  },
  {
    "path": "internal/htmlcheck/config.go",
    "content": "package htmlcheck\n\nimport \"regexp\"\n\n// HTML tests\nvar htmlTests = map[string]string{\n\t// body check is manually done because it always exists in *goquery.Document\n\t\"html-body\": \"body\",\n\t// HTML tests\n\t\"html-object\":         \"object, embed, image, pdf\",\n\t\"html-link\":           \"link\",\n\t\"html-hr\":             \"hr\",\n\t\"html-dialog\":         \"dialog\",\n\t\"html-srcset\":         \"[srcset]\",\n\t\"html-picture\":        \"picture\",\n\t\"html-svg\":            \"svg\",\n\t\"html-progress\":       \"progress\",\n\t\"html-required\":       \"[required]\",\n\t\"html-meter\":          \"meter\",\n\t\"html-audio\":          \"audio\",\n\t\"html-form\":           \"form\",\n\t\"html-input-submit\":   \"submit\",\n\t\"html-button-reset\":   \"button[type=\\\"reset\\\"]\",\n\t\"html-button-submit\":  \"submit, button[type=\\\"submit\\\"]\",\n\t\"html-base\":           \"base\",\n\t\"html-input-checkbox\": \"checkbox\",\n\t\"html-input-hidden\":   \"[type=\\\"hidden\\\"]\",\n\t\"html-input-radio\":    \"radio\",\n\t\"html-input-text\":     \"input[type=\\\"text\\\"]\",\n\t\"html-video\":          \"video\",\n\t\"html-semantics\":      \"article, aside, details, figcaption, figure, footer, header, main, mark, nav, section, summary, time\",\n\t\"html-select\":         \"select\",\n\t\"html-textarea\":       \"textarea\",\n\t\"html-anchor-links\":   \"a[href^=\\\"#\\\"]\",\n\t\"html-style\":          \"style\",\n\t\"html-image-maps\":     \"map, img[usemap]\",\n}\n\n// Image tests using regex to match against img[src]\nvar imageRegexpTests = map[string]*regexp.Regexp{\n\t\"image-apng\":   regexp.MustCompile(`(?i)\\.apng$`),\n\t\"image-avif\":   regexp.MustCompile(`(?i)\\.avif$`),\n\t\"image-base64\": regexp.MustCompile(`^(?i)data:image\\/`),\n\t\"image-bmp\":    regexp.MustCompile(`(?i)\\.bmp$`),\n\t\"image-gif\":    regexp.MustCompile(`(?i)\\.gif$`),\n\t\"image-hdr\":    regexp.MustCompile(`(?i)\\.hdr$`),\n\t\"image-heif\":   regexp.MustCompile(`(?i)\\.heif$`),\n\t\"image-ico\":    regexp.MustCompile(`(?i)\\.ico$`),\n\t\"image-mp4\":    regexp.MustCompile(`(?i)\\.mp4$`),\n\t\"image-ppm\":    regexp.MustCompile(`(?i)\\.ppm$`),\n\t\"image-svg\":    regexp.MustCompile(`(?i)\\.svg$`),\n\t\"image-tiff\":   regexp.MustCompile(`(?i)\\.tiff?$`),\n\t\"image-webp\":   regexp.MustCompile(`(?i)\\.webp$`),\n}\n\n// inline attribute <match>=\"\"\nvar styleInlineAttributes = map[string]string{\n\t\"css-background-color\": \"[bgcolor]\",\n\t\"css-background\":       \"[background]\",\n\t\"css-border\":           \"[border]\",\n\t\"css-height\":           \"[height]\",\n\t\"css-padding\":          \"[padding]\",\n\t\"css-width\":            \"[width]\",\n}\n\n// inline style=\"<match>\"\nvar cssInlineRegexTests = map[string]*regexp.Regexp{\n\t\"css-accent-color\":                   regexp.MustCompile(`(?i)(^|\\s|;)accent-color(\\s+)?:`),\n\t\"css-align-items\":                    regexp.MustCompile(`(?i)(^|\\s|;)align-items(\\s+)?:`),\n\t\"css-aspect-ratio\":                   regexp.MustCompile(`(?i)(^|\\s|;)aspect-ratio(\\s+)?:`),\n\t\"css-background-blend-mode\":          regexp.MustCompile(`(?i)(^|\\s|;)background-blend-mode(\\s+)?:`),\n\t\"css-background-clip\":                regexp.MustCompile(`(?i)(^|\\s|;)background-clip(\\s+)?:`),\n\t\"css-background-color\":               regexp.MustCompile(`(?i)(^|\\s|;)background-color(\\s+)?:`),\n\t\"css-background-image\":               regexp.MustCompile(`(?i)(^|\\s|;)background-image(\\s+)?:`),\n\t\"css-background-origin\":              regexp.MustCompile(`(?i)(^|\\s|;)background-origin(\\s+)?:`),\n\t\"css-background-position\":            regexp.MustCompile(`(?i)(^|\\s|;)background-position(\\s+)?:`),\n\t\"css-background-repeat\":              regexp.MustCompile(`(?i)(^|\\s|;)background-repeat(\\s+)?:`),\n\t\"css-background-size\":                regexp.MustCompile(`(?i)(^|\\s|;)background-size(\\s+)?:`),\n\t\"css-background\":                     regexp.MustCompile(`(?i)(^|\\s|;)background(\\s+)?:`),\n\t\"css-block-inline-size\":              regexp.MustCompile(`(?i)(^|\\s|;)block-inline-size(\\s+)?:`),\n\t\"css-border-image\":                   regexp.MustCompile(`(?i)(^|\\s|;)border-image(\\s+)?:`),\n\t\"css-border-inline-block-individual\": regexp.MustCompile(`(?i)(^|\\s|;)border-inline(\\s+)?:`),\n\t\"css-border-radius\":                  regexp.MustCompile(`(?i)(^|\\s|;)border-radius(\\s+)?:`),\n\t\"css-border\":                         regexp.MustCompile(`(?i)(^|\\s|;)border(\\s+)?:`),\n\t\"css-box-shadow\":                     regexp.MustCompile(`(?i)(^|\\s|;)box-shadow(\\s+)?:`),\n\t\"css-box-sizing\":                     regexp.MustCompile(`(?i)(^|\\s|;)box-sizing(\\s+)?:`),\n\t\"css-caption-side\":                   regexp.MustCompile(`(?i)(^|\\s|;)caption-side(\\s+)?:`),\n\t\"css-clip-path\":                      regexp.MustCompile(`(?i)(^|\\s|;)clip-path(\\s+)?:`),\n\t\"css-column-count\":                   regexp.MustCompile(`(?i)(^|\\s|;)column-count(\\s+)?:`),\n\t\"css-column-layout-properties\":       regexp.MustCompile(`(?i)(^|\\s|;)column-layout-properties(\\s+)?:`),\n\t\"css-conic-gradient\":                 regexp.MustCompile(`(?i)(^|\\s|;)conic-gradient(\\s+)?:`),\n\t\"css-direction\":                      regexp.MustCompile(`(?i)(^|\\s|;)direction(\\s+)?:`),\n\t\"css-display-flex\":                   regexp.MustCompile(`(?i)(^|\\s|;)display(\\s+)?:(\\s+)?flex($|\\s|;)`),\n\t\"css-display-grid\":                   regexp.MustCompile(`(?i)(^|\\s|;)display:grid`),\n\t\"css-display-none\":                   regexp.MustCompile(`(?i)(^|\\s|;)display:none`),\n\t\"css-display\":                        regexp.MustCompile(`(?i)(^|\\s|;)display(\\s+)?:`),\n\t\"css-filter\":                         regexp.MustCompile(`(?i)(^|\\s|;)filter(\\s+)?:`),\n\t\"css-flex-direction\":                 regexp.MustCompile(`(?i)(^|\\s|;)flex-direction(\\s+)?:`),\n\t\"css-flex-wrap\":                      regexp.MustCompile(`(?i)(^|\\s|;)flex-wrap(\\s+)?:`),\n\t\"css-float\":                          regexp.MustCompile(`(?i)(^|\\s|;)float(\\s+)?:`),\n\t\"css-font-kerning\":                   regexp.MustCompile(`(?i)(^|\\s|;)font-kerning(\\s+)?:`),\n\t\"css-font-weight\":                    regexp.MustCompile(`(?i)(^|\\s|;)font-weight(\\s+)?:`),\n\t\"css-font\":                           regexp.MustCompile(`(?i)(^|\\s|;)font(\\s+)?:`),\n\t\"css-gap\":                            regexp.MustCompile(`(?i)(^|\\s|;)gap(\\s+)?:`),\n\t\"css-grid-template\":                  regexp.MustCompile(`(?i)(^|\\s|;)grid-template(\\s+)?:`),\n\t\"css-height\":                         regexp.MustCompile(`(?i)(^|\\s|;)height(\\s+)?:`),\n\t\"css-hyphens\":                        regexp.MustCompile(`(?i)(^|\\s|;)hyphens(\\s+)?:`),\n\t\"css-important\":                      regexp.MustCompile(`(?i)!important($|\\s|;)`),\n\t\"css-inline-size\":                    regexp.MustCompile(`(?i)(^|\\s|;)inline-size(\\s+)?:`),\n\t\"css-intrinsic-size\":                 regexp.MustCompile(`(?i)(^|\\s|;)intrinsic-size(\\s+)?:`),\n\t\"css-justify-content\":                regexp.MustCompile(`(?i)(^|\\s|;)justify-content(\\s+)?:`),\n\t\"css-letter-spacing\":                 regexp.MustCompile(`(?i)(^|\\s|;)letter-spacing(\\s+)?:`),\n\t\"css-line-height\":                    regexp.MustCompile(`(?i)(^|\\s|;)line-height(\\s+)?:`),\n\t\"css-list-style-image\":               regexp.MustCompile(`(?i)(^|\\s|;)list-style-image(\\s+)?:`),\n\t\"css-list-style-position\":            regexp.MustCompile(`(?i)(^|\\s|;)list-style-position(\\s+)?:`),\n\t\"css-list-style\":                     regexp.MustCompile(`(?i)(^|\\s|;)list-style(\\s+)?:`),\n\t\"css-margin-block-start-end\":         regexp.MustCompile(`(?i)(^|\\s|;)margin-block-(start|end)(\\s+)?:`),\n\t\"css-margin-inline-block\":            regexp.MustCompile(`(?i)(^|\\s|;)margin-inline-block(\\s+)?:`),\n\t\"css-margin-inline-start-end\":        regexp.MustCompile(`(?i)(^|\\s|;)margin-inline-(start|end)(\\s+)?:`),\n\t\"css-margin-inline\":                  regexp.MustCompile(`(?i)(^|\\s|;)margin-inline(\\s+)?:`),\n\t\"css-margin\":                         regexp.MustCompile(`(?i)(^|\\s|;)margin(\\s+)?:`),\n\t\"css-max-block-size\":                 regexp.MustCompile(`(?i)(^|\\s|;)max-block-size(\\s+)?:`),\n\t\"css-max-height\":                     regexp.MustCompile(`(?i)(^|\\s|;)max-height(\\s+)?:`),\n\t\"css-max-width\":                      regexp.MustCompile(`(?i)(^|\\s|;)max-width(\\s+)?:`),\n\t\"css-min-height\":                     regexp.MustCompile(`(?i)(^|\\s|;)min-height(\\s+)?:`),\n\t\"css-min-inline-size\":                regexp.MustCompile(`(?i)(^|\\s|;)min-inline-size(\\s+)?:`),\n\t\"css-min-width\":                      regexp.MustCompile(`(?i)(^|\\s|;)min-width(\\s+)?:`),\n\t\"css-mix-blend-mode\":                 regexp.MustCompile(`(?i)(^|\\s|;)mix-blend-mode(\\s+)?:`),\n\t\"css-modern-color\":                   regexp.MustCompile(`(?i)(^|\\s|;)modern-color(\\s+)?:`),\n\t\"css-object-fit\":                     regexp.MustCompile(`(?i)(^|\\s|;)object-fit(\\s+)?:`),\n\t\"css-object-position\":                regexp.MustCompile(`(?i)(^|\\s|;)object-position(\\s+)?:`),\n\t\"css-opacity\":                        regexp.MustCompile(`(?i)(^|\\s|;)opacity(\\s+)?:`),\n\t\"css-outline-offset\":                 regexp.MustCompile(`(?i)(^|\\s|;)outline-offset(\\s+)?:`),\n\t\"css-outline\":                        regexp.MustCompile(`(?i)(^|\\s|;)outline(\\s+)?:`),\n\t\"css-overflow-wrap\":                  regexp.MustCompile(`(?i)(^|\\s|;)overflow-wrap(\\s+)?:`),\n\t\"css-overflow\":                       regexp.MustCompile(`(?i)(^|\\s|;)overflow(\\s+)?:`),\n\t\"css-padding-block-start-end\":        regexp.MustCompile(`(?i)(^|\\s|;)padding-block-(start|end)(\\s+)?:`),\n\t\"css-padding-inline-block\":           regexp.MustCompile(`(?i)(^|\\s|;)padding-inline-block(\\s+)?:`),\n\t\"css-padding-inline-start-end\":       regexp.MustCompile(`(?i)(^|\\s|;)padding-inline-(start|end)(\\s+)?:`),\n\t\"css-padding\":                        regexp.MustCompile(`(?i)(^|\\s|;)padding(\\s+)?:`),\n\t\"css-position\":                       regexp.MustCompile(`(?i)(^|\\s|;)position(\\s+)?:`),\n\t\"css-radial-gradient\":                regexp.MustCompile(`(?i)(^|\\s|;)radial-gradient(\\s+)?:`),\n\t\"css-rgb\":                            regexp.MustCompile(`(?i)(\\s|:)rgb\\(`),\n\t\"css-rgba\":                           regexp.MustCompile(`(?i)(\\s|:)rgba\\(`),\n\t\"css-scroll-snap\":                    regexp.MustCompile(`(?i)(^|\\s|;)roll-snap(\\s+)?:`),\n\t\"css-tab-size\":                       regexp.MustCompile(`(?i)(^|\\s|;)tab-size(\\s+)?:`),\n\t\"css-table-layout\":                   regexp.MustCompile(`(?i)(^|\\s|;)table-layout(\\s+)?:`),\n\t\"css-text-align-last\":                regexp.MustCompile(`(?i)(^|\\s|;)text-align-last(\\s+)?:`),\n\t\"css-text-align\":                     regexp.MustCompile(`(?i)(^|\\s|;)text-align(\\s+)?:`),\n\t\"css-text-decoration-color\":          regexp.MustCompile(`(?i)(^|\\s|;)text-decoration-color(\\s+)?:`),\n\t\"css-text-decoration-thickness\":      regexp.MustCompile(`(?i)(^|\\s|;)text-decoration-thickness(\\s+)?:`),\n\t\"css-text-decoration\":                regexp.MustCompile(`(?i)(^|\\s|;)text-decoration(\\s+)?:`),\n\t\"css-text-emphasis-position\":         regexp.MustCompile(`(?i)(^|\\s|;)text-emphasis-position(\\s+)?:`),\n\t\"css-text-emphasis\":                  regexp.MustCompile(`(?i)(^|\\s|;)text-emphasis(\\s+)?:`),\n\t\"css-text-indent\":                    regexp.MustCompile(`(?i)(^|\\s|;)text-indent(\\s+)?:`),\n\t\"css-text-overflow\":                  regexp.MustCompile(`(?i)(^|\\s|;)text-overflow(\\s+)?:`),\n\t\"css-text-shadow\":                    regexp.MustCompile(`(?i)(^|\\s|;)text-shadow(\\s+)?:`),\n\t\"css-text-transform\":                 regexp.MustCompile(`(?i)(^|\\s|;)text-transform(\\s+)?:`),\n\t\"css-text-underline-offset\":          regexp.MustCompile(`(?i)(^|\\s|;)text-underline-offset(\\s+)?:`),\n\t\"css-transform\":                      regexp.MustCompile(`(?i)(^|\\s|;)transform(\\s+)?:`),\n\t\"css-unit-calc\":                      regexp.MustCompile(`(?i)(\\s|:)calc\\(`),\n\t\"css-variables\":                      regexp.MustCompile(`(?i)(^|\\s|;)variables(\\s+)?:`),\n\t\"css-visibility\":                     regexp.MustCompile(`(?i)(^|\\s|;)visibility(\\s+)?:`),\n\t\"css-white-space\":                    regexp.MustCompile(`(?i)(^|\\s|;)white-space(\\s+)?:`),\n\t\"css-width\":                          regexp.MustCompile(`(?i)(^|\\s|;)width(\\s+)?:`),\n\t\"css-word-break\":                     regexp.MustCompile(`(?i)(^|\\s|;)word-break(\\s+)?:`),\n\t\"css-writing-mode\":                   regexp.MustCompile(`(?i)(^|\\s|;)writing-mode(\\s+)?:`),\n\t\"css-z-index\":                        regexp.MustCompile(`(?i)(^|\\s|;)z-index(\\s+)?:`),\n}\n\n// some CSS tests using regex for things that can't be merged inline\nvar cssRegexpTests = map[string]*regexp.Regexp{\n\t\"css-at-font-face\":                  regexp.MustCompile(`(?mi)@font\\-face\\s+?{`),\n\t\"css-at-import\":                     regexp.MustCompile(`(?mi)@import\\s`),\n\t\"css-at-keyframes\":                  regexp.MustCompile(`(?mi)@keyframes\\s`),\n\t\"css-at-media\":                      regexp.MustCompile(`(?mi)@media\\s?\\(`),\n\t\"css-at-supports\":                   regexp.MustCompile(`(?mi)@supports\\s?\\(`),\n\t\"css-pseudo-class-active\":           regexp.MustCompile(`:active`),\n\t\"css-pseudo-class-checked\":          regexp.MustCompile(`:checked`),\n\t\"css-pseudo-class-first-child\":      regexp.MustCompile(`:first\\-child`),\n\t\"css-pseudo-class-first-of-type\":    regexp.MustCompile(`:first\\-of\\-type`),\n\t\"css-pseudo-class-focus\":            regexp.MustCompile(`:focus`),\n\t\"css-pseudo-class-has\":              regexp.MustCompile(`:has`),\n\t\"css-pseudo-class-hover\":            regexp.MustCompile(`:hover`),\n\t\"css-pseudo-class-lang\":             regexp.MustCompile(`:lang\\s?\\(`),\n\t\"css-pseudo-class-last-child\":       regexp.MustCompile(`:last\\-child`),\n\t\"css-pseudo-class-last-of-type\":     regexp.MustCompile(`:last\\-of\\-type`),\n\t\"css-pseudo-class-link\":             regexp.MustCompile(`:link`),\n\t\"css-pseudo-class-not\":              regexp.MustCompile(`:not(\\s+)?\\(`),\n\t\"css-pseudo-class-nth-child\":        regexp.MustCompile(`:nth\\-child(\\s+)?\\(`),\n\t\"css-pseudo-class-nth-last-child\":   regexp.MustCompile(`:nth\\-last\\-child(\\s+)?\\(`),\n\t\"css-pseudo-class-nth-last-of-type\": regexp.MustCompile(`:nth\\-last\\-of\\-type(\\s+)?\\(`),\n\t\"css-pseudo-class-nth-of-type\":      regexp.MustCompile(`:nth\\-of\\-type(\\s+)?\\(`),\n\t\"css-pseudo-class-only-child\":       regexp.MustCompile(`:only\\-child(\\s+)?\\(`),\n\t\"css-pseudo-class-only-of-type\":     regexp.MustCompile(`:only\\-of\\-type(\\s+)?\\(`),\n\t\"css-pseudo-class-target\":           regexp.MustCompile(`:target`),\n\t\"css-pseudo-class-visited\":          regexp.MustCompile(`:visited`),\n\t\"css-pseudo-element-after\":          regexp.MustCompile(`:after`),\n\t\"css-pseudo-element-before\":         regexp.MustCompile(`:before`),\n\t\"css-pseudo-element-first-letter\":   regexp.MustCompile(`::first\\-letter`),\n\t\"css-pseudo-element-first-line\":     regexp.MustCompile(`::first\\-line`),\n\t\"css-pseudo-element-marker\":         regexp.MustCompile(`::marker`),\n\t\"css-pseudo-element-placeholder\":    regexp.MustCompile(`::placeholder`),\n}\n\n// some CSS tests using regex for units\nvar cssRegexpUnitTests = map[string]*regexp.Regexp{\n\t\"css-unit-ch\":      regexp.MustCompile(`\\b\\d+ch\\b`),\n\t\"css-unit-initial\": regexp.MustCompile(`:\\s?initial\\b`),\n\t\"css-unit-rem\":     regexp.MustCompile(`\\b\\d+rem\\b`),\n\t\"css-unit-vh\":      regexp.MustCompile(`\\b\\d+vh\\b`),\n\t\"css-unit-vmax\":    regexp.MustCompile(`\\b\\d+vmax\\b`),\n\t\"css-unit-vmin\":    regexp.MustCompile(`\\b\\d+vmin\\b`),\n\t\"css-unit-vw\":      regexp.MustCompile(`\\b\\d+vw\\b`),\n}\n"
  },
  {
    "path": "internal/htmlcheck/css.go",
    "content": "package htmlcheck\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/axllent/mailpit/internal/tools\"\n\t\"github.com/vanng822/go-premailer/premailer\"\n\t\"golang.org/x/net/html\"\n\t\"golang.org/x/net/html/atom\"\n)\n\n// Go cannot calculate any rendered CSS attributes, so we merge all styles\n// into the HTML and detect elements with styles containing the keywords.\nfunc runCSSTests(html string) ([]Warning, int, error) {\n\tresults := []Warning{}\n\ttotalTests := 0\n\n\tinlined, err := inlineRemoteCSS(html)\n\tif err != nil {\n\t\tinlined = html\n\t}\n\n\t// merge all CSS inline\n\tmerged, err := mergeInlineCSS(inlined)\n\tif err != nil {\n\t\tmerged = inlined\n\t}\n\n\treader := strings.NewReader(merged)\n\n\t// Load the HTML document\n\tdoc, err := goquery.NewDocumentFromReader(reader)\n\tif err != nil {\n\t\treturn results, totalTests, err\n\t}\n\n\tinlineStyleResults := testInlineStyles(doc)\n\ttotalTests = totalTests + len(cssInlineRegexTests) + len(styleInlineAttributes)\n\tfor key, count := range inlineStyleResults {\n\t\tresult, err := cie.getTest(key)\n\t\tif err == nil {\n\t\t\tresult.Score.Found = count\n\t\t\tresults = append(results, result)\n\t\t}\n\n\t}\n\n\t// get a list of all generated styles from all nodes\n\tallNodeStyles := []string{}\n\tfor _, n := range doc.Find(\"*[style]\").Nodes {\n\t\tstyle, err := tools.GetHTMLAttributeVal(n, \"style\")\n\t\tif err == nil {\n\t\t\tallNodeStyles = append(allNodeStyles, style)\n\t\t}\n\t}\n\n\tfor key, re := range cssRegexpUnitTests {\n\t\ttotalTests++\n\t\tresult, err := cie.getTest(key)\n\t\tif err != nil {\n\t\t\treturn results, totalTests, err\n\t\t}\n\n\t\tfound := 0\n\t\t// loop through all styles to count total\n\t\tfor _, styles := range allNodeStyles {\n\t\t\tfound = found + len(re.FindAllString(styles, -1))\n\t\t}\n\n\t\tif found > 0 {\n\t\t\tresult.Score.Found = found\n\t\t\tresults = append(results, result)\n\t\t}\n\t}\n\n\t// get all inline CSS block data\n\treader = strings.NewReader(inlined)\n\n\t// Load the HTML document\n\tdoc, _ = goquery.NewDocumentFromReader(reader)\n\n\tcssCode := \"\"\n\tfor _, n := range doc.Find(\"style\").Nodes {\n\t\tfor c := n.FirstChild; c != nil; c = c.NextSibling {\n\t\t\tcssCode = cssCode + c.Data\n\t\t}\n\t}\n\n\tfor key, re := range cssRegexpTests {\n\t\ttotalTests++\n\t\tresult, err := cie.getTest(key)\n\t\tif err != nil {\n\t\t\treturn results, totalTests, err\n\t\t}\n\n\t\tfound := len(re.FindAllString(cssCode, -1))\n\t\tif found > 0 {\n\t\t\tresult.Score.Found = found\n\t\t\tresults = append(results, result)\n\t\t}\n\t}\n\n\treturn results, totalTests, nil\n}\n\n// MergeInlineCSS merges header CSS into element attributes\nfunc mergeInlineCSS(html string) (string, error) {\n\toptions := premailer.NewOptions()\n\t// options.RemoveClasses = true\n\t// options.CssToAttributes = false\n\toptions.KeepBangImportant = true\n\tpre, err := premailer.NewPremailerFromString(html, options)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn pre.Transform()\n}\n\n// InlineRemoteCSS searches the HTML for linked stylesheets, downloads the content, and\n// inserts new <style> blocks into the head, unless BlockRemoteCSSAndFonts is set\nfunc inlineRemoteCSS(h string) (string, error) {\n\treader := strings.NewReader(h)\n\n\t// Load the HTML document\n\tdoc, err := goquery.NewDocumentFromReader(reader)\n\tif err != nil {\n\t\treturn h, err\n\t}\n\n\tremoteCSS := doc.Find(\"link[rel=\\\"stylesheet\\\"]\").Nodes\n\tfor _, link := range remoteCSS {\n\t\tattributes := link.Attr\n\t\tfor _, a := range attributes {\n\t\t\tif a.Key == \"href\" {\n\t\t\t\tif config.BlockRemoteCSSAndFonts {\n\t\t\t\t\tlogger.Log().Debugf(\"[html-check] skip testing remote CSS content: %s (--block-remote-css-and-fonts)\", a.Val)\n\t\t\t\t\treturn h, nil\n\t\t\t\t}\n\n\t\t\t\tif !isValidURL(a.Val) {\n\t\t\t\t\t// skip invalid URL\n\t\t\t\t\tlogger.Log().Warnf(\"[html-check] ignoring unsupported stylesheet URL: %s\", a.Val)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tresp, err := downloadCSSToBytes(a.Val)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.Log().Warnf(\"[html-check] %s\", err.Error())\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t// create new <style> block and insert downloaded CSS\n\t\t\t\tstyleBlock := &html.Node{\n\t\t\t\t\tType:     html.ElementNode,\n\t\t\t\t\tData:     \"style\",\n\t\t\t\t\tDataAtom: atom.Style,\n\t\t\t\t}\n\t\t\t\tstyleBlock.AppendChild(&html.Node{\n\t\t\t\t\tType: html.TextNode,\n\t\t\t\t\tData: string(resp),\n\t\t\t\t})\n\n\t\t\t\tlink.Parent.AppendChild(styleBlock)\n\t\t\t}\n\t\t}\n\t}\n\n\tnewDoc, err := doc.Html()\n\tif err != nil {\n\t\tlogger.Log().Warnf(\"[html-check] failed to download %s\", err.Error())\n\t\treturn h, err\n\t}\n\n\treturn newDoc, nil\n}\n\n// DownloadCSSToBytes returns a []byte slice from a URL.\n// It requires the HTTP response code to be 200 and the content-type to be text/css.\n// It will download a maximum of 5MB.\nfunc downloadCSSToBytes(url string) ([]byte, error) {\n\tclient := newSafeHTTPClient()\n\treq, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, url, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treq.Header.Set(\"User-Agent\", \"Mailpit HTML Checker/\"+config.Version)\n\n\t// Get the link response data\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer func() { _ = resp.Body.Close() }()\n\n\tif resp.StatusCode != 200 {\n\t\terr := fmt.Errorf(\"error downloading %s\", url)\n\t\treturn nil, err\n\t}\n\n\tct := strings.ToLower(resp.Header.Get(\"content-type\"))\n\tif !strings.Contains(ct, \"text/css\") {\n\t\terr := fmt.Errorf(\"invalid CSS content-type from %s: \\\"%s\\\" (expected \\\"text/css\\\")\", url, ct)\n\t\treturn nil, err\n\t}\n\n\t// set a limit on the number of bytes to read - max 5MB\n\tlimit := int64(5242880)\n\tlimitedReader := &io.LimitedReader{R: resp.Body, N: limit}\n\n\tbody, err := io.ReadAll(limitedReader)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn body, nil\n}\n\n// Test if the string is a supported URL.\n// The URL must have the \"http\" or \"https\" scheme, and must not contain any login info (http://user:pass@<host>).\nfunc isValidURL(str string) bool {\n\tu, err := url.Parse(str)\n\n\treturn err == nil && (u.Scheme == \"http\" || u.Scheme == \"https\") && u.Hostname() != \"\" && u.User.String() == \"\"\n}\n\n// Test the HTML for inline CSS styles and styling attributes\nfunc testInlineStyles(doc *goquery.Document) map[string]int {\n\tmatches := make(map[string]int)\n\n\t// find all elements containing a style attribute\n\tstyles := doc.Find(\"[style]\").Nodes\n\tfor _, s := range styles {\n\t\tstyle, err := tools.GetHTMLAttributeVal(s, \"style\")\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tfor id, test := range cssInlineRegexTests {\n\t\t\tif test.MatchString(style) {\n\t\t\t\tif _, ok := matches[id]; !ok {\n\t\t\t\t\tmatches[id] = 0\n\t\t\t\t}\n\t\t\t\tmatches[id]++\n\t\t\t}\n\t\t}\n\t}\n\n\t// find all elements containing styleInlineAttributes\n\tfor id, test := range styleInlineAttributes {\n\t\ta := doc.Find(test).Nodes\n\t\tif len(a) > 0 {\n\t\t\tif _, ok := matches[id]; !ok {\n\t\t\t\tmatches[id] = 0\n\t\t\t}\n\t\t\tmatches[id]++\n\t\t}\n\t}\n\n\treturn matches\n}\n\nfunc newSafeHTTPClient() *http.Client {\n\tdialer := &net.Dialer{\n\t\tTimeout:   5 * time.Second,\n\t\tKeepAlive: 30 * time.Second,\n\t}\n\n\ttr := &http.Transport{\n\t\tProxy: nil, // avoid env proxy surprises unless you explicitly want it\n\t\tDialContext: func(ctx context.Context, network, address string) (net.Conn, error) {\n\t\t\treturn dialer.DialContext(ctx, network, address)\n\t\t},\n\t\tTLSHandshakeTimeout:   5 * time.Second,\n\t\tResponseHeaderTimeout: 10 * time.Second,\n\t\tExpectContinueTimeout: 1 * time.Second,\n\t\tIdleConnTimeout:       30 * time.Second,\n\t\tMaxIdleConns:          50,\n\t}\n\n\tclient := &http.Client{\n\t\tTransport: tr,\n\t\tTimeout:   15 * time.Second,\n\t\tCheckRedirect: func(req *http.Request, via []*http.Request) error {\n\t\t\t// re-validate every redirect hop.\n\t\t\tif len(via) >= 3 {\n\t\t\t\treturn errors.New(\"too many redirects\")\n\t\t\t}\n\t\t\tif !isValidURL(req.URL.String()) {\n\t\t\t\treturn errors.New(\"invalid redirect URL\")\n\t\t\t}\n\n\t\t\treturn nil\n\t\t},\n\t}\n\n\treturn client\n}\n"
  },
  {
    "path": "internal/htmlcheck/html.go",
    "content": "package htmlcheck\n\nimport (\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"github.com/axllent/mailpit/internal/tools\"\n)\n\n// HTML tests\nfunc runHTMLTests(html string) ([]Warning, int, error) {\n\tresults := []Warning{}\n\ttotalTests := 0\n\n\treader := strings.NewReader(html)\n\n\t// Load the HTML document\n\tdoc, err := goquery.NewDocumentFromReader(reader)\n\tif err != nil {\n\t\treturn results, totalTests, err\n\t}\n\n\t// Almost all <script> is bad\n\tscripts := len(doc.Find(\"script:not([type=\\\"application/ld+json\\\"]):not([type=\\\"application/json\\\"])\").Nodes)\n\tif scripts > 0 {\n\t\tvar result = Warning{}\n\t\tresult.Title = \"<script> element\"\n\t\tresult.Slug = \"html-script\"\n\t\tresult.Category = \"html\"\n\t\tresult.Description = \"JavaScript is not supported in any email client.\"\n\t\tresult.Tags = []string{}\n\t\tresult.Results = []Result{}\n\t\tresult.NotesByNumber = map[string]string{}\n\t\tresult.Score.Found = scripts\n\t\tresult.Score.Supported = 0\n\t\tresult.Score.Partial = 0\n\t\tresult.Score.Unsupported = 100\n\t\tresults = append(results, result)\n\t\ttotalTests++\n\t}\n\n\tfor key, test := range htmlTests {\n\t\ttotalTests++\n\t\tif test == \"body\" {\n\t\t\tre := regexp.MustCompile(`(?im)</body>`)\n\t\t\tif re.MatchString(html) {\n\t\t\t\tresult, err := cie.getTest(key)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn results, totalTests, err\n\t\t\t\t}\n\n\t\t\t\tresult.Score.Found = 1\n\t\t\t\tresults = append(results, result)\n\t\t\t}\n\t\t} else if len(doc.Find(test).Nodes) > 0 {\n\t\t\tresult, err := cie.getTest(key)\n\t\t\tif err != nil {\n\t\t\t\treturn results, totalTests, err\n\t\t\t}\n\t\t\ttotalTests++\n\n\t\t\tresult.Score.Found = len(doc.Find(test).Nodes)\n\n\t\t\tresults = append(results, result)\n\t\t}\n\t}\n\n\t// find all images\n\timages := doc.Find(\"img[src]\").Nodes\n\timageResults := make(map[string]int)\n\ttotalTests = totalTests + len(imageRegexpTests)\n\n\tfor _, image := range images {\n\t\tsrc, err := tools.GetHTMLAttributeVal(image, \"src\")\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tfor key, test := range imageRegexpTests {\n\t\t\tif test.MatchString(src) {\n\t\t\t\tmatches, exists := imageResults[key]\n\t\t\t\tif exists {\n\t\t\t\t\timageResults[key] = matches + 1\n\t\t\t\t} else {\n\t\t\t\t\timageResults[key] = 1\n\t\t\t\t}\n\n\t\t\t}\n\t\t}\n\t}\n\n\tfor key, found := range imageResults {\n\t\tresult, err := cie.getTest(key)\n\t\tif err != nil {\n\t\t\treturn results, totalTests, err\n\t\t}\n\t\tresult.Score.Found = found\n\t\tresults = append(results, result)\n\t}\n\n\treturn results, totalTests, nil\n}\n"
  },
  {
    "path": "internal/htmlcheck/inline_test.go",
    "content": "package htmlcheck\n\nimport (\n\t\"fmt\"\n\t\"sort\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n)\n\nfunc TestInlineStyleDetection(t *testing.T) {\n\t/// tests should contain the HTML test, and expected test results in alphabetical order\n\ttests := map[string]string{}\n\ttests[`<h1 style=\"transform: rotate(20deg)\">Heading</h1>`] = \"css-transform\"\n\ttests[`<h1 style=\"color: green; transform:rotate(20deg)\">Heading</h1>`] = \"css-transform\"\n\ttests[`<h1 style=\"color:green; transform :rotate(20deg)\">Heading</h1>`] = \"css-transform\"\n\ttests[`<h1 style=\"transform:rotate(20deg)\">Heading</h1>`] = \"css-transform\"\n\ttests[`<h1 style=\"TRANSFORM:rotate(20deg)\">Heading</h1>`] = \"css-transform\"\n\ttests[`<h1 style=\"transform:\trotate(20deg)\">Heading</h1>`] = \"css-transform\"\n\ttests[`<h1 style=\"ignore-transform: something\">Heading</h1>`] = \"\" // no match\n\ttests[`<h1 style=\"text-transform: uppercase\">Heading</h1>`] = \"css-text-transform\"\n\ttests[`<h1 style=\"text-transform: uppercase; text-transform: uppercase\">Heading</h1>`] = \"css-text-transform\"\n\ttests[`<h1 style=\"test-transform: uppercase\">Heading</h1>`] = \"\" // no match\n\ttests[`<h1 style=\"padding-inline-start: 5rem\">Heading</h1>`] = \"css-padding-inline-start-end\"\n\ttests[`<h1 style=\"margin-inline-end: 5rem\">Heading</h1>`] = \"css-margin-inline-start-end\"\n\ttests[`<h1 style=\"margin-inline-middle: 5rem\">Heading</h1>`] = \"\" // no match\n\ttests[`<h1 style=\"color:green!important\">Heading</h1>`] = \"css-important\"\n\ttests[`<h1 style=\"color: green !important\">Heading</h1>`] = \"css-important\"\n\ttests[`<h1 style=\"color: green!important;\">Heading</h1>`] = \"css-important\"\n\ttests[`<h1 style=\"color:green!important-stuff;\">Heading</h1>`] = \"\" // no match\n\ttests[`<h1 style=\"background-image:url('img.jpg')\">Heading</h1>`] = \"css-background-image\"\n\ttests[`<h1 style=\"background-image:url('img.jpg'); color: green\">Heading</h1>`] = \"css-background-image\"\n\ttests[`<h1 style=\" color:green; background-image:url('img.jpg');\">Heading</h1>`] = \"css-background-image\"\n\ttests[`<h1 style=\"display  : flex ;\">Heading</h1>`] = \"css-display,css-display-flex\"\n\ttests[`<h1 style=\"DISPLAY:FLEX;\">Heading</h1>`] = \"css-display,css-display-flex\"\n\ttests[`<h1 style=\"display: flexing;\">Heading</h1>`] = \"css-display\" // should not match css-display-flex rule\n\ttests[`<h1 style=\"line-height: 1rem;opacity: 0.5; width: calc(10px + 100px)\">Heading</h1>`] = \"css-line-height,css-opacity,css-unit-calc,css-width\"\n\ttests[`<h1 style=\"color: rgb(255,255,255);\">Heading</h1>`] = \"css-rgb\"\n\ttests[`<h1 style=\"color:rgb(255,255,255);\">Heading</h1>`] = \"css-rgb\"\n\ttests[`<h1 style=\"color:rgb(255,255,255);\">Heading</h1>`] = \"css-rgb\"\n\ttests[`<h1 style=\"color:rgba(255,255,255, 0);\">Heading</h1>`] = \"css-rgba\"\n\ttests[`<h1 style=\"border: solid rgb(255,255,255) 1px; color:rgba(255,255,255, 0);\">Heading</h1>`] = \"css-border,css-rgb,css-rgba\"\n\ttests[`<h1 border=\"2\">Heading</h1>`] = \"css-border\"\n\ttests[`<h1 border=\"2\" background=\"green\">Heading</h1>`] = \"css-background,css-border\"\n\ttests[`<h1 BORDER=\"2\" BACKGROUND=\"GREEN\">Heading</h1>`] = \"css-background,css-border\"\n\ttests[`<h1 border-something=\"2\" background-something=\"green\">Heading</h1>`] = \"\" // no match\n\ttests[`<h1 border=\"2\" style=\"border: solid green 1px!important\">Heading</h1>`] = \"css-border,css-important\"\n\n\tfor html, expected := range tests {\n\t\treader := strings.NewReader(html)\n\t\tdoc, err := goquery.NewDocumentFromReader(reader)\n\t\tif err != nil {\n\t\t\tt.Log(\"error \", err)\n\t\t\tt.Fail()\n\t\t}\n\n\t\tresults := testInlineStyles(doc)\n\n\t\tmatches := []string{}\n\t\tuniqMap := make(map[string]bool)\n\t\tfor key := range results {\n\t\t\tif _, exists := uniqMap[key]; !exists {\n\t\t\t\tmatches = append(matches, key)\n\t\t\t}\n\t\t}\n\n\t\t// ensure results are sorted to ensure consistent results\n\t\tsort.Strings(matches)\n\n\t\tassertEqual(t, expected, strings.Join(matches, \",\"), fmt.Sprintf(\"inline style detection \\\"%s\\\"\", html))\n\t}\n}\n\nfunc assertEqual(t *testing.T, a any, b any, message string) {\n\tif a == b {\n\t\treturn\n\t}\n\tmessage = fmt.Sprintf(\"%s: \\\"%v\\\" != \\\"%v\\\"\", message, a, b)\n\tt.Fatal(message)\n}\n"
  },
  {
    "path": "internal/htmlcheck/main.go",
    "content": "package htmlcheck\n\nimport (\n\t\"fmt\"\n\t\"regexp\"\n\t\"sort\"\n\t\"strings\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"github.com/axllent/mailpit/internal/tools\"\n\t\"github.com/gomarkdown/markdown\"\n\t\"github.com/gomarkdown/markdown/html\"\n\t\"github.com/gomarkdown/markdown/parser\"\n)\n\n// RunTests will run all tests on an HTML string\nfunc RunTests(html string) (Response, error) {\n\ts := Response{}\n\ts.Warnings = []Warning{}\n\tif platforms, err := Platforms(); err == nil {\n\t\ts.Platforms = platforms\n\t}\n\n\ts.Total = Total{}\n\n\t// crude way to determine whether the HTML contains a <body> structure\n\t// or whether it's just plain HTML content\n\tre := regexp.MustCompile(`(?im)</body>`)\n\tnodeMatch := \"body *, script\"\n\tif re.MatchString(html) {\n\t\tnodeMatch = \"*:not(html):not(head):not(meta), script\"\n\t}\n\treader := strings.NewReader(html)\n\t// Load the HTML document\n\tdoc, err := goquery.NewDocumentFromReader(reader)\n\tif err != nil {\n\t\treturn s, err\n\t}\n\t// calculate the number of nodes in HTML\n\ts.Total.Nodes = len(doc.Find(nodeMatch).Nodes)\n\n\tif err := loadJSONData(); err != nil {\n\t\treturn s, err\n\t}\n\n\t// HTML tests\n\thtmlResults, totalTests, err := runHTMLTests(html)\n\tif err != nil {\n\t\treturn s, err\n\t}\n\n\ts.Total.Tests = s.Total.Tests + totalTests\n\n\t// add html test totals\n\ts.Warnings = append(s.Warnings, htmlResults...)\n\n\t// CSS tests\n\tcssResults, totalTests, err := runCSSTests(html)\n\tif err != nil {\n\t\treturn s, err\n\t}\n\n\ts.Total.Tests = s.Total.Tests + totalTests\n\n\t// add css test totals\n\ts.Warnings = append(s.Warnings, cssResults...)\n\n\t// calculate total score\n\tvar partial, unsupported float32\n\tpartial = 0\n\tunsupported = 0\n\n\tfor _, w := range s.Warnings {\n\t\tif w.Score.Found == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\t// supported is calculated by subtracting partial and unsupported from 100%\n\t\tif w.Score.Partial > 0 {\n\t\t\tweighted := w.Score.Partial * float32(w.Score.Found) / float32(s.Total.Nodes)\n\t\t\tif weighted > partial {\n\t\t\t\tpartial = weighted\n\t\t\t}\n\t\t}\n\t\tif w.Score.Unsupported > 0 {\n\t\t\tweighted := w.Score.Unsupported * float32(w.Score.Found) / float32(s.Total.Nodes)\n\t\t\tif weighted > unsupported {\n\t\t\t\tunsupported = weighted\n\t\t\t}\n\t\t}\n\t}\n\n\ts.Total.Supported = 100 - partial - unsupported\n\ts.Total.Partial = partial\n\ts.Total.Unsupported = unsupported\n\n\t// sort slice to get lowest scores first\n\tsort.Slice(s.Warnings, func(i, j int) bool {\n\t\treturn (s.Warnings[i].Score.Unsupported+s.Warnings[i].Score.Partial)*float32(s.Warnings[i].Score.Found)/float32(s.Total.Nodes) >\n\t\t\t(s.Warnings[j].Score.Unsupported+s.Warnings[j].Score.Partial)*float32(s.Warnings[j].Score.Found)/float32(s.Total.Nodes)\n\t})\n\n\treturn s, nil\n}\n\n// Test returns a test\nfunc (c CanIEmail) getTest(k string) (Warning, error) {\n\twarning := Warning{}\n\texists := false\n\tfound := JSONResult{}\n\tfor _, r := range cie.Data {\n\t\tif r.Slug == k {\n\t\t\tfound = r\n\t\t\texists = true\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif !exists {\n\t\treturn warning, fmt.Errorf(\"%s does not exist\", k)\n\t}\n\n\twarning.Slug = found.Slug\n\twarning.Title = found.Title\n\twarning.Description = mdToHTML(found.Description)\n\twarning.Category = found.Category\n\twarning.URL = found.URL\n\twarning.Tags = found.Tags\n\t// warning.Keywords = found.Keywords\n\t// warning.Notes = found.Notes\n\twarning.NotesByNumber = make(map[string]string, len(found.NotesByNumber))\n\tfor nr, note := range found.NotesByNumber {\n\t\twarning.NotesByNumber[nr] = mdToHTML(note)\n\t}\n\twarning.Results = []Result{}\n\n\tvar y, n, p float32\n\n\tfor family, stats := range found.Stats {\n\t\tif len(LimitFamilies) != 0 && !tools.InArray(family, LimitFamilies) {\n\t\t\tcontinue\n\t\t}\n\n\t\tfor platform, clients := range stats.(map[string]any) {\n\t\t\tif len(LimitPlatforms) != 0 && !tools.InArray(platform, LimitPlatforms) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor version, support := range clients.(map[string]any) {\n\t\t\t\ts := Result{}\n\t\t\t\ts.Name = fmt.Sprintf(\"%s %s (%s)\", c.NiceNames.Family[family], c.NiceNames.Platform[platform], version)\n\t\t\t\ts.Family = family\n\t\t\t\ts.Platform = platform\n\t\t\t\ts.Version = version\n\n\t\t\t\tswitch support {\n\t\t\t\tcase \"y\":\n\t\t\t\t\ty++\n\t\t\t\t\ts.Support = \"yes\"\n\t\t\t\tcase \"n\":\n\t\t\t\t\tn++\n\t\t\t\t\ts.Support = \"no\"\n\t\t\t\tdefault:\n\t\t\t\t\tp++\n\t\t\t\t\ts.Support = \"partial\"\n\n\t\t\t\t\tnoteIDs := noteMatch.FindStringSubmatch(fmt.Sprintf(\"%s\", support))\n\n\t\t\t\t\tfor _, id := range noteIDs {\n\t\t\t\t\t\ts.NoteNumber = id\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\twarning.Results = append(warning.Results, s)\n\t\t\t}\n\t\t}\n\n\t}\n\n\ttotal := y + n + p\n\twarning.Score.Supported = y / total * 100\n\twarning.Score.Unsupported = n / total * 100\n\twarning.Score.Partial = p / total * 100\n\n\treturn warning, nil\n}\n\n// Convert markdown to HTML, stripping <p> & </p>\nfunc mdToHTML(str string) string {\n\tmd := []byte(str)\n\t// create markdown parser with extensions\n\textensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock\n\t// extensions := parser.NoExtensions\n\tp := parser.NewWithExtensions(extensions)\n\tdoc := p.Parse(md)\n\n\t// create HTML renderer with extensions\n\thtmlFlags := html.CommonFlags | html.HrefTargetBlank\n\topts := html.RendererOptions{Flags: htmlFlags}\n\trenderer := html.NewRenderer(opts)\n\n\treturn strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(string(markdown.Render(doc, renderer))), \"<p>\"), \"</p>\")\n}\n"
  },
  {
    "path": "internal/htmlcheck/platforms.go",
    "content": "package htmlcheck\n\nimport (\n\t\"slices\"\n\n\t\"github.com/axllent/mailpit/internal/tools\"\n)\n\n// Platforms returns all platforms with their respective email clients\nfunc Platforms() (map[string][]string, error) {\n\t// [platform]clients\n\tdata := make(map[string][]string)\n\n\tif err := loadJSONData(); err != nil {\n\t\treturn data, err\n\t}\n\n\tfor _, t := range cie.Data {\n\t\tfor family, stats := range t.Stats {\n\t\t\tniceFamily := cie.NiceNames.Family[family]\n\t\t\tfor platform := range stats.(map[string]any) {\n\t\t\t\tc, found := data[platform]\n\t\t\t\tif !found {\n\t\t\t\t\tdata[platform] = []string{}\n\t\t\t\t}\n\t\t\t\tif !tools.InArray(niceFamily, c) {\n\t\t\t\t\tc = append(c, niceFamily)\n\t\t\t\t\tdata[platform] = c\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tfor group, clients := range data {\n\t\tslices.Sort(clients)\n\t\tdata[group] = clients\n\t}\n\n\treturn data, nil\n}\n"
  },
  {
    "path": "internal/htmlcheck/structs.go",
    "content": "package htmlcheck\n\n// Response represents the HTML check response struct\n//\n// swagger:model HTMLCheckResponse\ntype Response struct {\n\t// List of warnings from tests\n\tWarnings []Warning `json:\"Warnings\"`\n\t// All platforms tested, mainly for the web UI\n\tPlatforms map[string][]string `json:\"Platforms\"`\n\t// Total overall score\n\tTotal Total `json:\"Total\"`\n}\n\n// Warning represents a failed test\n//\n// swagger:model HTMLCheckWarning\ntype Warning struct {\n\t// Slug identifier\n\tSlug string `json:\"Slug\"`\n\t// Friendly title\n\tTitle string `json:\"Title\"`\n\t// Description\n\tDescription string `json:\"Description\"`\n\t// URL to caniemail.com\n\tURL string `json:\"URL\"`\n\t// Category [css, html]\n\tCategory string `json:\"Category\"`\n\t// Tags\n\tTags []string `json:\"Tags\"`\n\t// Keywords\n\tKeywords string `json:\"Keywords\"`\n\t// Test results\n\tResults []Result `json:\"Results\"`\n\t// Notes based on results\n\tNotesByNumber map[string]string `json:\"NotesByNumber\"`\n\t// Test score calculated from results\n\tScore Score `json:\"Score\"`\n}\n\n// Result struct\n//\n// swagger:model HTMLCheckResult\ntype Result struct {\n\t// Friendly name of result, combining family, platform & version\n\tName string `json:\"Name\"`\n\t// Platform eg: ios, android, windows\n\tPlatform string `json:\"Platform\"`\n\t// Family eg: Outlook, Mozilla Thunderbird\n\tFamily string `json:\"Family\"`\n\t// Family version eg: 4.7.1, 2019-10, 10.3\n\tVersion string `json:\"Version\"`\n\t// Support [yes, no, partial]\n\tSupport string `json:\"Support\"`\n\t// Note number for partially supported if applicable\n\tNoteNumber string `json:\"NoteNumber\"` // where applicable\n}\n\n// Score struct\n//\n// swagger:model HTMLCheckScore\ntype Score struct {\n\t// Number of matches in the document\n\tFound int `json:\"Found\"`\n\t// Total percentage supported\n\tSupported float32 `json:\"Supported\"`\n\t// Total percentage partially supported\n\tPartial float32 `json:\"Partial\"`\n\t// Total percentage unsupported\n\tUnsupported float32 `json:\"Unsupported\"`\n}\n\n// Total weighted result for all scores\n//\n// swagger:model HTMLCheckTotal\ntype Total struct {\n\t// Total number of tests done\n\tTests int `json:\"Tests\"`\n\t// Total number of HTML nodes detected in message\n\tNodes int `json:\"Nodes\"`\n\t// Overall percentage supported\n\tSupported float32 `json:\"Supported\"`\n\t// Overall percentage partially supported\n\tPartial float32 `json:\"Partial\"` // total percentage\n\t// Overall percentage unsupported\n\tUnsupported float32 `json:\"Unsupported\"` // total percentage\n}\n"
  },
  {
    "path": "internal/linkcheck/linkcheck_test.go",
    "content": "package linkcheck\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n\n\t\"github.com/axllent/mailpit/internal/storage\"\n)\n\nvar (\n\ttestHTML = `\n\t<html>\n\t<head>\n\t\t<link rel=stylesheet href=\"http://remote-host/style.css\"></link>\n\t\t<script async src=\"https://www.googletagmanager.com/gtag/js?id=ignored\"></script>\n\t</head>\n\t<body>\n\t\t<div>\n\t\t\t<p><a href=\"http://example.com\">HTTP link</a></p>\n\t\t\t<p><a href=\"https://example.com\">HTTPS link</a></p>\n\t\t\t<p><a href=\"HTTPS://EXAMPLE.COM\">HTTPS link</a></p>\n\t\t\t<p><a href=\"http://localhost\">Localhost link</a> (ignored)</p>\n\t\t\t<p><a href=\"https://localhost\">Localhost link</a> (ignored)</p>\n\t\t\t<p><a href='https://127.0.0.1'>Single quotes link</a> (ignored)</p>\n\t\t\t<p><img src=https://example.com/image.jpg></p>\n\t\t\t<p href=\"http://invalid-link.com\">This should be ignored</p>\n\t\t\t<p><a href=\"http://link with spaces\">Link with spaces</a></p>\n\t\t\t<p><a href=\"http://example.com/?blaah=yes&amp;test=true\">URL-encoded characters</a></p>\n\t\t</div>\n\t</body>\n\t</html>`\n\n\texpectedHTMLLinks = []string{\n\t\t\"http://example.com\",\n\t\t\"https://example.com\",\n\t\t\"HTTPS://EXAMPLE.COM\",\n\t\t\"http://localhost\",\n\t\t\"https://localhost\",\n\t\t\"https://127.0.0.1\",\n\t\t\"http://link with spaces\",\n\t\t\"http://example.com/?blaah=yes&test=true\",\n\t\t\"http://remote-host/style.css\",  // css\n\t\t\"https://example.com/image.jpg\", // images\n\t}\n\n\ttestTextLinks = `This is a line with http://example.com https://example.com\n\t\tHTTPS://EXAMPLE.COM\n\t\t[http://localhost]\n\t\twww.google.com < ignored\n\t\t|||http://example.com/?some=query-string|||\n\t\t// RFC2396 appendix E states angle brackets are recommended for text/plain emails to\n\t\t// recognize potential spaces in between the URL\n\t\t<https://example.com/ link with spaces>\n\t`\n\n\texpectedTextLinks = []string{\n\t\t\"http://example.com\",\n\t\t\"https://example.com\",\n\t\t\"HTTPS://EXAMPLE.COM\",\n\t\t\"http://localhost\",\n\t\t\"http://example.com/?some=query-string\",\n\t\t\"https://example.com/ link with spaces\",\n\t}\n)\n\nfunc TestLinkDetection(t *testing.T) {\n\n\tt.Log(\"Testing HTML link detection\")\n\n\tm := storage.Message{}\n\n\tm.Text = testTextLinks\n\tm.HTML = testHTML\n\n\ttextLinks := extractTextLinks(&m)\n\n\tif !reflect.DeepEqual(textLinks, expectedTextLinks) {\n\t\tt.Fatalf(\"Failed to detect text links correctly\")\n\t}\n\n\thtmlLinks := extractHTMLLinks(&m)\n\n\tif !reflect.DeepEqual(htmlLinks, expectedHTMLLinks) {\n\t\tt.Fatalf(\"Failed to detect HTML links correctly\")\n\t}\n}\n"
  },
  {
    "path": "internal/linkcheck/main.go",
    "content": "// Package linkcheck handles message links checking\npackage linkcheck\n\nimport (\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"github.com/axllent/mailpit/internal/storage\"\n\t\"github.com/axllent/mailpit/internal/tools\"\n)\n\nvar linkRe = regexp.MustCompile(`(?im)\\b(http|https):\\/\\/([\\-\\w@:%_\\+'!.~#?,&\\/\\/=;]+)`)\n\n// RunTests will run all tests on an HTML string\nfunc RunTests(msg *storage.Message, followRedirects bool) (Response, error) {\n\ts := Response{}\n\n\tallLinks := extractHTMLLinks(msg)\n\tallLinks = strUnique(append(allLinks, extractTextLinks(msg)...))\n\ts.Links = getHTTPStatuses(allLinks, followRedirects)\n\n\tfor _, l := range s.Links {\n\t\tif l.StatusCode >= 400 || l.StatusCode == 0 {\n\t\t\ts.Errors++\n\t\t}\n\t}\n\n\treturn s, nil\n}\n\nfunc extractTextLinks(msg *storage.Message) []string {\n\ttestLinkRe := regexp.MustCompile(`(?im)([^<]\\b)((http|https):\\/\\/([\\-\\w@:%_\\+'!.~#?,&\\/\\/=;]+))`)\n\t// RFC2396 appendix E states angle brackets are recommended for text/plain emails to\n\t// recognize potential spaces in between the URL\n\t// @see https://www.rfc-editor.org/rfc/rfc2396#appendix-E\n\tbracketLinkRe := regexp.MustCompile(`(?im)<((http|https):\\/\\/([\\-\\w@:%_\\+'!.~#?,&\\/\\/=;][^>]+))>`)\n\n\tlinks := []string{}\n\n\tmatches := testLinkRe.FindAllStringSubmatch(msg.Text, -1)\n\tfor _, match := range matches {\n\t\tif len(match) > 0 {\n\t\t\tlinks = append(links, match[2])\n\t\t}\n\t}\n\n\tangleMatches := bracketLinkRe.FindAllStringSubmatch(msg.Text, -1)\n\tfor _, match := range angleMatches {\n\t\tif len(match) > 0 {\n\t\t\tlink := strings.ReplaceAll(match[1], \"\\n\", \"\")\n\t\t\tlinks = append(links, link)\n\t\t}\n\t}\n\n\treturn links\n}\n\nfunc extractHTMLLinks(msg *storage.Message) []string {\n\tlinks := []string{}\n\n\treader := strings.NewReader(msg.HTML)\n\n\t// Load the HTML document\n\tdoc, err := goquery.NewDocumentFromReader(reader)\n\tif err != nil {\n\t\treturn links\n\t}\n\n\taLinks := doc.Find(\"a[href]\").Nodes\n\tfor _, link := range aLinks {\n\t\tl, err := tools.GetHTMLAttributeVal(link, \"href\")\n\t\tif err == nil && linkRe.MatchString(l) {\n\t\t\tlinks = append(links, l)\n\t\t}\n\t}\n\n\tcssLinks := doc.Find(\"link[rel=\\\"stylesheet\\\"]\").Nodes\n\tfor _, link := range cssLinks {\n\t\tl, err := tools.GetHTMLAttributeVal(link, \"href\")\n\t\tif err == nil && linkRe.MatchString(l) {\n\t\t\tlinks = append(links, l)\n\t\t}\n\t}\n\n\timgLinks := doc.Find(\"img[src]\").Nodes\n\tfor _, link := range imgLinks {\n\t\tl, err := tools.GetHTMLAttributeVal(link, \"src\")\n\t\tif err == nil && linkRe.MatchString(l) {\n\t\t\tlinks = append(links, l)\n\t\t}\n\t}\n\n\treturn links\n}\n\n// strUnique return a slice of unique strings from a slice\nfunc strUnique(strSlice []string) []string {\n\tkeys := make(map[string]bool)\n\tlist := []string{}\n\tfor _, entry := range strSlice {\n\t\tif _, value := keys[entry]; !value {\n\t\t\tkeys[entry] = true\n\t\t\tlist = append(list, entry)\n\t\t}\n\t}\n\n\treturn list\n}\n"
  },
  {
    "path": "internal/linkcheck/status.go",
    "content": "package linkcheck\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/axllent/mailpit/internal/tools\"\n)\n\nfunc getHTTPStatuses(links []string, followRedirects bool) []Link {\n\t// allow 5 threads\n\tthreads := make(chan int, 5)\n\n\tresults := make(map[string]Link, len(links))\n\tresultsMutex := sync.RWMutex{}\n\n\toutput := []Link{}\n\n\tvar wg sync.WaitGroup\n\n\tfor _, l := range links {\n\t\twg.Add(1)\n\t\tgo func(link string, w *sync.WaitGroup) {\n\t\t\tthreads <- 1 // will block if MAX threads\n\t\t\tdefer w.Done()\n\n\t\t\tcode, err := doHead(link, followRedirects)\n\t\t\tl := Link{}\n\t\t\tl.URL = link\n\t\t\tif err != nil {\n\t\t\t\tl.StatusCode = 0\n\t\t\t\tl.Status = httpErrorSummary(err)\n\t\t\t\tif strings.Contains(l.Status, \"private/reserved address\") {\n\t\t\t\t\tl.Status = \"Blocked private/reserved address\"\n\t\t\t\t\tl.StatusCode = 451\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tl.StatusCode = code\n\t\t\t\tl.Status = http.StatusText(code)\n\t\t\t}\n\t\t\tresultsMutex.Lock()\n\t\t\tresults[link] = l\n\t\t\tresultsMutex.Unlock()\n\n\t\t\t<-threads // remove from threads\n\t\t}(l, &wg)\n\t}\n\n\twg.Wait()\n\n\tfor _, l := range results {\n\t\toutput = append(output, l)\n\t}\n\n\treturn output\n}\n\n// Do a HEAD request to return HTTP status code\nfunc doHead(link string, followRedirects bool) (int, error) {\n\tif !tools.IsValidLinkURL(link) {\n\t\treturn 0, fmt.Errorf(\"invalid URL: %s\", link)\n\t}\n\n\tdialer := &net.Dialer{\n\t\tTimeout:   10 * time.Second,\n\t\tKeepAlive: 30 * time.Second,\n\t}\n\n\ttr := &http.Transport{\n\t\tDialContext: safeDialContext(dialer),\n\t}\n\n\tif config.AllowUntrustedTLS {\n\t\t// user has explicitly allowed untrusted TLS, so we will not verify it for link checks\n\t\ttr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec\n\t}\n\n\tclient := http.Client{\n\t\tTimeout:   10 * time.Second,\n\t\tTransport: tr,\n\t\tCheckRedirect: func(req *http.Request, via []*http.Request) error {\n\t\t\tif len(via) >= 3 {\n\t\t\t\treturn errors.New(\"too many redirects\")\n\t\t\t}\n\t\t\tif !followRedirects {\n\t\t\t\treturn http.ErrUseLastResponse\n\t\t\t}\n\t\t\tif !tools.IsValidLinkURL(req.URL.String()) {\n\t\t\t\treturn fmt.Errorf(\"blocked redirect to invalid URL: %s\", req.URL)\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t}\n\n\treq, err := http.NewRequest(\"HEAD\", link, nil)\n\tif err != nil {\n\t\tlogger.Log().Errorf(\"[link-check] %s\", err.Error())\n\t\treturn 0, err\n\t}\n\n\treq.Header.Set(\"User-Agent\", \"Mailpit/\"+config.Version)\n\n\tres, err := client.Do(req)\n\tif err != nil {\n\t\tif res != nil {\n\t\t\treturn res.StatusCode, err\n\t\t}\n\n\t\treturn 0, err\n\t}\n\n\treturn res.StatusCode, nil\n}\n\n// HTTP errors include a lot more info that just the actual error, so this\n// tries to take the final part of it, eg: `no such host`\nfunc httpErrorSummary(err error) string {\n\tvar re = regexp.MustCompile(`.*: (.*)$`)\n\n\te := err.Error()\n\tif !re.MatchString(e) {\n\t\treturn e\n\t}\n\tparts := re.FindAllStringSubmatch(e, -1)\n\n\treturn parts[0][len(parts[0])-1]\n}\n\n// SafeDialContext is a custom dialer that checks if the resolved IP addresses are internal before allowing the connection.\nfunc safeDialContext(dialer *net.Dialer) func(ctx context.Context, network, address string) (net.Conn, error) {\n\treturn func(ctx context.Context, network, address string) (net.Conn, error) {\n\t\thost, port, err := net.SplitHostPort(address)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tips, err := net.DefaultResolver.LookupIPAddr(ctx, host)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif !config.AllowInternalHTTPRequests {\n\t\t\tfor _, ip := range ips {\n\t\t\t\tif tools.IsInternalIP(ip.IP) {\n\t\t\t\t\tlogger.Log().Warnf(\"[link-check] Blocked HEAD request to private/reserved address: %s (%s)\", host, ip)\n\t\t\t\t\treturn nil, fmt.Errorf(\"blocked request to %s (%s): private/reserved address\", host, ip)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port))\n\t}\n}\n"
  },
  {
    "path": "internal/linkcheck/structs.go",
    "content": "package linkcheck\n\n// Response represents the Link check response\n//\n// swagger:model LinkCheckResponse\ntype Response struct {\n\t// Total number of errors\n\tErrors int `json:\"Errors\"`\n\t// Tested links\n\tLinks []Link `json:\"Links\"`\n}\n\n// Link struct\ntype Link struct {\n\t// Link URL\n\tURL string `json:\"URL\"`\n\t// HTTP status code\n\tStatusCode int `json:\"StatusCode\"`\n\t// HTTP status definition\n\tStatus string `json:\"Status\"`\n}\n"
  },
  {
    "path": "internal/logger/logger.go",
    "content": "// Package logger handles the logging\npackage logger\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\n\t\"github.com/sirupsen/logrus\"\n)\n\nvar (\n\tlog *logrus.Logger\n\t// VerboseLogging for verbose logging\n\tVerboseLogging bool\n\t// QuietLogging shows only errors\n\tQuietLogging bool\n\t// NoLogging shows only fatal errors\n\tNoLogging bool\n\t// LogFile sets a log file\n\tLogFile string\n)\n\n// Log returns the logger instance\nfunc Log() *logrus.Logger {\n\tif log == nil {\n\t\tlog = logrus.New()\n\t\tlog.SetLevel(logrus.InfoLevel)\n\t\tif VerboseLogging {\n\t\t\t// verbose logging (debug)\n\t\t\tlog.SetLevel(logrus.DebugLevel)\n\t\t} else if QuietLogging {\n\t\t\t// show errors only\n\t\t\tlog.SetLevel(logrus.ErrorLevel)\n\t\t} else if NoLogging {\n\t\t\t// disable all logging (tests)\n\t\t\tlog.SetLevel(logrus.PanicLevel)\n\t\t}\n\n\t\tif LogFile != \"\" {\n\t\t\tfile, err := os.OpenFile(filepath.Clean(LogFile), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0664) // #nosec\n\t\t\tif err == nil {\n\t\t\t\tlog.Out = file\n\t\t\t} else {\n\t\t\t\tlog.Out = os.Stdout\n\t\t\t\tlog.Warn(\"Failed to log to file, using default stderr\")\n\t\t\t}\n\t\t} else {\n\t\t\tlog.Out = os.Stdout\n\t\t}\n\n\t\tlog.SetFormatter(&logrus.TextFormatter{\n\t\t\tFullTimestamp:   true,\n\t\t\tTimestampFormat: \"2006/01/02 15:04:05\",\n\t\t})\n\t}\n\n\treturn log\n}\n\n// PrettyPrint for debugging\nfunc PrettyPrint(i any) {\n\ts, _ := json.MarshalIndent(i, \"\", \"\\t\")\n\tfmt.Println(string(s))\n}\n\n// CleanHTTPIP returns a human-readable IP for the logging interface\n// when starting services. It translates [::]:<port> to \"localhost:<port>\"\nfunc CleanHTTPIP(s string) string {\n\tre := regexp.MustCompile(`^\\[\\:\\:\\]\\:\\d+`)\n\tif re.MatchString(s) {\n\t\treturn \"localhost:\" + s[5:]\n\t}\n\n\treturn s\n}\n"
  },
  {
    "path": "internal/pop3/functions.go",
    "content": "package pop3\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"strings\"\n\n\t\"github.com/axllent/mailpit/internal/auth\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/axllent/mailpit/internal/storage\"\n\t\"github.com/axllent/mailpit/server/websockets\"\n)\n\nfunc authUser(username, password string) bool {\n\treturn auth.POP3Credentials.Match(username, password)\n}\n\n// Send a response with debug logging\nfunc sendResponse(c net.Conn, m string) {\n\t_, _ = fmt.Fprintf(c, \"%s\\r\\n\", m)\n\tlogger.Log().Debugf(\"[pop3] response: %s\", m)\n\n\tif strings.HasPrefix(m, \"-ERR \") {\n\t\tsub, _ := strings.CutPrefix(m, \"-ERR \")\n\t\twebsockets.BroadCastClientError(\"error\", \"pop3\", c.RemoteAddr().String(), sub)\n\t}\n}\n\n// Send a response without debug logging (for data)\nfunc sendData(c net.Conn, m string) {\n\t_, _ = fmt.Fprintf(c, \"%s\\r\\n\", m)\n}\n\n// Get the latest 100 messages\nfunc getMessages() ([]message, error) {\n\tmessages := []message{}\n\tlist, err := storage.List(0, 0, 100)\n\tif err != nil {\n\t\treturn messages, err\n\t}\n\n\tfor _, m := range list {\n\t\tmsg := message{}\n\t\tmsg.ID = m.ID\n\t\tmsg.Size = m.Size\n\t\tmessages = append(messages, msg)\n\t}\n\n\treturn messages, nil\n}\n\n// POP3 TOP command returns the headers, followed by the next x lines\nfunc getTop(id string, nr int) (string, string, error) {\n\tvar header, body string\n\traw, err := storage.GetMessageRaw(id)\n\tif err != nil {\n\t\treturn header, body, errors.New(\"-ERR no such message\")\n\t}\n\n\tparts := strings.SplitN(string(raw), \"\\r\\n\\r\\n\", 2)\n\theader = parts[0]\n\tlines := []string{}\n\tif nr > 0 && len(parts) == 2 {\n\t\tlines = strings.SplitN(parts[1], \"\\r\\n\", nr)\n\t}\n\n\treturn header, strings.Join(lines, \"\\r\\n\"), nil\n}\n\n// cuts the line into command and arguments\nfunc getCommand(line string) (string, []string) {\n\tline = strings.Trim(line, \"\\r \\n\")\n\tcmd := strings.Split(line, \" \")\n\treturn cmd[0], cmd[1:]\n}\n\nfunc getSafeArg(args []string, nr int) (string, error) {\n\tif nr < len(args) {\n\t\treturn args[nr], nil\n\t}\n\n\treturn \"\", errors.New(\"-ERR out of range\")\n}\n"
  },
  {
    "path": "internal/pop3/pop3_test.go",
    "content": "package pop3\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"math/rand/v2\"\n\t\"net\"\n\t\"os\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/internal/auth\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/axllent/mailpit/internal/pop3client\"\n\t\"github.com/axllent/mailpit/internal/storage\"\n\t\"github.com/jhillyerd/enmime/v2\"\n)\n\nvar (\n\ttestingPort int\n)\n\nfunc TestPOP3(t *testing.T) {\n\tt.Log(\"Testing POP3 server\")\n\tsetup()\n\tdefer storage.Close()\n\n\t// connect with bad password\n\tt.Log(\"Testing invalid login\")\n\tif _, err := connectBadAuth(); err == nil {\n\t\tt.Error(\"invalid login gained access\")\n\t\treturn\n\t}\n\n\tt.Log(\"Testing valid login\")\n\tc, err := connectAuth()\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tcount, size, err := c.Stat()\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tassertEqual(t, count, 0, \"incorrect message count\")\n\tassertEqual(t, size, 0, \"incorrect size\")\n\n\t// quit else we get old data\n\tif err := c.Quit(); err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tt.Log(\"Inserting 50 messages\")\n\n\tinsertEmailData(t) // insert 50 messages\n\n\tc, err = connectAuth()\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tcount, _, err = c.Stat()\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tassertEqual(t, count, 50, \"incorrect message count\")\n\n\tt.Log(\"Fetching 20 messages\")\n\n\tfor i := 1; i <= 20; i++ {\n\t\t_, err := c.Retr(i)\n\t\tif err != nil {\n\t\t\tt.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tt.Log(\"Checking UIDL with multiple arguments\")\n\n\t_, err = c.Cmd(\"UIDL\", false, 1, 2, 3)\n\tif err == nil {\n\t\tt.Error(\"UIDL with multiple arguments should return an error\")\n\t\treturn\n\t}\n\n\tt.Log(\"Checking UIDL without a message id\")\n\n\tmessageIDs, err := c.Uidl(0)\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tif len(messageIDs) != 50 {\n\t\tassertEqual(t, len(messageIDs), 50, \"incorrect UIDL message count\")\n\t}\n\n\tt.Log(\"Checking UIDL with a message ID\")\n\n\tmessageIDs, err = c.Uidl(50)\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tassertEqual(t, len(messageIDs), 1, \"incorrect UIDL message count\")\n\n\tt.Log(\"Checking UIDL with an invalid message ID\")\n\n\tif _, err := c.Uidl(51); err == nil {\n\t\tt.Errorf(\"UIDL 51 should return an error\")\n\t\treturn\n\t}\n\n\tt.Log(\"Deleting 25 messages\")\n\n\tfor i := 1; i <= 25; i++ {\n\t\tif err := c.Dele(i); err != nil {\n\t\t\tt.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\t// messages get deleted after a QUIT\n\tif err := c.Quit(); err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\t// allow for background delete when using rqlite driver\n\ttime.Sleep(time.Millisecond * 200)\n\n\tc, err = connectAuth()\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tt.Log(\"Fetching message count\")\n\n\tcount, _, err = c.Stat()\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tassertEqual(t, count, 25, \"incorrect message count\")\n\n\t// messages get deleted after a QUIT\n\tif err := c.Quit(); err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tc, err = connectAuth()\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tt.Log(\"Deleting 25 messages\")\n\n\tfor i := 1; i <= 25; i++ {\n\t\tif err := c.Dele(i); err != nil {\n\t\t\tt.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tt.Log(\"Undeleting messages\")\n\n\tif err := c.Rset(); err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tif err := c.Quit(); err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tc, err = connectAuth()\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tcount, _, err = c.Stat()\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tassertEqual(t, count, 25, \"incorrect message count\")\n\n\tif err := c.Quit(); err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n}\n\nfunc TestAuthentication(t *testing.T) {\n\t// commands only allowed after authentication\n\tauthCommands := make(map[string]bool)\n\tauthCommands[\"STAT\"] = false\n\tauthCommands[\"LIST\"] = true\n\tauthCommands[\"NOOP\"] = false\n\tauthCommands[\"RSET\"] = false\n\tauthCommands[\"RETR 1\"] = true\n\n\tt.Log(\"Testing authenticated commands while not logged in\")\n\tsetup()\n\tdefer storage.Close()\n\n\tinsertEmailData(t) // insert 50 messages\n\n\t// non-authenticated connection\n\tc, err := connect()\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tfor cmd, multi := range authCommands {\n\t\tif _, err := c.Cmd(cmd, multi); err == nil {\n\t\t\tt.Errorf(\"%s should require authentication\", cmd)\n\t\t\treturn\n\t\t}\n\n\t\tif _, err := c.Cmd(strings.ToLower(cmd), multi); err == nil {\n\t\t\tt.Errorf(\"%s should require authentication\", cmd)\n\t\t\treturn\n\t\t}\n\t}\n\n\tif err := c.Quit(); err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tt.Log(\"Testing authenticated commands while logged in\")\n\n\t// authenticated connection\n\tc, err = connectAuth()\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tfor cmd, multi := range authCommands {\n\t\tif _, err := c.Cmd(cmd, multi); err != nil {\n\t\t\tt.Errorf(\"%s should work when authenticated\", cmd)\n\t\t\treturn\n\t\t}\n\n\t\tif _, err := c.Cmd(strings.ToLower(cmd), multi); err != nil {\n\t\t\tt.Errorf(\"%s should work when authenticated\", cmd)\n\t\t\treturn\n\t\t}\n\t}\n\n\tif err := c.Quit(); err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n}\n\nfunc setup() {\n\tif err := auth.SetPOP3Auth(\"username:password\"); err != nil {\n\t\tpanic(err)\n\t}\n\tlogger.NoLogging = true\n\tconfig.MaxMessages = 0\n\tconfig.Database = os.Getenv(\"MP_DATABASE\")\n\tvar foundPort bool\n\tfor !foundPort {\n\t\ttestingPort = randRange(1111, 2000)\n\t\tif portFree(testingPort) {\n\t\t\tfoundPort = true\n\t\t}\n\t}\n\n\tconfig.POP3Listen = fmt.Sprintf(\"localhost:%d\", testingPort)\n\n\tif err := storage.InitDB(); err != nil {\n\t\tpanic(err)\n\t}\n\n\tif err := storage.DeleteAllMessages(); err != nil {\n\t\tpanic(err)\n\t}\n\n\tgo Run()\n\n\ttime.Sleep(time.Second)\n}\n\n// connect and authenticate\nfunc connectAuth() (*pop3client.Conn, error) {\n\tc, err := connect()\n\tif err != nil {\n\t\treturn c, err\n\t}\n\n\terr = c.Auth(\"username\", \"password\")\n\n\treturn c, err\n}\n\n// connect and authenticate\nfunc connectBadAuth() (*pop3client.Conn, error) {\n\tc, err := connect()\n\tif err != nil {\n\t\treturn c, err\n\t}\n\n\terr = c.Auth(\"username\", \"notPassword\")\n\n\treturn c, err\n}\n\n// connect but do not authenticate\nfunc connect() (*pop3client.Conn, error) {\n\tp := pop3client.New(pop3client.Opt{\n\t\tHost:       \"localhost\",\n\t\tPort:       testingPort,\n\t\tTLSEnabled: false,\n\t})\n\n\tc, err := p.NewConn()\n\tif err != nil {\n\t\treturn c, err\n\t}\n\n\treturn c, err\n}\n\nfunc portFree(port int) bool {\n\tln, err := net.Listen(\"tcp\", fmt.Sprintf(\"localhost:%d\", port))\n\tif err != nil {\n\t\treturn false\n\t}\n\n\tif err := ln.Close(); err != nil {\n\t\tpanic(err)\n\t}\n\n\treturn true\n}\n\nfunc randRange(min, max int) int {\n\treturn rand.IntN(max-min) + min\n}\n\nfunc insertEmailData(t *testing.T) {\n\tfor i := range 50 {\n\t\tmsg := enmime.Builder().\n\t\t\tFrom(fmt.Sprintf(\"From %d\", i), fmt.Sprintf(\"from-%d@example.com\", i)).\n\t\t\tSubject(fmt.Sprintf(\"Subject line %d end\", i)).\n\t\t\tText(fmt.Appendf(nil, \"This is the email body %d <jdsauk;dwqmdqw;>.\", i)).\n\t\t\tTo(fmt.Sprintf(\"To %d\", i), fmt.Sprintf(\"to-%d@example.com\", i))\n\n\t\tenv, err := msg.Build()\n\t\tif err != nil {\n\t\t\tt.Log(\"error \", err)\n\t\t\tt.Fail()\n\t\t}\n\n\t\tbuf := new(bytes.Buffer)\n\n\t\tif err := env.Encode(buf); err != nil {\n\t\t\tt.Log(\"error \", err)\n\t\t\tt.Fail()\n\t\t}\n\n\t\tbufBytes := buf.Bytes()\n\n\t\tid, err := storage.Store(&bufBytes, nil)\n\t\tif err != nil {\n\t\t\tt.Log(\"error \", err)\n\t\t\tt.Fail()\n\t\t}\n\n\t\tif _, err := storage.SetMessageTags(id, []string{fmt.Sprintf(\"Test tag %03d\", i)}); err != nil {\n\t\t\tt.Log(\"error \", err)\n\t\t\tt.Fail()\n\t\t}\n\t}\n}\n\nfunc assertEqual(t *testing.T, a any, b any, message string) {\n\tif a == b {\n\t\treturn\n\t}\n\tmessage = fmt.Sprintf(\"%s: \\\"%v\\\" != \\\"%v\\\"\", message, a, b)\n\tt.Fatal(message)\n}\n"
  },
  {
    "path": "internal/pop3/server.go",
    "content": "// Package pop3 is a simple POP3 server for Mailpit.\n// By default it is disabled unless password credentials have been loaded.\n//\n// References: https://github.com/r0stig/golang-pop3 | https://github.com/inbucket/inbucket\n// See RFC: https://datatracker.ietf.org/doc/html/rfc1939\npackage pop3\n\nimport (\n\t\"bufio\"\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/internal/auth\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/axllent/mailpit/internal/storage\"\n\t\"github.com/axllent/mailpit/server/websockets\"\n)\n\nconst (\n\t// AUTHORIZATION is the initial state\n\tAUTHORIZATION = 1\n\t// TRANSACTION is the state after login\n\tTRANSACTION = 2\n\t// UPDATE is the state before closing\n\tUPDATE = 3\n)\n\n// Run will start the POP3 server if enabled\nfunc Run() {\n\tif auth.POP3Credentials == nil || config.POP3Listen == \"\" {\n\t\t// POP3 server is disabled without authentication\n\t\treturn\n\t}\n\n\tvar listener net.Listener\n\tvar err error\n\n\tif config.POP3TLSCert != \"\" {\n\t\tcer, err2 := tls.LoadX509KeyPair(config.POP3TLSCert, config.POP3TLSKey)\n\t\tif err2 != nil {\n\t\t\tlogger.Log().Errorf(\"[pop3] %s\", err2.Error())\n\t\t\treturn\n\t\t}\n\n\t\ttlsConfig := &tls.Config{\n\t\t\tCertificates: []tls.Certificate{cer},\n\t\t\tMinVersion:   tls.VersionTLS12,\n\t\t}\n\n\t\tlistener, err = tls.Listen(\"tcp\", config.POP3Listen, tlsConfig)\n\t} else {\n\t\t// unencrypted\n\t\tlistener, err = net.Listen(\"tcp\", config.POP3Listen)\n\t}\n\n\tif err != nil {\n\t\tlogger.Log().Errorf(\"[pop3] %s\", err.Error())\n\t\treturn\n\t}\n\n\tlogger.Log().Infof(\"[pop3] starting on %s\", config.POP3Listen)\n\n\tfor {\n\t\tconn, err := listener.Accept()\n\t\tif err != nil {\n\t\t\tlogger.Log().Errorf(\"[pop3] accept error: %s\", err.Error())\n\t\t\tcontinue\n\t\t}\n\n\t\t// run as goroutine\n\t\tgo handleClient(conn)\n\t}\n}\n\ntype message struct {\n\tID   string\n\tSize uint64\n}\n\nfunc handleClient(conn net.Conn) {\n\tvar (\n\t\tuser     = \"\"\n\t\tstate    = AUTHORIZATION // Start with AUTHORIZATION state\n\t\ttoDelete []string        // Track messages marked for deletion\n\t\tmessages []message\n\t)\n\n\tdefer func() {\n\t\tif state == UPDATE {\n\t\t\tif len(toDelete) > 0 {\n\t\t\t\tif err := storage.DeleteMessages(toDelete); err != nil {\n\t\t\t\t\tlogger.Log().Errorf(\"[pop3] error deleting: %s\", err.Error())\n\t\t\t\t}\n\t\t\t\t// Update web UI to remove deleted messages\n\t\t\t\twebsockets.Broadcast(\"prune\", nil)\n\t\t\t}\n\t\t}\n\n\t\tif err := conn.Close(); err != nil {\n\t\t\tlogger.Log().Errorf(\"[pop3] %s\", err.Error())\n\t\t}\n\t}()\n\n\treader := bufio.NewReader(conn)\n\n\tlogger.Log().Debugf(\"[pop3] connection opened by %s\", conn.RemoteAddr().String())\n\n\t// First welcome the new connection\n\tserverName := \"Mailpit\"\n\tif config.Label != \"\" {\n\t\tserverName = fmt.Sprintf(\"Mailpit (%s)\", config.Label)\n\t}\n\tsendResponse(conn, fmt.Sprintf(\"+OK %s POP3 server\", serverName))\n\n\t// Set 10 minutes timeout according to RFC1939\n\ttimeoutDuration := 600 * time.Second\n\n\tfor {\n\t\t// Set read deadline\n\t\tif err := conn.SetReadDeadline(time.Now().Add(timeoutDuration)); err != nil {\n\t\t\tlogger.Log().Errorf(\"[pop3] %s\", err.Error())\n\t\t\treturn\n\t\t}\n\n\t\t// Reads a line from the client\n\t\trawLine, err := reader.ReadString('\\n')\n\t\tif err != nil {\n\t\t\tif err == io.EOF {\n\t\t\t\tlogger.Log().Debugf(\"[pop3] client disconnected: %s\", conn.RemoteAddr().String())\n\t\t\t} else {\n\t\t\t\tlogger.Log().Errorf(\"[pop3] read error: %s\", err.Error())\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\t// Parses the command\n\t\tcmd, args := getCommand(rawLine)\n\t\tcmd = strings.ToUpper(cmd) // Commands in the POP3 are case-insensitive\n\n\t\tlogger.Log().Debugf(\"[pop3] received: %s (%s)\", strings.TrimSpace(rawLine), conn.RemoteAddr().String())\n\n\t\tswitch cmd {\n\t\tcase \"CAPA\":\n\t\t\t// List our capabilities per RFC2449\n\t\t\tsendResponse(conn, \"+OK capability list follows\")\n\t\t\tsendResponse(conn, \"TOP\")\n\t\t\tsendResponse(conn, \"USER\")\n\t\t\tsendResponse(conn, \"UIDL\")\n\t\t\tsendResponse(conn, \"IMPLEMENTATION Mailpit\")\n\t\t\tsendResponse(conn, \".\")\n\t\tcase \"USER\":\n\t\t\tif state == AUTHORIZATION {\n\t\t\t\tif len(args) != 1 {\n\t\t\t\t\tsendResponse(conn, \"-ERR must supply a user\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tsendResponse(conn, \"+OK\")\n\t\t\t\tuser = args[0]\n\t\t\t} else {\n\t\t\t\tsendResponse(conn, \"-ERR user already specified\")\n\t\t\t}\n\t\tcase \"PASS\":\n\t\t\tif state == AUTHORIZATION {\n\t\t\t\tif user == \"\" {\n\t\t\t\t\tsendResponse(conn, \"-ERR must supply a user\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif len(args) != 1 {\n\t\t\t\t\tsendResponse(conn, \"-ERR must supply a password\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tpass := args[0]\n\t\t\t\tif authUser(user, pass) {\n\t\t\t\t\tsendResponse(conn, \"+OK signed in\")\n\t\t\t\t\tvar err error\n\t\t\t\t\tmessages, err = getMessages()\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tlogger.Log().Errorf(\"[pop3] %s\", err.Error())\n\t\t\t\t\t}\n\t\t\t\t\tstate = TRANSACTION\n\t\t\t\t} else {\n\t\t\t\t\tsendResponse(conn, \"-ERR invalid password\")\n\t\t\t\t\tlogger.Log().Warnf(\"[pop3] failed login: %s\", user)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tsendResponse(conn, \"-ERR user not specified\")\n\t\t\t}\n\t\tcase \"STAT\", \"LIST\", \"UIDL\", \"RETR\", \"TOP\", \"NOOP\", \"DELE\", \"RSET\":\n\t\t\tif state == TRANSACTION {\n\t\t\t\thandleTransactionCommand(conn, cmd, args, messages, &toDelete)\n\t\t\t} else {\n\t\t\t\tsendResponse(conn, \"-ERR user not authenticated\")\n\t\t\t}\n\t\tcase \"QUIT\":\n\t\t\tsendResponse(conn, \"+OK goodbye\")\n\t\t\tstate = UPDATE\n\t\t\treturn\n\t\tdefault:\n\t\t\tsendResponse(conn, \"-ERR unknown command\")\n\t\t}\n\t}\n}\n\nfunc handleTransactionCommand(conn net.Conn, cmd string, args []string, messages []message, toDelete *[]string) {\n\tswitch cmd {\n\tcase \"STAT\":\n\t\ttotalSize := uint64(0)\n\t\tfor _, m := range messages {\n\t\t\ttotalSize += m.Size\n\t\t}\n\t\tsendResponse(conn, fmt.Sprintf(\"+OK %d %d\", len(messages), totalSize))\n\tcase \"LIST\":\n\t\ttotalSize := uint64(0)\n\t\tfor _, m := range messages {\n\t\t\ttotalSize += m.Size\n\t\t}\n\n\t\tif len(args) > 0 {\n\t\t\targ, _ := getSafeArg(args, 0)\n\t\t\tnr, err := strconv.Atoi(arg)\n\t\t\tif err != nil || nr < 1 || nr > len(messages) {\n\t\t\t\tsendResponse(conn, \"-ERR no such message\")\n\t\t\t\treturn\n\t\t\t}\n\t\t\tsendResponse(conn, fmt.Sprintf(\"+OK %d %d\", nr, messages[nr-1].Size))\n\t\t} else {\n\t\t\tsendResponse(conn, fmt.Sprintf(\"+OK %d messages (%d octets)\", len(messages), totalSize))\n\n\t\t\tfor row, m := range messages {\n\t\t\t\tsendResponse(conn, fmt.Sprintf(\"%d %d\", row+1, m.Size))\n\t\t\t}\n\t\t\tsendResponse(conn, \".\")\n\t\t}\n\tcase \"UIDL\":\n\t\tif len(args) > 1 {\n\t\t\tsendResponse(conn, \"-ERR UIDL takes at most one argument\")\n\t\t} else if len(args) == 1 {\n\t\t\tnr, err := strconv.Atoi(args[0])\n\t\t\tif err != nil {\n\t\t\t\tsendResponse(conn, \"-ERR no such message\")\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif nr < 1 || nr > len(messages) {\n\t\t\t\tsendResponse(conn, \"-ERR no such message\")\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tm := messages[nr-1]\n\n\t\t\tsendResponse(conn, fmt.Sprintf(\"+OK %d %s\", nr, m.ID))\n\t\t} else {\n\t\t\tsendResponse(conn, \"+OK unique-id listing follows\")\n\t\t\tfor row, m := range messages {\n\t\t\t\tsendResponse(conn, fmt.Sprintf(\"%d %s\", row+1, m.ID))\n\t\t\t}\n\t\t\tsendResponse(conn, \".\")\n\t\t}\n\n\tcase \"RETR\":\n\t\tif len(args) != 1 {\n\t\t\tsendResponse(conn, \"-ERR no such message\")\n\t\t\treturn\n\t\t}\n\n\t\tnr, err := strconv.Atoi(args[0])\n\t\tif err != nil || nr < 1 || nr > len(messages) {\n\t\t\tsendResponse(conn, \"-ERR no such message\")\n\t\t\treturn\n\t\t}\n\n\t\tm := messages[nr-1]\n\t\traw, err := storage.GetMessageRaw(m.ID)\n\t\tif err != nil {\n\t\t\tsendResponse(conn, \"-ERR no such message\")\n\t\t\treturn\n\t\t}\n\t\tsize := len(raw)\n\t\tsendResponse(conn, fmt.Sprintf(\"+OK %d octets\", size))\n\n\t\t// When all lines of the response have been sent, a\n\t\t// final line is sent, consisting of a termination octet (decimal code\n\t\t// 046, \".\") and a CRLF pair. If any line of the multi-line response\n\t\t// begins with the termination octet, the line is \"byte-stuffed\" by\n\t\t// pre-pending the termination octet to that line of the response.\n\t\t// @see: https://www.ietf.org/rfc/rfc1939.txt\n\t\tsendData(conn, strings.ReplaceAll(string(raw), \"\\n.\", \"\\n..\"))\n\t\tsendResponse(conn, \".\")\n\tcase \"TOP\":\n\t\targ, err := getSafeArg(args, 0)\n\t\tif err != nil {\n\t\t\tsendResponse(conn, \"-ERR TOP requires two arguments\")\n\t\t\treturn\n\t\t}\n\t\tnr, err := strconv.Atoi(arg)\n\t\tif err != nil || nr < 1 || nr > len(messages) {\n\t\t\tsendResponse(conn, \"-ERR no such message\")\n\t\t\treturn\n\t\t}\n\n\t\targ2, err := getSafeArg(args, 1)\n\t\tif err != nil {\n\t\t\tsendResponse(conn, \"-ERR TOP requires two arguments\")\n\t\t\treturn\n\t\t}\n\n\t\tlines, err := strconv.Atoi(arg2)\n\t\tif err != nil {\n\t\t\tsendResponse(conn, \"-ERR TOP requires two arguments\")\n\t\t\treturn\n\t\t}\n\n\t\tm := messages[nr-1]\n\t\theaders, body, err := getTop(m.ID, lines)\n\t\tif err != nil {\n\t\t\tsendResponse(conn, err.Error())\n\t\t\treturn\n\t\t}\n\n\t\tsendResponse(conn, \"+OK top of message follows\")\n\t\tsendData(conn, headers+\"\\r\\n\")\n\t\tsendData(conn, body)\n\t\tsendResponse(conn, \".\")\n\tcase \"NOOP\":\n\t\tsendResponse(conn, \"+OK\")\n\tcase \"DELE\":\n\t\targ, _ := getSafeArg(args, 0)\n\t\tnr, err := strconv.Atoi(arg)\n\t\tif err != nil || nr < 1 || nr > len(messages) {\n\t\t\tsendResponse(conn, \"-ERR no such message\")\n\t\t\treturn\n\t\t}\n\n\t\tm := messages[nr-1]\n\t\t*toDelete = append(*toDelete, m.ID)\n\t\tsendResponse(conn, \"+OK message marked for deletion\")\n\tcase \"RSET\":\n\t\t*toDelete = []string{}\n\t\tsendResponse(conn, \"+OK\")\n\tdefault:\n\t\tsendResponse(conn, \"-ERR unknown command\")\n\t}\n}\n"
  },
  {
    "path": "internal/pop3client/client.go",
    "content": "// Package pop3client is borrowed directly from https://github.com/knadh/go-pop3 to reduce dependencies.\n// This is used solely for testing the POP3 server\npackage pop3client\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"crypto/tls\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/mail\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n)\n\n// Client implements a Client e-mail client.\ntype Client struct {\n\topt    Opt\n\tdialer Dialer\n}\n\n// Conn is a stateful connection with the POP3 server/\ntype Conn struct {\n\tconn net.Conn\n\tr    *bufio.Reader\n\tw    *bufio.Writer\n}\n\n// Opt represents the client configuration.\ntype Opt struct {\n\t// Host name\n\tHost string `json:\"host\"`\n\t// Port number\n\tPort int `json:\"port\"`\n\t// DialTimeout default is 3 seconds.\n\tDialTimeout time.Duration `json:\"dial_timeout\"`\n\t// Dialer\n\tDialer Dialer `json:\"-\"`\n\t// TLSEnabled sets whether SLS is enabled\n\tTLSEnabled bool `json:\"tls_enabled\"`\n\t// TLSSkipVerify skips TLS verification (ie: self-signed)\n\tTLSSkipVerify bool `json:\"tls_skip_verify\"`\n}\n\n// Dialer interface\ntype Dialer interface {\n\tDial(network, address string) (net.Conn, error)\n}\n\n// MessageID contains the ID and size of an individual message.\ntype MessageID struct {\n\t// ID is the numerical index (non-unique) of the message.\n\tID int\n\t// Size in bytes\n\tSize int\n\t// UID is only present if the response is to the UIDL command.\n\tUID string\n}\n\nvar (\n\tlineBreak   = []byte(\"\\r\\n\")\n\trespOK      = []byte(\"+OK\")   // `+OK` without additional info\n\trespOKInfo  = []byte(\"+OK \")  // `+OK <info>`\n\trespErr     = []byte(\"-ERR\")  // `-ERR` without additional info\n\trespErrInfo = []byte(\"-ERR \") // `-ERR <info>`\n)\n\n// New returns a new client object using an existing connection.\nfunc New(opt Opt) *Client {\n\tif opt.DialTimeout < time.Millisecond {\n\t\topt.DialTimeout = time.Second * 3\n\t}\n\n\tc := &Client{\n\t\topt:    opt,\n\t\tdialer: opt.Dialer,\n\t}\n\n\tif c.dialer == nil {\n\t\tc.dialer = &net.Dialer{Timeout: opt.DialTimeout}\n\t}\n\n\treturn c\n}\n\n// NewConn creates and returns live POP3 server connection.\nfunc (c *Client) NewConn() (*Conn, error) {\n\tvar (\n\t\taddr = fmt.Sprintf(\"%s:%d\", c.opt.Host, c.opt.Port)\n\t)\n\n\tconn, err := c.dialer.Dial(\"tcp\", addr)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// No TLS.\n\tif c.opt.TLSEnabled {\n\t\t// Skip TLS host verification.\n\t\ttlsCfg := tls.Config{} // #nosec\n\t\tif c.opt.TLSSkipVerify {\n\t\t\ttlsCfg.InsecureSkipVerify = c.opt.TLSSkipVerify // #nosec\n\t\t} else {\n\t\t\ttlsCfg.ServerName = c.opt.Host\n\t\t}\n\n\t\tconn = tls.Client(conn, &tlsCfg)\n\t}\n\n\tpCon := &Conn{\n\t\tconn: conn,\n\t\tr:    bufio.NewReader(conn),\n\t\tw:    bufio.NewWriter(conn),\n\t}\n\n\t// Verify the connection by reading the welcome +OK greeting.\n\tif _, err := pCon.ReadOne(); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn pCon, nil\n}\n\n// Send sends a POP3 command to the server. The given comand is suffixed with \"\\r\\n\".\nfunc (c *Conn) Send(b string) error {\n\tif _, err := c.w.WriteString(b + \"\\r\\n\"); err != nil {\n\t\treturn err\n\t}\n\n\treturn c.w.Flush()\n}\n\n// Cmd sends a command to the server. POP3 responses are either single line or multi-line.\n// The first line always with -ERR in case of an error or +OK in case of a successful operation.\n// OK+ is always followed by a response on the same line which is either the actual response data\n// in case of single line responses, or a help message followed by multiple lines of actual response\n// data in case of multiline responses.\n// See https://www.shellhacks.com/retrieve-email-pop3-server-command-line/ for examples.\nfunc (c *Conn) Cmd(cmd string, isMulti bool, args ...any) (*bytes.Buffer, error) {\n\tvar cmdLine string\n\n\t// Repeat a %v to format each arg.\n\tif len(args) > 0 {\n\t\tformat := \" \" + strings.TrimRight(strings.Repeat(\"%v \", len(args)), \" \")\n\n\t\t// CMD arg1 argn ...\\r\\n\n\t\tcmdLine = fmt.Sprintf(cmd+format, args...)\n\t} else {\n\t\tcmdLine = cmd\n\t}\n\n\tif err := c.Send(cmdLine); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Read the first line of response to get the +OK/-ERR status.\n\tb, err := c.ReadOne()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Single line response.\n\tif !isMulti {\n\t\treturn bytes.NewBuffer(b), err\n\t}\n\n\tbuf, err := c.ReadAll()\n\treturn buf, err\n}\n\n// ReadOne reads a single line response from the conn.\nfunc (c *Conn) ReadOne() ([]byte, error) {\n\tb, _, err := c.r.ReadLine()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tr, err := parseResp(b)\n\treturn r, err\n}\n\n// ReadAll reads all lines from the connection until the POP3 multiline terminator \".\" is encountered\n// and returns a bytes.Buffer of all the read lines.\nfunc (c *Conn) ReadAll() (*bytes.Buffer, error) {\n\tbuf := &bytes.Buffer{}\n\n\tfor {\n\t\tb, _, err := c.r.ReadLine()\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\t// \".\" indicates the end of a multi-line response.\n\t\tif bytes.Equal(b, []byte(\".\")) {\n\t\t\tbreak\n\t\t}\n\n\t\tif _, err := buf.Write(b); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif _, err := buf.Write(lineBreak); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn buf, nil\n}\n\n// Auth authenticates the given credentials with the server.\nfunc (c *Conn) Auth(user, password string) error {\n\tif err := c.User(user); err != nil {\n\t\treturn err\n\t}\n\n\tif err := c.Pass(password); err != nil {\n\t\treturn err\n\t}\n\n\t// Issue a NOOP to force the server to respond to the auth.\n\t// Courtesy: github.com/TheCreeper/go-pop3\n\treturn c.Noop()\n}\n\n// User sends the username to the server.\nfunc (c *Conn) User(s string) error {\n\t_, err := c.Cmd(\"USER\", false, s)\n\n\treturn err\n}\n\n// Pass sends the password to the server.\nfunc (c *Conn) Pass(s string) error {\n\t_, err := c.Cmd(\"PASS\", false, s)\n\n\treturn err\n}\n\n// Stat returns the number of messages and their total size in bytes in the inbox.\nfunc (c *Conn) Stat() (int, int, error) {\n\tb, err := c.Cmd(\"STAT\", false)\n\tif err != nil {\n\t\treturn 0, 0, err\n\t}\n\n\t// count size\n\tf := bytes.Fields(b.Bytes())\n\n\t// Total number of messages.\n\tcount, err := strconv.Atoi(string(f[0]))\n\tif err != nil {\n\t\treturn 0, 0, err\n\t}\n\tif count == 0 {\n\t\treturn 0, 0, nil\n\t}\n\n\t// Total size of all messages in bytes.\n\tsize, err := strconv.Atoi(string(f[1]))\n\tif err != nil {\n\t\treturn 0, 0, err\n\t}\n\n\treturn count, size, nil\n}\n\n// List returns a list of (message ID, message Size) pairs.\n// If the optional msgID > 0, then only that particular message is listed.\n// The message IDs are sequential, 1 to N.\nfunc (c *Conn) List(msgID int) ([]MessageID, error) {\n\tvar (\n\t\tbuf *bytes.Buffer\n\t\terr error\n\t)\n\n\tif msgID <= 0 {\n\t\t// Multiline response listing all messages.\n\t\tbuf, err = c.Cmd(\"LIST\", true)\n\t} else {\n\t\t// Single line response listing one message.\n\t\tbuf, err = c.Cmd(\"LIST\", false, msgID)\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar (\n\t\tout   []MessageID\n\t\tlines = bytes.Split(buf.Bytes(), lineBreak)\n\t)\n\n\tfor _, l := range lines {\n\t\t// id size\n\t\tf := bytes.Fields(l)\n\t\tif len(f) == 0 {\n\t\t\tbreak\n\t\t}\n\n\t\tid, err := strconv.Atoi(string(f[0]))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tsize, err := strconv.Atoi(string(f[1]))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tout = append(out, MessageID{ID: id, Size: size})\n\t}\n\n\treturn out, nil\n}\n\n// Uidl returns a list of (message ID, message UID) pairs. If the optional msgID\n// is > 0, then only that particular message is listed. It works like Top() but only works on\n// servers that support the UIDL command. Messages size field is not available in the UIDL response.\nfunc (c *Conn) Uidl(msgID int) ([]MessageID, error) {\n\tvar (\n\t\tbuf *bytes.Buffer\n\t\terr error\n\t)\n\n\tif msgID <= 0 {\n\t\t// Multiline response listing all messages.\n\t\tbuf, err = c.Cmd(\"UIDL\", true)\n\t} else {\n\t\t// Single line response listing one message.\n\t\tbuf, err = c.Cmd(\"UIDL\", false, msgID)\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar (\n\t\tout   []MessageID\n\t\tlines = bytes.Split(buf.Bytes(), lineBreak)\n\t)\n\n\tfor _, l := range lines {\n\t\t// id size\n\t\tf := bytes.Fields(l)\n\t\tif len(f) == 0 {\n\t\t\tbreak\n\t\t}\n\n\t\tid, err := strconv.Atoi(string(f[0]))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tout = append(out, MessageID{ID: id, UID: string(f[1])})\n\t}\n\n\treturn out, nil\n}\n\n// Retr downloads a message by the given msgID, parses it and returns it as a *mail.Message.\nfunc (c *Conn) Retr(msgID int) (*mail.Message, error) {\n\tb, err := c.Cmd(\"RETR\", true, msgID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tm, err := mail.ReadMessage(b)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn m, nil\n}\n\n// RetrRaw downloads a message by the given msgID and returns the raw []byte\n// of the entire message.\nfunc (c *Conn) RetrRaw(msgID int) (*bytes.Buffer, error) {\n\tb, err := c.Cmd(\"RETR\", true, msgID)\n\treturn b, err\n}\n\n// Top retrieves a message by its ID with full headers and numLines lines of the body.\nfunc (c *Conn) Top(msgID int, numLines int) (*mail.Message, error) {\n\tb, err := c.Cmd(\"TOP\", true, msgID, numLines)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tm, err := mail.ReadMessage(b)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn m, nil\n}\n\n// Dele deletes one or more messages. The server only executes the\n// deletions after a successful Quit().\nfunc (c *Conn) Dele(msgID ...int) error {\n\tfor _, id := range msgID {\n\t\t_, err := c.Cmd(\"DELE\", false, id)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// Rset clears the messages marked for deletion in the current session.\nfunc (c *Conn) Rset() error {\n\t_, err := c.Cmd(\"RSET\", false)\n\treturn err\n}\n\n// Noop issues a do-nothing NOOP command to the server. This is useful for\n// prolonging open connections.\nfunc (c *Conn) Noop() error {\n\t_, err := c.Cmd(\"NOOP\", false)\n\treturn err\n}\n\n// Quit sends the QUIT command to server and gracefully closes the connection.\n// Message deletions (DELE command) are only executed by the server on a graceful\n// quit and close.\nfunc (c *Conn) Quit() error {\n\tdefer func() { _ = c.conn.Close() }()\n\n\tif _, err := c.Cmd(\"QUIT\", false); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// parseResp checks if the response is an error that starts with `-ERR`\n// and returns an error with the message that succeeds the error indicator.\n// For success `+OK` messages, it returns the remaining response bytes.\nfunc parseResp(b []byte) ([]byte, error) {\n\tif len(b) == 0 {\n\t\treturn nil, nil\n\t}\n\n\tif bytes.Equal(b, respOK) {\n\t\treturn nil, nil\n\t} else if after, ok := bytes.CutPrefix(b, respOKInfo); ok {\n\t\treturn after, nil\n\t} else if bytes.Equal(b, respErr) {\n\t\treturn nil, errors.New(\"unknown error (no info specified in response)\")\n\t} else if after, ok := bytes.CutPrefix(b, respErrInfo); ok {\n\t\treturn nil, errors.New(string(after))\n\t}\n\n\treturn nil, fmt.Errorf(\"unknown response: %s. Neither -ERR, nor +OK\", string(b))\n}\n"
  },
  {
    "path": "internal/prometheus/metrics.go",
    "content": "// Package prometheus provides Prometheus metrics for Mailpit\npackage prometheus\n\nimport (\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/axllent/mailpit/internal/stats\"\n\t\"github.com/prometheus/client_golang/prometheus\"\n\t\"github.com/prometheus/client_golang/prometheus/promhttp\"\n)\n\nvar (\n\t// Registry is the Prometheus registry for Mailpit metrics\n\tRegistry = prometheus.NewRegistry()\n\n\t// Metrics\n\ttotalMessages    prometheus.Gauge\n\tunreadMessages   prometheus.Gauge\n\tdatabaseSize     prometheus.Gauge\n\tmessagesDeleted  prometheus.Counter\n\tsmtpAccepted     prometheus.Counter\n\tsmtpRejected     prometheus.Counter\n\tsmtpIgnored      prometheus.Counter\n\tsmtpAcceptedSize prometheus.Counter\n\tuptime           prometheus.Gauge\n\tmemoryUsage      prometheus.Gauge\n\ttagCounters      *prometheus.GaugeVec\n)\n\n// InitMetrics initializes all Prometheus metrics\nfunc initMetrics() {\n\t// Create metrics\n\ttotalMessages = prometheus.NewGauge(prometheus.GaugeOpts{\n\t\tName: \"mailpit_messages\",\n\t\tHelp: \"Total number of messages in the database\",\n\t})\n\n\tunreadMessages = prometheus.NewGauge(prometheus.GaugeOpts{\n\t\tName: \"mailpit_messages_unread\",\n\t\tHelp: \"Number of unread messages in the database\",\n\t})\n\n\tdatabaseSize = prometheus.NewGauge(prometheus.GaugeOpts{\n\t\tName: \"mailpit_database_size_bytes\",\n\t\tHelp: \"Size of the database in bytes\",\n\t})\n\n\tmessagesDeleted = prometheus.NewCounter(prometheus.CounterOpts{\n\t\tName: \"mailpit_messages_deleted_total\",\n\t\tHelp: \"Total number of messages deleted\",\n\t})\n\n\tsmtpAccepted = prometheus.NewCounter(prometheus.CounterOpts{\n\t\tName: \"mailpit_smtp_accepted_total\",\n\t\tHelp: \"Total number of SMTP messages accepted\",\n\t})\n\n\tsmtpRejected = prometheus.NewCounter(prometheus.CounterOpts{\n\t\tName: \"mailpit_smtp_rejected_total\",\n\t\tHelp: \"Total number of SMTP messages rejected\",\n\t})\n\n\tsmtpIgnored = prometheus.NewCounter(prometheus.CounterOpts{\n\t\tName: \"mailpit_smtp_ignored_total\",\n\t\tHelp: \"Total number of SMTP messages ignored (duplicates)\",\n\t})\n\n\tsmtpAcceptedSize = prometheus.NewCounter(prometheus.CounterOpts{\n\t\tName: \"mailpit_smtp_accepted_size_bytes_total\",\n\t\tHelp: \"Total size of accepted SMTP messages in bytes\",\n\t})\n\n\tuptime = prometheus.NewGauge(prometheus.GaugeOpts{\n\t\tName: \"mailpit_uptime_seconds\",\n\t\tHelp: \"Uptime of Mailpit in seconds\",\n\t})\n\n\tmemoryUsage = prometheus.NewGauge(prometheus.GaugeOpts{\n\t\tName: \"mailpit_memory_usage_bytes\",\n\t\tHelp: \"Memory usage in bytes\",\n\t})\n\n\ttagCounters = prometheus.NewGaugeVec(\n\t\tprometheus.GaugeOpts{\n\t\t\tName: \"mailpit_tag_messages\",\n\t\t\tHelp: \"Number of messages per tag\",\n\t\t},\n\t\t[]string{\"tag\"},\n\t)\n\n\t// Register metrics\n\tRegistry.MustRegister(totalMessages)\n\tRegistry.MustRegister(unreadMessages)\n\tRegistry.MustRegister(databaseSize)\n\tRegistry.MustRegister(messagesDeleted)\n\tRegistry.MustRegister(smtpAccepted)\n\tRegistry.MustRegister(smtpRejected)\n\tRegistry.MustRegister(smtpIgnored)\n\tRegistry.MustRegister(smtpAcceptedSize)\n\tRegistry.MustRegister(uptime)\n\tRegistry.MustRegister(memoryUsage)\n\tRegistry.MustRegister(tagCounters)\n}\n\n// UpdateMetrics updates all metrics with current values\nfunc updateMetrics() {\n\tinfo := stats.Load(false)\n\n\ttotalMessages.Set(float64(info.Messages))\n\tunreadMessages.Set(float64(info.Unread))\n\tdatabaseSize.Set(float64(info.DatabaseSize))\n\tmessagesDeleted.Add(float64(info.RuntimeStats.MessagesDeleted))\n\tsmtpAccepted.Add(float64(info.RuntimeStats.SMTPAccepted))\n\tsmtpRejected.Add(float64(info.RuntimeStats.SMTPRejected))\n\tsmtpIgnored.Add(float64(info.RuntimeStats.SMTPIgnored))\n\tsmtpAcceptedSize.Add(float64(info.RuntimeStats.SMTPAcceptedSize))\n\tuptime.Set(float64(info.RuntimeStats.Uptime))\n\tmemoryUsage.Set(float64(info.RuntimeStats.Memory))\n\n\t// Reset tag counters\n\ttagCounters.Reset()\n\n\t// Update tag counters\n\tfor tag, count := range info.Tags {\n\t\ttagCounters.WithLabelValues(tag).Set(float64(count))\n\t}\n}\n\n// GetHandler returns the Prometheus handler & disables double compression in middleware\nfunc GetHandler() http.Handler {\n\treturn promhttp.HandlerFor(Registry, promhttp.HandlerOpts{\n\t\tDisableCompression: true,\n\t})\n}\n\n// StartUpdater starts the periodic metrics update routine\nfunc StartUpdater() {\n\tinitMetrics()\n\tupdateMetrics()\n\n\t// Start periodic updates\n\tgo func() {\n\t\tticker := time.NewTicker(15 * time.Second)\n\t\tdefer ticker.Stop()\n\n\t\tfor range ticker.C {\n\t\t\tupdateMetrics()\n\t\t}\n\t}()\n}\n\n// StartSeparateServer starts a separate HTTP server for Prometheus metrics\nfunc StartSeparateServer() {\n\tStartUpdater()\n\n\tlogger.Log().Infof(\"[prometheus] metrics server listening on %s\", config.PrometheusListen)\n\n\t// Create a dedicated mux for the metrics server\n\tmux := http.NewServeMux()\n\tmux.Handle(\"/metrics\", promhttp.HandlerFor(Registry, promhttp.HandlerOpts{}))\n\n\t// Create a dedicated server instance\n\tserver := &http.Server{\n\t\tAddr:              config.PrometheusListen,\n\t\tHandler:           mux,\n\t\tReadHeaderTimeout: 5 * time.Second,\n\t}\n\n\t// Start HTTP server\n\tif err := server.ListenAndServe(); err != nil {\n\t\tlogger.Log().Errorf(\"[prometheus] metrics server error: %s\", err.Error())\n\t}\n}\n\n// GetMode returns the Prometheus run mode\nfunc GetMode() string {\n\tmode := strings.ToLower(strings.TrimSpace(config.PrometheusListen))\n\n\tswitch mode {\n\tcase \"false\", \"\":\n\t\treturn \"disabled\"\n\tcase \"true\":\n\t\treturn \"integrated\"\n\tdefault:\n\t\treturn \"separate\"\n\t}\n}\n"
  },
  {
    "path": "internal/smtpd/chaos/chaos.go",
    "content": "// Package chaos is used to simulate Chaos engineering (random failures) in the SMTPD server.\n// See https://en.wikipedia.org/wiki/Chaos_engineering\n// See https://mailpit.axllent.org/docs/integration/chaos/\npackage chaos\n\nimport (\n\t\"crypto/rand\"\n\t\"fmt\"\n\t\"math/big\"\n\t\"strings\"\n\n\t\"github.com/axllent/mailpit/internal/logger\"\n)\n\nvar (\n\t// Enabled is a flag to enable or disable support for chaos\n\tEnabled = false\n\n\t// Config is the global Chaos configuration\n\tConfig = Triggers{\n\t\tSender:         Trigger{ErrorCode: 451, Probability: 0},\n\t\tRecipient:      Trigger{ErrorCode: 451, Probability: 0},\n\t\tAuthentication: Trigger{ErrorCode: 535, Probability: 0},\n\t}\n)\n\n// Triggers for the Chaos configuration\n//\n// swagger:model ChaosTriggers\ntype Triggers struct {\n\t// Sender trigger to fail on From, Sender\n\tSender Trigger\n\t// Recipient trigger to fail on To, Cc, Bcc\n\tRecipient Trigger\n\t// Authentication trigger to fail while authenticating (auth must be configured)\n\tAuthentication Trigger\n}\n\n// Trigger for Chaos\n//\n// swagger:model ChaosTrigger\ntype Trigger struct {\n\t// SMTP error code to return. The value must range from 400 to 599.\n\t// required: true\n\t// example: 451\n\tErrorCode int\n\n\t// Probability (chance) of triggering the error. The value must range from 0 to 100.\n\t// required: true\n\t// example: 5\n\tProbability int\n}\n\n// SetFromStruct will set a whole map of chaos configurations (ie: API)\nfunc SetFromStruct(c Triggers) error {\n\tif c.Sender.ErrorCode == 0 {\n\t\tc.Sender.ErrorCode = 451 // default\n\t}\n\n\tif c.Recipient.ErrorCode == 0 {\n\t\tc.Recipient.ErrorCode = 451 // default\n\t}\n\n\tif c.Authentication.ErrorCode == 0 {\n\t\tc.Authentication.ErrorCode = 535 // default\n\t}\n\n\tif err := Set(\"Sender\", c.Sender.ErrorCode, c.Sender.Probability); err != nil {\n\t\treturn err\n\t}\n\tif err := Set(\"Recipient\", c.Recipient.ErrorCode, c.Recipient.Probability); err != nil {\n\t\treturn err\n\t}\n\tif err := Set(\"Authentication\", c.Authentication.ErrorCode, c.Authentication.Probability); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// Set will set the chaos configuration for the given key (CLI & setMap())\nfunc Set(key string, errorCode int, probability int) error {\n\tEnabled = true\n\tif errorCode < 400 || errorCode > 599 {\n\t\treturn fmt.Errorf(\"error code must be between 400 and 599\")\n\t}\n\n\tif probability > 100 || probability < 0 {\n\t\treturn fmt.Errorf(\"probability must be between 0 and 100\")\n\t}\n\n\tkey = strings.ToLower(key)\n\n\tswitch key {\n\tcase \"sender\":\n\t\tConfig.Sender = Trigger{ErrorCode: errorCode, Probability: probability}\n\t\tlogger.Log().Infof(\"[chaos] Sender to return %d error with %d%% probability\", errorCode, probability)\n\tcase \"recipient\", \"recipients\":\n\t\tConfig.Recipient = Trigger{ErrorCode: errorCode, Probability: probability}\n\t\tlogger.Log().Infof(\"[chaos] Recipient to return %d error with %d%% probability\", errorCode, probability)\n\tcase \"auth\", \"authentication\":\n\t\tConfig.Authentication = Trigger{ErrorCode: errorCode, Probability: probability}\n\t\tlogger.Log().Infof(\"[chaos] Authentication to return %d error with %d%% probability\", errorCode, probability)\n\tdefault:\n\t\treturn fmt.Errorf(\"unknown key %s\", key)\n\t}\n\n\treturn nil\n}\n\n// Trigger will return whether the Chaos rule is triggered based on the configuration\n// and a randomly-generated percentage value.\nfunc (c Trigger) Trigger() (bool, int) {\n\tif !Enabled || c.Probability == 0 {\n\t\treturn false, 0\n\t}\n\n\tnBig, _ := rand.Int(rand.Reader, big.NewInt(100))\n\n\t// rand.IntN(100) will return 0-99, whereas probability is 1-100,\n\t// so value must be less than (not <=) to the probability to trigger\n\treturn int(nBig.Int64()) < c.Probability, c.ErrorCode\n}\n"
  },
  {
    "path": "internal/smtpd/forward.go",
    "content": "package smtpd\n\nimport (\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"net/smtp\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/axllent/mailpit/internal/tools\"\n\t\"github.com/pkg/errors\"\n)\n\n// Wrapper to forward messages if configured\nfunc autoForwardMessage(from string, data *[]byte) error {\n\tif config.SMTPForwardConfig.Host == \"\" {\n\t\treturn nil\n\t}\n\n\tif err := forward(from, *data); err != nil {\n\t\treturn errors.WithMessage(err, \"[forward] error: %s\")\n\t}\n\n\tlogger.Log().Debugf(\n\t\t\"[forward] message from %s to %s via %s:%d\",\n\t\tfrom, config.SMTPForwardConfig.To, config.SMTPForwardConfig.Host, config.SMTPForwardConfig.Port,\n\t)\n\n\treturn nil\n}\n\nfunc createForwardingSMTPClient(config config.SMTPForwardConfigStruct, addr string) (*smtp.Client, error) {\n\tif config.TLS {\n\t\ttlsConf := &tls.Config{ServerName: config.Host} // #nosec\n\t\ttlsConf.InsecureSkipVerify = config.AllowInsecure\n\n\t\tconn, err := tls.Dial(\"tcp\", addr, tlsConf)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"TLS dial error: %v\", err)\n\t\t}\n\n\t\tclient, err := smtp.NewClient(conn, tlsConf.ServerName)\n\t\tif err != nil {\n\t\t\t_ = conn.Close()\n\t\t\treturn nil, fmt.Errorf(\"SMTP client error: %v\", err)\n\t\t}\n\n\t\t// Note: The caller is responsible for closing the client\n\t\treturn client, nil\n\t}\n\n\tclient, err := smtp.Dial(addr)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error connecting to %s: %v\", addr, err)\n\t}\n\n\t// Set the hostname for HELO/EHLO\n\tif hostname, err := os.Hostname(); err == nil {\n\t\tif err := client.Hello(hostname); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error saying HELO/EHLO to %s: %v\", addr, err)\n\t\t}\n\t}\n\n\tif config.STARTTLS {\n\t\ttlsConf := &tls.Config{ServerName: config.Host} // #nosec\n\t\ttlsConf.InsecureSkipVerify = config.AllowInsecure\n\n\t\tif err = client.StartTLS(tlsConf); err != nil {\n\t\t\t_ = client.Close()\n\t\t\treturn nil, fmt.Errorf(\"error creating StartTLS config: %v\", err)\n\t\t}\n\t}\n\n\t// Note: The caller is responsible for closing the client\n\treturn client, nil\n}\n\n// Forward will connect to a pre-configured SMTP server and send a message to one or more recipients.\nfunc forward(from string, msg []byte) error {\n\taddr := fmt.Sprintf(\"%s:%d\", config.SMTPForwardConfig.Host, config.SMTPForwardConfig.Port)\n\n\tc, err := createForwardingSMTPClient(config.SMTPForwardConfig, addr)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer func() { _ = c.Close() }()\n\n\tauth := forwardAuthFromConfig()\n\n\tif auth != nil {\n\t\tif err = c.Auth(auth); err != nil {\n\t\t\treturn fmt.Errorf(\"error response to AUTH command: %s\", err.Error())\n\t\t}\n\t}\n\n\tif config.SMTPForwardConfig.OverrideFrom != \"\" {\n\t\tmsg, err = tools.OverrideFromHeader(msg, config.SMTPForwardConfig.OverrideFrom)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error overriding From header: %s\", err.Error())\n\t\t}\n\n\t\tfrom = config.SMTPForwardConfig.OverrideFrom\n\t}\n\n\tif err = c.Mail(from); err != nil {\n\t\treturn fmt.Errorf(\"error response to MAIL command: %s\", err.Error())\n\t}\n\n\tto := strings.SplitSeq(config.SMTPForwardConfig.To, \",\")\n\n\tfor addr := range to {\n\t\tif err = c.Rcpt(addr); err != nil {\n\t\t\tlogger.Log().Warnf(\"error response to RCPT command for %s: %s\", addr, err.Error())\n\t\t\tif config.SMTPForwardConfig.ForwardSMTPErrors {\n\t\t\t\treturn errors.WithMessagef(err, \"error response to RCPT command for %s\", addr)\n\t\t\t}\n\t\t}\n\t}\n\n\tw, err := c.Data()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error response to DATA command: %s\", err.Error())\n\t}\n\n\tif _, err := w.Write(msg); err != nil {\n\t\treturn fmt.Errorf(\"error sending message: %s\", err.Error())\n\t}\n\n\tif err := w.Close(); err != nil {\n\t\treturn fmt.Errorf(\"error closing connection: %s\", err.Error())\n\t}\n\n\treturn c.Quit()\n}\n\n// Return the SMTP forwarding authentication based on config\nfunc forwardAuthFromConfig() smtp.Auth {\n\tvar a smtp.Auth\n\n\tif config.SMTPForwardConfig.Auth == \"plain\" {\n\t\ta = smtp.PlainAuth(\"\", config.SMTPForwardConfig.Username, config.SMTPForwardConfig.Password, config.SMTPForwardConfig.Host)\n\t}\n\n\tif config.SMTPForwardConfig.Auth == \"login\" {\n\t\ta = LoginAuth(config.SMTPForwardConfig.Username, config.SMTPForwardConfig.Password)\n\t}\n\n\tif config.SMTPForwardConfig.Auth == \"cram-md5\" {\n\t\ta = smtp.CRAMMD5Auth(config.SMTPForwardConfig.Username, config.SMTPForwardConfig.Secret)\n\t}\n\n\treturn a\n}\n"
  },
  {
    "path": "internal/smtpd/main.go",
    "content": "// Package smtpd is the SMTP daemon\npackage smtpd\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/mail\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/internal/auth\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/axllent/mailpit/internal/stats\"\n\t\"github.com/axllent/mailpit/internal/storage\"\n\t\"github.com/axllent/mailpit/internal/tools\"\n\t\"github.com/axllent/mailpit/server/websockets\"\n\t\"github.com/lithammer/shortuuid/v4\"\n\t\"github.com/pkg/errors\"\n)\n\nvar (\n\t// DisableReverseDNS allows rDNS to be disabled\n\tDisableReverseDNS bool\n\n\twarningResponse = regexp.MustCompile(`^4\\d\\d `)\n\terrorResponse   = regexp.MustCompile(`^5\\d\\d `)\n)\n\n// MailHandler handles the incoming message to store in the database\nfunc mailHandler(origin net.Addr, from string, to []string, data []byte, smtpUser *string) (string, error) {\n\treturn SaveToDatabase(origin, from, to, data, smtpUser)\n}\n\n// SaveToDatabase will attempt to save a message to the database\nfunc SaveToDatabase(origin net.Addr, from string, to []string, data []byte, smtpUser *string) (string, error) {\n\tif !config.SMTPStrictRFCHeaders && bytes.Contains(data, []byte(\"\\r\\r\\n\")) {\n\t\t// replace all <CR><CR><LF> (\\r\\r\\n) with <CR><LF> (\\r\\n)\n\t\t// @see https://github.com/axllent/mailpit/issues/87 & https://github.com/axllent/mailpit/issues/153\n\t\tdata = bytes.ReplaceAll(data, []byte(\"\\r\\r\\n\"), []byte(\"\\r\\n\"))\n\t}\n\n\tmsg, err := mail.ReadMessage(bytes.NewReader(data))\n\tif err != nil {\n\t\tlogger.Log().Warnf(\"[smtpd] error parsing message: %s\", err.Error())\n\t\tstats.LogSMTPRejected()\n\t\treturn \"\", err\n\t}\n\n\t// check / set the Return-Path based on SMTP from\n\treturnPath := strings.Trim(msg.Header.Get(\"Return-Path\"), \"<>\")\n\tif returnPath != from {\n\t\tdata, err = tools.SetMessageHeader(data, \"Return-Path\", \"<\"+from+\">\")\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t}\n\n\tmessageID := strings.Trim(msg.Header.Get(\"Message-ID\"), \"<>\")\n\n\t// add a message ID if not set\n\tif messageID == \"\" {\n\t\t// generate unique ID\n\t\tmessageID = shortuuid.New() + \"@mailpit\"\n\t\t// add unique ID\n\t\tdata = append([]byte(\"Message-ID: <\"+messageID+\">\\r\\n\"), data...)\n\t} else if config.IgnoreDuplicateIDs {\n\t\tif storage.MessageIDExists(messageID) {\n\t\t\tlogger.Log().Debugf(\"[smtpd] duplicate message found, ignoring %s\", messageID)\n\t\t\tstats.LogSMTPIgnored()\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\n\t// if enabled, this may conditionally relay the email through to the preconfigured smtp server\n\tif relayErr := autoRelayMessage(from, to, &data); relayErr != nil {\n\t\tlogger.Log().Error(relayErr.Error())\n\n\t\tif config.SMTPRelayConfig.ForwardSMTPErrors {\n\t\t\tfor {\n\t\t\t\tunwrappedErr := errors.Unwrap(relayErr)\n\t\t\t\tif unwrappedErr == nil {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\trelayErr = unwrappedErr\n\t\t\t}\n\t\t\treturn \"\", relayErr\n\t\t}\n\t}\n\n\t// if enabled, this will forward a copy to preconfigured addresses\n\tif forwardErr := autoForwardMessage(from, &data); forwardErr != nil {\n\t\tlogger.Log().Error(forwardErr.Error())\n\n\t\tif config.SMTPForwardConfig.ForwardSMTPErrors {\n\t\t\tfor {\n\t\t\t\tunwrappedErr := errors.Unwrap(forwardErr)\n\t\t\t\tif unwrappedErr == nil {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tforwardErr = unwrappedErr\n\t\t\t}\n\t\t\treturn \"\", forwardErr\n\t\t}\n\t}\n\n\t// build array of all addresses in the header to compare to the []to array\n\temails, hasBccHeader := scanAddressesInHeader(msg.Header)\n\n\tmissingAddresses := []string{}\n\tfor _, a := range to {\n\t\t// loop through passed email addresses to check if they are in the headers\n\t\tif _, err := mail.ParseAddress(a); err == nil {\n\t\t\t_, ok := emails[strings.ToLower(a)]\n\t\t\tif !ok {\n\t\t\t\tmissingAddresses = append(missingAddresses, a)\n\t\t\t}\n\t\t} else {\n\t\t\tlogger.Log().Warnf(\"[smtpd] ignoring invalid email address: %s\", a)\n\t\t}\n\t}\n\n\t// add missing email addresses to Bcc (eg: Laravel doesn't include these in the headers)\n\tif len(missingAddresses) > 0 {\n\t\tbccVal := strings.Join(missingAddresses, \", \")\n\t\tif hasBccHeader {\n\t\t\tb := msg.Header.Get(\"Bcc\")\n\t\t\tbccVal = \", \" + b\n\t\t}\n\n\t\tdata, err = tools.SetMessageHeader(data, \"Bcc\", bccVal)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\n\t\tlogger.Log().Debugf(\"[smtpd] added missing addresses to Bcc header: %s\", strings.Join(missingAddresses, \", \"))\n\t}\n\n\tid, err := storage.Store(&data, smtpUser)\n\tif err != nil {\n\t\tlogger.Log().Errorf(\"[db] error storing message: %s\", err.Error())\n\t\treturn \"\", err\n\t}\n\n\tstats.LogSMTPAccepted(len(data))\n\n\tdata = nil // avoid memory leaks\n\n\tsubject := msg.Header.Get(\"Subject\")\n\tlogger.Log().Debugf(\"[smtpd] received (%s) from:%s subject:%q\", cleanIP(origin), from, subject)\n\n\treturn id, err\n}\n\nfunc authHandler(remoteAddr net.Addr, mechanism string, username []byte, password []byte, _ []byte) (bool, error) {\n\tallow := auth.SMTPCredentials.Match(string(username), string(password))\n\tif allow {\n\t\tlogger.Log().Debugf(\"[smtpd] allow %s login:%q from:%s\", mechanism, string(username), cleanIP(remoteAddr))\n\t} else {\n\t\tlogger.Log().Warnf(\"[smtpd] deny %s login:%q from:%s\", mechanism, string(username), cleanIP(remoteAddr))\n\t}\n\n\treturn allow, nil\n}\n\n// Allow any username and password\nfunc authHandlerAny(remoteAddr net.Addr, mechanism string, username []byte, _ []byte, _ []byte) (bool, error) {\n\tlogger.Log().Debugf(\"[smtpd] allow %s login %q from %s\", mechanism, string(username), cleanIP(remoteAddr))\n\n\treturn true, nil\n}\n\n// HandlerRcpt used to optionally restrict recipients based on `--smtp-allowed-recipients`\nfunc handlerRcpt(remoteAddr net.Addr, from string, to string) bool {\n\tif config.SMTPAllowedRecipientsRegexp == nil {\n\t\treturn true\n\t}\n\n\tresult := config.SMTPAllowedRecipientsRegexp.MatchString(to)\n\n\tif !result {\n\t\tlogger.Log().Warnf(\"[smtpd] rejected message to %s from %s (%s)\", to, from, cleanIP(remoteAddr))\n\t\tstats.LogSMTPRejected()\n\t}\n\n\treturn result\n}\n\n// Listen starts the SMTPD server\nfunc Listen() error {\n\tif config.SMTPAuthAllowInsecure {\n\t\tif auth.SMTPCredentials != nil {\n\t\t\tlogger.Log().Info(\"[smtpd] enabling login authentication (insecure)\")\n\t\t} else if config.SMTPAuthAcceptAny {\n\t\t\tlogger.Log().Info(\"[smtpd] enabling any authentication (insecure)\")\n\t\t}\n\t} else {\n\t\tif auth.SMTPCredentials != nil {\n\t\t\tlogger.Log().Info(\"[smtpd] enabling login authentication\")\n\t\t} else if config.SMTPAuthAcceptAny {\n\t\t\tlogger.Log().Info(\"[smtpd] enabling any authentication\")\n\t\t}\n\t}\n\n\treturn listenAndServe(config.SMTPListen, mailHandler, authHandler)\n}\n\n// Translate the smtpd verb from READ/WRITE\nfunc verbLogTranslator(verb string) string {\n\tif verb == \"READ\" {\n\t\treturn \"received\"\n\t}\n\n\treturn \"response\"\n}\n\nfunc listenAndServe(addr string, handler MsgIDHandler, authHandler AuthHandler) error {\n\n\tsocketAddr, perm, isSocket := tools.UnixSocket(addr)\n\n\tDebug = true // to enable Mailpit logging\n\tsrv := &Server{\n\t\tAddr:                     addr,\n\t\tMsgIDHandler:             handler,\n\t\tHandlerRcpt:              handlerRcpt,\n\t\tAppName:                  \"Mailpit\",\n\t\tHostname:                 \"\",\n\t\tAuthHandler:              nil,\n\t\tAuthRequired:             false,\n\t\tMaxRecipients:            config.SMTPMaxRecipients,\n\t\tIgnoreRejectedRecipients: config.SMTPIgnoreRejectedRecipients,\n\t\tDisableReverseDNS:        DisableReverseDNS,\n\t\tLogRead: func(remoteIP, verb, line string) {\n\t\t\tlogger.Log().Debugf(\"[smtpd] %s (%s) %s\", verbLogTranslator(verb), remoteIP, line)\n\t\t},\n\t\tLogWrite: func(remoteIP, verb, line string) {\n\t\t\tif warningResponse.MatchString(line) {\n\t\t\t\tlogger.Log().Warnf(\"[smtpd] %s (%s) %s\", verbLogTranslator(verb), remoteIP, line)\n\t\t\t\twebsockets.BroadCastClientError(\"warning\", \"smtpd\", remoteIP, line)\n\t\t\t} else if errorResponse.MatchString(line) {\n\t\t\t\tlogger.Log().Errorf(\"[smtpd] %s (%s) %s\", verbLogTranslator(verb), remoteIP, line)\n\t\t\t\twebsockets.BroadCastClientError(\"error\", \"smtpd\", remoteIP, line)\n\t\t\t} else {\n\t\t\t\tlogger.Log().Debugf(\"[smtpd] %s (%s) %s\", verbLogTranslator(verb), remoteIP, line)\n\t\t\t}\n\t\t},\n\t}\n\n\tif config.Label != \"\" {\n\t\tsrv.AppName = fmt.Sprintf(\"Mailpit (%s)\", config.Label)\n\t}\n\n\tif config.SMTPAuthAllowInsecure {\n\t\tsrv.AuthMechs = map[string]bool{\n\t\t\t\"CRAM-MD5\": false,\n\t\t\t\"PLAIN\":    true,\n\t\t\t\"LOGIN\":    true,\n\t\t}\n\t}\n\n\tif auth.SMTPCredentials != nil {\n\t\tsrv.AuthMechs = map[string]bool{\n\t\t\t\"CRAM-MD5\": false,\n\t\t\t\"PLAIN\":    true,\n\t\t\t\"LOGIN\":    true,\n\t\t}\n\t\tsrv.AuthHandler = authHandler\n\t\tsrv.AuthRequired = true\n\t} else if config.SMTPAuthAcceptAny {\n\t\tsrv.AuthMechs = map[string]bool{\n\t\t\t\"CRAM-MD5\": false,\n\t\t\t\"PLAIN\":    true,\n\t\t\t\"LOGIN\":    true,\n\t\t}\n\t\tsrv.AuthHandler = authHandlerAny\n\t}\n\n\tif config.SMTPTLSCert != \"\" {\n\t\tsrv.TLSRequired = config.SMTPRequireSTARTTLS\n\t\tsrv.TLSListener = config.SMTPRequireTLS // if true overrules srv.TLSRequired\n\t\tif err := srv.ConfigureTLS(config.SMTPTLSCert, config.SMTPTLSKey); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif isSocket {\n\t\tsrv.Addr = socketAddr\n\t\tsrv.Protocol = \"unix\"\n\t\tsrv.SocketPerm = perm\n\n\t\tif err := tools.PrepareSocket(srv.Addr); err != nil {\n\t\t\tstorage.Close()\n\t\t\treturn err\n\t\t}\n\n\t\t// delete the Unix socket file on exit\n\t\tstorage.AddTempFile(srv.Addr)\n\n\t\tlogger.Log().Infof(\"[smtpd] starting on %s\", config.SMTPListen)\n\t} else {\n\t\tsmtpType := \"no encryption\"\n\n\t\tif config.SMTPTLSCert != \"\" {\n\t\t\tif config.SMTPRequireTLS {\n\t\t\t\tsmtpType = \"SSL/TLS required\"\n\t\t\t} else if config.SMTPRequireSTARTTLS {\n\t\t\t\tsmtpType = \"STARTTLS required\"\n\t\t\t} else {\n\t\t\t\tsmtpType = \"STARTTLS optional\"\n\t\t\t\tif !config.SMTPAuthAllowInsecure && auth.SMTPCredentials != nil {\n\t\t\t\t\tsmtpType = \"STARTTLS required\"\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tlogger.Log().Infof(\"[smtpd] starting on %s (%s)\", config.SMTPListen, smtpType)\n\t}\n\n\treturn srv.ListenAndServe()\n}\n\nfunc cleanIP(i net.Addr) string {\n\tparts := strings.Split(i.String(), \":\")\n\n\treturn parts[0]\n}\n\n// Returns a list of all lowercased emails found in To, Cc and Bcc,\n// as well as whether there is a Bcc field\nfunc scanAddressesInHeader(h mail.Header) (map[string]bool, bool) {\n\temails := make(map[string]bool)\n\thasBccHeader := false\n\n\tif recipients, err := h.AddressList(\"To\"); err == nil {\n\t\tfor _, r := range recipients {\n\t\t\temails[strings.ToLower(r.Address)] = true\n\t\t}\n\t}\n\n\tif recipients, err := h.AddressList(\"Cc\"); err == nil {\n\t\tfor _, r := range recipients {\n\t\t\temails[strings.ToLower(r.Address)] = true\n\t\t}\n\t}\n\n\trecipients, err := h.AddressList(\"Bcc\")\n\tif err == nil {\n\t\tfor _, r := range recipients {\n\t\t\temails[strings.ToLower(r.Address)] = true\n\t\t}\n\n\t\thasBccHeader = true\n\t}\n\n\treturn emails, hasBccHeader\n}\n"
  },
  {
    "path": "internal/smtpd/relay.go",
    "content": "package smtpd\n\nimport (\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"net/smtp\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/axllent/mailpit/internal/tools\"\n\t\"github.com/pkg/errors\"\n)\n\n// Wrapper to auto relay messages if configured\nfunc autoRelayMessage(from string, to []string, data *[]byte) error {\n\tif config.SMTPRelayConfig.BlockedRecipientsRegexp != nil {\n\t\tfilteredTo := []string{}\n\t\tfor _, address := range to {\n\t\t\tif config.SMTPRelayConfig.BlockedRecipientsRegexp.MatchString(address) {\n\t\t\t\tlogger.Log().Debugf(\"[relay] ignoring auto-relay to %s: found in blocklist\", address)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tfilteredTo = append(filteredTo, address)\n\t\t}\n\t\tto = filteredTo\n\t}\n\n\tif len(to) == 0 {\n\t\treturn nil\n\t}\n\n\tif config.SMTPRelayAll {\n\t\tif err := Relay(from, to, *data); err != nil {\n\t\t\treturn errors.WithMessage(err, \"[relay] error\")\n\t\t}\n\n\t\tlogger.Log().Debugf(\n\t\t\t\"[relay] sent message to %s from %s via %s:%d\",\n\t\t\tstrings.Join(to, \", \"), from, config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port,\n\t\t)\n\t} else if config.SMTPRelayMatchingRegexp != nil {\n\t\tfiltered := []string{}\n\t\tfor _, t := range to {\n\t\t\tif config.SMTPRelayMatchingRegexp.MatchString(t) {\n\t\t\t\tfiltered = append(filtered, t)\n\t\t\t}\n\t\t}\n\n\t\tif len(filtered) == 0 {\n\t\t\treturn nil\n\t\t}\n\n\t\tif err := Relay(from, filtered, *data); err != nil {\n\t\t\treturn errors.WithMessage(err, \"[relay] error\")\n\t\t}\n\n\t\tlogger.Log().Debugf(\n\t\t\t\"[relay] auto-relay message to %s from %s via %s:%d\",\n\t\t\tstrings.Join(filtered, \", \"), from, config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port,\n\t\t)\n\t}\n\n\treturn nil\n}\n\nfunc createRelaySMTPClient(config config.SMTPRelayConfigStruct, addr string) (*smtp.Client, error) {\n\tif config.TLS {\n\t\ttlsConf := &tls.Config{ServerName: config.Host} // #nosec\n\t\ttlsConf.InsecureSkipVerify = config.AllowInsecure\n\n\t\tconn, err := tls.Dial(\"tcp\", addr, tlsConf)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"TLS dial error: %v\", err)\n\t\t}\n\n\t\tclient, err := smtp.NewClient(conn, tlsConf.ServerName)\n\t\tif err != nil {\n\t\t\t_ = conn.Close()\n\t\t\treturn nil, fmt.Errorf(\"SMTP client error: %v\", err)\n\t\t}\n\n\t\t// Note: The caller is responsible for closing the client\n\t\treturn client, nil\n\t}\n\n\tclient, err := smtp.Dial(addr)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error connecting to %s: %v\", addr, err)\n\t}\n\n\t// Set the hostname for HELO/EHLO\n\tif hostname, err := os.Hostname(); err == nil {\n\t\tif err := client.Hello(hostname); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error saying HELO/EHLO to %s: %v\", addr, err)\n\t\t}\n\t}\n\n\tif config.STARTTLS {\n\t\ttlsConf := &tls.Config{ServerName: config.Host} // #nosec\n\t\ttlsConf.InsecureSkipVerify = config.AllowInsecure\n\n\t\tif err = client.StartTLS(tlsConf); err != nil {\n\t\t\t_ = client.Close()\n\t\t\treturn nil, fmt.Errorf(\"error creating StartTLS config: %v\", err)\n\t\t}\n\t}\n\n\t// Note: The caller is responsible for closing the client\n\treturn client, nil\n}\n\n// Relay will connect to a pre-configured SMTP server and send a message to one or more recipients.\nfunc Relay(from string, to []string, msg []byte) error {\n\taddr := fmt.Sprintf(\"%s:%d\", config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)\n\n\tc, err := createRelaySMTPClient(config.SMTPRelayConfig, addr)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer func() { _ = c.Close() }()\n\n\tauth := relayAuthFromConfig()\n\n\tif auth != nil {\n\t\tif err = c.Auth(auth); err != nil {\n\t\t\treturn fmt.Errorf(\"error response to AUTH command: %s\", err.Error())\n\t\t}\n\t}\n\n\tif config.SMTPRelayConfig.OverrideFrom != \"\" {\n\t\tmsg, err = tools.OverrideFromHeader(msg, config.SMTPRelayConfig.OverrideFrom)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error overriding From header: %s\", err.Error())\n\t\t}\n\n\t\tfrom = config.SMTPRelayConfig.OverrideFrom\n\t}\n\n\tif err = c.Mail(from); err != nil {\n\t\treturn errors.WithMessage(err, \"error sending MAIL command\")\n\t}\n\n\tfor _, addr := range to {\n\t\tif err = c.Rcpt(addr); err != nil {\n\t\t\tlogger.Log().Warnf(\"error response to RCPT command for %s: %s\", addr, err.Error())\n\t\t\tif config.SMTPRelayConfig.ForwardSMTPErrors {\n\t\t\t\treturn errors.WithMessagef(err, \"error response to RCPT command for %s\", addr)\n\t\t\t}\n\t\t}\n\t}\n\n\tw, err := c.Data()\n\tif err != nil {\n\t\treturn errors.WithMessage(err, \"error response to DATA command\")\n\t}\n\n\tif _, err := w.Write(msg); err != nil {\n\t\treturn errors.WithMessage(err, \"error sending message\")\n\t}\n\n\tif err := w.Close(); err != nil {\n\t\treturn errors.WithMessage(err, \"error closing connection\")\n\t}\n\n\treturn c.Quit()\n}\n\n// Return the SMTP relay authentication based on config\nfunc relayAuthFromConfig() smtp.Auth {\n\tvar a smtp.Auth\n\n\tif config.SMTPRelayConfig.Auth == \"plain\" {\n\t\ta = smtp.PlainAuth(\"\", config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Password, config.SMTPRelayConfig.Host)\n\t}\n\n\tif config.SMTPRelayConfig.Auth == \"login\" {\n\t\ta = LoginAuth(config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Password)\n\t}\n\n\tif config.SMTPRelayConfig.Auth == \"cram-md5\" {\n\t\ta = smtp.CRAMMD5Auth(config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Secret)\n\t}\n\n\treturn a\n}\n\n// Custom implementation of LOGIN SMTP authentication\n// @see https://gist.github.com/andelf/5118732\ntype loginAuth struct {\n\tusername, password string\n}\n\n// LoginAuth authentication\nfunc LoginAuth(username, password string) smtp.Auth {\n\treturn &loginAuth{\n\t\tusername,\n\t\tpassword,\n\t}\n}\n\nfunc (a *loginAuth) Start(_ *smtp.ServerInfo) (string, []byte, error) {\n\treturn \"LOGIN\", []byte{}, nil\n}\n\nfunc (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {\n\tif more {\n\t\tswitch string(fromServer) {\n\t\tcase \"Username:\":\n\t\t\treturn []byte(a.username), nil\n\t\tcase \"Password:\":\n\t\t\treturn []byte(a.password), nil\n\t\tdefault:\n\t\t\treturn nil, errors.New(\"unknown fromServer\")\n\t\t}\n\t}\n\n\treturn nil, nil\n}\n"
  },
  {
    "path": "internal/smtpd/smtpd.go",
    "content": "// Package smtpd implements a basic SMTP server.\n//\n// This is a modified version of https://github.com/mhale/smtpd to\n// add support for unix sockets and Mailpit Chaos.\npackage smtpd\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/tls\"\n\t\"encoding/base64\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"log\"\n\t\"net\"\n\t\"net/mail\"\n\t\"os\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/axllent/mailpit/internal/smtpd/chaos\"\n)\n\nvar (\n\t// Debug `true` enables verbose logging.\n\tDebug      = false\n\trcptToRE   = regexp.MustCompile(`(?i)TO: ?<([^<>\\v]+)>( |$)(.*)?`)\n\tmailFromRE = regexp.MustCompile(`(?i)FROM: ?<(|[^<>\\v]+)>( |$)(.*)?`) // Delivery Status Notifications are sent with \"MAIL FROM:<>\"\n\n\t// extract mail size from 'MAIL FROM' parameter\n\tmailFromSizeRE = regexp.MustCompile(`(?U)(^| |,)[Ss][Ii][Zz][Ee]=(.*)($|,| )`)\n)\n\n// Handler function called upon successful receipt of an email.\n// Results in a \"250 2.0.0 Ok: queued\" response.\ntype Handler func(remoteAddr net.Addr, from string, to []string, data []byte) error\n\n// MsgIDHandler function called upon successful receipt of an email. Returns a message ID.\n// Results in a \"250 2.0.0 Ok: queued as <message-id>\" response.\ntype MsgIDHandler func(remoteAddr net.Addr, from string, to []string, data []byte, username *string) (string, error)\n\n// HandlerRcpt function called on RCPT. Return accept status.\ntype HandlerRcpt func(remoteAddr net.Addr, from string, to string) bool\n\n// AuthHandler function called when a login attempt is performed. Returns true if credentials are correct.\ntype AuthHandler func(remoteAddr net.Addr, mechanism string, username []byte, password []byte, shared []byte) (bool, error)\n\n// ErrServerClosed is the default message when a server closes a connection\nvar ErrServerClosed = errors.New(\"Server has been closed\")\n\n// ListenAndServe listens on the TCP network address addr\n// and then calls Serve with handler to handle requests\n// on incoming connections.\nfunc ListenAndServe(addr string, handler Handler, appName string, hostname string) error {\n\tsrv := &Server{Addr: addr, Handler: handler, AppName: appName, Hostname: hostname}\n\treturn srv.ListenAndServe()\n}\n\n// ListenAndServeTLS listens on the TCP network address addr\n// and then calls Serve with handler to handle requests\n// on incoming connections. Connections may be upgraded to TLS if the client requests it.\nfunc ListenAndServeTLS(addr string, certFile string, keyFile string, handler Handler, appName string, hostname string) error {\n\tsrv := &Server{Addr: addr, Handler: handler, AppName: appName, Hostname: hostname}\n\terr := srv.ConfigureTLS(certFile, keyFile)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn srv.ListenAndServe()\n}\n\ntype maxSizeExceededError struct {\n\tlimit int\n}\n\nfunc maxSizeExceeded(limit int) maxSizeExceededError {\n\treturn maxSizeExceededError{limit}\n}\n\n// Error uses the RFC 5321 response message in preference to RFC 1870.\n// RFC 3463 defines enhanced status code x.3.4 as \"Message too big for system\".\nfunc (err maxSizeExceededError) Error() string {\n\treturn fmt.Sprintf(\"552 5.3.4 Requested mail action aborted: exceeded storage allocation (%d)\", err.limit)\n}\n\n// LogFunc is a function capable of logging the client-server communication.\ntype LogFunc func(remoteIP, verb, line string)\n\n// Server is an SMTP server.\ntype Server struct {\n\tAddr                     string // TCP address to listen on, defaults to \":25\" (all addresses, port 25) if empty\n\tAppName                  string\n\tAuthHandler              AuthHandler\n\tAuthMechs                map[string]bool // Override list of allowed authentication mechanisms. Currently supported: LOGIN, PLAIN, CRAM-MD5. Enabling LOGIN and PLAIN will reduce RFC 4954 compliance.\n\tAuthRequired             bool            // Require authentication for every command except AUTH, EHLO, HELO, NOOP, RSET or QUIT as per RFC 4954. Ignored if AuthHandler is not configured.\n\tDisableReverseDNS        bool            // Disable reverse DNS lookups, enforces \"unknown\" hostname\n\tHandler                  Handler\n\tHandlerRcpt              HandlerRcpt\n\tHostname                 string\n\tLogRead                  LogFunc\n\tLogWrite                 LogFunc\n\tMaxSize                  int // Maximum message size allowed, in bytes\n\tMaxRecipients            int // Maximum number of recipients, defaults to 100.\n\tMsgIDHandler             MsgIDHandler\n\tIgnoreRejectedRecipients bool // Accept emails to rejected recipients with 2xx response but silently drop them\n\tTimeout                  time.Duration\n\tTLSConfig                *tls.Config\n\tTLSListener              bool        // Listen for incoming TLS connections only (not recommended as it may reduce compatibility). Ignored if TLS is not configured.\n\tTLSRequired              bool        // Require TLS for every command except NOOP, EHLO, STARTTLS, or QUIT as per RFC 3207. Ignored if TLS is not configured.\n\tProtocol                 string      // Default tcp, supports unix\n\tSocketPerm               fs.FileMode // if using Unix socket, socket permissions\n\n\tinShutdown   int32 // server was closed or shutdown\n\topenSessions int32 // count of open sessions\n\tmu           sync.Mutex\n\tshutdownChan chan struct{} // let the sessions know we are shutting down\n\n\tXClientAllowed []string // List of XCLIENT allowed IP addresses\n}\n\n// ConfigureTLS creates a TLS configuration from certificate and key files.\nfunc (srv *Server) ConfigureTLS(certFile string, keyFile string) error {\n\tcert, err := tls.LoadX509KeyPair(certFile, keyFile)\n\tif err != nil {\n\t\treturn err\n\t}\n\tsrv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}} // #nosec\n\treturn nil\n}\n\n// // ConfigureTLSWithPassphrase creates a TLS configuration from a certificate,\n// // an encrypted key file and the associated passphrase:\n// func (srv *Server) ConfigureTLSWithPassphrase(\n// \tcertFile string,\n// \tkeyFile string,\n// \tpassphrase string,\n// ) error {\n// \tcertPEMBlock, err := os.ReadFile(certFile)\n// \tif err != nil {\n// \t\treturn err\n// \t}\n// \tkeyPEMBlock, err := os.ReadFile(keyFile)\n// \tif err != nil {\n// \t\treturn err\n// \t}\n// \tkeyDERBlock, _ := pem.Decode(keyPEMBlock)\n// \tkeyPEMDecrypted, err := x509.DecryptPEMBlock(keyDERBlock, []byte(passphrase))\n// \tif err != nil {\n// \t\treturn err\n// \t}\n// \tvar pemBlock pem.Block\n// \tpemBlock.Type = keyDERBlock.Type\n// \tpemBlock.Bytes = keyPEMDecrypted\n// \tkeyPEMBlock = pem.EncodeToMemory(&pemBlock)\n// \tcert, err := tls.X509KeyPair(certPEMBlock, keyPEMBlock)\n// \tif err != nil {\n// \t\treturn err\n// \t}\n// \tsrv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}}\n// \treturn nil\n// }\n\n// ListenAndServe listens on the either a TCP network address srv.Addr or\n// alternatively a Unix socket. and then calls Serve to handle requests on\n// incoming connections. If srv.Addr is blank, \":25\" is used.\nfunc (srv *Server) ListenAndServe() error {\n\tif atomic.LoadInt32(&srv.inShutdown) != 0 {\n\t\treturn ErrServerClosed\n\t}\n\n\tif srv.Addr == \"\" {\n\t\tsrv.Addr = \":25\"\n\t}\n\tif srv.AppName == \"\" {\n\t\tsrv.AppName = \"smtpd\"\n\t}\n\tif srv.Hostname == \"\" {\n\t\tsrv.Hostname, _ = os.Hostname()\n\t}\n\tif srv.Timeout == 0 {\n\t\tsrv.Timeout = 5 * time.Minute\n\t}\n\tif srv.Protocol == \"\" {\n\t\tsrv.Protocol = \"tcp\"\n\t}\n\n\tvar ln net.Listener\n\tvar err error\n\n\t// If TLSListener is enabled, listen for TLS connections only.\n\tif srv.TLSConfig != nil && srv.TLSListener {\n\t\tln, err = tls.Listen(srv.Protocol, srv.Addr, srv.TLSConfig)\n\t} else {\n\t\tln, err = net.Listen(srv.Protocol, srv.Addr)\n\t}\n\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif srv.Protocol == \"unix\" {\n\t\t// set permissions\n\t\tif err := os.Chmod(srv.Addr, srv.SocketPerm); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn srv.Serve(ln)\n}\n\n// Serve creates a new SMTP session after a network connection is established.\nfunc (srv *Server) Serve(ln net.Listener) error {\n\tif atomic.LoadInt32(&srv.inShutdown) != 0 {\n\t\treturn ErrServerClosed\n\t}\n\n\tdefer func() { _ = ln.Close() }()\n\n\tfor {\n\t\t// if we are shutting down, don't accept new connections\n\t\tselect {\n\t\tcase <-srv.getShutdownChan():\n\t\t\treturn ErrServerClosed\n\t\tdefault:\n\t\t}\n\n\t\tconn, err := ln.Accept()\n\t\tif err != nil {\n\t\t\tif netErr, ok := err.(net.Error); ok && netErr.Timeout() {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\treturn err\n\t\t}\n\n\t\tsession := srv.newSession(conn)\n\t\tatomic.AddInt32(&srv.openSessions, 1)\n\t\tgo session.serve()\n\t}\n}\n\ntype session struct {\n\tsrv           *Server\n\tconn          net.Conn\n\tbr            *bufio.Reader\n\tbw            *bufio.Writer\n\tremoteIP      string // Remote IP address\n\tremoteHost    string // Remote hostname according to reverse DNS lookup\n\tremoteName    string // Remote hostname as supplied with EHLO\n\txClient       string // Information string as supplied with XCLIENT\n\txClientADDR   string // Information string as supplied with XCLIENT ADDR\n\txClientNAME   string // Information string as supplied with XCLIENT NAME\n\txClientTrust  bool   // Trust XCLIENT from current IP address\n\ttls           bool\n\tauthenticated bool\n\tusername      *string // username, nil if not authenticated\n}\n\n// Create new session from connection.\nfunc (srv *Server) newSession(conn net.Conn) (s *session) {\n\ts = &session{\n\t\tsrv:  srv,\n\t\tconn: conn,\n\t\tbr:   bufio.NewReader(conn),\n\t\tbw:   bufio.NewWriter(conn),\n\t}\n\n\t// Get remote end info for the Received header.\n\ts.remoteIP, _, _ = net.SplitHostPort(s.conn.RemoteAddr().String())\n\tif s.remoteIP == \"\" {\n\t\ts.remoteIP = \"127.0.0.1\"\n\t}\n\tif !s.srv.DisableReverseDNS {\n\t\tnames, err := net.LookupAddr(s.remoteIP)\n\t\tif err == nil && len(names) > 0 {\n\t\t\ts.remoteHost = names[0]\n\t\t} else {\n\t\t\ts.remoteHost = \"unknown\"\n\t\t}\n\t} else {\n\t\ts.remoteHost = \"unknown\"\n\t}\n\n\t// Set tls = true if TLS is already in use.\n\t_, s.tls = s.conn.(*tls.Conn)\n\n\tfor _, checkIP := range srv.XClientAllowed {\n\t\tif s.remoteIP == checkIP {\n\t\t\ts.xClientTrust = true\n\t\t}\n\t}\n\treturn\n}\n\nfunc (srv *Server) getShutdownChan() <-chan struct{} {\n\tsrv.mu.Lock()\n\tdefer srv.mu.Unlock()\n\tif srv.shutdownChan == nil {\n\t\tsrv.shutdownChan = make(chan struct{})\n\t}\n\n\treturn srv.shutdownChan\n}\n\nfunc (srv *Server) closeShutdownChan() {\n\tsrv.mu.Lock()\n\tdefer srv.mu.Unlock()\n\tif srv.shutdownChan == nil {\n\t\tsrv.shutdownChan = make(chan struct{})\n\t}\n\n\tselect {\n\tcase <-srv.shutdownChan:\n\tdefault:\n\t\tclose(srv.shutdownChan)\n\t}\n}\n\n// Close - closes the connection without waiting\nfunc (srv *Server) Close() error {\n\tatomic.StoreInt32(&srv.inShutdown, 1)\n\tsrv.closeShutdownChan()\n\treturn nil\n}\n\n// Shutdown - waits for current sessions to complete before closing\nfunc (srv *Server) Shutdown(ctx context.Context) error {\n\tatomic.StoreInt32(&srv.inShutdown, 1)\n\tsrv.closeShutdownChan()\n\n\t// wait for up to 30 seconds to allow the current sessions to\n\t// end\n\ttimer := time.NewTimer(100 * time.Millisecond)\n\tdefer timer.Stop()\n\n\tfor range 300 {\n\t\t// wait for open sessions to close\n\t\tif atomic.LoadInt32(&srv.openSessions) == 0 {\n\t\t\tbreak\n\t\t}\n\n\t\tselect {\n\t\tcase <-timer.C:\n\t\t\ttimer.Reset(100 * time.Millisecond)\n\t\tcase <-ctx.Done():\n\t\t\treturn ctx.Err()\n\t\tdefault:\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// Function called to handle connection requests.\nfunc (s *session) serve() {\n\tdefer atomic.AddInt32(&s.srv.openSessions, -1)\n\t// pass the connection into the defer function to ensure it is closed,\n\t// otherwise results in a 5s timeout for each connection\n\tdefer func(c net.Conn) { _ = c.Close() }(s.conn)\n\n\tvar gotEHLO bool\n\tvar from string\n\tvar gotFROM bool\n\tvar to []string\n\tvar hasRejectedRecipients bool\n\tvar buffer bytes.Buffer\n\n\t// RFC 5321 specifies support for minimum of 100 recipients is required.\n\tif s.srv.MaxRecipients == 0 {\n\t\ts.srv.MaxRecipients = 100\n\t}\n\n\t// Send banner.\n\ts.writef(\"220 %s %s ESMTP Service ready\", s.srv.Hostname, s.srv.AppName)\n\nloop:\n\tfor {\n\t\t// Attempt to read a line from the socket.\n\t\t// On timeout, send a timeout message and return from serve().\n\t\t// On error, assume the client has gone away i.e. return from serve().\n\t\tline, err := s.readLine()\n\t\tif err != nil {\n\t\t\tif netErr, ok := err.(net.Error); ok && netErr.Timeout() {\n\t\t\t\ts.writef(\"421 4.4.2 %s %s ESMTP Service closing transmission channel after timeout exceeded\", s.srv.Hostname, s.srv.AppName)\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\n\t\tverb, args := s.parseLine(line)\n\n\t\tswitch verb {\n\t\tcase \"HELO\":\n\t\t\ts.remoteName = args\n\t\t\ts.writef(\"250 %s greets %s\", s.srv.Hostname, s.remoteName)\n\n\t\t\t// RFC 2821 section 4.1.4 specifies that EHLO has the same effect as RSET, so reset for HELO too.\n\t\t\tgotEHLO = true\n\t\t\tfrom = \"\"\n\t\t\tgotFROM = false\n\t\t\tto = nil\n\t\t\thasRejectedRecipients = false\n\t\t\tbuffer.Reset()\n\t\tcase \"EHLO\":\n\t\t\ts.remoteName = args\n\t\t\ts.writef(\"%s\", s.makeEHLOResponse())\n\n\t\t\t// RFC 2821 section 4.1.4 specifies that EHLO has the same effect as RSET.\n\t\t\tgotEHLO = true\n\t\t\tfrom = \"\"\n\t\t\tgotFROM = false\n\t\t\tto = nil\n\t\t\thasRejectedRecipients = false\n\t\t\tbuffer.Reset()\n\t\tcase \"MAIL\":\n\t\t\tif s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls {\n\t\t\t\ts.writef(\"530 5.7.0 Must issue a STARTTLS command first\")\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif s.srv.AuthHandler != nil && s.srv.AuthRequired && !s.authenticated {\n\t\t\t\ts.writef(\"530 5.7.0 Authentication required\")\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif !gotEHLO {\n\t\t\t\ts.writef(\"503 5.5.1 Bad sequence of commands (HELO/EHLO required before MAIL)\")\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif to != nil {\n\t\t\t\ts.writef(\"503 5.5.1 Bad sequence of commands (RSET/HELO/EHLO required before MAIL)\")\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\tmatch, err := extractAndValidateAddress(mailFromRE, args)\n\t\t\tif match == nil {\n\t\t\t\tif err != nil {\n\t\t\t\t\ts.writef(\"%s\", err.Error())\n\t\t\t\t} else {\n\t\t\t\t\ts.writef(\"501 5.5.4 Syntax error in parameters or arguments (invalid FROM parameter)\")\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Mailpit Chaos\n\t\t\t\tif fail, code := chaos.Config.Sender.Trigger(); fail {\n\t\t\t\t\ts.writef(\"%d Chaos sender error\", code)\n\t\t\t\t\tbreak\n\t\t\t\t}\n\n\t\t\t\t// Validate the SIZE parameter if one was sent.\n\t\t\t\tif len(match[2]) > 0 { // A parameter is present\n\t\t\t\t\tsizeMatch := mailFromSizeRE.FindStringSubmatch(match[3])\n\t\t\t\t\tif sizeMatch == nil {\n\t\t\t\t\t\t// ignore other parameter\n\t\t\t\t\t\tfrom = match[1]\n\t\t\t\t\t\tgotFROM = true\n\t\t\t\t\t\ts.writef(\"250 2.1.0 Ok\")\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Enforce the maximum message size if one is set.\n\t\t\t\t\t\tsize, err := strconv.Atoi(sizeMatch[2])\n\t\t\t\t\t\tif err != nil { // Bad SIZE parameter\n\t\t\t\t\t\t\ts.writef(\"501 5.5.4 Syntax error in parameters or arguments (invalid SIZE parameter)\")\n\t\t\t\t\t\t} else if s.srv.MaxSize > 0 && size > s.srv.MaxSize { // SIZE above maximum size, if set\n\t\t\t\t\t\t\terr = maxSizeExceeded(s.srv.MaxSize)\n\t\t\t\t\t\t\ts.writef(\"%s\", err.Error())\n\t\t\t\t\t\t} else { // SIZE ok\n\t\t\t\t\t\t\tfrom = match[1]\n\t\t\t\t\t\t\tgotFROM = true\n\t\t\t\t\t\t\ts.writef(\"250 2.1.0 Ok\")\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else { // No parameters after FROM\n\t\t\t\t\tfrom = match[1]\n\t\t\t\t\tgotFROM = true\n\t\t\t\t\ts.writef(\"250 2.1.0 Ok\")\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tto = nil\n\t\t\thasRejectedRecipients = false\n\t\t\tbuffer.Reset()\n\t\tcase \"RCPT\":\n\t\t\tif s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls {\n\t\t\t\ts.writef(\"530 5.7.0 Must issue a STARTTLS command first\")\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif s.srv.AuthHandler != nil && s.srv.AuthRequired && !s.authenticated {\n\t\t\t\ts.writef(\"530 5.7.0 Authentication required\")\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif !gotFROM {\n\t\t\t\ts.writef(\"503 5.5.1 Bad sequence of commands (MAIL required before RCPT)\")\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\tmatch, err := extractAndValidateAddress(rcptToRE, args)\n\t\t\tif match == nil {\n\t\t\t\tif err != nil {\n\t\t\t\t\ts.writef(\"%s\", err.Error())\n\t\t\t\t} else {\n\t\t\t\t\ts.writef(\"501 5.5.4 Syntax error in parameters or arguments (invalid TO parameter)\")\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Mailpit Chaos\n\t\t\t\tif fail, code := chaos.Config.Recipient.Trigger(); fail {\n\t\t\t\t\ts.writef(\"%d Chaos recipient error\", code)\n\t\t\t\t\tbreak\n\t\t\t\t}\n\n\t\t\t\tif len(to) >= s.srv.MaxRecipients {\n\t\t\t\t\ts.writef(\"452 4.5.3 Too many recipients\")\n\t\t\t\t} else {\n\t\t\t\t\taccept := true\n\t\t\t\t\tif s.srv.HandlerRcpt != nil {\n\t\t\t\t\t\taccept = s.srv.HandlerRcpt(s.conn.RemoteAddr(), from, match[1])\n\t\t\t\t\t}\n\t\t\t\t\tif accept {\n\t\t\t\t\t\tto = append(to, match[1])\n\t\t\t\t\t\ts.writef(\"250 2.1.5 Ok\")\n\t\t\t\t\t} else if s.srv.IgnoreRejectedRecipients {\n\t\t\t\t\t\thasRejectedRecipients = true\n\t\t\t\t\t\ts.writef(\"250 2.1.5 Ok\")\n\t\t\t\t\t} else {\n\t\t\t\t\t\ts.writef(\"550 5.1.0 Requested action not taken: mailbox unavailable\")\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"DATA\":\n\t\t\tif s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls {\n\t\t\t\ts.writef(\"530 5.7.0 Must issue a STARTTLS command first\")\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif s.srv.AuthHandler != nil && s.srv.AuthRequired && !s.authenticated {\n\t\t\t\ts.writef(\"530 5.7.0 Authentication required\")\n\t\t\t\tbreak\n\t\t\t}\n\t\t\thasRecipients := len(to) > 0 || hasRejectedRecipients\n\t\t\tif !gotFROM || !hasRecipients {\n\t\t\t\ts.writef(\"503 5.5.1 Bad sequence of commands (MAIL & RCPT required before DATA)\")\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\ts.writef(\"354 Start mail input; end with <CR><LF>.<CR><LF>\")\n\n\t\t\t// Attempt to read message body from the socket.\n\t\t\t// On timeout, send a timeout message and return from serve().\n\t\t\t// On net.Error, assume the client has gone away i.e. return from serve().\n\t\t\t// On other errors, allow the client to try again.\n\t\t\tdata, err := s.readData()\n\t\t\tif err != nil {\n\t\t\t\tswitch err := err.(type) {\n\t\t\t\tcase net.Error:\n\t\t\t\t\tif err.Timeout() {\n\t\t\t\t\t\ts.writef(\"421 4.4.2 %s %s ESMTP Service closing transmission channel after timeout exceeded\", s.srv.Hostname, s.srv.AppName)\n\t\t\t\t\t}\n\t\t\t\t\tbreak loop\n\t\t\t\tcase maxSizeExceededError:\n\t\t\t\t\ts.writef(\"%s\", err.Error())\n\t\t\t\t\tcontinue\n\t\t\t\tdefault:\n\t\t\t\t\ts.writef(\"451 4.3.0 Requested action aborted: local error in processing\")\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Create Received header & write message body into buffer.\n\t\t\tbuffer.Reset()\n\t\t\tif len(to) > 0 {\n\t\t\t\tbuffer.Write(s.makeHeaders(to))\n\t\t\t}\n\t\t\tbuffer.Write(data)\n\n\t\t\t// Pass mail on to handler only if there are valid recipients.\n\t\t\tif len(to) > 0 && s.srv.Handler != nil {\n\t\t\t\terr := s.srv.Handler(s.conn.RemoteAddr(), from, to, buffer.Bytes())\n\t\t\t\tif err != nil {\n\t\t\t\t\tcheckErrFormat := regexp.MustCompile(`^([2-5][0-9]{2})[\\s\\-](.+)$`)\n\t\t\t\t\tif checkErrFormat.MatchString(err.Error()) {\n\t\t\t\t\t\ts.writef(\"%s\", err.Error())\n\t\t\t\t\t} else {\n\t\t\t\t\t\ts.writef(\"451 4.3.5 Unable to process mail\")\n\t\t\t\t\t}\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\ts.writef(\"250 2.0.0 Ok: queued\")\n\t\t\t} else if len(to) > 0 && s.srv.MsgIDHandler != nil {\n\t\t\t\tmsgID, err := s.srv.MsgIDHandler(s.conn.RemoteAddr(), from, to, buffer.Bytes(), s.username)\n\t\t\t\tif err != nil {\n\t\t\t\t\tcheckErrFormat := regexp.MustCompile(`^([2-5][0-9]{2})[\\s\\-](.+)$`)\n\t\t\t\t\tif checkErrFormat.MatchString(err.Error()) {\n\t\t\t\t\t\ts.writef(\"%s\", err.Error())\n\t\t\t\t\t} else {\n\t\t\t\t\t\ts.writef(\"451 4.3.5 Unable to process mail\")\n\t\t\t\t\t}\n\t\t\t\t\tbreak\n\t\t\t\t}\n\n\t\t\t\tif msgID != \"\" {\n\t\t\t\t\ts.writef(\"250 2.0.0 Ok: queued as %s\", msgID)\n\t\t\t\t} else {\n\t\t\t\t\ts.writef(\"250 2.0.0 Ok: queued\")\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif hasRejectedRecipients && Debug {\n\t\t\t\t\tif s.srv.LogWrite != nil {\n\t\t\t\t\t\ts.srv.LogWrite(s.remoteIP, \"DEBUG\", \"Message from sender silently dropped (rejected recipients)\")\n\t\t\t\t\t} else {\n\t\t\t\t\t\tlog.Printf(\"%s DEBUG Message from sender silently dropped (rejected recipients)\", s.remoteIP)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\ts.writef(\"250 2.0.0 Ok: queued\")\n\t\t\t}\n\n\t\t\t// Reset for next mail.\n\t\t\tfrom = \"\"\n\t\t\tgotFROM = false\n\t\t\tto = nil\n\t\t\thasRejectedRecipients = false\n\t\t\tbuffer.Reset()\n\t\tcase \"QUIT\":\n\t\t\ts.writef(\"221 2.0.0 %s %s ESMTP Service closing transmission channel\", s.srv.Hostname, s.srv.AppName)\n\t\t\tbreak loop\n\t\tcase \"RSET\":\n\t\t\tif s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls {\n\t\t\t\ts.writef(\"530 5.7.0 Must issue a STARTTLS command first\")\n\t\t\t\tbreak\n\t\t\t}\n\t\t\ts.writef(\"250 2.0.0 Ok\")\n\t\t\tfrom = \"\"\n\t\t\tgotFROM = false\n\t\t\tto = nil\n\t\t\thasRejectedRecipients = false\n\t\t\tbuffer.Reset()\n\t\tcase \"NOOP\":\n\t\t\ts.writef(\"250 2.0.0 Ok\")\n\t\tcase \"XCLIENT\":\n\t\t\ts.xClient = args\n\t\t\tif s.xClientTrust {\n\t\t\t\txCArgs := strings.SplitSeq(args, \" \")\n\t\t\t\tfor xCArg := range xCArgs {\n\t\t\t\t\txCParse := strings.Split(strings.TrimSpace(xCArg), \"=\")\n\t\t\t\t\tif strings.ToUpper(xCParse[0]) == \"ADDR\" && (net.ParseIP(xCParse[1]) != nil) {\n\t\t\t\t\t\ts.xClientADDR = xCParse[1]\n\t\t\t\t\t}\n\t\t\t\t\tif strings.ToUpper(xCParse[0]) == \"NAME\" && len(xCParse[1]) > 0 {\n\t\t\t\t\t\tif xCParse[1] != \"[UNAVAILABLE]\" {\n\t\t\t\t\t\t\ts.xClientNAME = xCParse[1]\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif len(s.xClientADDR) > 7 {\n\t\t\t\t\ts.remoteIP = s.xClientADDR\n\t\t\t\t\tif len(s.xClientNAME) > 4 {\n\t\t\t\t\t\ts.remoteHost = s.xClientNAME\n\t\t\t\t\t} else {\n\t\t\t\t\t\tnames, err := net.LookupAddr(s.remoteIP)\n\t\t\t\t\t\tif err == nil && len(names) > 0 {\n\t\t\t\t\t\t\ts.remoteHost = names[0]\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\ts.remoteHost = \"unknown\"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\ts.writef(\"250 2.0.0 Ok\")\n\t\tcase \"HELP\", \"VRFY\", \"EXPN\":\n\t\t\t// See RFC 5321 section 4.2.4 for usage of 500 & 502 response codes.\n\t\t\ts.writef(\"502 5.5.1 Command not implemented\")\n\t\tcase \"STARTTLS\":\n\t\t\t// Parameters are not allowed (RFC 3207 section 4).\n\t\t\tif args != \"\" {\n\t\t\t\ts.writef(\"501 5.5.2 Syntax error (no parameters allowed)\")\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\t// Handle case where TLS is requested but not configured (and therefore not listed as a service extension).\n\t\t\tif s.srv.TLSConfig == nil {\n\t\t\t\ts.writef(\"502 5.5.1 Command not implemented\")\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\t// Handle case where STARTTLS is received when TLS is already in use.\n\t\t\tif s.tls {\n\t\t\t\ts.writef(\"503 5.5.1 Bad sequence of commands (TLS already in use)\")\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\ts.writef(\"220 2.0.0 Ready to start TLS\")\n\n\t\t\t// Establish a TLS connection with the client.\n\t\t\ttlsConn := tls.Server(s.conn, s.srv.TLSConfig)\n\t\t\terr := tlsConn.Handshake()\n\t\t\tif err != nil {\n\t\t\t\ts.writef(\"403 4.7.0 TLS handshake failed\")\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\t// TLS handshake succeeded, switch to using the TLS connection.\n\t\t\ts.conn = tlsConn\n\t\t\ts.br = bufio.NewReader(s.conn)\n\t\t\ts.bw = bufio.NewWriter(s.conn)\n\t\t\ts.tls = true\n\n\t\t\t// RFC 3207 specifies that the server must discard any prior knowledge obtained from the client.\n\t\t\ts.remoteName = \"\"\n\t\t\tfrom = \"\"\n\t\t\tgotFROM = false\n\t\t\tto = nil\n\t\t\thasRejectedRecipients = false\n\t\t\tbuffer.Reset()\n\t\tcase \"AUTH\":\n\t\t\tif s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls {\n\t\t\t\ts.writef(\"530 5.7.0 Must issue a STARTTLS command first\")\n\t\t\t\tbreak\n\t\t\t}\n\t\t\t// Handle case where AUTH is requested but not configured (and therefore not listed as a service extension).\n\t\t\tif s.srv.AuthHandler == nil {\n\t\t\t\ts.writef(\"502 5.5.1 Command not implemented\")\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\t// Handle case where AUTH is received when already authenticated.\n\t\t\tif s.authenticated {\n\t\t\t\ts.writef(\"503 5.5.1 Bad sequence of commands (already authenticated for this session)\")\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\t// RFC 4954 specifies that AUTH is not permitted during mail transactions.\n\t\t\tif gotFROM || len(to) > 0 {\n\t\t\t\ts.writef(\"503 5.5.1 Bad sequence of commands (AUTH not permitted during mail transaction)\")\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\t// RFC 4954 requires a mechanism parameter.\n\t\t\tauthType, authArgs := s.parseLine(args)\n\t\t\tif authType == \"\" {\n\t\t\t\ts.writef(\"501 5.5.4 Malformed AUTH input (argument required)\")\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\t// RFC 4954 requires rejecting unsupported authentication mechanisms with a 504 response.\n\t\t\tallowedAuth := s.authMechs()\n\t\t\tif allowed, found := allowedAuth[authType]; !found || !allowed {\n\t\t\t\ts.writef(\"504 5.5.4 Unrecognized authentication type\")\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\t// Mailpit Chaos\n\t\t\tif fail, code := chaos.Config.Authentication.Trigger(); fail {\n\t\t\t\ts.writef(\"%d Chaos authentication error\", code)\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\t// RFC 4954 also specifies that ESMTP code 5.5.4 (\"Invalid command arguments\") should be returned\n\t\t\t// when attempting to use an unsupported authentication type.\n\t\t\t// Many servers return 5.7.4 (\"Security features not supported\") instead.\n\t\t\tswitch authType {\n\t\t\tcase \"PLAIN\":\n\t\t\t\ts.authenticated, err = s.handleAuthPlain(authArgs)\n\t\t\tcase \"LOGIN\":\n\t\t\t\ts.authenticated, err = s.handleAuthLogin(authArgs)\n\t\t\tcase \"CRAM-MD5\":\n\t\t\t\ts.authenticated, err = s.handleAuthCramMD5()\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tif netErr, ok := err.(net.Error); ok && netErr.Timeout() {\n\t\t\t\t\ts.writef(\"421 4.4.2 %s %s ESMTP Service closing transmission channel after timeout exceeded\", s.srv.Hostname, s.srv.AppName)\n\t\t\t\t\tbreak loop\n\t\t\t\t}\n\n\t\t\t\ts.writef(\"%s\", err.Error())\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\tif s.authenticated {\n\t\t\t\ts.writef(\"235 2.7.0 Authentication successful\")\n\t\t\t} else {\n\t\t\t\ts.writef(\"535 5.7.8 Authentication credentials invalid\")\n\t\t\t}\n\t\tdefault:\n\t\t\t// See RFC 5321 section 4.2.4 for usage of 500 & 502 response codes.\n\t\t\ts.writef(\"500 5.5.2 Syntax error, command unrecognized\")\n\t\t}\n\t}\n}\n\n// Wrapper function for writing a complete line to the socket.\nfunc (s *session) writef(format string, args ...any) {\n\tif s.srv.Timeout > 0 {\n\t\t_ = s.conn.SetWriteDeadline(time.Now().Add(s.srv.Timeout))\n\t}\n\n\tline := fmt.Sprintf(format, args...)\n\t_, _ = fmt.Fprintf(s.bw, \"%s\\r\\n\", line)\n\t_ = s.bw.Flush()\n\n\tif Debug {\n\t\tverb := \"WROTE\"\n\t\tif s.srv.LogWrite != nil {\n\t\t\ts.srv.LogWrite(s.remoteIP, verb, line)\n\t\t} else {\n\t\t\tlog.Println(s.remoteIP, verb, line)\n\t\t}\n\t}\n}\n\n// Read a complete line from the socket.\nfunc (s *session) readLine() (string, error) {\n\tif s.srv.Timeout > 0 {\n\t\t_ = s.conn.SetReadDeadline(time.Now().Add(s.srv.Timeout))\n\t}\n\n\tline, err := s.br.ReadString('\\n')\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tline = strings.TrimSpace(line) // Strip trailing \\r\\n\n\n\tif Debug {\n\t\tverb := \"READ\"\n\t\tif s.srv.LogRead != nil {\n\t\t\ts.srv.LogRead(s.remoteIP, verb, line)\n\t\t} else {\n\t\t\tlog.Println(s.remoteIP, verb, line)\n\t\t}\n\t}\n\n\treturn line, err\n}\n\n// Parse a line read from the socket.\nfunc (s *session) parseLine(line string) (verb string, args string) {\n\tif before, after, ok := strings.Cut(line, \" \"); ok {\n\t\tverb = strings.ToUpper(before)\n\t\targs = strings.TrimSpace(after)\n\t} else {\n\t\tverb = strings.ToUpper(line)\n\t\targs = \"\"\n\t}\n\treturn verb, args\n}\n\n// Read the message data following a DATA command.\nfunc (s *session) readData() ([]byte, error) {\n\tvar data []byte\n\tfor {\n\t\tif s.srv.Timeout > 0 {\n\t\t\t_ = s.conn.SetReadDeadline(time.Now().Add(s.srv.Timeout))\n\t\t}\n\n\t\tline, err := s.br.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\t// Handle end of data denoted by lone period (\\r\\n.\\r\\n)\n\t\tif bytes.Equal(line, []byte(\".\\r\\n\")) {\n\t\t\tbreak\n\t\t}\n\t\t// Remove leading period (RFC 5321 section 4.5.2)\n\t\tif line[0] == '.' {\n\t\t\tline = line[1:]\n\t\t}\n\n\t\t// Enforce the maximum message size limit.\n\t\tif s.srv.MaxSize > 0 {\n\t\t\tif len(data)+len(line) > s.srv.MaxSize {\n\t\t\t\t_, _ = s.br.Discard(s.br.Buffered()) // Discard the buffer remnants.\n\t\t\t\treturn nil, maxSizeExceeded(s.srv.MaxSize)\n\t\t\t}\n\t\t}\n\n\t\tdata = append(data, line...)\n\t}\n\treturn data, nil\n}\n\n// Create the Received header to comply with RFC 2821 section 3.8.2.\n// TODO: Work out what to do with multiple to addresses.\nfunc (s *session) makeHeaders(to []string) []byte {\n\tvar buffer bytes.Buffer\n\tnow := time.Now().Format(\"Mon, 2 Jan 2006 15:04:05 -0700 (MST)\")\n\tbuffer.WriteString(fmt.Sprintf(\"Received: from %s (%s [%s])\\r\\n\", s.remoteName, s.remoteHost, s.remoteIP))\n\tbuffer.WriteString(fmt.Sprintf(\"        by %s (%s) with SMTP\\r\\n\", s.srv.Hostname, s.srv.AppName))\n\tbuffer.WriteString(fmt.Sprintf(\"        for <%s>; %s\\r\\n\", to[0], now))\n\treturn buffer.Bytes()\n}\n\n// Determine allowed authentication mechanisms.\n// RFC 4954 specifies that plaintext authentication mechanisms such as LOGIN and PLAIN require a TLS connection.\n// This can be explicitly overridden e.g. setting s.srv.AuthMechs[\"LOGIN\"] = true.\nfunc (s *session) authMechs() (mechs map[string]bool) {\n\tmechs = map[string]bool{\"LOGIN\": s.tls, \"PLAIN\": s.tls, \"CRAM-MD5\": true}\n\n\tfor mech := range mechs {\n\t\tallowed, found := s.srv.AuthMechs[mech]\n\t\tif found {\n\t\t\tmechs[mech] = allowed\n\t\t}\n\t}\n\n\treturn\n}\n\n// Create the greeting string sent in response to an EHLO command.\nfunc (s *session) makeEHLOResponse() (response string) {\n\tresponse = fmt.Sprintf(\"250-%s greets %s\\r\\n\", s.srv.Hostname, s.remoteName)\n\n\t// RFC 1870 specifies that \"SIZE 0\" indicates no maximum size is in force.\n\tresponse += fmt.Sprintf(\"250-SIZE %d\\r\\n\", s.srv.MaxSize)\n\n\t// Only list STARTTLS if TLS is configured, but not currently in use.\n\tif s.srv.TLSConfig != nil && !s.tls {\n\t\tresponse += \"250-STARTTLS\\r\\n\"\n\t}\n\n\t// Only list AUTH if an AuthHandler is configured and at least one mechanism is allowed.\n\tif s.srv.AuthHandler != nil {\n\t\tvar mechs []string\n\t\tfor mech, allowed := range s.authMechs() {\n\t\t\tif allowed {\n\t\t\t\tmechs = append(mechs, mech)\n\t\t\t}\n\t\t}\n\t\tif len(mechs) > 0 {\n\t\t\tresponse += \"250-AUTH \" + strings.Join(mechs, \" \") + \"\\r\\n\"\n\t\t}\n\t}\n\n\tresponse += \"250-ENHANCEDSTATUSCODES\\r\\n\"\n\t// RFC 6531 specifies that the presence of SMTPUTF8 should include 8BITMIME\n\t// \"Servers offering this extension MUST provide support for, and announce, the 8BITMIME extension\"\n\t// https://www.rfc-editor.org/rfc/rfc6531#section-3.1:\n\tresponse += \"250-8BITMIME\\r\\n\"\n\tresponse += \"250 SMTPUTF8\" // last entry must use a space instead of a dash\n\treturn\n}\n\nfunc (s *session) handleAuthLogin(arg string) (bool, error) {\n\tvar err error\n\n\tif arg == \"\" {\n\t\ts.writef(\"334 %s\", base64.StdEncoding.EncodeToString([]byte(\"Username:\")))\n\t\targ, err = s.readLine()\n\t\tif err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\n\tusername, err := base64.StdEncoding.DecodeString(arg)\n\tif err != nil {\n\t\treturn false, errors.New(\"501 5.5.2 Syntax error (unable to decode)\")\n\t}\n\n\ts.writef(\"334 %s\", base64.StdEncoding.EncodeToString([]byte(\"Password:\")))\n\tline, err := s.readLine()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\tpassword, err := base64.StdEncoding.DecodeString(line)\n\tif err != nil {\n\t\treturn false, errors.New(\"501 5.5.2 Syntax error (unable to decode)\")\n\t}\n\n\t// Validate credentials.\n\tauthenticated, err := s.srv.AuthHandler(s.conn.RemoteAddr(), \"LOGIN\", username, password, nil)\n\tif authenticated {\n\t\tuname := string(username)\n\t\ts.username = &uname\n\t} else {\n\t\ts.username = nil\n\t}\n\n\treturn authenticated, err\n}\n\nfunc (s *session) handleAuthPlain(arg string) (bool, error) {\n\tvar err error\n\n\t// If fast mode (AUTH PLAIN [arg]) is not used, prompt for credentials.\n\tif arg == \"\" {\n\t\ts.writef(\"334 \")\n\t\targ, err = s.readLine()\n\t\tif err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\n\tdata, err := base64.StdEncoding.DecodeString(arg)\n\tif err != nil {\n\t\treturn false, errors.New(\"501 5.5.2 Syntax error (unable to decode)\")\n\t}\n\n\tparts := bytes.Split(data, []byte{0})\n\tif len(parts) != 3 {\n\t\treturn false, errors.New(\"501 5.5.2 Syntax error (unable to parse)\")\n\t}\n\n\t// Validate credentials.\n\tauthenticated, err := s.srv.AuthHandler(s.conn.RemoteAddr(), \"PLAIN\", parts[1], parts[2], nil)\n\tif authenticated {\n\t\tuname := string(parts[1])\n\t\ts.username = &uname\n\t} else {\n\t\ts.username = nil\n\t}\n\n\treturn authenticated, err\n}\n\nfunc (s *session) handleAuthCramMD5() (bool, error) {\n\tshared := \"<\" + strconv.Itoa(os.Getpid()) + \".\" + strconv.Itoa(time.Now().Nanosecond()) + \"@\" + s.srv.Hostname + \">\"\n\n\ts.writef(\"334 %s\", base64.StdEncoding.EncodeToString([]byte(shared)))\n\n\tdata, err := s.readLine()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\tif data == \"*\" {\n\t\treturn false, errors.New(\"501 5.7.0 Authentication cancelled\")\n\t}\n\n\tbuf, err := base64.StdEncoding.DecodeString(data)\n\tif err != nil {\n\t\treturn false, errors.New(\"501 5.5.2 Syntax error (unable to decode)\")\n\t}\n\n\tfields := strings.Split(string(buf), \" \")\n\tif len(fields) < 2 {\n\t\treturn false, errors.New(\"501 5.5.2 Syntax error (unable to parse)\")\n\t}\n\n\t// Validate credentials.\n\tauthenticated, err := s.srv.AuthHandler(s.conn.RemoteAddr(), \"CRAM-MD5\", []byte(fields[0]), []byte(fields[1]), []byte(shared))\n\n\treturn authenticated, err\n}\n\n// Extract and validate email address from a regex match.\n// This ensures that only RFC 5322 compliant email addresses are accepted (if set).\nfunc extractAndValidateAddress(re *regexp.Regexp, args string) ([]string, error) {\n\tmatch := re.FindStringSubmatch(args)\n\tif match == nil {\n\t\treturn nil, nil\n\t}\n\n\tif strings.Contains(match[1], \" \") {\n\t\treturn nil, errors.New(\"553 5.1.3 The address is not a valid RFC 5321 address\")\n\t}\n\n\t// first argument will be the email address, validate it if not empty\n\tif match[1] != \"\" {\n\t\ta, err := mail.ParseAddress(match[1])\n\t\tif err != nil {\n\t\t\treturn nil, errors.New(\"553 5.1.3 The address is not a valid RFC 5321 address\")\n\t\t}\n\n\t\t// https://datatracker.ietf.org/doc/html/rfc5321#section-4.5.3.1\n\t\t// RFC states that the local part of an email address SHOULD not exceed 64 characters\n\t\t// and the domain part SHOULD not exceed 255 characters, however as per https://github.com/axllent/mailpit/issues/620\n\t\t// it appears that investigated mail servers do not actually implement this limit, but rather enforce\n\t\t// a much larger limit (ie: 1024 characters).\n\t\tif len(a.Address) > 1024 {\n\t\t\treturn nil, errors.New(\"500 The address is too long\")\n\t\t}\n\t}\n\n\treturn match, nil\n}\n"
  },
  {
    "path": "internal/smtpd/smtpd_test.go",
    "content": "package smtpd\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/hmac\"\n\t\"crypto/md5\"\n\t\"crypto/tls\"\n\t\"encoding/base64\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"reflect\"\n\t\"regexp\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n)\n\nvar cert = makeCertificate()\n\n// Create a client to run commands with. Parse the banner for 220 response.\nfunc newConn(t *testing.T, server *Server) net.Conn {\n\tclientConn, serverConn := net.Pipe()\n\tsession := server.newSession(serverConn)\n\tgo session.serve()\n\n\tbanner, err := bufio.NewReader(clientConn).ReadString('\\n')\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to read banner from test server: %v\", err)\n\t}\n\tif banner[0:3] != \"220\" {\n\t\tt.Fatalf(\"Read incorrect banner from test server: %v\", banner)\n\t}\n\treturn clientConn\n}\n\n// Send a command and verify the 3 digit code from the response.\nfunc cmdCode(t *testing.T, conn net.Conn, cmd string, code string) string {\n\t_, _ = fmt.Fprintf(conn, \"%s\\r\\n\", cmd)\n\tresp, err := bufio.NewReader(conn).ReadString('\\n')\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to read response from test server: %v\", err)\n\t}\n\tif resp[0:3] != code {\n\t\tt.Errorf(\"Command \\\"%s\\\" response code is %s, want %s\", cmd, resp[0:3], code)\n\t}\n\treturn strings.TrimSpace(resp)\n}\n\n// Simple tests: connect, send command, then send QUIT.\n// RFC 2821 section 4.1.4 specifies that these commands do not require a prior EHLO,\n// only that clients should send one, so test without EHLO.\nfunc TestSimpleCommands(t *testing.T) {\n\ttests := []struct {\n\t\tcmd  string\n\t\tcode string\n\t}{\n\t\t{\"NOOP\", \"250\"},\n\t\t{\"RSET\", \"250\"},\n\t\t{\"HELP\", \"502\"},\n\t\t{\"VRFY\", \"502\"},\n\t\t{\"EXPN\", \"502\"},\n\t\t{\"TEST\", \"500\"}, // Unsupported command\n\t\t{\"\", \"500\"},     // Blank command\n\t}\n\n\tfor _, tt := range tests {\n\t\tconn := newConn(t, &Server{})\n\t\tcmdCode(t, conn, tt.cmd, tt.code)\n\t\tcmdCode(t, conn, \"QUIT\", \"221\")\n\t\tif err := conn.Close(); err != nil {\n\t\t\tt.Errorf(\"Failed to close connection after command %s: %v\", tt.cmd, err)\n\t\t}\n\t}\n}\n\nfunc TestCmdHELO(t *testing.T) {\n\tconn := newConn(t, &Server{})\n\n\t// Send HELO, expect greeting.\n\tcmdCode(t, conn, \"HELO host.example.com\", \"250\")\n\n\t// Verify that HELO resets the current transaction state like RSET.\n\t// RFC 2821 section 4.1.4 says EHLO should cause a reset, so verify that HELO does it too.\n\tcmdCode(t, conn, \"mail from:<sender@example.com>\", \"250\") // Also testing case-insensitivity\n\tcmdCode(t, conn, \"rcpt to:<recipient@example.com>\", \"250\")\n\tcmdCode(t, conn, \"HELO host.example.com\", \"250\")\n\tcmdCode(t, conn, \"DATA\", \"503\")\n\n\tcmdCode(t, conn, \"QUIT\", \"221\")\n\t_ = conn.Close()\n}\n\nfunc TestCmdEHLO(t *testing.T) {\n\tconn := newConn(t, &Server{})\n\n\t// Send EHLO, expect greeting.\n\tcmdCode(t, conn, \"EHLO host.example.com\", \"250\")\n\n\t// Verify that EHLO resets the current transaction state like RSET.\n\t// See RFC 2821 section 4.1.4 for more detail.\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com>\", \"250\")\n\tcmdCode(t, conn, \"RCPT TO:<recipient@example.com>\", \"250\")\n\n\t// test invalid addresses & header injection\n\tcmdCode(t, conn, \"RCPT TO: <recipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipient@exampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexample.com>\", \"500\") // too long\n\tcmdCode(t, conn, \"RCPT TO:<recipientexample.com>\", \"553\")\n\tcmdCode(t, conn, \"RCPT TO: <recipient@test@example.com>\", \"553\")\n\tcmdCode(t, conn, \"RCPT TO: <recipient@@example.com>\", \"553\")\n\tcmdCode(t, conn, \"RCPT TO: <recipientexample.com>\", \"553\")\n\tcmdCode(t, conn, \"RCPT TO:  <recipientexample.com>\", \"501\")\n\tcmdCode(t, conn, \"RCPT TO:<recipient\\rexample.com>\", \"553\")\n\tcmdCode(t, conn, \"RCPT TO: <recipient\\rexample.com>\", \"553\")\n\tcmdCode(t, conn, \"RCPT TO:  <recipient\\rexample.com>\", \"501\")\n\tcmdCode(t, conn, \"RCPT TO: <>\", \"501\") // empty address not allowed here\n\n\tcmdCode(t, conn, \"EHLO host.example.com\", \"250\")\n\tcmdCode(t, conn, \"DATA\", \"503\")\n\n\tcmdCode(t, conn, \"QUIT\", \"221\")\n\t_ = conn.Close()\n}\n\nfunc TestCmdMAILBeforeEHLO(t *testing.T) {\n\tconn := newConn(t, &Server{})\n\t// RFC 5321 §4.1.4 — Order of Commands states (emphasis added):\n\t// “The SMTP client MUST issue HELO or EHLO before any other SMTP commands.”\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com>\", \"503\")\n\n\tcmdCode(t, conn, \"QUIT\", \"221\")\n\t_ = conn.Close()\n}\n\nfunc TestCmdMAILAfterRCPT(t *testing.T) {\n\tconn := newConn(t, &Server{})\n\n\t// Send EHLO, expect greeting\n\tcmdCode(t, conn, \"EHLO host.example.com\", \"250\")\n\n\t// Send MAIL FROM\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com>\", \"250\")\n\n\t// Send RCPT TO\n\tcmdCode(t, conn, \"RCPT TO:<recipient@example.com>\", \"250\")\n\n\t// MAIL FROM must not come after RCPT TO in the same transaction\n\tcmdCode(t, conn, \"MAIL FROM:<sender2@example.com>\", \"503\")\n\n\t// RSET to clear the transaction\n\tcmdCode(t, conn, \"RSET\", \"250\")\n\n\t// Now the MAIL FROM should be accepted\n\tcmdCode(t, conn, \"MAIL FROM:<sender2@example.com>\", \"250\")\n\n\tcmdCode(t, conn, \"QUIT\", \"221\")\n\t_ = conn.Close()\n}\n\nfunc TestCmdRSET(t *testing.T) {\n\tconn := newConn(t, &Server{})\n\tcmdCode(t, conn, \"EHLO host.example.com\", \"250\")\n\n\t// Verify that RSET clears the current transaction state.\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com>\", \"250\")\n\tcmdCode(t, conn, \"RCPT TO:<recipient@example.com>\", \"250\")\n\tcmdCode(t, conn, \"RSET\", \"250\")\n\tcmdCode(t, conn, \"DATA\", \"503\")\n\n\tcmdCode(t, conn, \"QUIT\", \"221\")\n\t_ = conn.Close()\n}\n\nfunc TestCmdMAIL(t *testing.T) {\n\tconn := newConn(t, &Server{})\n\tcmdCode(t, conn, \"EHLO host.example.com\", \"250\")\n\n\t// MAIL with no FROM arg should return 501 syntax error\n\tcmdCode(t, conn, \"MAIL\", \"501\")\n\t// // MAIL with empty FROM arg should return 501 syntax error\n\tcmdCode(t, conn, \"MAIL FROM:\", \"501\")\n\tcmdCode(t, conn, \"MAIL FROM: \", \"501\")\n\tcmdCode(t, conn, \"MAIL FROM:  \", \"501\")\n\t// MAIL with DSN-style FROM arg should return 250 Ok\n\tcmdCode(t, conn, \"MAIL FROM:<>\", \"250\")\n\t// MAIL with valid FROM arg should return 250 Ok\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com>\", \"250\")\n\n\t// MAIL with seemingly valid but noncompliant FROM arg (single space after the colon) should be tolerated and should return 250 Ok\n\tcmdCode(t, conn, \"MAIL FROM: <sender@example.com>\", \"250\")\n\t// MAIL with seemingly valid but noncompliant FROM arg (double space after the colon) should return 501 syntax error\n\tcmdCode(t, conn, \"MAIL FROM:  <sender@example.com>\", \"501\")\n\n\t// test invalid addresses & header injection\n\tcmdCode(t, conn, \"MAIL FROM: <sendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersender@exampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexample.com>\", \"500\") // too long\n\tcmdCode(t, conn, \"MAIL FROM:<sender\\rexample.com>\", \"553\")\n\tcmdCode(t, conn, \"MAIL FROM: <sender\\rexample.com>\", \"553\")\n\tcmdCode(t, conn, \"MAIL FROM:  <sender\\rexample.com>\", \"501\")\n\tcmdCode(t, conn, \"MAIL FROM:<senderexample.com>\", \"553\")\n\tcmdCode(t, conn, \"MAIL FROM: <sender@@example.com>\", \"553\")\n\tcmdCode(t, conn, \"MAIL FROM: <sender@test@example.com>\", \"553\")\n\tcmdCode(t, conn, \"MAIL FROM: <senderexample.com>\", \"553\")\n\tcmdCode(t, conn, \"MAIL FROM:  <senderexample.com>\", \"501\")\n\tcmdCode(t, conn, \"MAIL FROM: < sender@example.com >\", \"553\")\n\tcmdCode(t, conn, \"MAIL FROM: < sender@example.com>\", \"553\")\n\tcmdCode(t, conn, \"MAIL FROM: <sender@example.com >\", \"553\")\n\n\t// MAIL with valid SIZE parameter should return 250 Ok\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com> SIZE=1000\", \"250\")\n\n\t// MAIL with bad size parameter should return 501 syntax error\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com> SIZE=\", \"501\")\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com> SIZE= \", \"501\")\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com> SIZE=foo\", \"501\")\n\n\t// MAIL with BODY parameter should be accepted (8BITMIME support)\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com> BODY=8BITMIME\", \"250\")\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com> BODY=8BITMIME,SIZE=1000\", \"250\")\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com> BODY=8BITMIME,SIZE=foo\", \"501\") // SIZE validation error\n\n\t// TODO: MAIL with valid AUTH parameter should return 250 Ok\n\n\t// TODO: MAIL with invalid AUTH parameter must return 501 syntax error\n\n\tcmdCode(t, conn, \"QUIT\", \"221\")\n\t_ = conn.Close()\n}\n\nfunc TestCmdMAILMaxSize(t *testing.T) {\n\tmaxSize := 10 + time.Now().Minute()\n\tconn := newConn(t, &Server{MaxSize: maxSize})\n\tcmdCode(t, conn, \"EHLO host.example.com\", \"250\")\n\n\t// MAIL with no size parameter should return 250 Ok\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com>\", \"250\")\n\n\t// MAIL with bad size parameter should return 501 syntax error\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com> SIZE=\", \"501\")\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com> SIZE= \", \"501\")\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com> SIZE=foo\", \"501\")\n\n\t// MAIL with size parameter zero should return 250 Ok\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com> SIZE=0\", \"250\")\n\n\t// MAIL below the maximum size should return 250 Ok\n\tcmdCode(t, conn, fmt.Sprintf(\"MAIL FROM:<sender@example.com> SIZE=%d\", maxSize-1), \"250\")\n\n\t// MAIL matching the maximum size should return 250 Ok\n\tcmdCode(t, conn, fmt.Sprintf(\"MAIL FROM:<sender@example.com> SIZE=%d\", maxSize), \"250\")\n\n\t// MAIL above the maximum size should return a maximum size exceeded error.\n\tcmdCode(t, conn, fmt.Sprintf(\"MAIL FROM:<sender@example.com> SIZE=%d\", maxSize+1), \"552\")\n\n\t// Clients should send either RSET or QUIT after receiving 552 (RFC 1870 section 6.2).\n\tcmdCode(t, conn, \"QUIT\", \"221\")\n\t_ = conn.Close()\n}\n\nfunc TestCmdRCPT(t *testing.T) {\n\tconn := newConn(t, &Server{})\n\tcmdCode(t, conn, \"EHLO host.example.com\", \"250\")\n\n\t// RCPT without prior MAIL should return 503 bad sequence\n\tcmdCode(t, conn, \"RCPT\", \"503\")\n\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com>\", \"250\")\n\n\t// RCPT with no TO arg should return 501 syntax error\n\tcmdCode(t, conn, \"RCPT\", \"501\")\n\n\t// RCPT with empty TO arg should return 501 syntax error\n\tcmdCode(t, conn, \"RCPT TO:\", \"501\")\n\tcmdCode(t, conn, \"RCPT TO: \", \"501\")\n\tcmdCode(t, conn, \"RCPT TO:  \", \"501\")\n\tcmdCode(t, conn, \"RCPT TO:<@route.example user@example.com>\", \"553\")\n\n\t// RCPT with valid TO arg should return 250 Ok\n\tcmdCode(t, conn, \"RCPT TO:<recipient@example.com>\", \"250\")\n\n\t// Up to 100 valid recipients should return 250 Ok\n\tfor i := 2; i < 101; i++ {\n\t\tcmdCode(t, conn, fmt.Sprintf(\"RCPT TO:<recipient%v@example.com>\", i), \"250\")\n\t}\n\n\t// 101st valid recipient with valid TO arg should return 452 too many recipients\n\tcmdCode(t, conn, \"RCPT TO:<recipient101@example.com>\", \"452\")\n\n\t// RCPT with valid TO arg and prior DSN-style FROM arg should return 250 Ok\n\tcmdCode(t, conn, \"RSET\", \"250\")\n\tcmdCode(t, conn, \"MAIL FROM:<>\", \"250\")\n\tcmdCode(t, conn, \"RCPT TO:<recipient@example.com>\", \"250\")\n\n\t// RCPT with seemingly valid but noncompliant TO arg (single space after the colon) should be tolerated and should return 250 Ok\n\tcmdCode(t, conn, \"RSET\", \"250\")\n\tcmdCode(t, conn, \"MAIL FROM:<>\", \"250\")\n\tcmdCode(t, conn, \"RCPT TO: <recipient@example.com>\", \"250\")\n\n\t// RCPT with seemingly valid but noncompliant TO arg (double space after the colon) should return 501 syntax error\n\tcmdCode(t, conn, \"RSET\", \"250\")\n\tcmdCode(t, conn, \"MAIL FROM:<>\", \"250\")\n\tcmdCode(t, conn, \"RCPT TO:  <recipient@example.com>\", \"501\")\n\n\tcmdCode(t, conn, \"QUIT\", \"221\")\n\t_ = conn.Close()\n}\n\nfunc TestCmdMaxRecipients(t *testing.T) {\n\tconn := newConn(t, &Server{MaxRecipients: 3})\n\n\tcmdCode(t, conn, \"EHLO host.example.com\", \"250\")\n\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com>\", \"250\")\n\n\tcmdCode(t, conn, \"RCPT TO: <recipient1@example.com>\", \"250\")\n\tcmdCode(t, conn, \"RCPT TO: <recipient2@example.com>\", \"250\")\n\tcmdCode(t, conn, \"RCPT TO: <recipient3@example.com>\", \"250\")\n\tcmdCode(t, conn, \"RCPT TO: <recipient4@example.com>\", \"452\")\n\tcmdCode(t, conn, \"RCPT TO: <recipient5@example.com>\", \"452\")\n\n\tcmdCode(t, conn, \"QUIT\", \"221\")\n\t_ = conn.Close()\n}\n\nfunc TestCmdDATA(t *testing.T) {\n\tconn := newConn(t, &Server{})\n\tcmdCode(t, conn, \"EHLO host.example.com\", \"250\")\n\n\t// DATA without prior MAIL & RCPT should return 503 bad sequence\n\tcmdCode(t, conn, \"DATA\", \"503\")\n\tcmdCode(t, conn, \"RSET\", \"250\")\n\n\t// DATA without prior RCPT should return 503 bad sequence\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com>\", \"250\")\n\tcmdCode(t, conn, \"DATA\", \"503\")\n\tcmdCode(t, conn, \"RSET\", \"250\")\n\n\t// Test a full mail transaction.\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com>\", \"250\")\n\tcmdCode(t, conn, \"RCPT TO:<recipient@example.com>\", \"250\")\n\tcmdCode(t, conn, \"DATA\", \"354\")\n\tcmdCode(t, conn, \"Test message.\\r\\n.\", \"250\")\n\n\t// Test a full mail transaction with a bad last recipient.\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com>\", \"250\")\n\tcmdCode(t, conn, \"RCPT TO:<recipient@example.com>\", \"250\")\n\tcmdCode(t, conn, \"RCPT TO:\", \"501\")\n\tcmdCode(t, conn, \"DATA\", \"354\")\n\tcmdCode(t, conn, \"Test message.\\r\\n.\", \"250\")\n\n\tcmdCode(t, conn, \"QUIT\", \"221\")\n\t_ = conn.Close()\n}\n\nfunc TestCmdDATAWithMaxSize(t *testing.T) {\n\t// \"Test message.\\r\\n.\" is 15 bytes after trailing period is removed.\n\tconn := newConn(t, &Server{MaxSize: 15})\n\tcmdCode(t, conn, \"EHLO host.example.com\", \"250\")\n\n\t// Messages below the maximum size should return 250 Ok\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com>\", \"250\")\n\tcmdCode(t, conn, \"RCPT TO:<recipient@example.com>\", \"250\")\n\tcmdCode(t, conn, \"DATA\", \"354\")\n\tcmdCode(t, conn, \"Test message\\r\\n.\", \"250\")\n\n\t// Messages matching the maximum size should return 250 Ok\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com>\", \"250\")\n\tcmdCode(t, conn, \"RCPT TO:<recipient@example.com>\", \"250\")\n\tcmdCode(t, conn, \"DATA\", \"354\")\n\tcmdCode(t, conn, \"Test message.\\r\\n.\", \"250\")\n\n\t// Messages above the maximum size should return a maximum size exceeded error.\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com>\", \"250\")\n\tcmdCode(t, conn, \"RCPT TO:<recipient@example.com>\", \"250\")\n\tcmdCode(t, conn, \"DATA\", \"354\")\n\tcmdCode(t, conn, \"Test message that is too long.\\r\\n.\", \"552\")\n\n\t// Clients should send either RSET or QUIT after receiving 552 (RFC 1870 section 6.2).\n\tcmdCode(t, conn, \"RSET\", \"250\")\n\n\t// Messages above the maximum size should return a maximum size exceeded error.\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com>\", \"250\")\n\tcmdCode(t, conn, \"RCPT TO:<recipient@example.com>\", \"250\")\n\tcmdCode(t, conn, \"DATA\", \"354\")\n\tcmdCode(t, conn, \"Test message.\\r\\nSecond line that is too long.\\r\\n.\", \"552\")\n\n\t// Clients should send either RSET or QUIT after receiving 552 (RFC 1870 section 6.2).\n\tcmdCode(t, conn, \"QUIT\", \"221\")\n\t_ = conn.Close()\n}\n\ntype mockHandler struct {\n\thandlerCalled int\n}\n\nfunc (m *mockHandler) handler(err error) func(a net.Addr, f string, t []string, d []byte) error {\n\treturn func(a net.Addr, f string, t []string, d []byte) error {\n\t\tm.handlerCalled++\n\t\treturn err\n\t}\n}\n\nfunc TestCmdDATAWithHandler(t *testing.T) {\n\tm := mockHandler{}\n\tconn := newConn(t, &Server{Handler: m.handler(nil)})\n\n\tcmdCode(t, conn, \"EHLO host.example.com\", \"250\")\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com>\", \"250\")\n\tcmdCode(t, conn, \"RCPT TO:<recipient@example.com>\", \"250\")\n\tcmdCode(t, conn, \"DATA\", \"354\")\n\tcmdCode(t, conn, \"Test message.\\r\\n.\", \"250\")\n\tcmdCode(t, conn, \"QUIT\", \"221\")\n\t_ = conn.Close()\n\n\tif m.handlerCalled != 1 {\n\t\tt.Errorf(\"MailHandler called %d times, want one call\", m.handlerCalled)\n\t}\n}\n\nfunc TestCmdDATAWithHandlerError(t *testing.T) {\n\tm := mockHandler{}\n\tconn := newConn(t, &Server{Handler: m.handler(errors.New(\"Handler error\"))})\n\n\tcmdCode(t, conn, \"EHLO host.example.com\", \"250\")\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com>\", \"250\")\n\tcmdCode(t, conn, \"RCPT TO:<recipient@example.com>\", \"250\")\n\tcmdCode(t, conn, \"DATA\", \"354\")\n\tcmdCode(t, conn, \"Test message.\\r\\n.\", \"451\")\n\tcmdCode(t, conn, \"QUIT\", \"221\")\n\t_ = conn.Close()\n\n\tif m.handlerCalled != 1 {\n\t\tt.Errorf(\"MailHandler called %d times, want one call\", m.handlerCalled)\n\t}\n}\n\nfunc TestCmdSTARTTLS(t *testing.T) {\n\tconn := newConn(t, &Server{})\n\tcmdCode(t, conn, \"EHLO host.example.com\", \"250\")\n\n\t// By default, TLS is not configured, so STARTTLS should return 502 not implemented.\n\tcmdCode(t, conn, \"STARTTLS\", \"502\")\n\n\t// Parameters are not allowed (RFC 3207 section 4).\n\tcmdCode(t, conn, \"STARTTLS FOO\", \"501\")\n\n\tcmdCode(t, conn, \"QUIT\", \"221\")\n\t_ = conn.Close()\n}\n\nfunc TestCmdSTARTTLSFailure(t *testing.T) {\n\t// Deliberately misconfigure TLS to force a handshake failure.\n\tserver := &Server{TLSConfig: &tls.Config{}}\n\tconn := newConn(t, server)\n\tcmdCode(t, conn, \"EHLO host.example.com\", \"250\")\n\n\t// When TLS is configured, STARTTLS should return 220 Ready to start TLS.\n\tcmdCode(t, conn, \"STARTTLS\", \"220\")\n\n\t// A failed TLS handshake should return 403 TLS handshake failed\n\ttlsConn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true})\n\terr := tlsConn.Handshake()\n\tif err != nil {\n\t\treader := bufio.NewReader(conn)\n\t\tresp, readErr := reader.ReadString('\\n')\n\t\tif readErr != nil {\n\t\t\tt.Fatalf(\"Failed to read response after failed TLS handshake: %v\", err)\n\t\t}\n\t\tif resp[0:3] != \"403\" {\n\t\t\tt.Errorf(\"Failed TLS handshake response code is %s, want 403\", resp[0:3])\n\t\t}\n\t} else {\n\t\tt.Error(\"TLS handshake succeeded with empty tls.Config, want failure\")\n\t}\n\n\tcmdCode(t, conn, \"QUIT\", \"221\")\n\t_ = tlsConn.Close()\n}\n\n// Utility function to make a valid TLS certificate for use by the server.\nfunc makeCertificate() tls.Certificate {\n\tconst certPEM = `\n-----BEGIN CERTIFICATE-----\nMIID9DCCAtygAwIBAgIJAIX/1sxuqZKrMA0GCSqGSIb3DQEBCwUAMFkxCzAJBgNV\nBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX\naWRnaXRzIFB0eSBMdGQxEjAQBgNVBAMTCWxvY2FsaG9zdDAeFw0xNzA1MDYxNDIy\nMjVaFw0yNzA1MDQxNDIyMjVaMFkxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpTb21l\nLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxEjAQBgNV\nBAMTCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALO4\nXVY5Kw9eNblqBenC03Wz6qemLFw8zLDNrehvjYuJPn5WVwvzLNP+3S02iqQD+Y1k\nvszqDIZLQdjWLiEZdtxfemyIr+RePIMclnceGYFx3Zgg5qeyvOWlJLM41ZU8YZb/\nzGj3RtXzuOZ5vePSLGS1nudjrKSBs7shRY8bYjkOqFujsSVnEK7s3Kb2Sf/rO+7N\nRZ1df3hhyKtyq4Pb5eC1mtQqcRjRSZdTxva8kO4vRQbvGgjLUakvBVrrnwbww5a4\n2wKbQPKIClEbSLyKQ62zR8gW1rPwBdokd8u9+rLbcmr7l0OuAsSn5Xi9x6VxXTNE\nbgCa1KVoE4bpoGG+KQsCAwEAAaOBvjCBuzAdBgNVHQ4EFgQUILso/fozIhaoyi05\nXNSWzP/ck+4wgYsGA1UdIwSBgzCBgIAUILso/fozIhaoyi05XNSWzP/ck+6hXaRb\nMFkxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJ\nbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxEjAQBgNVBAMTCWxvY2FsaG9zdIIJAIX/\n1sxuqZKrMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAIbzsvTZb8LA\nJqyaTttsMMA1szf4WBX88lVWbIk91k0nlTa0BiU/UocKrU6c9PySwJ6FOFJpgpdH\nz/kmJ+S+d4pvgqBzWbKMoMrNlMt6vL+H8Mbf/l/CN91eNM+gJZu2HgBIFGW1y4Wy\ngOzjEm9bw15Hgqqs0P4CSy7jcelWA285DJ7IG1qdPGhAKxT4/UuDin8L/u2oeYWH\n3DwTDO4kAUnKetcmNQFSX3Ge50uQypl8viYgFJ2axOfZ3imjQZrs7M1Og6Wnj/SD\nF414wVQibsZyZp8cqwR/OinvxloPkPVnf163jPRtftuqezEY8Nyj83O5u5sC1Azs\nX/Gm54QNk6w=\n-----END CERTIFICATE-----`\n\tconst keyPEM = `\n-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAs7hdVjkrD141uWoF6cLTdbPqp6YsXDzMsM2t6G+Ni4k+flZX\nC/Ms0/7dLTaKpAP5jWS+zOoMhktB2NYuIRl23F96bIiv5F48gxyWdx4ZgXHdmCDm\np7K85aUkszjVlTxhlv/MaPdG1fO45nm949IsZLWe52OspIGzuyFFjxtiOQ6oW6Ox\nJWcQruzcpvZJ/+s77s1FnV1/eGHIq3Krg9vl4LWa1CpxGNFJl1PG9ryQ7i9FBu8a\nCMtRqS8FWuufBvDDlrjbAptA8ogKURtIvIpDrbNHyBbWs/AF2iR3y736sttyavuX\nQ64CxKfleL3HpXFdM0RuAJrUpWgThumgYb4pCwIDAQABAoIBAHzvYntJPKTvUhu2\nF6w8kvHVBABNpbLtVUJniUj3G4fv/bCn5tVY1EX/e9QtgU2psbbYXUdoQRKuiHTr\n15+M6zMhcKK4lsYDuL9QhU0DcKmq9WgHHzFfMK/YEN5CWT/ofNMSuhASLn0Xc+dM\npHQWrGPKWk/y25Z0z/P7mjZ0y+BrJOKlxV53A2AWpj4JtjX2YO6s/eiraFX+RNlv\nGyWzeQ7Gynm2TD9VXhS+m40VVBmmbbeZYDlziDoWWNe9r26A+C8K65gZtjKdarMd\n0LN89jJvI1pUxcIuvZJnumWUenZ7JhfBGpkfAwLB+MogUo9ekAHv1IZv/m3uWq9f\nZml2dZECgYEA2OCI8kkLRa3+IodqQNFrb/uZ16YouQ71B7nBgAxls9nuhyELKO7d\nfzf1snPx6cbaCQKTyxrlYvck4gz8P09R7nVYwJuTmP0+QIgeCCc3Y9A2dyExaC6I\nuKkFzJEqIVZNLvdjBRWQs5AiD1w58oto+wOvbagAQM483WiJ/qFaHCMCgYEA1CPo\nzwI6pCn39RSYffK25HXM1q3i8ypkYdNsG6IVqS2FqHqj8XJSnDvLeIm7W1Rtw+uM\nQdZ5O6PH31XgolG6LrFkW9vtfH+QnXQA2AnZQEfn034YZubhcexLqAkS9r0FUUZp\na1WI2jSxBBeB+to6MdNABuQOL3NHjPUidUKnOfkCgYA+HvKbE7ka2F+23DrfHh08\nEkFat8lqWJJvCBIY73QiNAZSxnA/5UukqQ7DctqUL9U8R3S19JpH4qq55SZLrBi3\nyP0HDokUhVVTfqm7hCAlgvpW3TcdtFaNLjzu/5WlvuaU0V+XkTnFdT+MTsp6YtxL\nKh8RtdF8vpZIhS0htm3tKQKBgQDQXoUp79KRtPdsrtIpw+GI/Xw50Yp9tkHrJLOn\nYMlN5vzFw9CMM/KYqtLsjryMtJ0sN40IjhV+UxzbbYq7ZPMvMeaVo6vdAZ+WSH8b\ntHDEBtzai5yEVntSXvrhDiimWnuCnVqmptlJG0BT+JMfRoKqtgjJu++DBARfm9hA\nvTtsYQKBgE1ttTzd3HJoIhBBSvSMbyDWTED6jecKvsVypb7QeDxZCbIwCkoK9zn1\ntwPDHLBcUNhHJx6JWTR6BxI5DZoIA1tcKHtdO5smjLWNSKhXTsKWee2aNkZJkNIW\nTDHSaTMOxVUEzpx84xClf561BTiTgzQy2MULpg3AK0Cv9l0+Yrvz\n-----END RSA PRIVATE KEY-----`\n\n\tcert, _ := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM))\n\treturn cert\n}\n\nfunc TestCmdSTARTTLSSuccess(t *testing.T) {\n\t// Configure a valid TLS certificate so the handshake will succeed.\n\tserver := &Server{TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}}}\n\tconn := newConn(t, server)\n\tcmdCode(t, conn, \"EHLO host.example.com\", \"250\")\n\n\t// When TLS is configured, STARTTLS should return 220 Ready to start TLS.\n\tcmdCode(t, conn, \"STARTTLS\", \"220\")\n\n\t// A successful TLS handshake shouldn't return anything, it should wait for EHLO.\n\ttlsConn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true})\n\terr := tlsConn.Handshake()\n\tif err != nil {\n\t\tt.Errorf(\"Failed to perform TLS handshake\")\n\t}\n\n\t// The subsequent EHLO should be successful.\n\tcmdCode(t, tlsConn, \"EHLO host.example.com\", \"250\")\n\n\t// When TLS is already in use, STARTTLS should return 503 bad sequence.\n\tcmdCode(t, tlsConn, \"STARTTLS\", \"503\")\n\n\tcmdCode(t, tlsConn, \"QUIT\", \"221\")\n\t_ = tlsConn.Close()\n}\n\nfunc TestCmdSTARTTLSRequired(t *testing.T) {\n\ttests := []struct {\n\t\tcmd        string\n\t\tcodeBefore string\n\t\tcodeAfter  string\n\t}{\n\t\t{\"EHLO host.example.com\", \"250\", \"250\"},\n\t\t{\"NOOP\", \"250\", \"250\"},\n\t\t{\"MAIL FROM:<sender@example.com>\", \"530\", \"250\"},\n\t\t{\"RCPT TO:<recipient@example.com>\", \"530\", \"250\"},\n\t\t{\"RSET\", \"530\", \"250\"}, // Reset before DATA to avoid having to actually send a message.\n\t\t{\"DATA\", \"530\", \"503\"},\n\t\t{\"HELP\", \"502\", \"502\"},\n\t\t{\"VRFY\", \"502\", \"502\"},\n\t\t{\"EXPN\", \"502\", \"502\"},\n\t\t{\"TEST\", \"500\", \"500\"}, // Unsupported command\n\t\t{\"\", \"500\", \"500\"},     // Blank command\n\t\t{\"AUTH\", \"530\", \"502\"}, // AuthHandler not configured\n\t}\n\n\t// If TLS is not configured, the TLSRequired setting is ignored, so it must be configured for this test.\n\tserver := &Server{TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}}, TLSRequired: true}\n\tconn := newConn(t, server)\n\n\t// If TLS is required, but not in use, reject every command except NOOP, EHLO, STARTTLS, or QUIT as per RFC 3207 section 4.\n\tfor _, tt := range tests {\n\t\tcmdCode(t, conn, tt.cmd, tt.codeBefore)\n\t}\n\n\t// Switch to using TLS.\n\tcmdCode(t, conn, \"STARTTLS\", \"220\")\n\n\t// A successful TLS handshake shouldn't return anything, it should wait for EHLO.\n\ttlsConn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true})\n\terr := tlsConn.Handshake()\n\tif err != nil {\n\t\tt.Errorf(\"Failed to perform TLS handshake\")\n\t}\n\n\t// The subsequent EHLO should be successful.\n\tcmdCode(t, tlsConn, \"EHLO host.example.com\", \"250\")\n\n\t// If TLS is required, and is in use, every command should work normally.\n\tfor _, tt := range tests {\n\t\tcmdCode(t, tlsConn, tt.cmd, tt.codeAfter)\n\t}\n\n\tcmdCode(t, tlsConn, \"QUIT\", \"221\")\n\t_ = tlsConn.Close()\n}\n\nfunc TestMakeHeaders(t *testing.T) {\n\tnow := time.Now().Format(\"Mon, 2 Jan 2006 15:04:05 -0700 (MST)\")\n\tvalid := \"Received: from clientName (clientHost [clientIP])\\r\\n\" +\n\t\t\"        by serverName (smtpd) with SMTP\\r\\n\" +\n\t\t\"        for <recipient@example.com>; \" +\n\t\tfmt.Sprintf(\"%s\\r\\n\", now)\n\n\tsrv := &Server{AppName: \"smtpd\", Hostname: \"serverName\"}\n\ts := &session{srv: srv, remoteIP: \"clientIP\", remoteHost: \"clientHost\", remoteName: \"clientName\"}\n\theaders := s.makeHeaders([]string{\"recipient@example.com\"})\n\tif string(headers) != valid {\n\t\tt.Errorf(\"makeHeaders() returned\\n%v, want\\n%v\", string(headers), valid)\n\t}\n}\n\n// Test parsing of commands into verbs and arguments.\nfunc TestParseLine(t *testing.T) {\n\ttests := []struct {\n\t\tline string\n\t\tverb string\n\t\targs string\n\t}{\n\t\t{\"EHLO host.example.com\", \"EHLO\", \"host.example.com\"},\n\t\t{\"MAIL FROM:<sender@example.com>\", \"MAIL\", \"FROM:<sender@example.com>\"},\n\t\t{\"RCPT TO:<recipient@example.com>\", \"RCPT\", \"TO:<recipient@example.com>\"},\n\t\t{\"QUIT\", \"QUIT\", \"\"},\n\t}\n\ts := &session{}\n\tfor _, tt := range tests {\n\t\tverb, args := s.parseLine(tt.line)\n\t\tif verb != tt.verb || args != tt.args {\n\t\t\tt.Errorf(\"ParseLine(%v) returned %v, %v, want %v, %v\", tt.line, verb, args, tt.verb, tt.args)\n\t\t}\n\t}\n}\n\n// Test reading of complete lines from the socket.\nfunc TestReadLine(t *testing.T) {\n\tvar buf bytes.Buffer\n\ts := &session{}\n\ts.srv = &Server{}\n\ts.br = bufio.NewReader(&buf)\n\n\t// Ensure readLine() returns an EOF error on an empty buffer.\n\t_, err := s.readLine()\n\tif err != io.EOF {\n\t\tt.Errorf(\"readLine() on empty buffer returned err: %v, want EOF\", err)\n\t}\n\n\t// Ensure trailing <CRLF> is stripped.\n\tline := \"FOO BAR BAZ\\r\\n\"\n\tcmd := \"FOO BAR BAZ\"\n\tbuf.Write([]byte(line))\n\toutput, err := s.readLine()\n\tif err != nil {\n\t\tt.Errorf(\"readLine(%v) returned err: %v\", line, err)\n\t} else if output != cmd {\n\t\tt.Errorf(\"readLine(%v) returned %v, want %v\", line, output, cmd)\n\t}\n}\n\n// Test reading of message data, including dot stuffing (see RFC 5321 section 4.5.2).\nfunc TestReadData(t *testing.T) {\n\ttests := []struct {\n\t\tlines string\n\t\tdata  string\n\t}{\n\t\t// Single line message.\n\t\t{\"Test message.\\r\\n.\\r\\n\", \"Test message.\\r\\n\"},\n\n\t\t// Single line message with leading period removed.\n\t\t{\".Test message.\\r\\n.\\r\\n\", \"Test message.\\r\\n\"},\n\n\t\t// Multiple line message.\n\t\t{\"Line 1.\\r\\nLine 2.\\r\\nLine 3.\\r\\n.\\r\\n\", \"Line 1.\\r\\nLine 2.\\r\\nLine 3.\\r\\n\"},\n\n\t\t// Multiple line message with leading period removed.\n\t\t{\"Line 1.\\r\\n.Line 2.\\r\\nLine 3.\\r\\n.\\r\\n\", \"Line 1.\\r\\nLine 2.\\r\\nLine 3.\\r\\n\"},\n\n\t\t// Multiple line message with one leading period removed.\n\t\t{\"Line 1.\\r\\n..Line 2.\\r\\nLine 3.\\r\\n.\\r\\n\", \"Line 1.\\r\\n.Line 2.\\r\\nLine 3.\\r\\n\"},\n\t}\n\tvar buf bytes.Buffer\n\ts := &session{}\n\ts.srv = &Server{}\n\ts.br = bufio.NewReader(&buf)\n\n\t// Ensure readData() returns an EOF error on an empty buffer.\n\t_, err := s.readData()\n\tif err != io.EOF {\n\t\tt.Errorf(\"readData() on empty buffer returned err: %v, want EOF\", err)\n\t}\n\n\tfor _, tt := range tests {\n\t\tbuf.Write([]byte(tt.lines))\n\t\tdata, err := s.readData()\n\t\tif err != nil {\n\t\t\tt.Errorf(\"readData(%v) returned err: %v\", tt.lines, err)\n\t\t} else if string(data) != tt.data {\n\t\t\tt.Errorf(\"readData(%v) returned %v, want %v\", tt.lines, string(data), tt.data)\n\t\t}\n\t}\n}\n\n// Test reading of message data with maximum size set (see RFC 1870 section 6.3).\nfunc TestReadDataWithMaxSize(t *testing.T) {\n\ttests := []struct {\n\t\tlines   string\n\t\tmaxSize int\n\t\terr     error\n\t}{\n\t\t// Maximum size of zero (the default) should not return an error.\n\t\t{\"Test message.\\r\\n.\\r\\n\", 0, nil},\n\n\t\t// Messages below the maximum size should not return an error.\n\t\t{\"Test message.\\r\\n.\\r\\n\", 16, nil},\n\n\t\t// Messages matching the maximum size should not return an error.\n\t\t{\"Test message.\\r\\n.\\r\\n\", 15, nil},\n\n\t\t// Messages above the maximum size should return a maximum size exceeded error.\n\t\t{\"Test message.\\r\\n.\\r\\n\", 14, maxSizeExceeded(14)},\n\t}\n\tvar buf bytes.Buffer\n\ts := &session{}\n\ts.br = bufio.NewReader(&buf)\n\n\tfor _, tt := range tests {\n\t\ts.srv = &Server{MaxSize: tt.maxSize}\n\t\tbuf.Write([]byte(tt.lines))\n\t\t_, err := s.readData()\n\t\tif err != tt.err {\n\t\t\tt.Errorf(\"readData(%v) returned err: %v\", tt.lines, tt.err)\n\t\t}\n\t}\n}\n\n// Utility function for parsing extensions listed as service extensions in response to an EHLO command.\nfunc parseExtensions(t *testing.T, greeting string) map[string]string {\n\textensions := make(map[string]string)\n\tlines := strings.Split(greeting, \"\\n\")\n\n\tif len(lines) > 1 {\n\t\tiLast := len(lines) - 1\n\t\tfor i, line := range lines {\n\t\t\tprefix := line[0:4]\n\n\t\t\t// All but the last extension code prefix should be \"250-\".\n\t\t\tif i != iLast && prefix != \"250-\" {\n\t\t\t\tt.Errorf(\"Extension code prefix is %s, want '250-'\", prefix)\n\t\t\t}\n\n\t\t\t// The last extension code prefix should be \"250 \".\n\t\t\tif i == iLast && prefix != \"250 \" {\n\t\t\t\tt.Errorf(\"Extension code prefix is %s, want '250 '\", prefix)\n\t\t\t}\n\n\t\t\t// Skip greeting line.\n\t\t\tif i == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// Add line as extension.\n\t\t\tline = strings.TrimSpace(line[4:]) // Strip code prefix and trailing \\r\\n\n\t\t\tif before, after, ok := strings.Cut(line, \" \"); ok {\n\t\t\t\textensions[before] = after\n\t\t\t} else {\n\t\t\t\textensions[line] = \"\"\n\t\t\t}\n\t\t}\n\t}\n\n\treturn extensions\n}\n\n// Test handler function for validating authentication credentials.\n// The secret parameter is passed as nil for LOGIN and PLAIN authentication mechanisms.\nfunc testAuthHandler(_ net.Addr, _ string, username []byte, _ []byte, _ []byte) (bool, error) {\n\treturn string(username) == \"valid\", nil\n}\n\n// Test the extensions listed in response to an EHLO command.\nfunc TestMakeEHLOResponse(t *testing.T) {\n\ts := &session{}\n\ts.srv = &Server{}\n\n\t// Greeting should be returned without trailing newlines.\n\tgreeting := s.makeEHLOResponse()\n\tif len(greeting) != len(strings.TrimSpace(greeting)) {\n\t\tt.Errorf(\"EHLO greeting string has leading or trailing whitespace\")\n\t}\n\n\t// By default, TLS is not configured, so STARTTLS should not appear.\n\textensions := parseExtensions(t, s.makeEHLOResponse())\n\tif _, ok := extensions[\"STARTTLS\"]; ok {\n\t\tt.Errorf(\"STARTTLS appears in the extension list when TLS is not configured\")\n\t}\n\n\t// If TLS is configured, but not already in use, STARTTLS should appear.\n\ts.srv.TLSConfig = &tls.Config{}\n\textensions = parseExtensions(t, s.makeEHLOResponse())\n\tif _, ok := extensions[\"STARTTLS\"]; !ok {\n\t\tt.Errorf(\"STARTTLS does not appear in the extension list when TLS is configured\")\n\t}\n\n\t// If TLS is already used on the connection, STARTTLS should not appear.\n\ts.tls = true\n\textensions = parseExtensions(t, s.makeEHLOResponse())\n\tif _, ok := extensions[\"STARTTLS\"]; ok {\n\t\tt.Errorf(\"STARTTLS appears in the extension list when TLS is already in use\")\n\t}\n\n\t// Verify default SIZE extension is zero.\n\ts.srv = &Server{}\n\textensions = parseExtensions(t, s.makeEHLOResponse())\n\tif _, ok := extensions[\"SIZE\"]; !ok {\n\t\tt.Errorf(\"SIZE does not appear in the extension list\")\n\t} else if extensions[\"SIZE\"] != \"0\" {\n\t\tt.Errorf(\"SIZE appears in the extension list with incorrect parameter %s, want %s\", extensions[\"SIZE\"], \"0\")\n\t}\n\n\t// Verify configured maximum message size is listed correctly.\n\t// Any integer will suffice, as long as it's not hardcoded.\n\tmaxSize := 10 + time.Now().Minute()\n\tmaxSizeStr := fmt.Sprintf(\"%d\", maxSize)\n\ts.srv = &Server{MaxSize: maxSize}\n\textensions = parseExtensions(t, s.makeEHLOResponse())\n\tif _, ok := extensions[\"SIZE\"]; !ok {\n\t\tt.Errorf(\"SIZE does not appear in the extension list\")\n\t} else if extensions[\"SIZE\"] != maxSizeStr {\n\t\tt.Errorf(\"SIZE appears in the extension list with incorrect parameter %s, want %s\", extensions[\"SIZE\"], maxSizeStr)\n\t}\n\n\t// With no authentication handler configured, AUTH should not be advertised.\n\ts.srv = &Server{}\n\textensions = parseExtensions(t, s.makeEHLOResponse())\n\tif _, ok := extensions[\"AUTH\"]; ok {\n\t\tt.Errorf(\"AUTH appears in the extension list when no AuthHandler is specified\")\n\t}\n\n\t// With an authentication handler configured, AUTH should be advertised.\n\ts.srv = &Server{AuthHandler: testAuthHandler}\n\textensions = parseExtensions(t, s.makeEHLOResponse())\n\tif _, ok := extensions[\"AUTH\"]; !ok {\n\t\tt.Errorf(\"AUTH does not appear in the extension list when an AuthHandler is specified\")\n\t}\n\n\treLogin := regexp.MustCompile(`\\bLOGIN\\b`)\n\trePlain := regexp.MustCompile(`\\bPLAIN\\b`)\n\n\t// RFC 4954 specifies that, without TLS in use, plaintext authentication mechanisms must not be advertised.\n\ts.tls = false\n\textensions = parseExtensions(t, s.makeEHLOResponse())\n\tif reLogin.MatchString(extensions[\"AUTH\"]) {\n\t\tt.Errorf(\"AUTH mechanism LOGIN appears in the extension list when an AuthHandler is specified and TLS is not in use\")\n\t}\n\tif rePlain.MatchString(extensions[\"AUTH\"]) {\n\t\tt.Errorf(\"AUTH mechanism PLAIN appears in the extension list when an AuthHandler is specified and TLS is not in use\")\n\t}\n\n\t// RFC 4954 specifies that, with TLS in use, plaintext authentication mechanisms can be advertised.\n\ts.tls = true\n\textensions = parseExtensions(t, s.makeEHLOResponse())\n\tif !reLogin.MatchString(extensions[\"AUTH\"]) {\n\t\tt.Errorf(\"AUTH mechanism LOGIN does not appear in the extension list when an AuthHandler is specified and TLS is in use\")\n\t}\n\tif !rePlain.MatchString(extensions[\"AUTH\"]) {\n\t\tt.Errorf(\"AUTH mechanism PLAIN does not appear in the extension list when an AuthHandler is specified and TLS is in use\")\n\t}\n\n\t// 8BITMIME should always be advertised\n\ts.srv = &Server{}\n\ts.tls = false\n\textensions = parseExtensions(t, s.makeEHLOResponse())\n\tif _, ok := extensions[\"8BITMIME\"]; !ok {\n\t\tt.Errorf(\"8BITMIME does not appear in the extension list\")\n\t}\n\n\t// SMTPUTF8 should always be advertised\n\tif _, ok := extensions[\"SMTPUTF8\"]; !ok {\n\t\tt.Errorf(\"SMTPUTF8 does not appear in the extension list\")\n\t}\n\n\t// ENHANCEDSTATUSCODES should always be advertised\n\tif _, ok := extensions[\"ENHANCEDSTATUSCODES\"]; !ok {\n\t\tt.Errorf(\"ENHANCEDSTATUSCODES does not appear in the extension list\")\n\t}\n}\n\n// Test 8BITMIME BODY parameter parsing in MAIL FROM command\nfunc TestCmd8BITMIME(t *testing.T) {\n\tsrv := &Server{}\n\tconn := newConn(t, srv)\n\tcmdCode(t, conn, \"EHLO host.example.com\", \"250\")\n\n\t// Create a session to check internal state\n\tclientConn, serverConn := net.Pipe()\n\tsession := srv.newSession(serverConn)\n\tgo session.serve()\n\n\t// Read and discard banner\n\t_, _ = bufio.NewReader(clientConn).ReadString('\\n')\n\n\t// Send EHLO\n\t_, _ = fmt.Fprintf(clientConn, \"EHLO test.example.com\\r\\n\")\n\treader := bufio.NewReader(clientConn)\n\tfor {\n\t\tline, _ := reader.ReadString('\\n')\n\t\tif strings.HasPrefix(line, \"250 \") {\n\t\t\tbreak\n\t\t}\n\t}\n\n\t// Test BODY=8BITMIME parameter\n\t_, _ = fmt.Fprintf(clientConn, \"MAIL FROM:<sender@example.com> BODY=8BITMIME\\r\\n\")\n\tresp, _ := reader.ReadString('\\n')\n\tif !strings.HasPrefix(resp, \"250\") {\n\t\tt.Errorf(\"MAIL FROM with BODY=8BITMIME failed: %s\", resp)\n\t}\n\n\t// Verify bodyEncoding was set (we can't directly access it, but we can test the behavior)\n\t// Reset and test BODY=7BIT\n\t_, _ = fmt.Fprintf(clientConn, \"RSET\\r\\n\")\n\t_, _ = reader.ReadString('\\n')\n\n\t_, _ = fmt.Fprintf(clientConn, \"MAIL FROM:<sender@example.com> BODY=7BIT\\r\\n\")\n\tresp, _ = reader.ReadString('\\n')\n\tif !strings.HasPrefix(resp, \"250\") {\n\t\tt.Errorf(\"MAIL FROM with BODY=7BIT failed: %s\", resp)\n\t}\n\n\t// Test BODY parameter with SIZE parameter\n\t_, _ = fmt.Fprintf(clientConn, \"RSET\\r\\n\")\n\t_, _ = reader.ReadString('\\n')\n\n\t_, _ = fmt.Fprintf(clientConn, \"MAIL FROM:<sender@example.com> SIZE=1000 BODY=8BITMIME\\r\\n\")\n\tresp, _ = reader.ReadString('\\n')\n\tif !strings.HasPrefix(resp, \"250\") {\n\t\tt.Errorf(\"MAIL FROM with SIZE and BODY parameters failed: %s\", resp)\n\t}\n\n\t// Test case insensitivity\n\t_, _ = fmt.Fprintf(clientConn, \"RSET\\r\\n\")\n\t_, _ = reader.ReadString('\\n')\n\n\t_, _ = fmt.Fprintf(clientConn, \"MAIL FROM:<sender@example.com> body=8bitmime\\r\\n\")\n\tresp, _ = reader.ReadString('\\n')\n\tif !strings.HasPrefix(resp, \"250\") {\n\t\tt.Errorf(\"MAIL FROM with lowercase body parameter failed: %s\", resp)\n\t}\n\n\t// Clean up\n\t_, _ = fmt.Fprintf(clientConn, \"QUIT\\r\\n\")\n\t_, _ = reader.ReadString('\\n')\n\t_ = clientConn.Close()\n\n\t// Also test via the original connection\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com> BODY=8BITMIME\", \"250\")\n\tcmdCode(t, conn, \"RCPT TO:<recipient@example.com>\", \"250\")\n\n\tcmdCode(t, conn, \"RSET\", \"250\")\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com> BODY=7BIT\", \"250\")\n\n\tcmdCode(t, conn, \"RSET\", \"250\")\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com> BODY=8BITMIME SIZE=5000\", \"250\")\n\n\tcmdCode(t, conn, \"QUIT\", \"221\")\n\t_ = conn.Close()\n}\n\n// func createTmpFile(content string) (file *os.File, err error) {\n// \tfile, err = os.CreateTemp(\"\", \"\")\n// \tif err != nil {\n// \t\treturn\n// \t}\n// \t_, err = file.Write([]byte(content))\n// \tif err != nil {\n// \t\treturn\n// \t}\n// \terr = file.Close()\n// \treturn\n// }\n\n// func createTLSFiles() (\n// \tcertFile *os.File,\n// \tkeyFile *os.File,\n// \tpassphrase string,\n// \terr error,\n// ) {\n// \tconst certPEM = `-----BEGIN CERTIFICATE-----\n// MIIDRzCCAi+gAwIBAgIJAKtg4oViVwv4MA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV\n// BAMMCWxvY2FsaG9zdDAgFw0xODA0MjAxMzMxNTBaGA8yMDg2MDUwODEzMzE1MFow\n// FDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\n// CgKCAQEA8h7vl0gUquis5jRtcnETyD+8WITZO0s53aIzp0Y+9HXiHW6FGJjbOZjM\n// IvozNVni+83QWKumRTgeSzIIW2j4V8iFMSNrvWmhmCKloesXS1aY6H979e01Ve8J\n// WAJFRe6vZJd6gC6Z/P+ELU3ie4Vtr1GYfkV7nZ6VFp5/V/5nxGFag5TUlpP5hcoS\n// 9r2kvXofosVwe3x3udT8SEbv5eBD4bKeVyJs/RLbxSuiU1358Y1cDdVuHjcvfm3c\n// ajhheQ4vX9WXsk7LGGhnf1SrrPN/y+IDTXfvoHn+nJh4vMAB4yzQdE1V1N1AB8RA\n// 0yBVJ6dwxRrSg4BFrNWhj3gfsvrA7wIDAQABo4GZMIGWMB0GA1UdDgQWBBQ4/ncp\n// befFuKH1hoYkPqLwuRrPRjAfBgNVHSMEGDAWgBQ4/ncpbefFuKH1hoYkPqLwuRrP\n// RjAJBgNVHRMEAjAAMBEGCWCGSAGG+EIBAQQEAwIGQDALBgNVHQ8EBAMCBaAwEwYD\n// VR0lBAwwCgYIKwYBBQUHAwEwFAYDVR0RBA0wC4IJbG9jYWxob3N0MA0GCSqGSIb3\n// DQEBCwUAA4IBAQBJBetEXiEIzKAEpXGX87j6aUON51Fdf6BiLMCghuGKyhnaOG32\n// 4KJhtvVoS3ZUKPylh9c2VdItYlhWp76zd7YKk+3xUOixWeTMQHIvCvRGTyFibOPT\n// mApwp2pEnJCe4vjUrBaRhiyI+xnB70cWVF2qeernlLUeJA1mfYyQLz+v06ebDWOL\n// c/hPVQFB94lEdiyjGO7RZfIe8KwcK48g7iv0LQU4+c9MoWM2ZsVM1AL2tHzokSeA\n// u64gDTW4K0Tzx1ab7KmOFXYUjbz/xWuReMt33EwDXAErKCjbVt2T55Qx8UoKzSh1\n// tY0KDHdnYOzgsm2HIj2xcJqbeylYQvckNnoC\n// -----END CERTIFICATE-----`\n\n// \tconst keyPEM = `-----BEGIN RSA PRIVATE KEY-----\n// Proc-Type: 4,ENCRYPTED\n// DEK-Info: AES-256-CBC,C16BF8745B2CDB53AC2B1D7609893AA0\n\n// O13z7Yq7butaJmMfg9wRis9YnIDPsp4coYI6Ud+JGcP7iXoy95QMhovKWx25o1ol\n// tvUTsrsG27fHGf9qG02KizApIVtO9c1e0swCWzFrKRQX0JDiZDmilb9xosBNNst1\n// BOzOTRZEwFGSOCKZRBfSXyqC93TvLJ3DO9IUnKIeGt7upipvg29b/Dur/fyCy2WV\n// bLHXwUTDBm7j49yfoEyGkDjoB2QO9wgcgbacbnQJQ25fTFUwZpZJEJv6o1tRhoYM\n// ZMOhC9x1URmdHKN1+z2y5BrB6oNpParfeAMEvs/9FE6jJwYUR28Ql6Mhphfvr9W2\n// 5Gxd3J65Ao9Vi2I5j5X6aBuNjyhXN3ScLjPG4lVZm9RU/uTPEt81pig/d5nSAjvF\n// Nfc08NuG3cnMyJSE/xScJ4D+GtX8U969wO4oKPCR4E/NFyXPR730ppupDFG6hzPD\n// PDmiszDtU438JAZ8AuFa1LkbyFnEW6KVD4h7VRr8YDjirCqnkgjNSI6dFY0NQ8H7\n// SyexB0lrceX6HZc+oNdAtkX3tYdzY3ExzUM5lSF1dkldnRbApLbqc4uuNIVXhXFM\n// dJnoPdKAzM6i+2EeVUxWNdafKDxnjVSHIHzHfIFJLQ4GS5rnz9keRFdyDjQL07tT\n// Lu9pPOmsadDXp7oSa81RgoCUfNZeR4jKpCk2BOft0L6ZSqwYFLcQHLIfJaGfn902\n// TUOTxHt0KzEUYeYSrXC2a6cyvXAd1YI7lOgy60qG89VHyCc2v5Bs4c4FNUDC/+Dj\n// 4ZwogaAbSNkLaE0q3sYQRPdxSqLftyX0KitAgE7oGtdzBfe1cdBoozw3U67NEMMT\n// 6qvk5j7RepPRSrapHtK5pMMdg5XpKFWcOXZ26VHVrDCj4JKdjVb4iyiQi94VveV0\n// w9+KcOtyrM7/jbQlCWnXpsIkP8VA/RIgh7CBn/h4oF1sO8ywP25OGQ7VWAVq1R9D\n// 8bl8GzIdR9PZpFyOxuIac4rPa8tkDeoXKs4cxoao7H/OZO9o9aTB7CJMTL9yv0Kb\n// ntWuYxQchE6syoGsOgdGyZhaw4JeFkasDUP5beyNY+278NkzgGTOIMMTXIX46woP\n// ehzHKGHXVGf7ZiSFF+zAHMXZRSwNVMkOYwlIoRg1IbvIRbAXqAR6xXQTCVzNG0SU\n// cskojycBca1Cz3hDVIKYZd9beDhprVdr2a4K2nft2g2xRNjKPopsaqXx+VPibFUx\n// X7542eQ3eAlhkWUuXvt0q5a9WJdjJp9ODA0/d0akF6JQlEHIAyLfoUKB1HYwgUGG\n// 6uRm651FDAab9U4cVC5PY1hfv/QwzpkNDkzgJAZ5SMOfZhq7IdBcqGd3lzPmq2FP\n// Vy1LVZIl3eM+9uJx5TLsBHH6NhMwtNhFCNa/5ksodQYlTvR8IrrgWlYg4EL69vjS\n// yt6HhhEN3lFCWvrQXQMp93UklbTlpVt6qcDXiC7HYbs3+EINargRd5Z+xL5i5vkN\n// f9k7s0xqhloWNPZcyOXMrox8L81WOY+sP4mVlGcfDRLdEJ8X2ofJpOAcwYCnjsKd\n// uEGsi+l2fTj/F+eZLE6sYoMprgJrbfeqtRWFguUgTn7s5hfU0tZ46al5d0vz8fWK\n// -----END RSA PRIVATE KEY-----`\n\n// \tpassphrase = \"test\"\n\n// \tcertFile, err = createTmpFile(certPEM)\n// \tif err != nil {\n// \t\treturn\n// \t}\n// \tkeyFile, err = createTmpFile(keyPEM)\n// \treturn\n// }\n\nfunc TestAuthMechs(t *testing.T) {\n\ts := session{}\n\ts.srv = &Server{}\n\n\t// Validate that non-TLS (default) configuration does not allow plaintext authentication mechanisms.\n\tcorrect := map[string]bool{\"LOGIN\": false, \"PLAIN\": false, \"CRAM-MD5\": true}\n\tmechs := s.authMechs()\n\tif !reflect.DeepEqual(mechs, correct) {\n\t\tt.Errorf(\"authMechs() returned %v, want %v\", mechs, correct)\n\t}\n\n\t// Validate that TLS configuration allows plaintext authentication mechanisms.\n\tcorrect = map[string]bool{\"LOGIN\": true, \"PLAIN\": true, \"CRAM-MD5\": true}\n\ts.tls = true\n\tmechs = s.authMechs()\n\tif !reflect.DeepEqual(mechs, correct) {\n\t\tt.Errorf(\"authMechs() returned %v, want %v\", mechs, correct)\n\t}\n\n\t// Validate that overridden values take precedence over RFC compliance when not using TLS.\n\tcorrect = map[string]bool{\"LOGIN\": true, \"PLAIN\": true, \"CRAM-MD5\": false}\n\ts.tls = false\n\ts.srv.AuthMechs = map[string]bool{\"LOGIN\": true, \"PLAIN\": true, \"CRAM-MD5\": false}\n\tmechs = s.authMechs()\n\tif !reflect.DeepEqual(mechs, correct) {\n\t\tt.Errorf(\"authMechs() returned %v, want %v\", mechs, correct)\n\t}\n\n\t// Validate that overridden values take precedence over RFC compliance when using TLS.\n\tcorrect = map[string]bool{\"LOGIN\": false, \"PLAIN\": false, \"CRAM-MD5\": true}\n\ts.tls = true\n\ts.srv.AuthMechs = map[string]bool{\"LOGIN\": false, \"PLAIN\": false, \"CRAM-MD5\": true}\n\tmechs = s.authMechs()\n\tif !reflect.DeepEqual(mechs, correct) {\n\t\tt.Errorf(\"authMechs() returned %v, want %v\", mechs, correct)\n\t}\n\n\t// Validate ability to explicitly disallow all mechanisms.\n\tcorrect = map[string]bool{\"LOGIN\": false, \"PLAIN\": false, \"CRAM-MD5\": false}\n\ts.srv.AuthMechs = map[string]bool{\"LOGIN\": false, \"PLAIN\": false, \"CRAM-MD5\": false}\n\tmechs = s.authMechs()\n\tif !reflect.DeepEqual(mechs, correct) {\n\t\tt.Errorf(\"authMechs() returned %v, want %v\", mechs, correct)\n\t}\n\n\t// Validate ability to explicitly allow all mechanisms.\n\tcorrect = map[string]bool{\"LOGIN\": true, \"PLAIN\": true, \"CRAM-MD5\": true}\n\ts.srv.AuthMechs = map[string]bool{\"LOGIN\": true, \"PLAIN\": true, \"CRAM-MD5\": true}\n\tmechs = s.authMechs()\n\tif !reflect.DeepEqual(mechs, correct) {\n\t\tt.Errorf(\"authMechs() returned %v, want %v\", mechs, correct)\n\t}\n}\n\nfunc TestCmdAUTH(t *testing.T) {\n\tserver := &Server{}\n\tconn := newConn(t, server)\n\tcmdCode(t, conn, \"EHLO host.example.com\", \"250\")\n\n\t// By default no authentication handler is configured, so AUTH should return 502 not implemented.\n\tcmdCode(t, conn, \"AUTH\", \"502\")\n\n\tcmdCode(t, conn, \"QUIT\", \"221\")\n\t_ = conn.Close()\n}\n\nfunc TestCmdAUTHOptional(t *testing.T) {\n\tserver := &Server{AuthHandler: testAuthHandler}\n\tconn := newConn(t, server)\n\tcmdCode(t, conn, \"EHLO host.example.com\", \"250\")\n\n\t// AUTH without mechanism parameter must return 501 syntax error.\n\tcmdCode(t, conn, \"AUTH\", \"501\")\n\n\t// AUTH with a supported mechanism should return 334.\n\tcmdCode(t, conn, \"AUTH CRAM-MD5\", \"334\")\n\n\t// AUTH must support cancellation with '*' and return 501 syntax error.\n\tcmdCode(t, conn, \"*\", \"501\")\n\n\t// AUTH with an unsupported mechanism should return 504 unrecognized type.\n\tcmdCode(t, conn, \"AUTH FOO\", \"504\")\n\n\t// The LOGIN and PLAIN mechanisms require a TLS connection, and are disabled by default.\n\tcmdCode(t, conn, \"AUTH LOGIN\", \"504\")\n\tcmdCode(t, conn, \"AUTH PLAIN\", \"504\")\n\n\t// AUTH attempt during a mail transaction must return 503 bad sequence.\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com>\", \"250\")\n\tcmdCode(t, conn, \"AUTH CRAM-MD5\", \"503\")\n\tcmdCode(t, conn, \"RCPT TO:<recipient@example.com>\", \"250\")\n\tcmdCode(t, conn, \"AUTH CRAM-MD5\", \"503\")\n\n\t// AUTH after a mail transaction must return 334.\n\t// TODO: Work out what should happen if AUTH is received after DATA.\n\tcmdCode(t, conn, \"DATA\", \"354\")\n\tcmdCode(t, conn, \"Test message\\r\\n.\", \"250\")\n\tcmdCode(t, conn, \"AUTH CRAM-MD5\", \"334\")\n\n\t// Cancel the authentication attempt, otherwise the QUIT below will return 502.\n\t// TODO: Work out what should happen if QUIT is received after AUTH.\n\tcmdCode(t, conn, \"*\", \"501\")\n\n\tcmdCode(t, conn, \"QUIT\", \"221\")\n\t_ = conn.Close()\n}\n\nfunc TestCmdAUTHRequired(t *testing.T) {\n\tserver := &Server{AuthHandler: testAuthHandler, AuthRequired: true}\n\tconn := newConn(t, server)\n\n\ttests := []struct {\n\t\tcmd        string\n\t\tcodeBefore string\n\t\tcodeAfter  string\n\t}{\n\t\t{\"EHLO host.example.com\", \"250\", \"250\"},\n\t\t{\"NOOP\", \"250\", \"250\"},\n\t\t{\"MAIL FROM:<sender@example.com>\", \"530\", \"250\"},\n\t\t{\"RCPT TO:<recipient@example.com>\", \"530\", \"250\"},\n\t\t{\"RSET\", \"250\", \"250\"}, // Reset before DATA to avoid having to actually send a message.\n\t\t{\"DATA\", \"530\", \"503\"},\n\t\t{\"HELP\", \"502\", \"502\"},\n\t\t{\"VRFY\", \"502\", \"502\"},\n\t\t{\"EXPN\", \"502\", \"502\"},\n\t\t{\"TEST\", \"500\", \"500\"},     // Unsupported command\n\t\t{\"\", \"500\", \"500\"},         // Blank command\n\t\t{\"STARTTLS\", \"502\", \"502\"}, // TLS not configured\n\t}\n\n\t// If authentication is configured and required, but not already in use, reject every command except\n\t// AUTH, EHLO, HELO, NOOP, RSET, or QUIT as per RFC 4954.\n\tfor _, tt := range tests {\n\t\tcmdCode(t, conn, tt.cmd, tt.codeBefore)\n\t}\n\n\t// AUTH without mechanism parameter must return 501 syntax error.\n\tcmdCode(t, conn, \"AUTH\", \"501\")\n\n\t// AUTH with a supported mechanism should return 334.\n\tcmdCode(t, conn, \"AUTH CRAM-MD5\", \"334\")\n\n\t// AUTH must support cancellation with '*' and return 501 syntax error.\n\tcmdCode(t, conn, \"*\", \"501\")\n\n\t// AUTH with an unsupported mechanism should return 504 unrecognized type.\n\tcmdCode(t, conn, \"AUTH FOO\", \"504\")\n\n\t// The LOGIN and PLAIN mechanisms require a TLS connection, and are disabled by default.\n\tcmdCode(t, conn, \"AUTH LOGIN\", \"504\")\n\tcmdCode(t, conn, \"AUTH PLAIN\", \"504\")\n\n\tcmdCode(t, conn, \"QUIT\", \"221\")\n\t_ = conn.Close()\n}\n\nfunc TestCmdAUTHLOGIN(t *testing.T) {\n\tserver := &Server{TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}}, AuthHandler: testAuthHandler}\n\tconn := newConn(t, server)\n\tcmdCode(t, conn, \"EHLO host.example.com\", \"250\")\n\n\t// AUTH LOGIN without TLS in use must return 504 unrecognised type.\n\tcmdCode(t, conn, \"AUTH LOGIN\", \"504\")\n\n\t// Upgrade to TLS.\n\tcmdCode(t, conn, \"STARTTLS\", \"220\")\n\ttlsConn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true})\n\terr := tlsConn.Handshake()\n\tif err != nil {\n\t\tt.Errorf(\"Failed to perform TLS handshake\")\n\t}\n\tcmdCode(t, tlsConn, \"EHLO host.example.com\", \"250\")\n\n\t// AUTH LOGIN with TLS in use can proceed.\n\n\t// LOGIN authentication process:\n\t// Client sends \"AUTH LOGIN\"\n\t// Server sends \"334 VXNlcm5hbWU6\" (Base64-encoded \"Username:\").\n\t// Client sends Base64-encoded username.\n\t// Server sends \"334 UGFzc3dvcmQ6\" (Base64-encoded \"Password:\").\n\t// Client sends Base64-encoded password.\n\tinvalidBase64 := \"==\" // Invalid Base64 string.\n\tvalidUsername := base64.StdEncoding.EncodeToString([]byte(\"valid\"))\n\tinvalidUsername := base64.StdEncoding.EncodeToString([]byte(\"invalid\"))\n\tpassword := base64.StdEncoding.EncodeToString([]byte(\"password\"))\n\n\t// Corrupt credentials must return 501 syntax error.\n\tcmdCode(t, tlsConn, \"AUTH LOGIN\", \"334\")\n\tcmdCode(t, tlsConn, invalidBase64, \"501\")\n\n\tcmdCode(t, tlsConn, \"AUTH LOGIN\", \"334\")\n\tcmdCode(t, tlsConn, validUsername, \"334\")\n\tcmdCode(t, tlsConn, invalidBase64, \"501\")\n\n\t// Invalid credentials must return 535 authentication credentials invalid.\n\tcmdCode(t, tlsConn, \"AUTH LOGIN\", \"334\")\n\tcmdCode(t, tlsConn, invalidUsername, \"334\")\n\tcmdCode(t, tlsConn, password, \"535\")\n\n\t// Valid credentials must return 235 authentication succeeded.\n\tcmdCode(t, tlsConn, \"AUTH LOGIN\", \"334\")\n\tcmdCode(t, tlsConn, validUsername, \"334\")\n\tcmdCode(t, tlsConn, password, \"235\")\n\n\t// AUTH after prior successful AUTH must return 503 bad sequence.\n\tcmdCode(t, tlsConn, \"AUTH LOGIN\", \"503\")\n\tcmdCode(t, tlsConn, \"AUTH PLAIN\", \"503\")\n\tcmdCode(t, tlsConn, \"AUTH CRAM-MD5\", \"503\")\n\n\tcmdCode(t, tlsConn, \"QUIT\", \"221\")\n\t_ = tlsConn.Close()\n}\n\nfunc TestCmdAUTHLOGINFast(t *testing.T) {\n\tserver := &Server{TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}}, AuthHandler: testAuthHandler}\n\tconn := newConn(t, server)\n\tcmdCode(t, conn, \"EHLO host.example.com\", \"250\")\n\n\t// AUTH LOGIN without TLS in use must return 504 unrecognised type.\n\tcmdCode(t, conn, \"AUTH LOGIN\", \"504\")\n\n\t// Upgrade to TLS.\n\tcmdCode(t, conn, \"STARTTLS\", \"220\")\n\ttlsConn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true})\n\terr := tlsConn.Handshake()\n\tif err != nil {\n\t\tt.Errorf(\"Failed to perform TLS handshake\")\n\t}\n\tcmdCode(t, tlsConn, \"EHLO host.example.com\", \"250\")\n\n\t// AUTH LOGIN with TLS in use can proceed.\n\n\t// Fast LOGIN authentication process:\n\t// Client sends \"AUTH LOGIN \" plus Base64-encoded username.\n\t// Server sends \"334 UGFzc3dvcmQ6\" (Base64-encoded \"Password:\").\n\t// Client sends Base64-encoded password.\n\tinvalidBase64 := \"==\" // Invalid Base64 string.\n\tvalidUsername := base64.StdEncoding.EncodeToString([]byte(\"valid\"))\n\tinvalidUsername := base64.StdEncoding.EncodeToString([]byte(\"invalid\"))\n\tpassword := base64.StdEncoding.EncodeToString([]byte(\"password\"))\n\n\t// Corrupt credentials must return 501 syntax error.\n\tcmdCode(t, tlsConn, \"AUTH LOGIN \"+invalidBase64, \"501\")\n\n\tcmdCode(t, tlsConn, \"AUTH LOGIN \"+validUsername, \"334\")\n\tcmdCode(t, tlsConn, invalidBase64, \"501\")\n\n\t// Invalid credentials must return 535 authentication credentials invalid.\n\tcmdCode(t, tlsConn, \"AUTH LOGIN \"+invalidUsername, \"334\")\n\tcmdCode(t, tlsConn, password, \"535\")\n\n\t// Valid credentials must return 235 authentication succeeded.\n\tcmdCode(t, tlsConn, \"AUTH LOGIN\", \"334\")\n\tcmdCode(t, tlsConn, validUsername, \"334\")\n\tcmdCode(t, tlsConn, password, \"235\")\n\n\t// AUTH after prior successful AUTH must return 503 bad sequence.\n\tcmdCode(t, tlsConn, \"AUTH LOGIN\", \"503\")\n\tcmdCode(t, tlsConn, \"AUTH PLAIN\", \"503\")\n\tcmdCode(t, tlsConn, \"AUTH CRAM-MD5\", \"503\")\n\n\tcmdCode(t, tlsConn, \"QUIT\", \"221\")\n\t_ = tlsConn.Close()\n}\n\nfunc TestCmdAUTHPLAIN(t *testing.T) {\n\tserver := &Server{TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}}, AuthHandler: testAuthHandler}\n\tconn := newConn(t, server)\n\tcmdCode(t, conn, \"EHLO host.example.com\", \"250\")\n\n\t// AUTH PLAIN without TLS in use must return 504 unrecognised type.\n\tcmdCode(t, conn, \"AUTH PLAIN\", \"504\")\n\n\t// Upgrade to TLS.\n\tcmdCode(t, conn, \"STARTTLS\", \"220\")\n\ttlsConn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true})\n\terr := tlsConn.Handshake()\n\tif err != nil {\n\t\tt.Errorf(\"Failed to perform TLS handshake\")\n\t}\n\tcmdCode(t, tlsConn, \"EHLO host.example.com\", \"250\")\n\n\t// AUTH PLAIN with TLS in use can proceed.\n\t// RFC 2595 specifies:\n\t// The client sends the authorization identity (identity to\n\t// login as), followed by a US-ASCII NUL character, followed by the\n\t// authentication identity (identity whose password will be used),\n\t// followed by a US-ASCII NUL character, followed by the clear-text\n\t// password.  The client may leave the authorization identity empty to\n\t// indicate that it is the same as the authentication identity.\n\n\t// PLAIN authentication process:\n\t// Client sends \"AUTH PLAIN\"\n\t// Server sends \"334 \" (RFC 4954 requires the space).\n\t// Client sends Base64-encoded string: identity\\0username\\0password\n\tinvalidBase64 := \"==\" // Invalid Base64 string.\n\tmissingNUL := base64.StdEncoding.EncodeToString([]byte(\"valid\\x00password\"))\n\tvalid := base64.StdEncoding.EncodeToString([]byte(\"identity\\x00valid\\x00password\"))\n\tinvalid := base64.StdEncoding.EncodeToString([]byte(\"identity\\x00invalid\\x00password\"))\n\n\t// Corrupt credentials must return 501 syntax error.\n\tcmdCode(t, tlsConn, \"AUTH PLAIN\", \"334\")\n\tcmdCode(t, tlsConn, invalidBase64, \"501\")\n\n\tcmdCode(t, tlsConn, \"AUTH PLAIN\", \"334\")\n\tcmdCode(t, tlsConn, missingNUL, \"501\")\n\n\t// Invalid credentials must return 535 authentication credentials invalid.\n\tcmdCode(t, tlsConn, \"AUTH PLAIN\", \"334\")\n\tcmdCode(t, tlsConn, invalid, \"535\")\n\n\t// Valid credentials must return 235 authentication succeeded.\n\tcmdCode(t, tlsConn, \"AUTH PLAIN\", \"334\")\n\tcmdCode(t, tlsConn, valid, \"235\")\n\n\t// AUTH after prior successful AUTH must return 503 bad sequence.\n\tcmdCode(t, tlsConn, \"AUTH LOGIN\", \"503\")\n\tcmdCode(t, tlsConn, \"AUTH PLAIN\", \"503\")\n\tcmdCode(t, tlsConn, \"AUTH CRAM-MD5\", \"503\")\n\n\tcmdCode(t, tlsConn, \"QUIT\", \"221\")\n\t_ = tlsConn.Close()\n}\n\nfunc TestCmdAUTHPLAINEmpty(t *testing.T) {\n\tserver := &Server{TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}}, AuthHandler: testAuthHandler}\n\tconn := newConn(t, server)\n\tcmdCode(t, conn, \"EHLO host.example.com\", \"250\")\n\n\t// AUTH PLAIN without TLS in use must return 504 unrecognised type.\n\tcmdCode(t, conn, \"AUTH PLAIN\", \"504\")\n\n\t// Upgrade to TLS.\n\tcmdCode(t, conn, \"STARTTLS\", \"220\")\n\ttlsConn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true})\n\terr := tlsConn.Handshake()\n\tif err != nil {\n\t\tt.Errorf(\"Failed to perform TLS handshake\")\n\t}\n\tcmdCode(t, tlsConn, \"EHLO host.example.com\", \"250\")\n\n\t// AUTH PLAIN with TLS in use can proceed.\n\t// RFC 2595 specifies:\n\t// The client sends the authorization identity (identity to\n\t// login as), followed by a US-ASCII NUL character, followed by the\n\t// authentication identity (identity whose password will be used),\n\t// followed by a US-ASCII NUL character, followed by the clear-text\n\t// password.  The client may leave the authorization identity empty to\n\t// indicate that it is the same as the authentication identity.\n\n\t// PLAIN authentication process with empty authorisation identity:\n\t// Client sends \"AUTH PLAIN\"\n\t// Server sends \"334 \" (RFC 4954 requires the space).\n\t// Client sends Base64-encoded string: \\0username\\0password\n\tinvalidBase64 := \"==\" // Invalid Base64 string.\n\tmissingNUL := base64.StdEncoding.EncodeToString([]byte(\"valid\\x00password\"))\n\tvalid := base64.StdEncoding.EncodeToString([]byte(\"\\x00valid\\x00password\"))\n\tinvalid := base64.StdEncoding.EncodeToString([]byte(\"\\x00invalid\\x00password\"))\n\n\t// Corrupt credentials must return 501 syntax error.\n\tcmdCode(t, tlsConn, \"AUTH PLAIN\", \"334\")\n\tcmdCode(t, tlsConn, invalidBase64, \"501\")\n\n\tcmdCode(t, tlsConn, \"AUTH PLAIN\", \"334\")\n\tcmdCode(t, tlsConn, missingNUL, \"501\")\n\n\t// Invalid credentials must return 535 authentication credentials invalid.\n\tcmdCode(t, tlsConn, \"AUTH PLAIN\", \"334\")\n\tcmdCode(t, tlsConn, invalid, \"535\")\n\n\t// Valid credentials must return 235 authentication succeeded.\n\tcmdCode(t, tlsConn, \"AUTH PLAIN\", \"334\")\n\tcmdCode(t, tlsConn, valid, \"235\")\n\n\t// AUTH after prior successful AUTH must return 503 bad sequence.\n\tcmdCode(t, tlsConn, \"AUTH LOGIN\", \"503\")\n\tcmdCode(t, tlsConn, \"AUTH PLAIN\", \"503\")\n\tcmdCode(t, tlsConn, \"AUTH CRAM-MD5\", \"503\")\n\n\tcmdCode(t, tlsConn, \"QUIT\", \"221\")\n\t_ = tlsConn.Close()\n}\n\nfunc TestCmdAUTHPLAINFast(t *testing.T) {\n\tserver := &Server{TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}}, AuthHandler: testAuthHandler}\n\tconn := newConn(t, server)\n\tcmdCode(t, conn, \"EHLO host.example.com\", \"250\")\n\n\t// AUTH PLAIN without TLS in use must return 504 unrecognised type.\n\tcmdCode(t, conn, \"AUTH PLAIN\", \"504\")\n\n\t// Upgrade to TLS.\n\tcmdCode(t, conn, \"STARTTLS\", \"220\")\n\ttlsConn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true})\n\terr := tlsConn.Handshake()\n\tif err != nil {\n\t\tt.Errorf(\"Failed to perform TLS handshake\")\n\t}\n\tcmdCode(t, tlsConn, \"EHLO host.example.com\", \"250\")\n\n\t// AUTH PLAIN with TLS in use can proceed.\n\t// RFC 2595 specifies:\n\t// The client sends the authorization identity (identity to\n\t// login as), followed by a US-ASCII NUL character, followed by the\n\t// authentication identity (identity whose password will be used),\n\t// followed by a US-ASCII NUL character, followed by the clear-text\n\t// password.  The client may leave the authorization identity empty to\n\t// indicate that it is the same as the authentication identity.\n\n\t// Fast PLAIN authentication process:\n\t// Client sends \"AUTH PLAIN \" plus Base64-encoded string: identity\\0username\\0password\n\tinvalidBase64 := \"==\" // Invalid Base64 string.\n\tmissingNUL := base64.StdEncoding.EncodeToString([]byte(\"valid\\x00password\"))\n\tvalid := base64.StdEncoding.EncodeToString([]byte(\"identity\\x00valid\\x00password\"))\n\tinvalid := base64.StdEncoding.EncodeToString([]byte(\"identity\\x00invalid\\x00password\"))\n\n\t// Corrupt credentials must return 501 syntax error.\n\tcmdCode(t, tlsConn, \"AUTH PLAIN \"+invalidBase64, \"501\")\n\tcmdCode(t, tlsConn, \"AUTH PLAIN \"+missingNUL, \"501\")\n\n\t// Invalid credentials must return 535 authentication credentials invalid.\n\tcmdCode(t, tlsConn, \"AUTH PLAIN \"+invalid, \"535\")\n\n\t// Valid credentials must return 235 authentication succeeded.\n\tcmdCode(t, tlsConn, \"AUTH PLAIN \"+valid, \"235\")\n\n\t// AUTH after prior successful AUTH must return 503 bad sequence.\n\tcmdCode(t, tlsConn, \"AUTH LOGIN\", \"503\")\n\tcmdCode(t, tlsConn, \"AUTH PLAIN\", \"503\")\n\tcmdCode(t, tlsConn, \"AUTH CRAM-MD5\", \"503\")\n\n\tcmdCode(t, tlsConn, \"QUIT\", \"221\")\n\t_ = tlsConn.Close()\n}\n\nfunc TestCmdAUTHPLAINFastAndEmpty(t *testing.T) {\n\tserver := &Server{TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}}, AuthHandler: testAuthHandler}\n\tconn := newConn(t, server)\n\tcmdCode(t, conn, \"EHLO host.example.com\", \"250\")\n\n\t// AUTH PLAIN without TLS in use must return 504 unrecognised type.\n\tcmdCode(t, conn, \"AUTH PLAIN\", \"504\")\n\n\t// Upgrade to TLS.\n\tcmdCode(t, conn, \"STARTTLS\", \"220\")\n\ttlsConn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true})\n\terr := tlsConn.Handshake()\n\tif err != nil {\n\t\tt.Errorf(\"Failed to perform TLS handshake\")\n\t}\n\tcmdCode(t, tlsConn, \"EHLO host.example.com\", \"250\")\n\n\t// AUTH PLAIN with TLS in use can proceed.\n\t// RFC 2595 specifies:\n\t// The client sends the authorization identity (identity to\n\t// login as), followed by a US-ASCII NUL character, followed by the\n\t// authentication identity (identity whose password will be used),\n\t// followed by a US-ASCII NUL character, followed by the clear-text\n\t// password.  The client may leave the authorization identity empty to\n\t// indicate that it is the same as the authentication identity.\n\n\t// Fast PLAIN authentication process with empty authorisation identity:\n\t// Client sends \"AUTH PLAIN \" plus Base64-encoded string: \\0username\\0password\n\tinvalidBase64 := \"==\" // Invalid Base64 string.\n\tmissingNUL := base64.StdEncoding.EncodeToString([]byte(\"valid\\x00password\"))\n\tvalid := base64.StdEncoding.EncodeToString([]byte(\"\\x00valid\\x00password\"))\n\tinvalid := base64.StdEncoding.EncodeToString([]byte(\"\\x00invalid\\x00password\"))\n\n\t// Corrupt credentials must return 501 syntax error.\n\tcmdCode(t, tlsConn, \"AUTH PLAIN \"+invalidBase64, \"501\")\n\tcmdCode(t, tlsConn, \"AUTH PLAIN \"+missingNUL, \"501\")\n\n\t// Invalid credentials must return 535 authentication credentials invalid.\n\tcmdCode(t, tlsConn, \"AUTH PLAIN \"+invalid, \"535\")\n\n\t// Valid credentials must return 235 authentication succeeded.\n\tcmdCode(t, tlsConn, \"AUTH PLAIN \"+valid, \"235\")\n\n\t// AUTH after prior successful AUTH must return 503 bad sequence.\n\tcmdCode(t, tlsConn, \"AUTH LOGIN\", \"503\")\n\tcmdCode(t, tlsConn, \"AUTH PLAIN\", \"503\")\n\tcmdCode(t, tlsConn, \"AUTH CRAM-MD5\", \"503\")\n\n\tcmdCode(t, tlsConn, \"QUIT\", \"221\")\n\t_ = tlsConn.Close()\n}\n\n// makeCRAMMD5Response is a helper function to create the CRAM-MD5 hash.\nfunc makeCRAMMD5Response(challenge string, username string, secret string) (string, error) {\n\tdecoded, err := base64.StdEncoding.DecodeString(challenge)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\thash := hmac.New(md5.New, []byte(secret))\n\thash.Write(decoded)\n\tbuffer := make([]byte, 0, hash.Size())\n\tresponse := fmt.Sprintf(\"%s %x\", username, hash.Sum(buffer))\n\treturn base64.StdEncoding.EncodeToString([]byte(response)), nil\n}\n\nfunc TestCmdAUTHCRAMMD5(t *testing.T) {\n\tserver := &Server{AuthHandler: testAuthHandler}\n\tconn := newConn(t, server)\n\tcmdCode(t, conn, \"EHLO host.example.com\", \"250\")\n\n\t// AUTH CRAM-MD5 without TLS in use can proceed.\n\t// RFC 2195 specifies:\n\t// The challenge format is that of a Message-ID email header value.\n\t// Challenge format: '<' + random digits + '.' + timestamp in digits + '@' + fully-qualified server hostname + '>'\n\t// Challenge example: <1896.697170952@postoffice.reston.mci.net>\n\t// The response format consists of the username, a space and a digest.\n\t// Digest calculation: MD5((secret XOR opad), MD5((secret XOR ipad), challenge))\n\t// Response example: tim b913a602c7eda7a495b4e6e7334d3890\n\n\t// CRAM-MD5 authentication process:\n\t// Client sends \"AUTH CRAM-MD5\".\n\t// Server sends \"334 \" plus Base64-encoded challenge.\n\t// Client sends Base64-encoded response.\n\tinvalidBase64 := \"==\" // Invalid Base64 string.\n\n\t// Corrupt credentials must return 501 syntax error.\n\tcmdCode(t, conn, \"AUTH CRAM-MD5\", \"334\")\n\tcmdCode(t, conn, invalidBase64, \"501\")\n\n\t// Test valid credentials with missing space (causing a parse error).\n\tline := cmdCode(t, conn, \"AUTH CRAM-MD5\", \"334\")\n\tvalid, _ := makeCRAMMD5Response(line[4:], \"valid\", \"password\")\n\tbuffer, _ := base64.StdEncoding.DecodeString(valid)\n\tbuffer = bytes.Replace(buffer, []byte(\" \"), []byte(\"\"), 1)\n\tmissingSpace := base64.StdEncoding.EncodeToString(buffer)\n\tcmdCode(t, conn, string(missingSpace), \"501\")\n\n\t// Invalid credentials must return 535 authentication credentials invalid.\n\tline = cmdCode(t, conn, \"AUTH CRAM-MD5\", \"334\")\n\tinvalid, err := makeCRAMMD5Response(line[4:], \"invalid\", \"password\")\n\tif err != nil {\n\t\tcmdCode(t, conn, \"*\", \"501\")\n\t}\n\tcmdCode(t, conn, invalid, \"535\")\n\n\t// Valid credentials must return 235 authentication succeeded.\n\tline = cmdCode(t, conn, \"AUTH CRAM-MD5\", \"334\")\n\tvalid, err = makeCRAMMD5Response(line[4:], \"valid\", \"password\")\n\tif err != nil {\n\t\tcmdCode(t, conn, \"*\", \"501\")\n\t}\n\tcmdCode(t, conn, valid, \"235\")\n\n\t// AUTH after prior successful AUTH must return 503 bad sequence.\n\tcmdCode(t, conn, \"AUTH LOGIN\", \"503\")\n\tcmdCode(t, conn, \"AUTH PLAIN\", \"503\")\n\tcmdCode(t, conn, \"AUTH CRAM-MD5\", \"503\")\n\n\tcmdCode(t, conn, \"QUIT\", \"221\")\n\t_ = conn.Close()\n}\n\nfunc TestCmdAUTHCRAMMD5WithTLS(t *testing.T) {\n\tserver := &Server{TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}}, AuthHandler: testAuthHandler}\n\tconn := newConn(t, server)\n\tcmdCode(t, conn, \"EHLO host.example.com\", \"250\")\n\n\t// Upgrade to TLS.\n\tcmdCode(t, conn, \"STARTTLS\", \"220\")\n\ttlsConn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true})\n\terr := tlsConn.Handshake()\n\tif err != nil {\n\t\tt.Errorf(\"Failed to perform TLS handshake\")\n\t}\n\tcmdCode(t, tlsConn, \"EHLO host.example.com\", \"250\")\n\n\t// AUTH CRAM-MD5 with TLS in use can proceed.\n\t// RFC 2195 specifies:\n\t// The challenge format is that of a Message-ID email header value.\n\t// Challenge format: '<' + random digits + '.' + timestamp in digits + '@' + fully-qualified server hostname + '>'\n\t// Challenge example: <1896.697170952@postoffice.reston.mci.net>\n\t// The response format consists of the username, a space and a digest.\n\t// Digest calculation: MD5((secret XOR opad), MD5((secret XOR ipad), challenge))\n\t// Response example: tim b913a602c7eda7a495b4e6e7334d3890\n\n\t// CRAM-MD5 authentication process:\n\t// Client sends \"AUTH CRAM-MD5\".\n\t// Server sends \"334 \" plus Base64-encoded challenge.\n\t// Client sends Base64-encoded response.\n\tinvalidBase64 := \"==\" // Invalid Base64 string.\n\n\t// Corrupt credentials must return 501 syntax error.\n\tcmdCode(t, tlsConn, \"AUTH CRAM-MD5\", \"334\")\n\tcmdCode(t, tlsConn, invalidBase64, \"501\")\n\n\t// Test valid credentials with missing space (causing a parse error).\n\tline := cmdCode(t, tlsConn, \"AUTH CRAM-MD5\", \"334\")\n\tvalid, _ := makeCRAMMD5Response(line[4:], \"valid\", \"password\")\n\tbuffer, _ := base64.StdEncoding.DecodeString(valid)\n\tbuffer = bytes.Replace(buffer, []byte(\" \"), []byte(\"\"), 1)\n\tmissingSpace := base64.StdEncoding.EncodeToString(buffer)\n\tcmdCode(t, tlsConn, string(missingSpace), \"501\")\n\n\t// Invalid credentials must return 535 authentication credentials invalid.\n\tline = cmdCode(t, tlsConn, \"AUTH CRAM-MD5\", \"334\")\n\tinvalid, err := makeCRAMMD5Response(line[4:], \"invalid\", \"password\")\n\tif err != nil {\n\t\tcmdCode(t, tlsConn, \"*\", \"501\")\n\t}\n\tcmdCode(t, tlsConn, invalid, \"535\")\n\n\t// Valid credentials must return 235 authentication succeeded.\n\tline = cmdCode(t, tlsConn, \"AUTH CRAM-MD5\", \"334\")\n\tvalid, err = makeCRAMMD5Response(line[4:], \"valid\", \"password\")\n\tif err != nil {\n\t\tcmdCode(t, tlsConn, \"*\", \"501\")\n\t}\n\tcmdCode(t, tlsConn, valid, \"235\")\n\n\t// AUTH after prior successful AUTH must return 503 bad sequence.\n\tcmdCode(t, tlsConn, \"AUTH LOGIN\", \"503\")\n\tcmdCode(t, tlsConn, \"AUTH PLAIN\", \"503\")\n\tcmdCode(t, tlsConn, \"AUTH CRAM-MD5\", \"503\")\n\n\tcmdCode(t, tlsConn, \"QUIT\", \"221\")\n\t_ = tlsConn.Close()\n}\n\n// Benchmark the mail handling without the network stack introducing latency.\nfunc BenchmarkReceive(b *testing.B) {\n\tserver := &Server{} // Default server configuration.\n\tclientConn, serverConn := net.Pipe()\n\tsession := server.newSession(serverConn)\n\tgo session.serve()\n\n\treader := bufio.NewReader(clientConn)\n\t_, _ = reader.ReadString('\\n') // Read greeting message first.\n\n\tb.ResetTimer()\n\n\t// Benchmark a full mail transaction.\n\tfor i := 0; i < b.N; i++ {\n\t\t_, _ = fmt.Fprintf(clientConn, \"%s\\r\\n\", \"HELO host.example.com\")\n\t\t_, _ = reader.ReadString('\\n')\n\t\t_, _ = fmt.Fprintf(clientConn, \"%s\\r\\n\", \"MAIL FROM:<sender@example.com>\")\n\t\t_, _ = reader.ReadString('\\n')\n\t\t_, _ = fmt.Fprintf(clientConn, \"%s\\r\\n\", \"RCPT TO:<recipient@example.com>\")\n\t\t_, _ = reader.ReadString('\\n')\n\t\t_, _ = fmt.Fprintf(clientConn, \"%s\\r\\n\", \"DATA\")\n\t\t_, _ = reader.ReadString('\\n')\n\t\t_, _ = fmt.Fprintf(clientConn, \"%s\\r\\n\", \"Test message.\\r\\n.\")\n\t\t_, _ = reader.ReadString('\\n')\n\t\t_, _ = fmt.Fprintf(clientConn, \"%s\\r\\n\", \"QUIT\")\n\t\t_, _ = reader.ReadString('\\n')\n\t}\n}\n\nfunc TestCmdShutdown(t *testing.T) {\n\n\tsrv := &Server{}\n\n\tconn := newConn(t, srv)\n\n\t// Send HELO, expect greeting.\n\tcmdCode(t, conn, \"HELO host.example.com\", \"250\")\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com>\", \"250\")\n\tcmdCode(t, conn, \"RCPT TO:<recipient@example.com>\", \"250\")\n\tcmdCode(t, conn, \"HELO host.example.com\", \"250\")\n\tcmdCode(t, conn, \"DATA\", \"503\")\n\n\tgo func() {\n\t\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\t\tdefer cancel()\n\n\t\tif err := srv.Shutdown(ctx); err != nil {\n\t\t\tt.Errorf(\"Error shutting down server: %v\\n\", err)\n\t\t}\n\t}()\n\n\t// give the shutdown time to act\n\ttime.Sleep(200 * time.Millisecond)\n\n\t// shutdown will wait until the end of the session\n\tcmdCode(t, conn, \"HELO host.example.com\", \"250\")\n\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com>\", \"250\")\n\tcmdCode(t, conn, \"RCPT TO:<recipient@example.com>\", \"250\")\n\n\t// this will trigger the close\n\tcmdCode(t, conn, \"QUIT\", \"221\")\n\n\t// connection should now be closed\n\t_, _ = fmt.Fprintf(conn, \"%s\\r\\n\", \"HELO host.example.com\")\n\t_, err := bufio.NewReader(conn).ReadString('\\n')\n\tif err != io.EOF {\n\t\tt.Errorf(\"Expected connection to be closed\\n\")\n\t}\n\n\t_ = conn.Close()\n}\n\ntype mockDropRejectedHandler struct {\n\thandlerCalled int\n\tlastFrom      string\n\tlastTo        []string\n\tmsgIDCalled   int\n\tlastMsgIDFrom string\n\tlastMsgIDTo   []string\n}\n\nfunc (m *mockDropRejectedHandler) handler(remoteAddr net.Addr, from string, to []string, data []byte) error {\n\tm.handlerCalled++\n\tm.lastFrom = from\n\tm.lastTo = append([]string{}, to...) // copy slice\n\treturn nil\n}\n\nfunc (m *mockDropRejectedHandler) msgIDHandler(remoteAddr net.Addr, from string, to []string, data []byte, username *string) (string, error) {\n\tm.msgIDCalled++\n\tm.lastMsgIDFrom = from\n\tm.lastMsgIDTo = append([]string{}, to...) // copy slice\n\treturn \"test-message-id\", nil\n}\n\n// Test the IgnoreRejectedRecipients option\nfunc TestIgnoreRejectedRecipients(t *testing.T) {\n\ttests := []struct {\n\t\tname                      string\n\t\tIgnoreRejectedRecipients  bool\n\t\thandlerRcpt               func(net.Addr, string, string) bool\n\t\trcptCommands              []struct{ addr, expectedCode string }\n\t\texpectedHandlerCalls      int\n\t\texpectedHandlerRecipients []string\n\t\tuseMsgIDHandler           bool\n\t}{\n\t\t{\n\t\t\tname:                     \"Disabled_DefaultBehavior\",\n\t\t\tIgnoreRejectedRecipients: false,\n\t\t\thandlerRcpt: func(remoteAddr net.Addr, from string, to string) bool {\n\t\t\t\treturn !strings.HasSuffix(to, \"@rejected.com\")\n\t\t\t},\n\t\t\trcptCommands: []struct{ addr, expectedCode string }{\n\t\t\t\t{\"valid@example.com\", \"250\"},\n\t\t\t\t{\"invalid@rejected.com\", \"550\"},\n\t\t\t},\n\t\t\texpectedHandlerCalls:      1,\n\t\t\texpectedHandlerRecipients: []string{\"valid@example.com\"},\n\t\t},\n\t\t{\n\t\t\tname:                     \"Enabled_MixedRecipients\",\n\t\t\tIgnoreRejectedRecipients: true,\n\t\t\thandlerRcpt: func(remoteAddr net.Addr, from string, to string) bool {\n\t\t\t\treturn !strings.HasSuffix(to, \"@rejected.com\")\n\t\t\t},\n\t\t\trcptCommands: []struct{ addr, expectedCode string }{\n\t\t\t\t{\"valid1@example.com\", \"250\"},\n\t\t\t\t{\"valid2@example.com\", \"250\"},\n\t\t\t\t{\"invalid1@rejected.com\", \"250\"}, // Now accepted but dropped\n\t\t\t\t{\"invalid2@rejected.com\", \"250\"}, // Now accepted but dropped\n\t\t\t},\n\t\t\texpectedHandlerCalls:      1,\n\t\t\texpectedHandlerRecipients: []string{\"valid1@example.com\", \"valid2@example.com\"},\n\t\t},\n\t\t{\n\t\t\tname:                     \"Enabled_AllRejected\",\n\t\t\tIgnoreRejectedRecipients: true,\n\t\t\thandlerRcpt: func(remoteAddr net.Addr, from string, to string) bool {\n\t\t\t\treturn false // Reject all\n\t\t\t},\n\t\t\trcptCommands: []struct{ addr, expectedCode string }{\n\t\t\t\t{\"test1@example.com\", \"250\"}, // Accepted but dropped\n\t\t\t\t{\"test2@example.com\", \"250\"}, // Accepted but dropped\n\t\t\t},\n\t\t\texpectedHandlerCalls:      0, // No handler calls since all rejected\n\t\t\texpectedHandlerRecipients: nil,\n\t\t},\n\t\t{\n\t\t\tname:                     \"Enabled_OnlyValid\",\n\t\t\tIgnoreRejectedRecipients: true,\n\t\t\thandlerRcpt: func(remoteAddr net.Addr, from string, to string) bool {\n\t\t\t\treturn strings.HasSuffix(to, \"@valid.com\")\n\t\t\t},\n\t\t\trcptCommands: []struct{ addr, expectedCode string }{\n\t\t\t\t{\"user1@valid.com\", \"250\"},\n\t\t\t\t{\"user2@valid.com\", \"250\"},\n\t\t\t\t{\"user3@valid.com\", \"250\"},\n\t\t\t},\n\t\t\texpectedHandlerCalls:      1,\n\t\t\texpectedHandlerRecipients: []string{\"user1@valid.com\", \"user2@valid.com\", \"user3@valid.com\"},\n\t\t},\n\t\t{\n\t\t\tname:                     \"Enabled_WithMsgIDHandler\",\n\t\t\tIgnoreRejectedRecipients: true,\n\t\t\thandlerRcpt: func(remoteAddr net.Addr, from string, to string) bool {\n\t\t\t\treturn !strings.HasSuffix(to, \"@rejected.com\")\n\t\t\t},\n\t\t\trcptCommands: []struct{ addr, expectedCode string }{\n\t\t\t\t{\"valid@example.com\", \"250\"},\n\t\t\t\t{\"invalid@rejected.com\", \"250\"}, // Accepted but dropped\n\t\t\t},\n\t\t\texpectedHandlerCalls:      1,\n\t\t\texpectedHandlerRecipients: []string{\"valid@example.com\"},\n\t\t\tuseMsgIDHandler:           true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tmock := &mockDropRejectedHandler{}\n\n\t\t\tserver := &Server{\n\t\t\t\tHostname:                 \"mail.example.com\",\n\t\t\t\tAppName:                  \"TestMail\",\n\t\t\t\tMaxRecipients:            100,\n\t\t\t\tHandlerRcpt:              tt.handlerRcpt,\n\t\t\t\tIgnoreRejectedRecipients: tt.IgnoreRejectedRecipients,\n\t\t\t}\n\n\t\t\tif tt.useMsgIDHandler {\n\t\t\t\tserver.MsgIDHandler = mock.msgIDHandler\n\t\t\t} else {\n\t\t\t\tserver.Handler = mock.handler\n\t\t\t}\n\n\t\t\tconn := newConn(t, server)\n\t\t\tdefer func() { _ = conn.Close() }()\n\n\t\t\tcmdCode(t, conn, \"HELO host.example.com\", \"250\")\n\t\t\tcmdCode(t, conn, \"MAIL FROM:<sender@example.com>\", \"250\")\n\n\t\t\t// Send RCPT commands\n\t\t\tfor _, rcpt := range tt.rcptCommands {\n\t\t\t\tcmdCode(t, conn, \"RCPT TO:<\"+rcpt.addr+\">\", rcpt.expectedCode)\n\t\t\t}\n\n\t\t\t// Send DATA\n\t\t\tcmdCode(t, conn, \"DATA\", \"354\")\n\t\t\tcmdCode(t, conn, \"Subject: Test\\r\\n\\r\\nTest message\\r\\n.\", \"250\")\n\t\t\tcmdCode(t, conn, \"QUIT\", \"221\")\n\n\t\t\t// Verify handler calls\n\t\t\tif tt.useMsgIDHandler {\n\t\t\t\tif mock.msgIDCalled != tt.expectedHandlerCalls {\n\t\t\t\t\tt.Errorf(\"Expected %d MsgIDHandler calls, got %d\", tt.expectedHandlerCalls, mock.msgIDCalled)\n\t\t\t\t}\n\t\t\t\tif tt.expectedHandlerCalls > 0 {\n\t\t\t\t\tif mock.lastMsgIDFrom != \"sender@example.com\" {\n\t\t\t\t\t\tt.Errorf(\"Expected from 'sender@example.com', got '%s'\", mock.lastMsgIDFrom)\n\t\t\t\t\t}\n\t\t\t\t\tif !reflect.DeepEqual(mock.lastMsgIDTo, tt.expectedHandlerRecipients) {\n\t\t\t\t\t\tt.Errorf(\"Expected recipients %v, got %v\", tt.expectedHandlerRecipients, mock.lastMsgIDTo)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif mock.handlerCalled != tt.expectedHandlerCalls {\n\t\t\t\t\tt.Errorf(\"Expected %d handler calls, got %d\", tt.expectedHandlerCalls, mock.handlerCalled)\n\t\t\t\t}\n\t\t\t\tif tt.expectedHandlerCalls > 0 {\n\t\t\t\t\tif mock.lastFrom != \"sender@example.com\" {\n\t\t\t\t\t\tt.Errorf(\"Expected from 'sender@example.com', got '%s'\", mock.lastFrom)\n\t\t\t\t\t}\n\t\t\t\t\tif !reflect.DeepEqual(mock.lastTo, tt.expectedHandlerRecipients) {\n\t\t\t\t\t\tt.Errorf(\"Expected recipients %v, got %v\", tt.expectedHandlerRecipients, mock.lastTo)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/snakeoil/snakeoil.go",
    "content": "// Package snakeoil provides functionality to generate a temporary self-signed certificates\n// for testing purposes. It generates a public and private key pair, stores them in the\n// OS's temporary directory, returning the paths to these files.\npackage snakeoil\n\nimport (\n\t\"crypto/rand\"\n\t\"crypto/rsa\"\n\t\"crypto/sha256\"\n\t\"crypto/x509\"\n\t\"crypto/x509/pkix\"\n\t\"encoding/base64\"\n\t\"encoding/pem\"\n\t\"errors\"\n\t\"math/big\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/axllent/mailpit/internal/tools\"\n)\n\nvar keys = make(map[string]KeyPair)\n\n// KeyPair holds the public and private key paths for a self-signed certificate.\ntype KeyPair struct {\n\tPublic  string\n\tPrivate string\n}\n\n// Certificates returns all configured self-signed certificates in use,\n// used for file deletion on exit.\nfunc Certificates() map[string]KeyPair {\n\treturn keys\n}\n\n// Public returns the path to a generated PEM-encoded RSA public key.\nfunc Public(str string) string {\n\tdomains, key, err := parse(str)\n\tif err != nil {\n\t\tlogger.Log().Errorf(\"[tls] failed to parse domains: %v\", err)\n\t\treturn \"\"\n\t}\n\n\tif pair, ok := keys[key]; ok {\n\t\treturn pair.Public\n\t}\n\n\tprivate, public, err := generate(domains)\n\tif err != nil {\n\t\tlogger.Log().Errorf(\"[tls] failed to generate public certificate: %v\", err)\n\t\treturn \"\"\n\t}\n\n\tkeys[key] = KeyPair{\n\t\tPublic:  public,\n\t\tPrivate: private,\n\t}\n\n\treturn public\n}\n\n// Private returns the path to a generated PEM-encoded RSA private key.\nfunc Private(str string) string {\n\tdomains, key, err := parse(str)\n\tif err != nil {\n\t\tlogger.Log().Errorf(\"[tls] failed to parse domains: %v\", err)\n\t\treturn \"\"\n\t}\n\n\tif pair, ok := keys[key]; ok {\n\t\treturn pair.Private\n\t}\n\n\tprivate, public, err := generate(domains)\n\tif err != nil {\n\t\tlogger.Log().Errorf(\"[tls] failed to generate public certificate: %v\", err)\n\t\treturn \"\"\n\t}\n\n\tkeys[key] = KeyPair{\n\t\tPublic:  public,\n\t\tPrivate: private,\n\t}\n\n\treturn private\n}\n\n// Parse takes the original string input, removes the \"sans:\" prefix,\n// splits the result into individual domains, and returns a slice of unique domains,\n// along with a unique key that is a comma-separated list of these domains.\nfunc parse(str string) ([]string, string, error) {\n\t// remove \"sans:\" prefix\n\tstr = str[5:]\n\tvar domains []string\n\t// split the string by commas and trim whitespace\n\tfor domain := range strings.SplitSeq(str, \",\") {\n\t\tdomain = strings.ToLower(strings.TrimSpace(domain))\n\t\tif domain != \"\" && !tools.InArray(domain, domains) {\n\t\t\tdomains = append(domains, domain)\n\t\t}\n\t}\n\n\tif len(domains) == 0 {\n\t\treturn domains, \"\", errors.New(\"no valid domains provided\")\n\t}\n\n\t// generate sha256 hash of the domains to create a unique key\n\thasher := sha256.New()\n\thasher.Write([]byte(strings.Join(domains, \",\")))\n\tkey := base64.URLEncoding.EncodeToString(hasher.Sum(nil))\n\n\treturn domains, key, nil\n}\n\n// Generate a new self-signed certificate and return a public & private key paths.\nfunc generate(domains []string) (string, string, error) {\n\tlogger.Log().Infof(\"[tls] generating temp self-signed certificate for: %s\", strings.Join(domains, \",\"))\n\tkey, err := rsa.GenerateKey(rand.Reader, 4096)\n\tif err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\n\tkeyBytes := x509.MarshalPKCS1PrivateKey(key)\n\t// PEM encoding of private key\n\tkeyPEM := pem.EncodeToMemory(\n\t\t&pem.Block{\n\t\t\tType:  \"RSA PRIVATE KEY\",\n\t\t\tBytes: keyBytes,\n\t\t},\n\t)\n\n\tnotBefore := time.Now()\n\tnotAfter := notBefore.Add(365 * 24 * time.Hour)\n\n\t// create certificate template\n\ttemplate := x509.Certificate{\n\t\tSerialNumber: big.NewInt(0),\n\t\tSubject: pkix.Name{\n\t\t\tCommonName:   domains[0],\n\t\t\tOrganization: []string{\"Mailpit self-signed certificate\"},\n\t\t},\n\t\tDNSNames:              domains,\n\t\tSignatureAlgorithm:    x509.SHA256WithRSA,\n\t\tNotBefore:             notBefore,\n\t\tNotAfter:              notAfter,\n\t\tBasicConstraintsValid: true,\n\t\tKeyUsage:              x509.KeyUsageDigitalSignature | x509.KeyUsageKeyAgreement | x509.KeyUsageKeyEncipherment | x509.KeyUsageDataEncipherment,\n\t\tExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},\n\t}\n\n\t// create certificate using template\n\tderBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)\n\tif err != nil {\n\t\treturn \"\", \"\", err\n\n\t}\n\n\t// PEM encoding of certificate\n\tcertPem := pem.EncodeToMemory(\n\t\t&pem.Block{\n\t\t\tType:  \"CERTIFICATE\",\n\t\t\tBytes: derBytes,\n\t\t},\n\t)\n\n\t// Store the paths to the generated keys\n\tpriv, err := os.CreateTemp(\"\", \".mailpit-*-private.pem\")\n\tif err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\n\tif _, err := priv.Write(keyPEM); err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\n\tif err := priv.Close(); err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\n\tpub, err := os.CreateTemp(\"\", \".mailpit-*-public.pem\")\n\tif err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\n\tif _, err := pub.Write(certPem); err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\n\tif err := pub.Close(); err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\n\treturn priv.Name(), pub.Name(), nil\n}\n"
  },
  {
    "path": "internal/spamassassin/postmark/postmark.go",
    "content": "// Package postmark uses the free https://spamcheck.postmarkapp.com/\n// See https://spamcheck.postmarkapp.com/doc/ for more details.\npackage postmark\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n)\n\n// Response struct\ntype Response struct {\n\tSuccess bool   `json:\"success\"`\n\tMessage string `json:\"message\"` // for errors only\n\tScore   string `json:\"score\"`\n\tRules   []Rule `json:\"rules\"`\n\tReport  string `json:\"report\"` // ignored\n}\n\n// Rule struct\ntype Rule struct {\n\tScore string `json:\"score\"`\n\t// Name not returned by postmark but rather extracted from description\n\tName        string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// Check will post the email data to Postmark\nfunc Check(email []byte, timeout int) (Response, error) {\n\tr := Response{}\n\t// '{\"email\":\"raw dump of email\", \"options\":\"short\"}'\n\tvar d struct {\n\t\t// The raw dump of the email to be filtered, including all headers.\n\t\tEmail string `json:\"email\"`\n\t\t// Default \"long\". Must either be \"long\" for a full report of processing rules, or \"short\" for a score request.\n\t\tOptions string `json:\"options\"`\n\t}\n\n\td.Email = string(email)\n\td.Options = \"long\"\n\n\tdata, err := json.Marshal(d)\n\tif err != nil {\n\t\treturn r, err\n\t}\n\n\tclient := http.Client{\n\t\tTimeout: time.Duration(timeout) * time.Second,\n\t}\n\n\tresp, err := client.Post(\"https://spamcheck.postmarkapp.com/filter\", \"application/json\",\n\t\tbytes.NewBuffer(data))\n\n\tif err != nil {\n\t\treturn r, err\n\t}\n\n\tdefer func() { _ = resp.Body.Close() }()\n\n\terr = json.NewDecoder(resp.Body).Decode(&r)\n\n\t// remove trailing line spaces for all lines in report\n\tre := regexp.MustCompile(\"\\r?\\n\")\n\tlines := re.Split(r.Report, -1)\n\treportLines := []string{}\n\tfor _, l := range lines {\n\t\tline := strings.TrimRight(l, \" \")\n\t\treportLines = append(reportLines, line)\n\t}\n\treportRaw := strings.Join(reportLines, \"\\n\")\n\n\t// join description lines to make a single line per rule\n\tre2 := regexp.MustCompile(\"\\n                                \")\n\treport := re2.ReplaceAllString(reportRaw, \"\")\n\tfor i, rule := range r.Rules {\n\t\t// populate rule name\n\t\tr.Rules[i].Name = nameFromReport(rule.Score, rule.Description, report)\n\t}\n\n\treturn r, err\n}\n\n// Extract the name of the test from the report as Postmark does not include this in the JSON reports\nfunc nameFromReport(score, description, report string) string {\n\tscore = regexp.QuoteMeta(score)\n\tdescription = regexp.QuoteMeta(description)\n\tstr := fmt.Sprintf(\"%s\\\\s+([A-Z0-9\\\\_]+)\\\\s+%s\", score, description)\n\tre := regexp.MustCompile(str)\n\n\tmatches := re.FindAllStringSubmatch(report, 1)\n\tif len(matches) > 0 && len(matches[0]) == 2 {\n\t\treturn strings.TrimSpace(matches[0][1])\n\t}\n\n\treturn \"\"\n}\n"
  },
  {
    "path": "internal/spamassassin/spamassassin.go",
    "content": "// Package spamassassin will return results from either a SpamAssassin server or\n// Postmark's public API depending on configuration\npackage spamassassin\n\nimport (\n\t\"errors\"\n\t\"math\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/axllent/mailpit/internal/spamassassin/postmark\"\n\t\"github.com/axllent/mailpit/internal/spamassassin/spamc\"\n)\n\nvar (\n\t// Service to use, either \"<host>:<ip>\" for self-hosted SpamAssassin or \"postmark\"\n\tservice string\n\n\t// SpamScore is the score at which a message is determined to be spam\n\tspamScore = 5.0\n\n\t// Timeout in seconds\n\ttimeout = 8\n)\n\n// Result is a SpamAssassin result\n//\n// swagger:model SpamAssassinResponse\ntype Result struct {\n\t// Whether the message is spam or not\n\tIsSpam bool\n\t// If populated will return an error string\n\tError string\n\t// Total spam score based on triggered rules\n\tScore float64\n\t// Spam rules triggered\n\tRules []Rule\n}\n\n// Rule struct\ntype Rule struct {\n\t// Spam rule score\n\tScore float64\n\t// SpamAssassin rule name\n\tName string\n\t// SpamAssassin rule description\n\tDescription string\n}\n\n// SetService defines which service should be used.\nfunc SetService(s string) {\n\tswitch s {\n\tcase \"postmark\":\n\t\tservice = \"postmark\"\n\tdefault:\n\t\tservice = s\n\t}\n}\n\n// Ping returns whether a service is active or not\nfunc Ping() error {\n\tif service == \"postmark\" {\n\t\treturn nil\n\t}\n\n\tvar client *spamc.Client\n\tif strings.HasPrefix(service, \"unix:\") {\n\t\tclient = spamc.NewUnix(strings.TrimLeft(service, \"unix:\"))\n\t} else {\n\t\tclient = spamc.NewTCP(service, timeout)\n\t}\n\n\treturn client.Ping()\n}\n\n// Check will return a Result\nfunc Check(msg []byte) (Result, error) {\n\tr := Result{Score: 0}\n\n\tif service == \"\" {\n\t\treturn r, errors.New(\"no SpamAssassin service defined\")\n\t}\n\n\tif service == \"postmark\" {\n\t\tres, err := postmark.Check(msg, timeout)\n\t\tif err != nil {\n\t\t\tr.Error = err.Error()\n\t\t\treturn r, nil\n\t\t}\n\t\tresFloat, err := strconv.ParseFloat(res.Score, 32)\n\t\tif err == nil {\n\t\t\tr.Score = round1dm(resFloat)\n\t\t\tr.IsSpam = resFloat >= spamScore\n\t\t}\n\t\tr.Error = res.Message\n\t\tfor _, pr := range res.Rules {\n\t\t\trule := Rule{}\n\t\t\tvalue, err := strconv.ParseFloat(pr.Score, 32)\n\t\t\tif err == nil {\n\t\t\t\trule.Score = round1dm(value)\n\t\t\t}\n\t\t\trule.Name = pr.Name\n\t\t\trule.Description = pr.Description\n\t\t\tr.Rules = append(r.Rules, rule)\n\t\t}\n\t} else {\n\t\tvar client *spamc.Client\n\t\tif strings.HasPrefix(service, \"unix:\") {\n\t\t\tclient = spamc.NewUnix(strings.TrimLeft(service, \"unix:\"))\n\t\t} else {\n\t\t\tclient = spamc.NewTCP(service, timeout)\n\t\t}\n\n\t\tres, err := client.Report(msg)\n\t\tif err != nil {\n\t\t\tr.Error = err.Error()\n\t\t\treturn r, nil\n\t\t}\n\t\tr.IsSpam = res.Score >= spamScore\n\t\tr.Score = round1dm(res.Score)\n\t\tr.Rules = []Rule{}\n\t\tfor _, sr := range res.Rules {\n\t\t\trule := Rule{}\n\t\t\tvalue, err := strconv.ParseFloat(sr.Points, 32)\n\t\t\tif err == nil {\n\t\t\t\trule.Score = round1dm(value)\n\t\t\t}\n\t\t\trule.Name = sr.Name\n\t\t\trule.Description = sr.Description\n\t\t\tr.Rules = append(r.Rules, rule)\n\t\t}\n\t}\n\n\treturn r, nil\n}\n\n// Round to one decimal place\nfunc round1dm(n float64) float64 {\n\treturn math.Floor(n*10) / 10\n}\n"
  },
  {
    "path": "internal/spamassassin/spamc/spamc.go",
    "content": "// Package spamc provides a client for the SpamAssassin spamd protocol.\n// http://svn.apache.org/repos/asf/spamassassin/trunk/spamd/PROTOCOL\n//\n// Modified to add timeouts from https://github.com/cgt/spamc\npackage spamc\n\nimport (\n\t\"bufio\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/axllent/mailpit/internal/tools\"\n)\n\n// ProtoVersion is the protocol version\nconst ProtoVersion = \"1.5\"\n\nvar (\n\tspamInfoRe    = regexp.MustCompile(`(.+)\\/(.+) (\\d+) (.+)`)\n\tspamMainRe    = regexp.MustCompile(`^Spam: (.+) ; (.+) . (.+)$`)\n\tspamDetailsRe = regexp.MustCompile(`^\\s?(-?[0-9\\.]+)\\s([a-zA-Z0-9_]*)(\\W*)(.*)`)\n)\n\n// connection is like net.Conn except that it also has a CloseWrite method.\n// CloseWrite is implemented by net.TCPConn and net.UnixConn, but for some\n// reason it is not present in the net.Conn interface.\ntype connection interface {\n\tnet.Conn\n\tCloseWrite() error\n}\n\n// Client is a spamd client.\ntype Client struct {\n\tnet     string\n\taddr    string\n\ttimeout int\n}\n\n// NewTCP returns a *Client that connects to spamd via the given TCP address.\nfunc NewTCP(addr string, timeout int) *Client {\n\treturn &Client{\"tcp\", addr, timeout}\n}\n\n// NewUnix returns a *Client that connects to spamd via the given Unix socket.\nfunc NewUnix(addr string) *Client {\n\treturn &Client{\"unix\", addr, 0}\n}\n\n// Rule represents a matched SpamAssassin rule.\ntype Rule struct {\n\tPoints      string\n\tName        string\n\tDescription string\n}\n\n// Result struct\ntype Result struct {\n\tResponseCode int\n\tMessage      string\n\tSpam         bool\n\tScore        float64\n\tThreshold    float64\n\tRules        []Rule\n}\n\n// dial connects to spamd through TCP or a Unix socket.\nfunc (c *Client) dial() (connection, error) {\n\tswitch c.net {\n\tcase \"tcp\":\n\t\ttcpAddr, err := net.ResolveTCPAddr(\"tcp\", c.addr)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn net.DialTCP(\"tcp\", nil, tcpAddr)\n\tcase \"unix\":\n\t\tunixAddr, err := net.ResolveUnixAddr(\"unix\", c.addr)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn net.DialUnix(\"unix\", nil, unixAddr)\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported network type: %s\", c.net)\n\t}\n}\n\n// Report checks if message is spam or not, and returns score plus report\nfunc (c *Client) Report(email []byte) (Result, error) {\n\toutput, err := c.report(email)\n\tif err != nil {\n\t\treturn Result{}, err\n\t}\n\n\treturn c.parseOutput(output), nil\n}\n\nfunc (c *Client) report(email []byte) ([]string, error) {\n\tconn, err := c.dial()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdefer func() { _ = conn.Close() }()\n\n\tif err := conn.SetDeadline(time.Now().Add(time.Duration(c.timeout) * time.Second)); err != nil {\n\t\treturn nil, err\n\t}\n\n\tbw := bufio.NewWriter(conn)\n\tif _, err := bw.WriteString(\"REPORT SPAMC/\" + ProtoVersion + \"\\r\\n\"); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif _, err := bw.WriteString(\"Content-length: \" + strconv.Itoa(len(email)) + \"\\r\\n\\r\\n\"); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif _, err := bw.Write(email); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := bw.Flush(); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Client is supposed to close its writing side of the connection\n\t// after sending its request.\n\tif err := conn.CloseWrite(); err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar (\n\t\tlines []string\n\t\tbr    = bufio.NewReader(conn)\n\t)\n\n\tfor {\n\t\tline, err := br.ReadString('\\n')\n\t\tif err == io.EOF {\n\t\t\tbreak\n\t\t}\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tline = strings.TrimRight(line, \" \\t\\r\\n\")\n\t\tlines = append(lines, line)\n\t}\n\n\t// join lines, and replace multi-line descriptions with single line for each\n\ttmp := strings.Join(lines, \"\\n\")\n\tre := regexp.MustCompile(\"\\n                            \")\n\tn := re.ReplaceAllString(tmp, \" \")\n\n\t//split lines again\n\treturn strings.Split(n, \"\\n\"), nil\n}\n\nfunc (c *Client) parseOutput(output []string) Result {\n\tvar result Result\n\tvar reachedRules bool\n\tfor _, row := range output {\n\t\t// header\n\t\tif spamInfoRe.MatchString(row) {\n\t\t\tres := spamInfoRe.FindStringSubmatch(row)\n\t\t\tif len(res) == 5 {\n\t\t\t\tresCode, err := strconv.Atoi(res[3])\n\t\t\t\tif err == nil {\n\t\t\t\t\tresult.ResponseCode = resCode\n\t\t\t\t}\n\t\t\t\tresult.Message = res[4]\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\n\t\t// summary\n\t\tif spamMainRe.MatchString(row) {\n\t\t\tres := spamMainRe.FindStringSubmatch(row)\n\t\t\tif len(res) == 4 {\n\t\t\t\tif tools.InArray(res[1], []string{\"true\", \"yes\"}) {\n\t\t\t\t\tresult.Spam = true\n\t\t\t\t} else {\n\t\t\t\t\tresult.Spam = false\n\t\t\t\t}\n\t\t\t\tresFloat, err := strconv.ParseFloat(res[2], 32)\n\t\t\t\tif err == nil {\n\t\t\t\t\tresult.Score = resFloat\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tresFloat, err = strconv.ParseFloat(res[3], 32)\n\t\t\t\tif err == nil {\n\t\t\t\t\tresult.Threshold = resFloat\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif strings.HasPrefix(row, \"Content analysis details\") {\n\t\t\treachedRules = true\n\t\t\tcontinue\n\t\t}\n\n\t\t// details\n\t\tif reachedRules && spamDetailsRe.MatchString(row) {\n\t\t\tres := spamDetailsRe.FindStringSubmatch(row)\n\t\t\tif len(res) == 5 {\n\t\t\t\trule := Rule{Points: res[1], Name: res[2], Description: res[4]}\n\t\t\t\tresult.Rules = append(result.Rules, rule)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn result\n}\n\n// Ping the spamd\nfunc (c *Client) Ping() error {\n\tconn, err := c.dial()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer func() { _ = conn.Close() }()\n\n\tif err := conn.SetDeadline(time.Now().Add(time.Duration(c.timeout) * time.Second)); err != nil {\n\t\treturn err\n\t}\n\n\tif _, err := io.WriteString(conn, fmt.Sprintf(\"PING SPAMC/%s\\r\\n\\r\\n\", ProtoVersion)); err != nil {\n\t\treturn err\n\t}\n\n\tif err := conn.CloseWrite(); err != nil {\n\t\treturn err\n\t}\n\n\tbr := bufio.NewReader(conn)\n\tfor {\n\t\t_, err = br.ReadSlice('\\n')\n\t\tif err == io.EOF {\n\t\t\tbreak\n\t\t}\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/stats/stats.go",
    "content": "// Package stats stores and returns Mailpit statistics\npackage stats\n\nimport (\n\t\"runtime\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/axllent/mailpit/internal/storage\"\n\t\"github.com/axllent/mailpit/internal/tools\"\n)\n\n// Stores cached version  along with its expiry time and error count.\n// Used to minimize repeated version lookups and track consecutive errors.\ntype versionCache struct {\n\t// github version string\n\tvalue string\n\t// time to expire the cache\n\texpiry time.Time\n\t// count of consecutive errors\n\terrCount int\n}\n\nvar (\n\t// Version cache storing the latest GitHub version\n\tvCache versionCache\n\n\t// StartedAt is set to the current ime when Mailpit starts\n\tstartedAt time.Time\n\n\t// sync mutex to prevent race condition with simultaneous requests\n\tmu sync.RWMutex\n\n\tsmtpAccepted     uint64\n\tsmtpAcceptedSize uint64\n\tsmtpRejected     uint64\n\tsmtpIgnored      uint64\n)\n\n// AppInformation struct\n// swagger:model AppInformation\ntype AppInformation struct {\n\t// Current Mailpit version\n\tVersion string\n\t// Latest Mailpit version\n\tLatestVersion string\n\t// Database path\n\tDatabase string\n\t// Database size in bytes\n\tDatabaseSize uint64\n\t// Total number of messages in the database\n\tMessages uint64\n\t// Total number of messages in the database\n\tUnread uint64\n\t// Tags and message totals per tag\n\tTags map[string]int64\n\t// Runtime statistics\n\tRuntimeStats struct {\n\t\t// Mailpit server uptime in seconds\n\t\tUptime uint64\n\t\t// Current memory usage in bytes\n\t\tMemory uint64\n\t\t// Database runtime messages deleted\n\t\tMessagesDeleted uint64\n\t\t// Accepted runtime SMTP messages\n\t\tSMTPAccepted uint64\n\t\t// Total runtime accepted messages size in bytes\n\t\tSMTPAcceptedSize uint64\n\t\t// Rejected runtime SMTP messages\n\t\tSMTPRejected uint64\n\t\t// Ignored runtime SMTP messages (when using --ignore-duplicate-ids)\n\t\tSMTPIgnored uint64\n\t}\n}\n\n// Calculates exponential backoff duration based on the error count.\nfunc getBackoff(errCount int) time.Duration {\n\tbackoff := min(time.Duration(1<<errCount)*time.Minute, 30*time.Minute)\n\treturn backoff\n}\n\n// Load the current statistics\nfunc Load(detectLatestVersion bool) AppInformation {\n\tinfo := AppInformation{}\n\tinfo.Version = config.Version\n\n\tvar m runtime.MemStats\n\truntime.ReadMemStats(&m)\n\n\tinfo.RuntimeStats.Memory = m.Sys - m.HeapReleased\n\tinfo.RuntimeStats.Uptime = uint64(time.Since(startedAt).Seconds())\n\tinfo.RuntimeStats.MessagesDeleted = storage.StatsDeleted\n\tinfo.RuntimeStats.SMTPAccepted = smtpAccepted\n\tinfo.RuntimeStats.SMTPAcceptedSize = smtpAcceptedSize\n\tinfo.RuntimeStats.SMTPRejected = smtpRejected\n\tinfo.RuntimeStats.SMTPIgnored = smtpIgnored\n\n\tif config.DisableVersionCheck {\n\t\tinfo.LatestVersion = \"disabled\"\n\t} else if detectLatestVersion {\n\t\tmu.RLock()\n\t\tcacheValid := time.Now().Before(vCache.expiry)\n\t\tcacheValue := vCache.value\n\t\tmu.RUnlock()\n\n\t\tif cacheValid {\n\t\t\tinfo.LatestVersion = cacheValue\n\t\t} else {\n\t\t\tmu.Lock()\n\t\t\t// Re-check after acquiring write lock in case another goroutine refreshed it\n\t\t\tif time.Now().Before(vCache.expiry) {\n\t\t\t\tinfo.LatestVersion = vCache.value\n\t\t\t} else {\n\t\t\t\tlatest, err := config.GHRUConfig.Latest()\n\t\t\t\tif err == nil {\n\t\t\t\t\tvCache = versionCache{value: latest.Tag, expiry: time.Now().Add(15 * time.Minute)}\n\t\t\t\t\tinfo.LatestVersion = latest.Tag\n\t\t\t\t} else {\n\t\t\t\t\tlogger.Log().Errorf(\"Failed to fetch latest version: %v\", err)\n\t\t\t\t\tvCache.errCount++\n\t\t\t\t\tvCache.value = \"\"\n\t\t\t\t\tvCache.expiry = time.Now().Add(getBackoff(vCache.errCount))\n\t\t\t\t\tinfo.LatestVersion = \"\"\n\t\t\t\t}\n\t\t\t}\n\t\t\tmu.Unlock()\n\t\t}\n\t}\n\n\tinfo.Database = config.Database\n\tinfo.DatabaseSize = storage.DbSize()\n\tinfo.Messages = storage.CountTotal()\n\tinfo.Unread = storage.CountUnread()\n\tinfo.Tags = storage.GetAllTagsCount()\n\n\treturn info\n}\n\n// Track will start the statistics logging in memory\nfunc Track() {\n\tstartedAt = time.Now()\n}\n\n// LogSMTPAccepted logs a successful SMTP transaction\nfunc LogSMTPAccepted(size int) {\n\tmu.Lock()\n\tsmtpAccepted = smtpAccepted + 1\n\tsmtpAcceptedSize = smtpAcceptedSize + tools.SafeUint64(size)\n\tmu.Unlock()\n}\n\n// LogSMTPRejected logs a rejected SMTP transaction\nfunc LogSMTPRejected() {\n\tmu.Lock()\n\tsmtpRejected = smtpRejected + 1\n\tmu.Unlock()\n}\n\n// LogSMTPIgnored logs an ignored SMTP transaction\nfunc LogSMTPIgnored() {\n\tmu.Lock()\n\tsmtpIgnored = smtpIgnored + 1\n\tmu.Unlock()\n}\n"
  },
  {
    "path": "internal/storage/cron.go",
    "content": "package storage\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"math\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/axllent/mailpit/internal/tools\"\n\t\"github.com/axllent/mailpit/server/websockets\"\n\t\"github.com/leporo/sqlf\"\n)\n\n// Database cron runs every minute\nfunc dbCron() {\n\tfor {\n\t\ttime.Sleep(60 * time.Second)\n\n\t\tcurrentTime := time.Now()\n\t\tsinceLastDbAction := currentTime.Sub(dbLastAction)\n\n\t\t// only run the database has been idle for 5 minutes\n\t\tif math.Floor(sinceLastDbAction.Minutes()) == 5 {\n\t\t\tdeletedSize := getDeletedSize()\n\n\t\t\tif deletedSize > 0 {\n\t\t\t\ttotal := totalMessagesSize()\n\t\t\t\tvar deletedPercent float64\n\t\t\t\tif total == 0 {\n\t\t\t\t\tdeletedPercent = 100\n\t\t\t\t} else {\n\t\t\t\t\tdeletedPercent = float64(deletedSize * 100 / total)\n\t\t\t\t}\n\t\t\t\t// only vacuum the DB if at least 1% of mail storage size has been deleted\n\t\t\t\tif deletedPercent >= 1 {\n\t\t\t\t\tlogger.Log().Debugf(\"[db] deleted messages is %f%% of total size, reclaim space\", deletedPercent)\n\t\t\t\t\tvacuumDb()\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tpruneMessages()\n\t}\n}\n\n// PruneMessages will auto-delete the oldest messages if messages > config.MaxMessages.\n// Set config.MaxMessages to 0 to disable.\nfunc pruneMessages() {\n\tif config.MaxMessages < 1 && config.MaxAgeInHours == 0 {\n\t\treturn\n\t}\n\n\tstart := time.Now()\n\n\tids := []string{}\n\tvar prunedSize uint64\n\tvar size float64 // use float64 for rqlite compatibility\n\n\t// prune using `--max` if set\n\tif config.MaxMessages > 0 && CountTotal() > uint64(config.MaxMessages) {\n\t\toffset := config.MaxMessages\n\t\tif config.DemoMode {\n\t\t\toffset = 500\n\t\t}\n\t\tq := sqlf.Select(\"ID, Size\").\n\t\t\tFrom(tenant(\"mailbox\")).\n\t\t\tOrderBy(\"Created DESC\").\n\t\t\tLimit(5000).\n\t\t\tOffset(offset)\n\n\t\tif err := q.QueryAndClose(\n\t\t\tcontext.TODO(), db, func(row *sql.Rows) {\n\t\t\t\tvar id string\n\n\t\t\t\tif err := row.Scan(&id, &size); err != nil {\n\t\t\t\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tids = append(ids, id)\n\t\t\t\tprunedSize = prunedSize + uint64(size)\n\n\t\t\t},\n\t\t); err != nil {\n\t\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\t// prune using `--max-age` if set\n\tif config.MaxAgeInHours > 0 {\n\t\t// now() minus the number of hours\n\t\tts := time.Now().Add(time.Duration(-config.MaxAgeInHours) * time.Hour).UnixMilli()\n\n\t\tq := sqlf.Select(\"ID, Size\").\n\t\t\tFrom(tenant(\"mailbox\")).\n\t\t\tWhere(\"Created < ?\", ts).\n\t\t\tLimit(5000)\n\n\t\tif err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {\n\t\t\tvar id string\n\n\t\t\tif err := row.Scan(&id, &size); err != nil {\n\t\t\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif !tools.InArray(id, ids) {\n\t\t\t\tids = append(ids, id)\n\t\t\t\tprunedSize = prunedSize + uint64(size)\n\t\t\t}\n\n\t\t}); err != nil {\n\t\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tif len(ids) == 0 {\n\t\treturn\n\t}\n\n\ttx, err := db.BeginTx(context.Background(), nil)\n\tif err != nil {\n\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t\treturn\n\t}\n\n\t// roll back if it fails\n\tdefer func() { _ = tx.Rollback() }()\n\n\targs := make([]any, len(ids))\n\tfor i, id := range ids {\n\t\targs[i] = id\n\t}\n\n\t_, err = tx.Exec(`DELETE FROM `+tenant(\"mailbox_data\")+` WHERE ID IN (?`+strings.Repeat(\",?\", len(ids)-1)+`)`, args...) // #nosec\n\tif err != nil {\n\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t\treturn\n\t}\n\n\t_, err = tx.Exec(`DELETE FROM `+tenant(\"message_tags\")+` WHERE ID IN (?`+strings.Repeat(\",?\", len(ids)-1)+`)`, args...) // #nosec\n\tif err != nil {\n\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t\treturn\n\t}\n\n\t_, err = tx.Exec(`DELETE FROM `+tenant(\"mailbox\")+` WHERE ID IN (?`+strings.Repeat(\",?\", len(ids)-1)+`)`, args...) // #nosec\n\tif err != nil {\n\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t\treturn\n\t}\n\n\tif err = tx.Commit(); err != nil {\n\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t}\n\n\tif err := pruneUnusedTags(); err != nil {\n\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t}\n\n\taddDeletedSize(prunedSize)\n\tdbLastAction = time.Now()\n\n\telapsed := time.Since(start)\n\tlogger.Log().Debugf(\"[db] auto-pruned %d messages in %s\", len(ids), elapsed)\n\n\tlogMessagesDeleted(len(ids))\n\n\tif config.DemoMode {\n\t\tvacuumDb()\n\t}\n\n\twebsockets.Broadcast(\"prune\", nil)\n}\n\n// Vacuum the database to reclaim space from deleted messages\nfunc vacuumDb() {\n\tif sqlDriver == \"rqlite\" {\n\t\t// let rqlite handle vacuuming\n\t\treturn\n\t}\n\n\tstart := time.Now()\n\n\t// set WAL file checkpoint\n\tif _, err := db.Exec(\"PRAGMA wal_checkpoint\"); err != nil {\n\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t\treturn\n\t}\n\n\t// vacuum database\n\tif _, err := db.Exec(\"VACUUM\"); err != nil {\n\t\tlogger.Log().Errorf(\"[db] VACUUM: %s\", err.Error())\n\t\treturn\n\t}\n\n\t// truncate WAL file\n\tif _, err := db.Exec(\"PRAGMA wal_checkpoint(TRUNCATE)\"); err != nil {\n\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t\treturn\n\t}\n\n\tif err := SettingPut(\"DeletedSize\", \"0\"); err != nil {\n\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t}\n\n\telapsed := time.Since(start)\n\tlogger.Log().Debugf(\"[db] vacuum completed in %s\", elapsed)\n}\n"
  },
  {
    "path": "internal/storage/database.go",
    "content": "// Package storage handles all database actions\npackage storage\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/signal\"\n\t\"path\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/klauspost/compress/zstd\"\n\t\"github.com/leporo/sqlf\"\n\n\t// sqlite - https://gitlab.com/cznic/sqlite\n\t_ \"modernc.org/sqlite\"\n\n\t// rqlite - https://github.com/rqlite/gorqlite | https://rqlite.io/\n\t_ \"github.com/rqlite/gorqlite/stdlib\"\n)\n\nvar (\n\tdb           *sql.DB\n\tsqlDriver    string\n\tdbLastAction time.Time\n\n\t// zstd compression encoder & decoder\n\tdbEncoder    *zstd.Encoder\n\tdbDecoder, _ = zstd.NewReader(nil)\n\n\ttemporaryFiles = []string{}\n)\n\n// InitDB will initialise the database\nfunc InitDB() error {\n\t// dbEncoder\n\tvar (\n\t\tdsn string\n\t\terr error\n\t)\n\n\tif config.Compression > 0 {\n\t\tvar compression zstd.EncoderLevel\n\t\tswitch config.Compression {\n\t\tcase 1:\n\t\t\tcompression = zstd.SpeedFastest\n\t\tcase 2:\n\t\t\tcompression = zstd.SpeedDefault\n\t\tcase 3:\n\t\t\tcompression = zstd.SpeedBestCompression\n\t\t}\n\t\tdbEncoder, err = zstd.NewWriter(nil, zstd.WithEncoderLevel(compression))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tlogger.Log().Debugf(\"[db] storing messages with compression: %s\", compression.String())\n\t} else {\n\t\tlogger.Log().Debug(\"[db] storing messages with no compression\")\n\t}\n\n\tp := config.Database\n\n\tif p == \"\" {\n\t\t// when no path is provided then we create a temporary file\n\t\t// which will get deleted on Close(), SIGINT or SIGTERM\n\t\tp = fmt.Sprintf(\"%s-%d.db\", path.Join(os.TempDir(), \"mailpit\"), time.Now().UnixNano())\n\t\t// delete the Unix socket file on exit\n\t\tAddTempFile(p)\n\t\tsqlDriver = \"sqlite\"\n\t\tdsn = p\n\t\tlogger.Log().Debugf(\"[db] using temporary database: %s\", p)\n\t} else if strings.HasPrefix(p, \"http://\") || strings.HasPrefix(p, \"https://\") {\n\t\tsqlDriver = \"rqlite\"\n\t\tdsn = p\n\t\tlogger.Log().Debugf(\"[db] opening rqlite database %s\", p)\n\t} else {\n\t\tp = filepath.Clean(p)\n\t\tsqlDriver = \"sqlite\"\n\t\tdsn = fmt.Sprintf(\"file:%s?cache=shared\", p)\n\t\tlogger.Log().Debugf(\"[db] opening database %s\", p)\n\t}\n\n\tconfig.Database = p\n\n\tif sqlDriver == \"sqlite\" {\n\t\tif !isFile(p) {\n\t\t\t// try create a file to ensure permissions\n\t\t\tf, err := os.Create(p)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"[db] %s\", err.Error())\n\t\t\t}\n\t\t\t_ = f.Close()\n\t\t}\n\t}\n\n\tdb, err = sql.Open(sqlDriver, dsn)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor i := 1; i < 6; i++ {\n\t\tif err := Ping(); err != nil {\n\t\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t\t\tlogger.Log().Infof(\"[db] reconnecting in 5 seconds (attempt %d/5)\", i)\n\t\t\ttime.Sleep(5 * time.Second)\n\t\t} else {\n\t\t\tbreak\n\t\t}\n\t}\n\n\t// prevent \"database locked\" errors\n\t// @see https://github.com/mattn/go-sqlite3#faq\n\tdb.SetMaxOpenConns(1)\n\n\tif sqlDriver == \"sqlite\" {\n\t\tif config.DisableWAL {\n\t\t\t// disable WAL mode for SQLite, allows NFS mounted DBs\n\t\t\t_, err = db.Exec(\"PRAGMA journal_mode=DELETE; PRAGMA synchronous=NORMAL;\")\n\t\t} else {\n\t\t\t// SQLite performance tuning (https://phiresky.github.io/blog/2020/sqlite-performance-tuning/)\n\t\t\t_, err = db.Exec(\"PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;\")\n\t\t}\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// create tables if necessary & apply migrations\n\tif err := dbApplySchemas(); err != nil {\n\t\treturn err\n\t}\n\n\tLoadTagFilters()\n\n\tdbLastAction = time.Now()\n\n\tsigs := make(chan os.Signal, 1)\n\t// catch all signals since not explicitly listing\n\t// Program that will listen to the SIGINT and SIGTERM\n\t// SIGINT will listen to CTRL-C.\n\t// SIGTERM will be caught if kill command executed\n\tsignal.Notify(sigs, os.Interrupt, syscall.SIGTERM)\n\t// method invoked upon seeing signal\n\tgo func() {\n\t\ts := <-sigs\n\t\tfmt.Printf(\"[db] got %s signal, shutting down\\n\", s)\n\t\tClose()\n\t\tos.Exit(0)\n\t}()\n\n\t// auto-prune & delete\n\tgo dbCron()\n\n\tgo dataMigrations()\n\n\treturn nil\n}\n\n// Tenant applies an optional prefix to the table name\nfunc tenant(table string) string {\n\treturn fmt.Sprintf(\"%s%s\", config.TenantID, table)\n}\n\n// Close will close the database, and delete if temporary\nfunc Close() {\n\t// on a fatal exit (eg: ports blocked), allow Mailpit to run migration tasks before closing the DB\n\ttime.Sleep(200 * time.Millisecond)\n\n\tif db != nil {\n\t\tif err := db.Close(); err != nil {\n\t\t\tlogger.Log().Warn(\"[db] error closing database, ignoring\")\n\t\t}\n\t}\n\n\t// allow SQLite to finish closing DB & write WAL logs if local\n\ttime.Sleep(100 * time.Millisecond)\n\n\t// delete all temporary files\n\tdeleteTempFiles()\n}\n\n// Ping the database connection and return an error if unsuccessful\nfunc Ping() error {\n\treturn db.Ping()\n}\n\n// StatsGet returns the total/unread statistics for a mailbox\nfunc StatsGet() MailboxStats {\n\tvar (\n\t\ttotal  = CountTotal()\n\t\tunread = CountUnread()\n\t\ttags   = GetAllTags()\n\t)\n\n\tdbLastAction = time.Now()\n\n\treturn MailboxStats{\n\t\tTotal:  total,\n\t\tUnread: unread,\n\t\tTags:   tags,\n\t}\n}\n\n// CountTotal returns the number of emails in the database\nfunc CountTotal() uint64 {\n\tvar total float64 // use float64 for rqlite compatibility\n\n\t_ = sqlf.From(tenant(\"mailbox\")).\n\t\tSelect(\"COUNT(*)\").To(&total).\n\t\tQueryRowAndClose(context.TODO(), db)\n\n\treturn uint64(total)\n}\n\n// CountUnread returns the number of emails in the database that are unread.\nfunc CountUnread() uint64 {\n\tvar total float64 // use float64 for rqlite compatibility\n\n\t_ = sqlf.From(tenant(\"mailbox\")).\n\t\tSelect(\"COUNT(*)\").To(&total).\n\t\tWhere(\"Read = ?\", 0).\n\t\tQueryRowAndClose(context.TODO(), db)\n\n\treturn uint64(total)\n}\n\n// CountRead returns the number of emails in the database that are read.\nfunc CountRead() uint64 {\n\tvar total float64 // use float64 for rqlite compatibility\n\n\t_ = sqlf.From(tenant(\"mailbox\")).\n\t\tSelect(\"COUNT(*)\").To(&total).\n\t\tWhere(\"Read = ?\", 1).\n\t\tQueryRowAndClose(context.TODO(), db)\n\n\treturn uint64(total)\n}\n\n// DbSize returns the size of the SQLite database.\nfunc DbSize() uint64 {\n\tvar total sql.NullFloat64 // use float64 for rqlite compatibility\n\n\terr := db.QueryRow(\"SELECT page_count * page_size AS size FROM pragma_page_count(), pragma_page_size()\").Scan(&total)\n\n\tif err != nil {\n\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t}\n\n\treturn uint64(total.Float64)\n}\n\n// MessageIDExists checks whether a Message-ID exists in the DB\nfunc MessageIDExists(id string) bool {\n\tvar total int\n\n\t_ = sqlf.From(tenant(\"mailbox\")).\n\t\tSelect(\"COUNT(*)\").To(&total).\n\t\tWhere(\"MessageID = ?\", id).\n\t\tQueryRowAndClose(context.TODO(), db)\n\n\treturn total != 0\n}\n"
  },
  {
    "path": "internal/storage/functions_test.go",
    "content": "package storage\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n)\n\nvar (\n\ttestTextEmail []byte\n\ttestTagEmail  []byte\n\ttestMimeEmail []byte\n\ttestRuns      = 100\n)\n\nfunc setup(tenantID string) {\n\tlogger.NoLogging = true\n\tconfig.MaxMessages = 0\n\tconfig.Database = os.Getenv(\"MP_DATABASE\")\n\tconfig.TenantID = config.DBTenantID(tenantID)\n\n\tif err := InitDB(); err != nil {\n\t\tpanic(err)\n\t}\n\n\tvar err error\n\n\t// ensure DB is empty\n\tif err := DeleteAllMessages(); err != nil {\n\t\tpanic(err)\n\t}\n\n\ttestTextEmail, err = os.ReadFile(\"testdata/plain-text.eml\")\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\ttestTagEmail, err = os.ReadFile(\"testdata/tags.eml\")\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\ttestMimeEmail, err = os.ReadFile(\"testdata/mime-attachment.eml\")\n\tif err != nil {\n\t\tpanic(err)\n\t}\n}\n\nfunc assertEqual(t *testing.T, a any, b any, message string) {\n\tif a == b {\n\t\treturn\n\t}\n\tmessage = fmt.Sprintf(\"%s: \\\"%v\\\" != \\\"%v\\\"\", message, a, b)\n\tt.Fatal(message)\n}\n\nfunc assertEqualStats(t *testing.T, total int, unread int) {\n\ts := StatsGet()\n\tif uint64(total) != s.Total {\n\t\tt.Fatalf(\"Incorrect total mailbox stats: \\\"%v\\\" != \\\"%v\\\"\", total, s.Total)\n\t}\n\n\tif uint64(unread) != s.Unread {\n\t\tt.Fatalf(\"Incorrect unread mailbox stats: \\\"%v\\\" != \\\"%v\\\"\", unread, s.Unread)\n\t}\n}\n"
  },
  {
    "path": "internal/storage/messages.go",
    "content": "package storage\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/md5\"  // #nosec\n\t\"crypto/sha1\" // #nosec\n\t\"crypto/sha256\"\n\t\"database/sql\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/mail\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/axllent/mailpit/internal/tools\"\n\t\"github.com/axllent/mailpit/server/webhook\"\n\t\"github.com/axllent/mailpit/server/websockets\"\n\t\"github.com/jhillyerd/enmime/v2\"\n\t\"github.com/leporo/sqlf\"\n\t\"github.com/lithammer/shortuuid/v4\"\n)\n\n// Store will save an email to the database tables.\n// The username is the authentication username of either the SMTP or HTTP client (blank for none).\n// Returns the database ID of the saved message.\nfunc Store(body *[]byte, username *string) (string, error) {\n\tparser := enmime.NewParser(enmime.DisableCharacterDetection(true))\n\n\t// Parse message body with enmime\n\tenv, err := parser.ReadEnvelope(bytes.NewReader(*body))\n\tif err != nil {\n\t\tlogger.Log().Warnf(\"[message] %s\", err.Error())\n\t\treturn \"\", nil\n\t}\n\n\tfrom := &mail.Address{}\n\tfromJSON := addressToSlice(env, \"From\")\n\tif len(fromJSON) > 0 {\n\t\tfrom = fromJSON[0]\n\t} else if env.GetHeader(\"From\") != \"\" {\n\t\tfrom = &mail.Address{Name: env.GetHeader(\"From\")}\n\t}\n\n\tobj := Metadata{\n\t\tFrom:    from,\n\t\tTo:      addressToSlice(env, \"To\"),\n\t\tCc:      addressToSlice(env, \"Cc\"),\n\t\tBcc:     addressToSlice(env, \"Bcc\"),\n\t\tReplyTo: addressToSlice(env, \"Reply-To\"),\n\t}\n\tif username != nil {\n\t\tobj.Username = *username\n\t}\n\n\tmessageID := strings.Trim(env.GetHeader(\"Message-ID\"), \"<>\")\n\tcreated := time.Now()\n\n\t// use message date instead of created date\n\tif config.UseMessageDates {\n\t\tif mDate, err := env.Date(); err == nil {\n\t\t\tcreated = mDate\n\t\t}\n\t}\n\n\t// generate the search text\n\tsearchText := createSearchText(env)\n\n\t// generate unique ID\n\tid := shortuuid.New()\n\n\tsummaryJSON, err := json.Marshal(obj)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// begin a transaction to ensure both the message\n\t// and data are stored successfully\n\tctx := context.Background()\n\ttx, err := db.BeginTx(ctx, nil)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// roll back if it fails\n\tdefer func() { _ = tx.Rollback() }()\n\n\tsubject := env.GetHeader(\"Subject\")\n\tsize := uint64(len(*body))\n\tinline := len(env.Inlines)\n\tattachments := len(env.Attachments)\n\tsnippet := tools.CreateSnippet(env.Text, env.HTML)\n\n\tsql := fmt.Sprintf(`INSERT INTO %s \n    \t(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Snippet) \n\t    VALUES(?,?,?,?,?,?,?,?,?,0,?)`,\n\t\ttenant(\"mailbox\"),\n\t) // #nosec\n\n\t// insert mail summary data\n\t_, err = tx.Exec(sql, created.UnixMilli(), id, messageID, subject, string(summaryJSON), size, inline, attachments, searchText, snippet)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tif config.Compression > 0 {\n\t\t// insert compressed raw message\n\t\tcompressed := dbEncoder.EncodeAll(*body, make([]byte, 0, size))\n\n\t\tif sqlDriver == \"rqlite\" {\n\t\t\t// rqlite does not support binary data in query, so we need to encode the compressed message into hexadecimal\n\t\t\t// string and then generate the SQL query, which is more memory intensive, especially with large messages\n\t\t\thexStr := hex.EncodeToString(compressed)\n\t\t\t_, err = tx.Exec(fmt.Sprintf(`INSERT INTO %s (ID, Email, Compressed) VALUES(?, x'%s', 1)`, tenant(\"mailbox_data\"), hexStr), id) // #nosec\n\t\t} else {\n\t\t\t_, err = tx.Exec(fmt.Sprintf(`INSERT INTO %s (ID, Email, Compressed) VALUES(?, ?, 1)`, tenant(\"mailbox_data\")), id, compressed) // #nosec\n\t\t}\n\t} else {\n\t\t// insert uncompressed raw message\n\t\t_, err = tx.Exec(fmt.Sprintf(`INSERT INTO %s (ID, Email, Compressed) VALUES(?, ?, 0)`, tenant(\"mailbox_data\")), id, string(*body)) // #nosec\n\t}\n\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tif err := tx.Commit(); err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// extract tags using pre-set tag filters, empty slice if not set\n\ttags := findTagsInRawMessage(body)\n\n\tif !config.TagsDisableXTags {\n\t\txTagsHdr := env.GetHeader(\"X-Tags\")\n\t\tif xTagsHdr != \"\" {\n\t\t\t// extract tags from X-Tags header\n\t\t\ttags = append(tags, tools.SetTagCasing(strings.Split(strings.TrimSpace(xTagsHdr), \",\"))...)\n\t\t}\n\t}\n\n\tif !config.TagsDisablePlus {\n\t\t// get tags from plus-addresses\n\t\ttags = append(tags, obj.tagsFromPlusAddresses()...)\n\t}\n\n\t// auto-tag by username if enabled\n\tif config.TagsUsername && username != nil && *username != \"\" {\n\t\ttags = append(tags, *username)\n\t}\n\n\t// extract tags from search matches, and sort and extract unique tags\n\ttags = sortedUniqueTags(append(tags, tagFilterMatches(id)...))\n\n\tsetTags := []string{}\n\tif len(tags) > 0 {\n\t\tsetTags, err = SetMessageTags(id, tags)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t}\n\n\tc := &MessageSummary{}\n\tif err := json.Unmarshal(summaryJSON, c); err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// we do not want to to broadcast null values for MetaData else this does not align\n\t// with the message summary documented in the API docs, so we set them to empty slices.\n\tif c.From == nil {\n\t\tc.From = &mail.Address{}\n\t}\n\tif c.To == nil {\n\t\tc.To = []*mail.Address{}\n\t}\n\tif c.Cc == nil {\n\t\tc.Cc = []*mail.Address{}\n\t}\n\tif c.Bcc == nil {\n\t\tc.Bcc = []*mail.Address{}\n\t}\n\tif c.ReplyTo == nil {\n\t\tc.ReplyTo = []*mail.Address{}\n\t}\n\n\tc.Created = created\n\tc.ID = id\n\tc.MessageID = messageID\n\tc.Attachments = attachments\n\tc.Subject = subject\n\tc.Size = size\n\tc.Tags = setTags\n\tc.Snippet = snippet\n\n\twebsockets.Broadcast(\"new\", c)\n\twebhook.Send(c)\n\n\tdbLastAction = time.Now()\n\n\tBroadcastMailboxStats()\n\n\tlogger.Log().Debugf(\"[db] saved message %s (%d bytes)\", id, size)\n\n\treturn id, nil\n}\n\n// List returns a subset of messages from the mailbox,\n// sorted latest to oldest\nfunc List(start int, beforeTS int64, limit int) ([]MessageSummary, error) {\n\tresults := []MessageSummary{}\n\ttsStart := time.Now()\n\n\tq := sqlf.From(tenant(\"mailbox\") + \" m\").\n\t\tSelect(`m.Created, m.ID, m.MessageID, m.Subject, m.Metadata, m.Size, m.Attachments, m.Read, m.Snippet`).\n\t\tOrderBy(\"m.Created DESC\")\n\n\tif limit > 0 {\n\t\tq = q.Limit(limit).Offset(start)\n\t}\n\n\tif beforeTS > 0 {\n\t\tq = q.Where(\"Created < ?\", beforeTS)\n\t}\n\n\tif err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {\n\t\tvar created float64 // use float64 for rqlite compatibility\n\t\tvar id string\n\t\tvar messageID string\n\t\tvar subject string\n\t\tvar metadataJSON string\n\t\tvar size float64 // use float64 for rqlite compatibility\n\t\tvar attachments int\n\t\tvar read int\n\t\tvar snippet string\n\t\tem := MessageSummary{}\n\t\tvar meta Metadata\n\n\t\terr := row.Scan(&created, &id, &messageID, &subject, &metadataJSON, &size, &attachments, &read, &snippet)\n\t\tif err != nil {\n\t\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t\t\treturn\n\t\t}\n\n\t\tif err := json.Unmarshal([]byte(metadataJSON), &meta); err != nil {\n\t\t\tlogger.Log().Errorf(\"[json] %s\", err.Error())\n\t\t\treturn\n\t\t}\n\n\t\tem.From = meta.From\n\t\tem.To = meta.To\n\t\tem.Cc = meta.Cc\n\t\tem.Bcc = meta.Bcc\n\t\tem.ReplyTo = meta.ReplyTo\n\t\tem.Username = meta.Username\n\n\t\tem.Created = time.UnixMilli(int64(created))\n\t\tem.ID = id\n\t\tem.MessageID = messageID\n\t\tem.Subject = subject\n\t\tem.Size = uint64(size)\n\t\tem.Attachments = attachments\n\t\tem.Read = read == 1\n\t\tem.Snippet = snippet\n\t\t// artificially generate ReplyTo if legacy data is missing Reply-To field\n\t\tif em.ReplyTo == nil {\n\t\t\tem.ReplyTo = []*mail.Address{}\n\t\t}\n\n\t\tresults = append(results, em)\n\t}); err != nil {\n\t\treturn results, err\n\t}\n\n\t// set tags for listed messages only\n\tfor i, m := range results {\n\t\tresults[i].Tags = getMessageTags(m.ID)\n\t}\n\n\tdbLastAction = time.Now()\n\n\telapsed := time.Since(tsStart)\n\n\tlogger.Log().Debugf(\"[db] list INBOX in %s\", elapsed)\n\n\treturn results, nil\n}\n\n// GetMessage returns a Message generated from the mailbox_data collection.\n// If the message lacks a date header, then the received datetime is used.\nfunc GetMessage(id string) (*Message, error) {\n\traw, err := GetMessageRaw(id)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tr := bytes.NewReader(raw)\n\n\tparser := enmime.NewParser(enmime.DisableCharacterDetection(true))\n\n\tenv, err := parser.ReadEnvelope(r)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Load metadata from DB\n\tmeta, err := GetMetadata(id)\n\tif err != nil {\n\t\tmeta = Metadata{}\n\t}\n\n\tfrom := meta.From\n\tif from == nil {\n\t\tfromData := addressToSlice(env, \"From\")\n\t\tif len(fromData) > 0 {\n\t\t\tfrom = fromData[0]\n\t\t} else if env.GetHeader(\"From\") != \"\" {\n\t\t\tfrom = &mail.Address{Name: env.GetHeader(\"From\")}\n\t\t}\n\t}\n\n\tmessageID := strings.Trim(env.GetHeader(\"Message-ID\"), \"<>\")\n\n\treturnPath := strings.Trim(env.GetHeader(\"Return-Path\"), \"<>\")\n\tif returnPath == \"\" && from != nil {\n\t\treturnPath = from.Address\n\t}\n\n\tdate, err := env.Date()\n\tif err != nil {\n\t\t// return received datetime when message does not contain a date header\n\t\tq := sqlf.From(tenant(\"mailbox\")).\n\t\t\tSelect(`Created`).\n\t\t\tWhere(`ID = ?`, id)\n\n\t\tif err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {\n\t\t\tvar created float64 // use float64 for rqlite compatibility\n\n\t\t\tif err := row.Scan(&created); err != nil {\n\t\t\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tlogger.Log().Debugf(\"[db] %s does not contain a date header, using received datetime\", id)\n\t\t\tdate = time.UnixMilli(int64(created))\n\t\t}); err != nil {\n\t\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t\t}\n\t}\n\n\tobj := Message{\n\t\tID:         id,\n\t\tMessageID:  messageID,\n\t\tFrom:       from,\n\t\tDate:       date,\n\t\tTo:         addressToSlice(env, \"To\"),\n\t\tCc:         addressToSlice(env, \"Cc\"),\n\t\tBcc:        addressToSlice(env, \"Bcc\"),\n\t\tReplyTo:    addressToSlice(env, \"Reply-To\"),\n\t\tReturnPath: returnPath,\n\t\tSubject:    env.GetHeader(\"Subject\"),\n\t\tTags:       getMessageTags(id),\n\t\tSize:       uint64(len(raw)),\n\t\tText:       env.Text,\n\t\tUsername:   meta.Username,\n\t}\n\tobj.HTML = env.HTML\n\tobj.Inline = []Attachment{}\n\tobj.Attachments = []Attachment{}\n\n\tfor _, i := range env.Inlines {\n\t\tif i.FileName != \"\" || i.ContentID != \"\" {\n\t\t\tobj.Inline = append(obj.Inline, AttachmentSummary(i))\n\t\t}\n\t}\n\n\tfor _, i := range env.OtherParts {\n\t\tif i.FileName != \"\" || i.ContentID != \"\" {\n\t\t\tobj.Inline = append(obj.Inline, AttachmentSummary(i))\n\t\t}\n\t}\n\n\tfor _, a := range env.Attachments {\n\t\tif a.FileName != \"\" || a.ContentID != \"\" {\n\t\t\tobj.Attachments = append(obj.Attachments, AttachmentSummary(a))\n\t\t}\n\t}\n\n\t// get List-Unsubscribe links if set\n\tobj.ListUnsubscribe = ListUnsubscribe{}\n\tobj.ListUnsubscribe.Links = []string{}\n\tif env.GetHeader(\"List-Unsubscribe\") != \"\" {\n\t\tl := env.GetHeader(\"List-Unsubscribe\")\n\t\tlinks, err := tools.ListUnsubscribeParser(l)\n\t\tobj.ListUnsubscribe.Header = l\n\t\tobj.ListUnsubscribe.Links = links\n\t\tif err != nil {\n\t\t\tobj.ListUnsubscribe.Errors = err.Error()\n\t\t}\n\t\tobj.ListUnsubscribe.HeaderPost = env.GetHeader(\"List-Unsubscribe-Post\")\n\t}\n\n\t// mark message as read\n\tif err := MarkRead([]string{id}); err != nil {\n\t\treturn &obj, err\n\t}\n\n\tdbLastAction = time.Now()\n\n\treturn &obj, nil\n}\n\n// GetMessageRaw returns an []byte of the full message\nfunc GetMessageRaw(id string) ([]byte, error) {\n\tvar i, msg string\n\tvar compressed int\n\tq := sqlf.From(tenant(\"mailbox_data\")).\n\t\tSelect(`ID`).To(&i).\n\t\tSelect(`Email`).To(&msg).\n\t\tSelect(`Compressed`).To(&compressed).\n\t\tWhere(`ID = ?`, id)\n\terr := q.QueryRowAndClose(context.Background(), db)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif i == \"\" {\n\t\treturn nil, errors.New(\"message not found\")\n\t}\n\n\tvar data []byte\n\tif sqlDriver == \"rqlite\" && compressed == 1 {\n\t\tdata, err = base64.StdEncoding.DecodeString(msg)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error decoding base64 message: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(msg)\n\t}\n\n\tdbLastAction = time.Now()\n\n\tif compressed == 1 {\n\t\traw, err := dbDecoder.DecodeAll(data, nil)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error decompressing message: %s\", err.Error())\n\t\t}\n\n\t\treturn raw, err\n\t}\n\n\treturn data, nil\n}\n\n// GetAttachmentPart returns an *enmime.Part (attachment or inline) from a message\nfunc GetAttachmentPart(id, partID string) (*enmime.Part, error) {\n\traw, err := GetMessageRaw(id)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tr := bytes.NewReader(raw)\n\n\tparser := enmime.NewParser(enmime.DisableCharacterDetection(true))\n\n\tenv, err := parser.ReadEnvelope(r)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, a := range env.Inlines {\n\t\tif a.PartID == partID {\n\t\t\treturn a, nil\n\t\t}\n\t}\n\n\tfor _, a := range env.OtherParts {\n\t\tif a.PartID == partID {\n\t\t\treturn a, nil\n\t\t}\n\t}\n\n\tfor _, a := range env.Attachments {\n\t\tif a.PartID == partID {\n\t\t\treturn a, nil\n\t\t}\n\t}\n\n\tdbLastAction = time.Now()\n\n\treturn nil, errors.New(\"attachment not found\")\n}\n\n// AttachmentSummary returns a summary of the attachment without any binary data\nfunc AttachmentSummary(a *enmime.Part) Attachment {\n\to := Attachment{}\n\to.PartID = a.PartID\n\to.FileName = a.FileName\n\tif o.FileName == \"\" {\n\t\to.FileName = a.ContentID\n\t}\n\to.ContentType = a.ContentType\n\to.ContentID = a.ContentID\n\to.Size = uint64(len(a.Content))\n\n\tmd5Hash := md5.Sum(a.Content)   // #nosec\n\tsha1Hash := sha1.Sum(a.Content) // #nosec\n\tsha256Hash := sha256.Sum256(a.Content)\n\n\to.Checksums.MD5 = hex.EncodeToString(md5Hash[:])\n\to.Checksums.SHA1 = hex.EncodeToString(sha1Hash[:])\n\to.Checksums.SHA256 = hex.EncodeToString(sha256Hash[:])\n\n\treturn o\n}\n\n// LatestID returns the latest message ID\n//\n// If a query argument is set in the request the function will return the\n// latest message matching the search\nfunc LatestID(r *http.Request) (string, error) {\n\tvar messages []MessageSummary\n\tvar err error\n\n\tsearch := strings.TrimSpace(r.URL.Query().Get(\"query\"))\n\tif search != \"\" {\n\t\tmessages, _, err = Search(search, r.URL.Query().Get(\"tz\"), 0, 0, 1)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t} else {\n\t\tmessages, err = List(0, 0, 1)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t}\n\tif len(messages) == 0 {\n\t\treturn \"\", errors.New(\"Message not found\")\n\t}\n\n\treturn messages[0].ID, nil\n}\n\n// MarkRead will mark a message as read\nfunc MarkRead(ids []string) error {\n\tfor _, id := range ids {\n\t\t_, err := sqlf.Update(tenant(\"mailbox\")).\n\t\t\tSet(\"Read\", 1).\n\t\t\tWhere(\"ID = ?\", id).\n\t\t\tExecAndClose(context.Background(), db)\n\n\t\tif err == nil {\n\t\t\tlogger.Log().Debugf(\"[db] marked message %s as read\", id)\n\t\t}\n\n\t\td := struct {\n\t\t\tID   string\n\t\t\tRead bool\n\t\t}{ID: id, Read: true}\n\n\t\twebsockets.Broadcast(\"update\", d)\n\t}\n\n\tBroadcastMailboxStats()\n\n\treturn nil\n}\n\n// MarkAllRead will mark all messages as read\nfunc MarkAllRead() error {\n\tvar (\n\t\tstart = time.Now()\n\t\ttotal = CountUnread()\n\t)\n\n\t_, err := sqlf.Update(tenant(\"mailbox\")).\n\t\tSet(\"Read\", 1).\n\t\tWhere(\"Read = ?\", 0).\n\t\tExecAndClose(context.Background(), db)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\telapsed := time.Since(start)\n\tlogger.Log().Debugf(\"[db] marked %v messages as read in %s\", total, elapsed)\n\n\tBroadcastMailboxStats()\n\n\tdbLastAction = time.Now()\n\n\treturn nil\n}\n\n// MarkAllUnread will mark all messages as unread\nfunc MarkAllUnread() error {\n\tvar (\n\t\tstart = time.Now()\n\t\ttotal = CountRead()\n\t)\n\n\t_, err := sqlf.Update(tenant(\"mailbox\")).\n\t\tSet(\"Read\", 0).\n\t\tWhere(\"Read = ?\", 1).\n\t\tExecAndClose(context.Background(), db)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\telapsed := time.Since(start)\n\tlogger.Log().Debugf(\"[db] marked %v messages as unread in %s\", total, elapsed)\n\n\tBroadcastMailboxStats()\n\n\tdbLastAction = time.Now()\n\n\treturn nil\n}\n\n// MarkUnread will mark a message as unread\nfunc MarkUnread(ids []string) error {\n\tfor _, id := range ids {\n\t\t_, err := sqlf.Update(tenant(\"mailbox\")).\n\t\t\tSet(\"Read\", 0).\n\t\t\tWhere(\"ID = ?\", id).\n\t\t\tExecAndClose(context.Background(), db)\n\n\t\tif err == nil {\n\t\t\tlogger.Log().Debugf(\"[db] marked message %s as unread\", id)\n\t\t}\n\n\t\tdbLastAction = time.Now()\n\n\t\td := struct {\n\t\t\tID   string\n\t\t\tRead bool\n\t\t}{ID: id, Read: false}\n\n\t\twebsockets.Broadcast(\"update\", d)\n\t}\n\n\tBroadcastMailboxStats()\n\n\treturn nil\n}\n\n// DeleteMessages deletes one or more messages in bulk\nfunc DeleteMessages(ids []string) error {\n\tif len(ids) == 0 {\n\t\treturn nil\n\t}\n\n\tstart := time.Now()\n\n\targs := make([]any, len(ids))\n\tfor i, id := range ids {\n\t\targs[i] = id\n\t}\n\n\tsql := fmt.Sprintf(`SELECT ID, Size FROM %s WHERE  ID IN (?%s)`, tenant(\"mailbox\"), strings.Repeat(\",?\", len(args)-1)) // #nosec\n\trows, err := db.Query(sql, args...)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer func() { _ = rows.Close() }()\n\n\ttoDelete := []string{}\n\tvar totalSize uint64\n\n\tfor rows.Next() {\n\t\tvar id string\n\t\tvar size float64 // use float64 for rqlite compatibility\n\n\t\tif err := rows.Scan(&id, &size); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\ttoDelete = append(toDelete, id)\n\t\ttotalSize = totalSize + uint64(size)\n\t}\n\n\tif err = rows.Err(); err != nil {\n\t\treturn err\n\t}\n\n\tif len(toDelete) == 0 {\n\t\treturn nil // nothing to delete\n\t}\n\n\ttx, err := db.BeginTx(context.Background(), nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\targs = make([]any, len(toDelete))\n\tfor i, id := range toDelete {\n\t\targs[i] = id\n\t}\n\n\ttables := []string{\"mailbox\", \"mailbox_data\", \"message_tags\"}\n\n\tfor _, t := range tables {\n\t\tsql = fmt.Sprintf(`DELETE FROM %s WHERE ID IN (?%s)`, tenant(t), strings.Repeat(\",?\", len(ids)-1))\n\n\t\t_, err = tx.Exec(sql, args...) // #nosec\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err := tx.Commit(); err != nil {\n\t\treturn err\n\t}\n\n\tdbLastAction = time.Now()\n\taddDeletedSize(totalSize)\n\n\tlogMessagesDeleted(len(toDelete))\n\n\t_ = pruneUnusedTags()\n\n\telapsed := time.Since(start)\n\n\tmessages := \"messages\"\n\tif len(toDelete) == 1 {\n\t\tmessages = \"message\"\n\t}\n\n\tlogger.Log().Debugf(\"[db] deleted %d %s in %s\", len(toDelete), messages, elapsed)\n\n\tBroadcastMailboxStats()\n\n\t// broadcast individual message deletions\n\tfor _, id := range toDelete {\n\t\td := struct {\n\t\t\tID string\n\t\t}{ID: id}\n\n\t\twebsockets.Broadcast(\"delete\", d)\n\t}\n\n\treturn nil\n}\n\n// DeleteAllMessages will delete all messages from a mailbox\nfunc DeleteAllMessages() error {\n\tvar (\n\t\tstart = time.Now()\n\t\ttotal int\n\t)\n\n\t_ = sqlf.From(tenant(\"mailbox\")).\n\t\tSelect(\"COUNT(*)\").To(&total).\n\t\tQueryRowAndClose(context.TODO(), db)\n\n\t// begin a transaction to ensure both the message\n\t// summaries and data are deleted successfully\n\ttx, err := db.BeginTx(context.Background(), nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// roll back if it fails\n\tdefer func() { _ = tx.Rollback() }()\n\n\ttables := []string{\"mailbox\", \"mailbox_data\", \"tags\", \"message_tags\"}\n\n\tfor _, t := range tables {\n\t\tsql := fmt.Sprintf(`DELETE FROM %s`, tenant(t)) // #nosec\n\t\t_, err := tx.Exec(sql)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err := tx.Commit(); err != nil {\n\t\treturn err\n\t}\n\n\telapsed := time.Since(start)\n\tlogger.Log().Debugf(\"[db] deleted %d messages in %s\", total, elapsed)\n\n\tvacuumDb()\n\n\tdbLastAction = time.Now()\n\tif err := SettingPut(\"DeletedSize\", \"0\"); err != nil {\n\t\tlogger.Log().Warnf(\"[db] %s\", err.Error())\n\t}\n\n\tlogMessagesDeleted(total)\n\n\tBroadcastMailboxStats()\n\n\twebsockets.Broadcast(\"truncate\", nil)\n\n\treturn err\n}\n\n// GetMetadata retrieves the metadata for a message by its ID\nfunc GetMetadata(id string) (Metadata, error) {\n\tvar metadataJSON string\n\trow := db.QueryRow(fmt.Sprintf(\"SELECT Metadata FROM %s WHERE ID = ?\", tenant(\"mailbox\")), id)\n\tif err := row.Scan(&metadataJSON); err != nil {\n\t\treturn Metadata{}, err\n\t}\n\tvar meta Metadata\n\tif err := json.Unmarshal([]byte(metadataJSON), &meta); err != nil {\n\t\treturn Metadata{}, err\n\t}\n\treturn meta, nil\n}\n"
  },
  {
    "path": "internal/storage/messages_test.go",
    "content": "package storage\n\nimport (\n\t\"os\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/axllent/mailpit/config\"\n)\n\nfunc TestTextEmailInserts(t *testing.T) {\n\tsetup(\"\")\n\tdefer Close()\n\n\tt.Log(\"Testing text email storage\")\n\n\tstart := time.Now()\n\n\tfor range testRuns {\n\t\tif _, err := Store(&testTextEmail, nil); err != nil {\n\t\t\tt.Log(\"error \", err)\n\t\t\tt.Fail()\n\t\t}\n\t}\n\n\tassertEqual(t, CountTotal(), uint64(testRuns), \"Incorrect number of text emails stored\")\n\n\tt.Logf(\"Inserted %d text emails in %s\", testRuns, time.Since(start))\n\n\tdelStart := time.Now()\n\tif err := DeleteAllMessages(); err != nil {\n\t\tt.Log(\"error \", err)\n\t\tt.Fail()\n\t}\n\n\tassertEqual(t, CountTotal(), uint64(0), \"incorrect number of text emails deleted\")\n\n\tt.Logf(\"deleted %d text emails in %s\", testRuns, time.Since(delStart))\n\n\tassertEqualStats(t, 0, 0)\n}\n\nfunc TestMimeEmailInserts(t *testing.T) {\n\tfor _, tenantID := range []string{\"\", \"MyServer 3\", \"host.example.com\"} {\n\t\ttenantID = config.DBTenantID(tenantID)\n\n\t\tsetup(tenantID)\n\n\t\tif tenantID == \"\" {\n\t\t\tt.Log(\"Testing mime email storage\")\n\t\t} else {\n\t\t\tt.Logf(\"Testing mime email storage (tenant %s)\", tenantID)\n\t\t}\n\n\t\tstart := time.Now()\n\n\t\tfor range testRuns {\n\t\t\tif _, err := Store(&testMimeEmail, nil); err != nil {\n\t\t\t\tt.Log(\"error \", err)\n\t\t\t\tt.Fail()\n\t\t\t}\n\t\t}\n\n\t\tassertEqual(t, CountTotal(), uint64(testRuns), \"Incorrect number of mime emails stored\")\n\n\t\tt.Logf(\"Inserted %d text emails in %s\", testRuns, time.Since(start))\n\n\t\tdelStart := time.Now()\n\t\tif err := DeleteAllMessages(); err != nil {\n\t\t\tt.Log(\"error \", err)\n\t\t\tt.Fail()\n\t\t}\n\n\t\tassertEqual(t, CountTotal(), uint64(0), \"incorrect number of mime emails deleted\")\n\n\t\tt.Logf(\"Deleted %d mime emails in %s\", testRuns, time.Since(delStart))\n\n\t\tClose()\n\t}\n}\n\nfunc TestRetrieveMimeEmail(t *testing.T) {\n\tcompressionLevels := []int{0, 1, 2, 3}\n\n\tfor _, compressionLevel := range compressionLevels {\n\t\tt.Logf(\"Testing compression level: %d\", compressionLevel)\n\t\tfor _, tenantID := range []string{\"\", \"MyServer 3\", \"host.example.com\"} {\n\t\t\ttenantID = config.DBTenantID(tenantID)\n\t\t\tconfig.Compression = compressionLevel\n\t\t\tsetup(tenantID)\n\n\t\t\tif tenantID == \"\" {\n\t\t\t\tt.Log(\"Testing mime email retrieval\")\n\t\t\t} else {\n\t\t\t\tt.Logf(\"Testing mime email retrieval (tenant %s)\", tenantID)\n\t\t\t}\n\n\t\t\tid, err := Store(&testMimeEmail, nil)\n\t\t\tif err != nil {\n\t\t\t\tt.Log(\"error \", err)\n\t\t\t\tt.Fail()\n\t\t\t}\n\n\t\t\tmsg, err := GetMessage(id)\n\t\t\tif err != nil {\n\t\t\t\tt.Log(\"error \", err)\n\t\t\t\tt.Fail()\n\t\t\t}\n\n\t\t\tassertEqual(t, msg.From.Name, \"Sender Smith\", \"\\\"From\\\" name does not match\")\n\t\t\tassertEqual(t, msg.From.Address, \"sender2@example.com\", \"\\\"From\\\" address does not match\")\n\t\t\tassertEqual(t, msg.Subject, \"inline + attachment\", \"subject does not match\")\n\t\t\tassertEqual(t, len(msg.To), 1, \"incorrect number of recipients\")\n\t\t\tassertEqual(t, msg.To[0].Name, \"Recipient Ross\", \"\\\"To\\\" name does not match\")\n\t\t\tassertEqual(t, msg.To[0].Address, \"recipient2@example.com\", \"\\\"To\\\" address does not match\")\n\t\t\tassertEqual(t, len(msg.Attachments), 1, \"incorrect number of attachments\")\n\t\t\tassertEqual(t, msg.Attachments[0].FileName, \"Sample PDF.pdf\", \"attachment filename does not match\")\n\t\t\tassertEqual(t, len(msg.Inline), 1, \"incorrect number of inline attachments\")\n\t\t\tassertEqual(t, msg.Inline[0].FileName, \"inline-image.jpg\", \"inline attachment filename does not match\")\n\n\t\t\tattachmentData, err := GetAttachmentPart(id, msg.Attachments[0].PartID)\n\t\t\tif err != nil {\n\t\t\t\tt.Log(\"error \", err)\n\t\t\t\tt.Fail()\n\t\t\t}\n\t\t\tassertEqual(t, uint64(len(attachmentData.Content)), msg.Attachments[0].Size, \"attachment size does not match\")\n\n\t\t\tinlineData, err := GetAttachmentPart(id, msg.Inline[0].PartID)\n\t\t\tif err != nil {\n\t\t\t\tt.Log(\"error \", err)\n\t\t\t\tt.Fail()\n\t\t\t}\n\t\t\tassertEqual(t, uint64(len(inlineData.Content)), msg.Inline[0].Size, \"inline attachment size does not match\")\n\n\t\t\tClose()\n\t\t}\n\t}\n\n\t// reset compression\n\tconfig.Compression = 1\n}\n\nfunc TestMessageSummary(t *testing.T) {\n\tfor _, tenantID := range []string{\"\", \"MyServer 3\", \"host.example.com\"} {\n\t\ttenantID = config.DBTenantID(tenantID)\n\n\t\tsetup(tenantID)\n\n\t\tif tenantID == \"\" {\n\t\t\tt.Log(\"Testing message summary\")\n\t\t} else {\n\t\t\tt.Logf(\"Testing message summary (tenant %s)\", tenantID)\n\t\t}\n\n\t\tif _, err := Store(&testMimeEmail, nil); err != nil {\n\t\t\tt.Log(\"error \", err)\n\t\t\tt.Fail()\n\t\t}\n\n\t\tsummaries, err := List(0, 0, 1)\n\t\tif err != nil {\n\t\t\tt.Log(\"error \", err)\n\t\t\tt.Fail()\n\t\t}\n\n\t\tassertEqual(t, len(summaries), 1, \"Expected 1 result\")\n\n\t\tmsg := summaries[0]\n\n\t\tassertEqual(t, msg.From.Name, \"Sender Smith\", \"\\\"From\\\" name does not match\")\n\t\tassertEqual(t, msg.From.Address, \"sender2@example.com\", \"\\\"From\\\" address does not match\")\n\t\tassertEqual(t, msg.Subject, \"inline + attachment\", \"subject does not match\")\n\t\tassertEqual(t, len(msg.To), 1, \"incorrect number of recipients\")\n\t\tassertEqual(t, msg.To[0].Name, \"Recipient Ross\", \"\\\"To\\\" name does not match\")\n\t\tassertEqual(t, msg.To[0].Address, \"recipient2@example.com\", \"\\\"To\\\" address does not match\")\n\t\tassertEqual(t, msg.Snippet, \"Message with inline image and attachment:\", \"\\\"Snippet\\\" does does not match\")\n\t\tassertEqual(t, msg.Attachments, 1, \"Expected 1 attachment\")\n\t\tassertEqual(t, msg.MessageID, \"33af2ac1-c33d-9738-35e3-a6daf90bbd89@gmail.com\", \"\\\"MessageID\\\" does not match\")\n\n\t\tClose()\n\t}\n}\n\nfunc BenchmarkImportText(b *testing.B) {\n\tsetup(\"\")\n\tdefer Close()\n\n\tfor i := 0; i < b.N; i++ {\n\t\tif _, err := Store(&testTextEmail, nil); err != nil {\n\t\t\tb.Log(\"error \", err)\n\t\t\tb.Fail()\n\t\t}\n\t}\n}\n\nfunc BenchmarkImportMime(b *testing.B) {\n\tsetup(\"\")\n\tdefer Close()\n\n\tfor i := 0; i < b.N; i++ {\n\t\tif _, err := Store(&testMimeEmail, nil); err != nil {\n\t\t\tb.Log(\"error \", err)\n\t\t\tb.Fail()\n\t\t}\n\t}\n\n}\n\nfunc TestInlineImageContentIdHandling(t *testing.T) {\n\tsetup(\"\")\n\tdefer Close()\n\tt.Log(\"Testing inline content handling\")\n\t// Test case: Proper inline image with Content-Disposition: inline\n\tinlineAttachment, err := os.ReadFile(\"testdata/inline-attachment.eml\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to read test email: %v\", err)\n\t}\n\tstoredMessage, err := Store(&inlineAttachment, nil)\n\tif err != nil {\n\t\tt.Fatal(\"Failed to store test case 1:\", err)\n\t}\n\n\tmsg, err := GetMessage(storedMessage)\n\tif err != nil {\n\t\tt.Fatal(\"Failed to retrieve test case 1:\", err)\n\t}\n\t// Assert\n\tif len(msg.Inline) != 1 {\n\t\tt.Errorf(\"Test case 1: Expected 1 inline attachment, got %d\", len(msg.Inline))\n\t}\n\tif len(msg.Attachments) != 0 {\n\t\tt.Errorf(\"Test case 1: Expected 0 regular attachments, got %d\", len(msg.Attachments))\n\t}\n\tif msg.Inline[0].ContentID != \"test1@example.com\" {\n\t\tt.Errorf(\"Test case 1: Expected ContentID 'test1@example.com', got '%s'\", msg.Inline[0].ContentID)\n\t}\n}\n\nfunc TestRegularAttachmentHandling(t *testing.T) {\n\tsetup(\"\")\n\tdefer Close()\n\tt.Log(\"Testing regular attachment handling\")\n\t// Test case: Regular attachment without Content-ID\n\tregularAttachment, err := os.ReadFile(\"testdata/regular-attachment.eml\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to read test email: %v\", err)\n\t}\n\tstoredMessage, err := Store(&regularAttachment, nil)\n\tif err != nil {\n\t\tt.Fatal(\"Failed to store test case 3:\", err)\n\t}\n\tmsg, err := GetMessage(storedMessage)\n\tif err != nil {\n\t\tt.Fatal(\"Failed to retrieve test case 3:\", err)\n\t}\n\t// Assert\n\tif len(msg.Inline) != 0 {\n\t\tt.Errorf(\"Test case 3: Expected 0 inline attachments, got %d\", len(msg.Inline))\n\t}\n\tif len(msg.Attachments) != 1 {\n\t\tt.Errorf(\"Test case 3: Expected 1 regular attachment, got %d\", len(msg.Attachments))\n\t}\n\tif msg.Attachments[0].ContentID != \"\" {\n\t\tt.Errorf(\"Test case 3: Expected empty ContentID, got '%s'\", msg.Attachments[0].ContentID)\n\t}\n\n\t// Checksum tests\n\tassertEqual(t, msg.Attachments[0].Checksums.MD5, \"b04930eb1ba0c62066adfa87e5d262c4\", \"Attachment MD5 checksum does not match\")\n\tassertEqual(t, msg.Attachments[0].Checksums.SHA1, \"15605d6a2fca44e966209d1701f16ecf816df880\", \"Attachment SHA1 checksum does not match\")\n\tassertEqual(t, msg.Attachments[0].Checksums.SHA256, \"92c4ccff376003381bd9054d3da7b32a3c5661905b55e3b0728c17aba6d223ec\", \"Attachment SHA256 checksum does not match\")\n}\n\nfunc TestMixedAttachmentHandling(t *testing.T) {\n\tsetup(\"\")\n\tdefer Close()\n\tt.Log(\"Testing mixed attachment handling\")\n\t// Mixed scenario with both inline and regular attachment\n\tmixedAttachment, err := os.ReadFile(\"testdata/mixed-attachment.eml\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to read test email: %v\", err)\n\t}\n\tstoredMessage, err := Store(&mixedAttachment, nil)\n\tif err != nil {\n\t\tt.Fatal(\"Failed to store test case 4:\", err)\n\t}\n\tmsg, err := GetMessage(storedMessage)\n\tif err != nil {\n\t\tt.Fatal(\"Failed to retrieve test case 4:\", err)\n\t}\n\t// Assert: Should have 1 inline (with ContentID) and 1 attachment (without ContentID)\n\tif len(msg.Inline) != 1 {\n\t\tt.Errorf(\"Test case 4: Expected 1 inline attachment, got %d\", len(msg.Inline))\n\t}\n\tif len(msg.Attachments) != 1 {\n\t\tt.Errorf(\"Test case 4: Expected 1 regular attachment, got %d\", len(msg.Attachments))\n\t}\n\tif msg.Inline[0].ContentID != \"inline@example.com\" {\n\t\tt.Errorf(\"Test case 4: Expected inline ContentID 'inline@example.com', got '%s'\", msg.Inline[0].ContentID)\n\t}\n\tif msg.Attachments[0].ContentID != \"\" {\n\t\tt.Errorf(\"Test case 4: Expected attachment ContentID to be empty, got '%s'\", msg.Attachments[0].ContentID)\n\t}\n}\n"
  },
  {
    "path": "internal/storage/notifications.go",
    "content": "package storage\n\nimport (\n\t\"time\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/server/websockets\"\n)\n\nvar bcStatsDelay = false\n\n// BroadcastMailboxStats broadcasts the total number of messages\n// displayed to the web UI, as well as the total unread messages.\n// The lookup is very fast (< 10ms / 100k messages under load).\n// Rate limited to 4x per second.\nfunc BroadcastMailboxStats() {\n\tif bcStatsDelay {\n\t\treturn\n\t}\n\n\tbcStatsDelay = true\n\n\tgo func() {\n\t\ttime.Sleep(250 * time.Millisecond)\n\t\tbcStatsDelay = false\n\t\tb := struct {\n\t\t\tTotal   uint64\n\t\t\tUnread  uint64\n\t\t\tVersion string\n\t\t}{\n\t\t\tTotal:   CountTotal(),\n\t\t\tUnread:  CountUnread(),\n\t\t\tVersion: config.Version,\n\t\t}\n\n\t\twebsockets.Broadcast(\"stats\", b)\n\t}()\n}\n"
  },
  {
    "path": "internal/storage/reindex.go",
    "content": "package storage\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/mail\"\n\t\"os\"\n\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/axllent/mailpit/internal/tools\"\n\t\"github.com/jhillyerd/enmime/v2\"\n\t\"github.com/leporo/sqlf\"\n)\n\n// ReindexAll will regenerate the search text and snippet for a message\n// and update the database.\nfunc ReindexAll() {\n\tids := []string{}\n\tvar i string\n\tchunkSize := 1000\n\n\tfinished := 0\n\n\terr := sqlf.Select(\"ID\").To(&i).\n\t\tFrom(tenant(\"mailbox\")).\n\t\tOrderBy(\"Created DESC\").\n\t\tQueryAndClose(context.TODO(), db, func(_ *sql.Rows) {\n\t\t\tids = append(ids, i)\n\t\t})\n\n\tif err != nil {\n\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t\tos.Exit(1)\n\t}\n\n\ttotal := len(ids)\n\n\tchunks := chunkBy(ids, chunkSize)\n\n\tlogger.Log().Infof(\"reindexing %d messages\", total)\n\n\ttype updateStruct struct {\n\t\t// ID in database\n\t\tID string\n\t\t// SearchText for searching\n\t\tSearchText string\n\t\t// Snippet for UI\n\t\tSnippet string\n\t\t// Metadata info\n\t\tMetadata string\n\t}\n\n\tparser := enmime.NewParser(enmime.DisableCharacterDetection(true))\n\n\tfor _, ids := range chunks {\n\t\tupdates := []updateStruct{}\n\n\t\tfor _, id := range ids {\n\t\t\traw, err := GetMessageRaw(id)\n\t\t\tif err != nil {\n\t\t\t\tlogger.Log().Error(err)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tr := bytes.NewReader(raw)\n\n\t\t\tenv, err := parser.ReadEnvelope(r)\n\t\t\tif err != nil {\n\t\t\t\tlogger.Log().Errorf(\"[message] %s\", err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tmeta, _ := GetMetadata(id)\n\n\t\t\tfromJSON := addressToSlice(env, \"From\")\n\t\t\tif len(fromJSON) > 0 {\n\t\t\t\tmeta.From = fromJSON[0]\n\t\t\t} else if env.GetHeader(\"From\") != \"\" {\n\t\t\t\tmeta.From = &mail.Address{Name: env.GetHeader(\"From\")}\n\t\t\t} else {\n\t\t\t\tmeta.From = nil\n\t\t\t}\n\t\t\tmeta.To = addressToSlice(env, \"To\")\n\t\t\tmeta.Cc = addressToSlice(env, \"Cc\")\n\t\t\tmeta.Bcc = addressToSlice(env, \"Bcc\")\n\t\t\tmeta.ReplyTo = addressToSlice(env, \"Reply-To\")\n\n\t\t\tMetadataJSON, err := json.Marshal(meta)\n\t\t\tif err != nil {\n\t\t\t\tlogger.Log().Errorf(\"[message] %s\", err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tsearchText := createSearchText(env)\n\t\t\tsnippet := tools.CreateSnippet(env.Text, env.HTML)\n\n\t\t\tu := updateStruct{}\n\t\t\tu.ID = id\n\t\t\tu.SearchText = searchText\n\t\t\tu.Snippet = snippet\n\t\t\tu.Metadata = string(MetadataJSON)\n\n\t\t\tupdates = append(updates, u)\n\t\t}\n\n\t\tctx := context.Background()\n\t\ttx, err := db.BeginTx(ctx, nil)\n\t\tif err != nil {\n\t\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t\t\tcontinue\n\t\t}\n\n\t\t// roll back if it fails\n\t\tdefer func() { _ = tx.Rollback() }()\n\n\t\t// insert mail summary data\n\t\tfor _, u := range updates {\n\t\t\t_, err = tx.Exec(fmt.Sprintf(`UPDATE %s SET SearchText = ?, Snippet = ?, Metadata = ? WHERE ID = ?`, tenant(\"mailbox\")), u.SearchText, u.Snippet, u.Metadata, u.ID)\n\t\t\tif err != nil {\n\t\t\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\n\t\tif err := tx.Commit(); err != nil {\n\t\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t\t\tcontinue\n\t\t}\n\n\t\tfinished += len(updates)\n\n\t\tlogger.Log().Printf(\"reindexed: %d / %d (%d%%)\", finished, total, finished*100/total)\n\t}\n}\n\nfunc chunkBy[T any](items []T, chunkSize int) (chunks [][]T) {\n\tfor chunkSize < len(items) {\n\t\titems, chunks = items[chunkSize:], append(chunks, items[0:chunkSize:chunkSize])\n\t}\n\n\treturn append(chunks, items)\n}\n"
  },
  {
    "path": "internal/storage/schemas/1.0.0.sql",
    "content": "-- CREATE TABLES\nCREATE TABLE IF NOT EXISTS {{ tenant \"mailbox\" }} (\n\tSort INTEGER PRIMARY KEY AUTOINCREMENT,\n\tID TEXT NOT NULL,\n\tData BLOB,\n\tSearch TEXT,\n\tRead INTEGER\n);\n\nCREATE INDEX IF NOT EXISTS {{ tenant \"idx_sort\" }} ON {{ tenant \"mailbox\" }} (Sort);\nCREATE UNIQUE INDEX IF NOT EXISTS {{ tenant \"idx_id\" }} ON {{ tenant \"mailbox\" }} (ID);\nCREATE INDEX IF NOT EXISTS {{ tenant \"idx_read\" }} ON {{ tenant \"mailbox\" }} (Read);\n\nCREATE TABLE IF NOT EXISTS {{ tenant \"mailbox_data\" }} (\n\tID TEXT KEY NOT NULL,\n\tEmail BLOB\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS {{ tenant \"idx_data_id\" }} ON {{ tenant \"mailbox_data\" }} (ID);\n"
  },
  {
    "path": "internal/storage/schemas/1.1.0.sql",
    "content": "-- CREATE TAGS COLUMN\nALTER TABLE {{ tenant \"mailbox\" }} ADD COLUMN Tags Text NOT NULL DEFAULT '[]';\nCREATE INDEX IF NOT EXISTS {{ tenant \"idx_tags\" }} ON {{ tenant \"mailbox\" }} (Tags);\n"
  },
  {
    "path": "internal/storage/schemas/1.2.0.sql",
    "content": "-- CREATING NEW MAILBOX FORMAT\nCREATE TABLE IF NOT EXISTS {{ tenant \"mailboxtmp\" }} (\n\tCreated INTEGER NOT NULL,\n\tID TEXT NOT NULL,\n\tMessageID TEXT NOT NULL,\n\tSubject TEXT NOT NULL,\n\tMetadata TEXT,\n\tSize INTEGER NOT NULL,\n\tInline INTEGER NOT NULL,\n\tAttachments INTEGER NOT NULL,\n\tRead INTEGER,\n\tTags TEXT,\n\tSearchText TEXT\n);\n\nINSERT INTO {{ tenant \"mailboxtmp\" }}\n\t(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Tags) \n\tSELECT \n\t\tSort, ID, '', json_extract(Data, '$.Subject'),Data, \n\t\tjson_extract(Data, '$.Size'), json_extract(Data, '$.Inline'), json_extract(Data, '$.Attachments'), \n\t\tSearch, Read, Tags\n\tFROM {{ tenant \"mailbox\" }};\n\nDROP TABLE IF EXISTS {{ tenant \"mailbox\" }};\n\nALTER TABLE {{ tenant \"mailboxtmp\" }} RENAME TO {{ tenant \"mailbox\" }};\n\nCREATE INDEX IF NOT EXISTS {{ tenant \"idx_created\" }} ON {{ tenant \"mailbox\" }} (Created);\nCREATE UNIQUE INDEX IF NOT EXISTS {{ tenant \"idx_id\" }} ON {{ tenant \"mailbox\" }} (ID);\nCREATE INDEX IF NOT EXISTS {{ tenant \"idx_message_id\" }} ON {{ tenant \"mailbox\" }} (MessageID);\nCREATE INDEX IF NOT EXISTS {{ tenant \"idx_subject\" }} ON {{ tenant \"mailbox\" }} (Subject);\nCREATE INDEX IF NOT EXISTS {{ tenant \"idx_size\" }} ON {{ tenant \"mailbox\" }} (Size);\nCREATE INDEX IF NOT EXISTS {{ tenant \"idx_inline\" }} ON {{ tenant \"mailbox\" }} (Inline);\nCREATE INDEX IF NOT EXISTS {{ tenant \"idx_attachments\" }} ON {{ tenant \"mailbox\" }} (Attachments);\nCREATE INDEX IF NOT EXISTS {{ tenant \"idx_read\" }} ON {{ tenant \"mailbox\" }} (Read);\nCREATE INDEX IF NOT EXISTS {{ tenant \"idx_tags\" }} ON {{ tenant \"mailbox\" }} (Tags);\n"
  },
  {
    "path": "internal/storage/schemas/1.21.2.sql",
    "content": "-- DROP LEGACY MIGRATION TABLE\nDROP TABLE IF EXISTS {{ tenant \"darwin_migrations\" }};\n\n-- DROP LEGACY TAGS COLUMN\nDROP INDEX IF EXISTS {{ tenant \"idx_tags\" }};\nALTER TABLE {{ tenant \"mailbox\" }} DROP COLUMN Tags;\n"
  },
  {
    "path": "internal/storage/schemas/1.21.8.sql",
    "content": "-- Rebuild message_tags to remove FOREIGN KEY REFERENCES\nPRAGMA foreign_keys=OFF;\n\nDROP INDEX IF EXISTS {{ tenant \"idx_message_tag_id\" }};\nDROP INDEX IF EXISTS {{ tenant \"idx_message_tag_tagid\" }};\n\nALTER TABLE {{ tenant \"message_tags\" }} RENAME TO _{{ tenant \"message_tags\" }}_old;\n\nCREATE TABLE IF NOT EXISTS {{ tenant \"message_tags\" }} (\n\tKey INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,\n\tID TEXT NOT NULL,\n\tTagID INTEGER NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS {{ tenant \"idx_message_tags_id\" }} ON {{ tenant \"message_tags\" }} (ID);\nCREATE INDEX IF NOT EXISTS {{ tenant \"idx_message_tags_tagid\" }} ON {{ tenant \"message_tags\" }} (TagID);\n\nINSERT INTO {{ tenant \"message_tags\" }} SELECT * FROM _{{ tenant \"message_tags\" }}_old;\n\nDROP TABLE IF EXISTS _{{ tenant \"message_tags\" }}_old;\n\nPRAGMA foreign_keys=ON;\n"
  },
  {
    "path": "internal/storage/schemas/1.23.0.sql",
    "content": "-- CREATE Compressed COLUMN IN mailbox_data\nALTER TABLE {{ tenant \"mailbox_data\" }} ADD COLUMN Compressed INTEGER NOT NULL DEFAULT '0';\n\n-- SET Compressed = 1 for all existing data\nUPDATE {{ tenant \"mailbox_data\" }} SET Compressed = 1;\n"
  },
  {
    "path": "internal/storage/schemas/1.3.0.sql",
    "content": "-- CREATE SNIPPET COLUMN\nALTER TABLE {{ tenant \"mailbox\" }} ADD COLUMN Snippet TEXT NOT NULL DEFAULT '';\n"
  },
  {
    "path": "internal/storage/schemas/1.4.0.sql",
    "content": "-- CREATE TAG TABLES\nCREATE TABLE IF NOT EXISTS {{ tenant \"tags\" }} (\n\tID INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,\n\tName TEXT COLLATE NOCASE\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS {{ tenant \"idx_tag_name\" }} ON {{ tenant \"tags\" }} (Name);\n\nCREATE TABLE IF NOT EXISTS {{ tenant \"message_tags\" }} (\n\tKey INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,\n\tID TEXT REFERENCES {{ tenant \"mailbox\" }} (ID),\n\tTagID INT REFERENCES {{ tenant \"tags\" }} (ID)\n);\n\nCREATE INDEX IF NOT EXISTS {{ tenant \"idx_message_tag_id\" }} ON {{ tenant \"message_tags\" }} (ID);\nCREATE INDEX IF NOT EXISTS {{ tenant \"idx_message_tag_tagid\" }} ON {{ tenant \"message_tags\" }} (TagID);\n"
  },
  {
    "path": "internal/storage/schemas/1.5.0.sql",
    "content": "-- CREATE SETTINGS TABLE\nCREATE TABLE IF NOT EXISTS {{ tenant \"settings\" }} (\n\tKey TEXT,\n\tValue TEXT\n);\nCREATE UNIQUE INDEX IF NOT EXISTS {{ tenant \"idx_settings_key\" }} ON {{ tenant \"settings\" }} (Key);\nINSERT INTO {{ tenant \"settings\" }} (Key, Value) VALUES (\"DeletedSize\", (SELECT SUM(Size)/2 FROM {{ tenant \"mailbox\" }}));\n"
  },
  {
    "path": "internal/storage/schemas/README.md",
    "content": "# Migration scripts\n\n- Scripts should be named using semver and have the `.sql` extension.\n- Inline comments should be prefixed with a `--`\n- All references to tables and indexes should be wrapped with a `{{ tenant \"<name>\" }}`\n"
  },
  {
    "path": "internal/storage/schemas.go",
    "content": "package storage\n\nimport (\n\t\"bytes\"\n\t\"embed\"\n\t\"log\"\n\t\"path\"\n\t\"sort\"\n\t\"strings\"\n\t\"text/template\"\n\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/axllent/semver\"\n)\n\n//go:embed schemas/*\nvar schemaScripts embed.FS\n\n// Create tables and apply schemas if required\nfunc dbApplySchemas() error {\n\tif _, err := db.Exec(`CREATE TABLE IF NOT EXISTS ` + tenant(\"schemas\") + ` (Version TEXT PRIMARY KEY NOT NULL)`); err != nil {\n\t\treturn err\n\t}\n\n\tvar legacyMigrationTable int\n\terr := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?)`, tenant(\"darwin_migrations\")).Scan(&legacyMigrationTable)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif legacyMigrationTable == 1 {\n\t\trows, err := db.Query(`SELECT version FROM ` + tenant(\"darwin_migrations\"))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tlegacySchemas := []string{}\n\n\t\tfor rows.Next() {\n\t\t\tvar oldID string\n\t\t\tif err := rows.Scan(&oldID); err == nil {\n\t\t\t\tlegacySchemas = append(legacySchemas, semver.MajorMinor(oldID)+\".\"+semver.Patch(oldID))\n\t\t\t}\n\t\t}\n\n\t\tlegacySchemas = semver.SortMin(legacySchemas)\n\n\t\tfor _, v := range legacySchemas {\n\t\t\tvar migrated int\n\t\t\terr := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM `+tenant(\"schemas\")+` WHERE Version = ?)`, v).Scan(&migrated)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif migrated == 0 {\n\t\t\t\t// copy to tenant(\"schemas\")\n\t\t\t\tif _, err := db.Exec(`INSERT INTO `+tenant(\"schemas\")+` (Version) VALUES (?)`, v); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tschemaFiles, err := schemaScripts.ReadDir(\"schemas\")\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\n\ttemp := template.New(\"\")\n\ttemp.Funcs(\n\t\ttemplate.FuncMap{\n\t\t\t\"tenant\": tenant,\n\t\t},\n\t)\n\n\ttype schema struct {\n\t\tName   string\n\t\tSemver string\n\t}\n\n\tscripts := []schema{}\n\n\tfor _, s := range schemaFiles {\n\t\tif !s.Type().IsRegular() || !strings.HasSuffix(s.Name(), \".sql\") {\n\t\t\tcontinue\n\t\t}\n\n\t\tschemaID := strings.TrimRight(s.Name(), \".sql\")\n\n\t\tif !semver.IsValid(schemaID) {\n\t\t\tlogger.Log().Warnf(\"[db] invalid schema name: %s\", s.Name())\n\t\t\tcontinue\n\t\t}\n\n\t\tscript := schema{s.Name(), semver.MajorMinor(schemaID) + \".\" + semver.Patch(schemaID)}\n\t\tscripts = append(scripts, script)\n\t}\n\n\t// sort schemas by semver, low to high\n\tsort.Slice(scripts, func(i, j int) bool {\n\t\treturn semver.Compare(scripts[j].Semver, scripts[i].Semver) == 1\n\t})\n\n\tfor _, s := range scripts {\n\t\tvar complete int\n\t\terr := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM `+tenant(\"schemas\")+` WHERE Version = ?)`, s.Semver).Scan(&complete)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif complete == 1 {\n\t\t\t// already completed, ignore\n\t\t\tcontinue\n\t\t}\n\t\t// use path.Join for Windows compatibility, see https://github.com/golang/go/issues/44305\n\t\tb, err := schemaScripts.ReadFile(path.Join(\"schemas\", s.Name))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// parse import script\n\t\tt1, err := temp.Parse(string(b))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tbuf := new(bytes.Buffer)\n\n\t\tif err := t1.Execute(buf, nil); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err := db.Exec(buf.String()); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err := db.Exec(`INSERT INTO `+tenant(\"schemas\")+` (Version) VALUES (?)`, s.Semver); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tlogger.Log().Debugf(\"[db] applied schema: %s\", s.Name)\n\t}\n\n\treturn nil\n}\n\n// These functions are used to migrate data formats/structure on startup.\nfunc dataMigrations() {\n\t// ensure DeletedSize has a value if empty\n\tif SettingGet(\"DeletedSize\") == \"\" {\n\t\t_ = SettingPut(\"DeletedSize\", \"0\")\n\t}\n}\n"
  },
  {
    "path": "internal/storage/search.go",
    "content": "package storage\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/araddon/dateparse\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/axllent/mailpit/internal/tools\"\n\t\"github.com/axllent/mailpit/server/websockets\"\n\t\"github.com/leporo/sqlf\"\n)\n\n// Search will search a mailbox for search terms.\n// The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as:\n// is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term>\n// Negative searches also also included by prefixing the search term with a `-` or `!`\nfunc Search(search, timezone string, start int, beforeTS int64, limit int) ([]MessageSummary, int, error) {\n\tresults := []MessageSummary{}\n\tallResults := []MessageSummary{}\n\ttsStart := time.Now()\n\tnrResults := 0\n\tif limit < 0 {\n\t\tlimit = 50\n\t}\n\n\tq := searchQueryBuilder(search, timezone)\n\n\tif beforeTS > 0 {\n\t\tq = q.Where(`Created < ?`, beforeTS)\n\t}\n\n\tvar err error\n\n\tif err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {\n\t\tvar created float64 // use float64 for rqlite compatibility\n\t\tvar id string\n\t\tvar messageID string\n\t\tvar subject string\n\t\tvar metadata string\n\t\tvar size float64 // use float64 for rqlite compatibility\n\t\tvar attachments int\n\t\tvar snippet string\n\t\tvar read int\n\t\tvar ignore string\n\t\tem := MessageSummary{}\n\n\t\tif err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &snippet, &ignore, &ignore, &ignore, &ignore, &ignore); err != nil {\n\t\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t\t\treturn\n\t\t}\n\n\t\tif err := json.Unmarshal([]byte(metadata), &em); err != nil {\n\t\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t\t\treturn\n\t\t}\n\n\t\tem.Created = time.UnixMilli(int64(created))\n\t\tem.ID = id\n\t\tem.MessageID = messageID\n\t\tem.Subject = subject\n\t\tem.Size = uint64(size)\n\t\tem.Attachments = attachments\n\t\tem.Read = read == 1\n\t\tem.Snippet = snippet\n\n\t\tallResults = append(allResults, em)\n\t}); err != nil {\n\t\treturn results, nrResults, err\n\t}\n\n\tdbLastAction = time.Now()\n\n\tnrResults = len(allResults)\n\n\tif nrResults > start {\n\t\tend := min(nrResults, start+limit)\n\n\t\tresults = allResults[start:end]\n\t}\n\n\t// set tags for listed messages only\n\tfor i, m := range results {\n\t\tresults[i].Tags = getMessageTags(m.ID)\n\t}\n\n\telapsed := time.Since(tsStart)\n\n\tlogger.Log().Debugf(\"[db] search for \\\"%s\\\" in %s\", search, elapsed)\n\n\treturn results, nrResults, err\n}\n\n// SearchUnreadCount returns the number of unread messages matching a search.\n// This is run one at a time to allow connected browsers to be updated.\nfunc SearchUnreadCount(search, timezone string, beforeTS int64) (int64, error) {\n\ttsStart := time.Now()\n\n\tq := searchQueryBuilder(search, timezone)\n\n\tif beforeTS > 0 {\n\t\tq = q.Where(`Created < ?`, beforeTS)\n\t}\n\n\tvar unread float64 // use float64 for rqlite compatibility\n\n\tq = q.Where(\"Read = 0\").Select(`COUNT(*)`)\n\n\terr := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {\n\t\tvar ignore sql.NullString\n\t\tif err := row.Scan(&ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &unread); err != nil {\n\t\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t\t\treturn\n\t\t}\n\n\t})\n\n\tdbLastAction = time.Now()\n\n\telapsed := time.Since(tsStart)\n\n\tlogger.Log().Debugf(\"[db] counted %d unread for \\\"%s\\\" in %s\", int64(unread), search, elapsed)\n\n\treturn int64(unread), err\n}\n\n// DeleteSearch will delete all messages for search terms.\n// The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as:\n// is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term>\n// Negative searches also also included by prefixing the search term with a `-` or `!`\nfunc DeleteSearch(search, timezone string) error {\n\tq := searchQueryBuilder(search, timezone)\n\n\tids := []string{}\n\tdeleteSize := uint64(0)\n\n\tif err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {\n\t\tvar created float64 // use float64 for rqlite compatibility\n\t\tvar id string\n\t\tvar messageID string\n\t\tvar subject string\n\t\tvar metadata string\n\t\tvar size float64 // use float64 for rqlite compatibility\n\t\tvar attachments int\n\t\tvar read int\n\t\tvar snippet string\n\t\tvar ignore string\n\n\t\tif err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &snippet, &ignore, &ignore, &ignore, &ignore, &ignore); err != nil {\n\t\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t\t\treturn\n\t\t}\n\n\t\tids = append(ids, id)\n\t\tdeleteSize = deleteSize + uint64(size)\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\tif len(ids) > 0 {\n\t\ttotal := len(ids)\n\n\t\t// split ids into chunks of 1000 ids\n\t\tvar chunks [][]string\n\t\tif total > 1000 {\n\t\t\tchunkSize := 1000\n\t\t\tchunks = make([][]string, 0, (len(ids)+chunkSize-1)/chunkSize)\n\t\t\tfor chunkSize < len(ids) {\n\t\t\t\tids, chunks = ids[chunkSize:], append(chunks, ids[0:chunkSize:chunkSize])\n\t\t\t}\n\t\t\tif len(ids) > 0 {\n\t\t\t\t// add remaining ids <= 1000\n\t\t\t\tchunks = append(chunks, ids)\n\t\t\t}\n\t\t} else {\n\t\t\tchunks = append(chunks, ids)\n\t\t}\n\n\t\t// begin a transaction to ensure both the message\n\t\t// and data are deleted successfully\n\t\ttx, err := db.BeginTx(context.Background(), nil)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// roll back if it fails\n\t\tdefer func() { _ = tx.Rollback() }()\n\n\t\tfor _, ids := range chunks {\n\t\t\tdelIDs := make([]any, len(ids))\n\t\t\tfor i, id := range ids {\n\t\t\t\tdelIDs[i] = id\n\t\t\t}\n\n\t\t\tsqlDelete1 := `DELETE FROM ` + tenant(\"mailbox\") + ` WHERE ID IN (?` + strings.Repeat(\",?\", len(ids)-1) + `)` // #nosec\n\n\t\t\t_, err = tx.Exec(sqlDelete1, delIDs...)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tsqlDelete2 := `DELETE FROM ` + tenant(\"mailbox_data\") + ` WHERE ID IN (?` + strings.Repeat(\",?\", len(ids)-1) + `)` // #nosec\n\n\t\t\t_, err = tx.Exec(sqlDelete2, delIDs...)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tsqlDelete3 := `DELETE FROM ` + tenant(\"message_tags\") + ` WHERE ID IN (?` + strings.Repeat(\",?\", len(ids)-1) + `)` // #nosec\n\n\t\t\t_, err = tx.Exec(sqlDelete3, delIDs...)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\tif err := tx.Commit(); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := pruneUnusedTags(); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tlogger.Log().Debugf(\"[db] deleted %d messages matching %s\", total, search)\n\n\t\tdbLastAction = time.Now()\n\n\t\t// broadcast changes\n\t\tif len(ids) > 200 {\n\t\t\twebsockets.Broadcast(\"prune\", nil)\n\t\t} else {\n\t\t\tfor _, id := range ids {\n\t\t\t\td := struct {\n\t\t\t\t\tID string\n\t\t\t\t}{ID: id}\n\t\t\t\twebsockets.Broadcast(\"delete\", d)\n\t\t\t}\n\t\t}\n\n\t\taddDeletedSize(deleteSize)\n\n\t\tlogMessagesDeleted(total)\n\n\t\tBroadcastMailboxStats()\n\t}\n\n\treturn nil\n}\n\n// SetSearchReadStatus marks all messages matching the search as read or unread\nfunc SetSearchReadStatus(search, timezone string, read bool) error {\n\tq := searchQueryBuilder(search, timezone).Where(\"Read = ?\", !read)\n\n\tids := []string{}\n\n\tif err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {\n\t\tvar created float64 // use float64 for rqlite compatibility\n\t\tvar id string\n\t\tvar messageID string\n\t\tvar subject string\n\t\tvar metadata string\n\t\tvar size float64 // use float64 for rqlite compatibility\n\t\tvar attachments int\n\t\tvar read int\n\t\tvar snippet string\n\t\tvar ignore string\n\n\t\tif err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &snippet, &ignore, &ignore, &ignore, &ignore, &ignore); err != nil {\n\t\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t\t\treturn\n\t\t}\n\n\t\tids = append(ids, id)\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\tif read {\n\t\tif err := MarkRead(ids); err != nil {\n\t\t\treturn err\n\t\t}\n\t} else {\n\t\tif err := MarkUnread(ids); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// SearchParser returns the SQL syntax for the database search based on the search arguments\nfunc searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {\n\t// group strings with quotes as a single argument and remove quotes\n\targs := tools.ArgsParser(searchString)\n\n\tloc := time.Local\n\tif timezone != \"\" {\n\t\tif l, err := time.LoadLocation(timezone); err != nil {\n\t\t\tlogger.Log().Warnf(\"ignoring invalid timezone:\\\"%s\\\"\", timezone)\n\t\t} else {\n\t\t\tloc = l\n\t\t}\n\t}\n\n\tq := sqlf.From(tenant(\"mailbox\") + \" m\").\n\t\tSelect(`m.Created, m.ID, m.MessageID, m.Subject, m.Metadata, m.Size, m.Attachments, m.Read,\n\t\t\tm.Snippet,\n\t\t\tIFNULL(json_extract(Metadata, '$.To'), '{}') as ToJSON,\n\t\t\tIFNULL(json_extract(Metadata, '$.From'), '{}') as FromJSON,\n\t\t\tIFNULL(json_extract(Metadata, '$.Cc'), '{}') as CcJSON,\n\t\t\tIFNULL(json_extract(Metadata, '$.Bcc'), '{}') as BccJSON,\n\t\t\tIFNULL(json_extract(Metadata, '$.ReplyTo'), '{}') as ReplyToJSON\n\t\t`).\n\t\tOrderBy(\"m.Created DESC\")\n\n\tfor _, w := range args {\n\t\tif cleanString(w) == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\t// lowercase search to try match search prefixes\n\t\tlw := strings.ToLower(w)\n\n\t\texclude := false\n\t\t// search terms starting with a `-` or `!` imply an exclude\n\t\tif len(w) > 1 && (strings.HasPrefix(w, \"-\") || strings.HasPrefix(w, \"!\")) {\n\t\t\texclude = true\n\t\t\tw = w[1:]\n\t\t\tlw = lw[1:]\n\t\t}\n\n\t\t// ignore blank searches\n\t\tif len(w) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tif strings.HasPrefix(lw, \"to:\") {\n\t\t\tw = cleanString(w[3:])\n\t\t\tif w != \"\" {\n\t\t\t\tif exclude {\n\t\t\t\t\tq.Where(\"ToJSON NOT LIKE ?\", \"%\"+escPercentChar(w)+\"%\")\n\t\t\t\t} else {\n\t\t\t\t\tq.Where(\"ToJSON LIKE ?\", \"%\"+escPercentChar(w)+\"%\")\n\t\t\t\t}\n\t\t\t}\n\t\t} else if strings.HasPrefix(lw, \"from:\") {\n\t\t\tw = cleanString(w[5:])\n\t\t\tif w != \"\" {\n\t\t\t\tif exclude {\n\t\t\t\t\tq.Where(\"FromJSON NOT LIKE ?\", \"%\"+escPercentChar(w)+\"%\")\n\t\t\t\t} else {\n\t\t\t\t\tq.Where(\"FromJSON LIKE ?\", \"%\"+escPercentChar(w)+\"%\")\n\t\t\t\t}\n\t\t\t}\n\t\t} else if strings.HasPrefix(lw, \"cc:\") {\n\t\t\tw = cleanString(w[3:])\n\t\t\tif w != \"\" {\n\t\t\t\tif exclude {\n\t\t\t\t\tq.Where(\"CcJSON NOT LIKE ?\", \"%\"+escPercentChar(w)+\"%\")\n\t\t\t\t} else {\n\t\t\t\t\tq.Where(\"CcJSON LIKE ?\", \"%\"+escPercentChar(w)+\"%\")\n\t\t\t\t}\n\t\t\t}\n\t\t} else if strings.HasPrefix(lw, \"bcc:\") {\n\t\t\tw = cleanString(w[4:])\n\t\t\tif w != \"\" {\n\t\t\t\tif exclude {\n\t\t\t\t\tq.Where(\"BccJSON NOT LIKE ?\", \"%\"+escPercentChar(w)+\"%\")\n\t\t\t\t} else {\n\t\t\t\t\tq.Where(\"BccJSON LIKE ?\", \"%\"+escPercentChar(w)+\"%\")\n\t\t\t\t}\n\t\t\t}\n\t\t} else if strings.HasPrefix(lw, \"reply-to:\") {\n\t\t\tw = cleanString(w[9:])\n\t\t\tif w != \"\" {\n\t\t\t\tif exclude {\n\t\t\t\t\tq.Where(\"ReplyToJSON NOT LIKE ?\", \"%\"+escPercentChar(w)+\"%\")\n\t\t\t\t} else {\n\t\t\t\t\tq.Where(\"ReplyToJSON LIKE ?\", \"%\"+escPercentChar(w)+\"%\")\n\t\t\t\t}\n\t\t\t}\n\t\t} else if strings.HasPrefix(lw, \"addressed:\") {\n\t\t\tw = cleanString(w[10:])\n\t\t\targ := \"%\" + escPercentChar(w) + \"%\"\n\t\t\tif w != \"\" {\n\t\t\t\tif exclude {\n\t\t\t\t\tq.Where(\"(ToJSON NOT LIKE ? AND FromJSON NOT LIKE ? AND CcJSON NOT LIKE ? AND BccJSON NOT LIKE ? AND ReplyToJSON NOT LIKE ?)\", arg, arg, arg, arg, arg)\n\t\t\t\t} else {\n\t\t\t\t\tq.Where(\"(ToJSON LIKE ? OR FromJSON LIKE ? OR CcJSON LIKE ? OR BccJSON LIKE ? OR ReplyToJSON LIKE ?)\", arg, arg, arg, arg, arg)\n\t\t\t\t}\n\t\t\t}\n\t\t} else if strings.HasPrefix(lw, \"subject:\") {\n\t\t\tw = w[8:]\n\t\t\tif w != \"\" {\n\t\t\t\tif exclude {\n\t\t\t\t\tq.Where(\"Subject NOT LIKE ?\", \"%\"+escPercentChar(w)+\"%\")\n\t\t\t\t} else {\n\t\t\t\t\tq.Where(\"Subject LIKE ?\", \"%\"+escPercentChar(w)+\"%\")\n\t\t\t\t}\n\t\t\t}\n\t\t} else if strings.HasPrefix(lw, \"message-id:\") {\n\t\t\tw = cleanString(w[11:])\n\t\t\tif w != \"\" {\n\t\t\t\tif exclude {\n\t\t\t\t\tq.Where(\"MessageID NOT LIKE ?\", \"%\"+escPercentChar(w)+\"%\")\n\t\t\t\t} else {\n\t\t\t\t\tq.Where(\"MessageID LIKE ?\", \"%\"+escPercentChar(w)+\"%\")\n\t\t\t\t}\n\t\t\t}\n\t\t} else if strings.HasPrefix(lw, \"tag:\") {\n\t\t\tw = cleanString(w[4:])\n\t\t\tif w != \"\" {\n\t\t\t\tif exclude {\n\t\t\t\t\tq.Where(`m.ID NOT IN (SELECT mt.ID FROM `+tenant(\"message_tags\")+` mt JOIN `+tenant(\"tags\")+` t ON mt.TagID = t.ID WHERE t.Name = ?)`, w)\n\t\t\t\t} else {\n\t\t\t\t\tq.Where(`m.ID IN (SELECT mt.ID FROM `+tenant(\"message_tags\")+` mt JOIN `+tenant(\"tags\")+` t ON mt.TagID = t.ID WHERE t.Name = ?)`, w)\n\t\t\t\t}\n\t\t\t}\n\t\t} else if lw == \"is:read\" {\n\t\t\tif exclude {\n\t\t\t\tq.Where(\"Read = 0\")\n\t\t\t} else {\n\t\t\t\tq.Where(\"Read = 1\")\n\t\t\t}\n\t\t} else if lw == \"is:unread\" {\n\t\t\tif exclude {\n\t\t\t\tq.Where(\"Read = 1\")\n\t\t\t} else {\n\t\t\t\tq.Where(\"Read = 0\")\n\t\t\t}\n\t\t} else if lw == \"is:tagged\" {\n\t\t\tif exclude {\n\t\t\t\tq.Where(`m.ID NOT IN (SELECT DISTINCT mt.ID FROM ` + tenant(\"message_tags\") + ` mt JOIN ` + tenant(\"tags\") + ` t ON mt.TagID = t.ID)`)\n\t\t\t} else {\n\t\t\t\tq.Where(`m.ID IN (SELECT DISTINCT mt.ID FROM ` + tenant(\"message_tags\") + ` mt JOIN ` + tenant(\"tags\") + ` t ON mt.TagID = t.ID)`)\n\t\t\t}\n\t\t} else if lw == \"has:inline\" || lw == \"has:inlines\" {\n\t\t\tif exclude {\n\t\t\t\tq.Where(\"Inline = 0\")\n\t\t\t} else {\n\t\t\t\tq.Where(\"Inline > 0\")\n\t\t\t}\n\t\t} else if lw == \"has:attachment\" || lw == \"has:attachments\" {\n\t\t\tif exclude {\n\t\t\t\tq.Where(\"Attachments = 0\")\n\t\t\t} else {\n\t\t\t\tq.Where(\"Attachments > 0\")\n\t\t\t}\n\t\t} else if strings.HasPrefix(lw, \"after:\") {\n\t\t\tw = cleanString(w[6:])\n\t\t\tif w != \"\" {\n\t\t\t\tt, err := dateparse.ParseIn(w, loc)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.Log().Warnf(\"ignoring invalid after: date \\\"%s\\\"\", w)\n\t\t\t\t} else {\n\t\t\t\t\ttimestamp := t.UnixMilli()\n\t\t\t\t\tif exclude {\n\t\t\t\t\t\tq.Where(`m.Created <= ?`, timestamp)\n\t\t\t\t\t} else {\n\t\t\t\t\t\tq.Where(`m.Created >= ?`, timestamp)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t} else if strings.HasPrefix(lw, \"before:\") {\n\t\t\tw = cleanString(w[7:])\n\t\t\tif w != \"\" {\n\t\t\t\tt, err := dateparse.ParseIn(w, loc)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.Log().Warnf(\"ignoring invalid before: date \\\"%s\\\"\", w)\n\t\t\t\t} else {\n\t\t\t\t\ttimestamp := t.UnixMilli()\n\t\t\t\t\tif exclude {\n\t\t\t\t\t\tq.Where(`m.Created >= ?`, timestamp)\n\t\t\t\t\t} else {\n\t\t\t\t\t\tq.Where(`m.Created <= ?`, timestamp)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t} else if strings.HasPrefix(lw, \"larger:\") && sizeToBytes(cleanString(w[7:])) > 0 {\n\t\t\tw = cleanString(w[7:])\n\t\t\tsize := sizeToBytes(w)\n\t\t\tif exclude {\n\t\t\t\tq.Where(\"Size < ?\", size)\n\t\t\t} else {\n\t\t\t\tq.Where(\"Size > ?\", size)\n\t\t\t}\n\t\t} else if strings.HasPrefix(lw, \"smaller:\") && sizeToBytes(cleanString(w[8:])) > 0 {\n\t\t\tw = cleanString(w[8:])\n\t\t\tsize := sizeToBytes(w)\n\t\t\tif exclude {\n\t\t\t\tq.Where(\"Size > ?\", size)\n\t\t\t} else {\n\t\t\t\tq.Where(\"Size < ?\", size)\n\t\t\t}\n\t\t} else {\n\t\t\t// search text\n\t\t\tif exclude {\n\t\t\t\tq.Where(\"SearchText NOT LIKE ?\", \"%\"+cleanString(escPercentChar(strings.ToLower(w)))+\"%\")\n\t\t\t} else {\n\t\t\t\tq.Where(\"SearchText LIKE ?\", \"%\"+cleanString(escPercentChar(strings.ToLower(w)))+\"%\")\n\t\t\t}\n\t\t}\n\t}\n\n\treturn q\n}\n\n// Simple function to return a size in bytes, eg 2kb, 4MB or 1.5m.\n//\n// K, k, Kb, KB, kB and kb are treated as Kilobytes.\n// M, m, Mb, MB and mb are treated as Megabytes.\nfunc sizeToBytes(v string) uint64 {\n\tv = strings.ToLower(v)\n\tre := regexp.MustCompile(`^(\\d+)(\\.\\d+)?\\s?([a-z]{1,2})?$`)\n\n\tm := re.FindAllStringSubmatch(v, -1)\n\tif len(m) == 0 {\n\t\treturn 0\n\t}\n\n\tval := fmt.Sprintf(\"%s%s\", m[0][1], m[0][2])\n\tunit := m[0][3]\n\n\ti, err := strconv.ParseFloat(strings.TrimSpace(val), 64)\n\tif err != nil {\n\t\treturn 0\n\t}\n\n\tif unit == \"\" {\n\t\treturn uint64(i)\n\t}\n\n\tif unit == \"k\" || unit == \"kb\" {\n\t\treturn uint64(i * 1024)\n\t}\n\n\tif unit == \"m\" || unit == \"mb\" {\n\t\treturn uint64(i * 1024 * 1024)\n\t}\n\n\treturn 0\n}\n"
  },
  {
    "path": "internal/storage/search_test.go",
    "content": "package storage\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"math/rand/v2\"\n\t\"testing\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/jhillyerd/enmime/v2\"\n)\n\nfunc TestSearch(t *testing.T) {\n\tfor _, tenantID := range []string{\"\", \"MyServer 3\", \"host.example.com\"} {\n\t\ttenantID = config.DBTenantID(tenantID)\n\n\t\tsetup(tenantID)\n\n\t\tif tenantID == \"\" {\n\t\t\tt.Log(\"Testing search\")\n\t\t} else {\n\t\t\tt.Logf(\"Testing search (tenant %s)\", tenantID)\n\t\t}\n\n\t\tfor i := range testRuns {\n\t\t\tmsg := enmime.Builder().\n\t\t\t\tFrom(fmt.Sprintf(\"From %d\", i), fmt.Sprintf(\"from-%d@example.com\", i)).\n\t\t\t\tCC(fmt.Sprintf(\"CC %d\", i), fmt.Sprintf(\"cc-%d@example.com\", i)).\n\t\t\t\tCC(fmt.Sprintf(\"CC2 %d\", i), fmt.Sprintf(\"cc2-%d@example.com\", i)).\n\t\t\t\tSubject(fmt.Sprintf(\"Subject line %d end\", i)).\n\t\t\t\tText(fmt.Appendf(nil, \"This is the email body %d <jdsauk;dwqmdqw;>.\", i)).\n\t\t\t\tTo(fmt.Sprintf(\"To %d\", i), fmt.Sprintf(\"to-%d@example.com\", i)).\n\t\t\t\tTo(fmt.Sprintf(\"To2 %d\", i), fmt.Sprintf(\"to2-%d@example.com\", i)).\n\t\t\t\tReplyTo(fmt.Sprintf(\"Reply To %d\", i), fmt.Sprintf(\"reply-to-%d@example.com\", i))\n\n\t\t\tenv, err := msg.Build()\n\t\t\tif err != nil {\n\t\t\t\tt.Log(\"error \", err)\n\t\t\t\tt.Fail()\n\t\t\t}\n\n\t\t\tbuf := new(bytes.Buffer)\n\n\t\t\tif err := env.Encode(buf); err != nil {\n\t\t\t\tt.Log(\"error \", err)\n\t\t\t\tt.Fail()\n\t\t\t}\n\n\t\t\tbufBytes := buf.Bytes()\n\n\t\t\tif _, err := Store(&bufBytes, nil); err != nil {\n\t\t\t\tt.Log(\"error \", err)\n\t\t\t\tt.Fail()\n\t\t\t}\n\t\t}\n\n\t\tfor i := 1; i < 51; i++ {\n\t\t\t// search a random something that will return a single result\n\t\t\tuniqueSearches := []string{\n\t\t\t\tfmt.Sprintf(\"from-%d@example.com\", i),\n\t\t\t\tfmt.Sprintf(\"from:from-%d@example.com\", i),\n\t\t\t\tfmt.Sprintf(\"to-%d@example.com\", i),\n\t\t\t\tfmt.Sprintf(\"to:to-%d@example.com\", i),\n\t\t\t\tfmt.Sprintf(\"to2-%d@example.com\", i),\n\t\t\t\tfmt.Sprintf(\"to:to2-%d@example.com\", i),\n\t\t\t\tfmt.Sprintf(\"cc-%d@example.com\", i),\n\t\t\t\tfmt.Sprintf(\"cc:cc-%d@example.com\", i),\n\t\t\t\tfmt.Sprintf(\"cc2-%d@example.com\", i),\n\t\t\t\tfmt.Sprintf(\"cc:cc2-%d@example.com\", i),\n\t\t\t\tfmt.Sprintf(\"reply-to-%d@example.com\", i),\n\t\t\t\tfmt.Sprintf(\"reply-to:\\\"reply-to-%d@example.com\\\"\", i),\n\t\t\t\tfmt.Sprintf(\"\\\"Subject line %d end\\\"\", i),\n\t\t\t\tfmt.Sprintf(\"subject:\\\"Subject line %d end\\\"\", i),\n\t\t\t\tfmt.Sprintf(\"\\\"the email body %d jdsauk dwqmdqw\\\"\", i),\n\t\t\t}\n\t\t\tsearchIdx := rand.IntN(len(uniqueSearches))\n\n\t\t\tsearch := uniqueSearches[searchIdx]\n\n\t\t\tsummaries, _, err := Search(search, \"\", 0, 0, 100)\n\t\t\tif err != nil {\n\t\t\t\tt.Log(\"error \", err)\n\t\t\t\tt.Fail()\n\t\t\t}\n\n\t\t\tassertEqual(t, len(summaries), 1, \"search result expected\")\n\n\t\t\tassertEqual(t, summaries[0].From.Name, fmt.Sprintf(\"From %d\", i), \"\\\"From\\\" name does not match\")\n\t\t\tassertEqual(t, summaries[0].From.Address, fmt.Sprintf(\"from-%d@example.com\", i), \"\\\"From\\\" address does not match\")\n\t\t\tassertEqual(t, summaries[0].To[0].Name, fmt.Sprintf(\"To %d\", i), \"\\\"To\\\" name does not match\")\n\t\t\tassertEqual(t, summaries[0].To[0].Address, fmt.Sprintf(\"to-%d@example.com\", i), \"\\\"To\\\" address does not match\")\n\t\t\tassertEqual(t, summaries[0].Subject, fmt.Sprintf(\"Subject line %d end\", i), \"\\\"Subject\\\" does not match\")\n\t\t}\n\n\t\t// search something that will return 200 results\n\t\tsummaries, _, err := Search(\"This is the email body\", \"\", 0, 0, testRuns)\n\t\tif err != nil {\n\t\t\tt.Log(\"error \", err)\n\t\t\tt.Fail()\n\t\t}\n\t\tassertEqual(t, len(summaries), testRuns, \"search results expected\")\n\n\t\tClose()\n\t}\n}\n\nfunc TestSearchDelete100(t *testing.T) {\n\tfor _, tenantID := range []string{\"\", \"MyServer 3\", \"host.example.com\"} {\n\t\ttenantID = config.DBTenantID(tenantID)\n\n\t\tsetup(tenantID)\n\n\t\tif tenantID == \"\" {\n\t\t\tt.Log(\"Testing search delete of 100 messages\")\n\t\t} else {\n\t\t\tt.Logf(\"Testing search delete of 100 messages (tenant %s)\", tenantID)\n\t\t}\n\n\t\tfor range 100 {\n\t\t\tif _, err := Store(&testTextEmail, nil); err != nil {\n\t\t\t\tt.Log(\"error \", err)\n\t\t\t\tt.Fail()\n\t\t\t}\n\t\t\tif _, err := Store(&testMimeEmail, nil); err != nil {\n\t\t\t\tt.Log(\"error \", err)\n\t\t\t\tt.Fail()\n\t\t\t}\n\t\t}\n\n\t\t_, total, err := Search(\"from:sender@example.com\", \"\", 0, 0, 100)\n\t\tif err != nil {\n\t\t\tt.Log(\"error \", err)\n\t\t\tt.Fail()\n\t\t}\n\n\t\tassertEqual(t, total, 100, \"100 search results expected\")\n\n\t\tif err := DeleteSearch(\"from:sender@example.com\", \"\"); err != nil {\n\t\t\tt.Log(\"error \", err)\n\t\t\tt.Fail()\n\t\t}\n\n\t\t_, total, err = Search(\"from:sender@example.com\", \"\", 0, 0, 100)\n\t\tif err != nil {\n\t\t\tt.Log(\"error \", err)\n\t\t\tt.Fail()\n\t\t}\n\n\t\tassertEqual(t, total, 0, \"0 search results expected\")\n\n\t\tClose()\n\t}\n}\n\nfunc TestSearchDelete1100(t *testing.T) {\n\tsetup(\"\")\n\tdefer Close()\n\n\tt.Log(\"Testing search delete of 1100 messages\")\n\tfor range 1100 {\n\t\tif _, err := Store(&testTextEmail, nil); err != nil {\n\t\t\tt.Log(\"error \", err)\n\t\t\tt.Fail()\n\t\t}\n\t}\n\n\t_, total, err := Search(\"from:sender@example.com\", \"\", 0, 0, 100)\n\tif err != nil {\n\t\tt.Log(\"error \", err)\n\t\tt.Fail()\n\t}\n\n\tassertEqual(t, total, 1100, \"100 search results expected\")\n\n\tif err := DeleteSearch(\"from:sender@example.com\", \"\"); err != nil {\n\t\tt.Log(\"error \", err)\n\t\tt.Fail()\n\t}\n\n\t_, total, err = Search(\"from:sender@example.com\", \"\", 0, 0, 100)\n\tif err != nil {\n\t\tt.Log(\"error \", err)\n\t\tt.Fail()\n\t}\n\n\tassertEqual(t, total, 0, \"0 search results expected\")\n}\n\nfunc TestEscPercentChar(t *testing.T) {\n\ttests := map[string]string{}\n\ttests[\"this is a test\"] = \"this is a test\"\n\ttests[\"this is% a test\"] = \"this is%% a test\"\n\ttests[\"this is%% a test\"] = \"this is%%%% a test\"\n\ttests[\"this is%%% a test\"] = \"this is%%%%%% a test\"\n\ttests[\"%this is% a test\"] = \"%%this is%% a test\"\n\ttests[\"Ä\"] = \"Ä\"\n\ttests[\"Ä%\"] = \"Ä%%\"\n\n\tfor search, expected := range tests {\n\t\tres := escPercentChar(search)\n\t\tassertEqual(t, res, expected, \"no match\")\n\t}\n}\n\nfunc TestSizeToBytes(t *testing.T) {\n\ttests := map[string]uint64{}\n\ttests[\"1m\"] = 1048576\n\ttests[\"1mb\"] = 1048576\n\ttests[\"1 M\"] = 1048576\n\ttests[\"1 MB\"] = 1048576\n\ttests[\"1k\"] = 1024\n\ttests[\"1kb\"] = 1024\n\ttests[\"1 K\"] = 1024\n\ttests[\"1 kB\"] = 1024\n\ttests[\"1.5M\"] = 1572864\n\ttests[\"1234567890\"] = 1234567890\n\ttests[\"invalid\"] = 0\n\ttests[\"1.2.3\"] = 0\n\ttests[\"1.2.3M\"] = 0\n\n\tfor search, expected := range tests {\n\t\tres := sizeToBytes(search)\n\t\tassertEqual(t, res, expected, \"size does not match\")\n\t}\n}\n"
  },
  {
    "path": "internal/storage/settings.go",
    "content": "package storage\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/leporo/sqlf\"\n)\n\n// SettingGet returns a setting string value, blank is it does not exist\nfunc SettingGet(k string) string {\n\tvar result sql.NullString\n\terr := sqlf.From(tenant(\"settings\")).\n\t\tSelect(\"Value\").To(&result).\n\t\tWhere(\"Key = ?\", k).\n\t\tLimit(1).\n\t\tQueryAndClose(context.TODO(), db, func(_ *sql.Rows) {})\n\tif err != nil {\n\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t\treturn \"\"\n\t}\n\n\treturn result.String\n}\n\n// SettingPut sets a setting string value, inserting if new\nfunc SettingPut(k, v string) error {\n\t_, err := db.Exec(`INSERT INTO `+tenant(\"settings\")+` (Key, Value) VALUES(?, ?) ON CONFLICT(Key) DO UPDATE SET Value = ?`, k, v, v)\n\tif err != nil {\n\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t}\n\n\treturn err\n}\n\n// The total deleted message size as an int64 value\nfunc getDeletedSize() uint64 {\n\tvar result sql.NullFloat64 // use float64 for rqlite compatibility\n\terr := sqlf.From(tenant(\"settings\")).\n\t\tSelect(\"Value\").To(&result).\n\t\tWhere(\"Key = ?\", \"DeletedSize\").\n\t\tLimit(1).\n\t\tQueryAndClose(context.TODO(), db, func(_ *sql.Rows) {})\n\tif err != nil {\n\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t\treturn 0\n\t}\n\n\treturn uint64(result.Float64)\n}\n\n// The total raw non-compressed messages size in bytes of all messages in the database\nfunc totalMessagesSize() uint64 {\n\tvar result sql.NullFloat64\n\terr := sqlf.From(tenant(\"mailbox\")).\n\t\tSelect(\"SUM(Size)\").To(&result).\n\t\tQueryAndClose(context.TODO(), db, func(_ *sql.Rows) {})\n\tif err != nil {\n\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t\treturn 0\n\t}\n\n\treturn uint64(result.Float64)\n}\n\n// AddDeletedSize will add the value to the DeletedSize setting\nfunc addDeletedSize(v uint64) {\n\tif _, err := db.Exec(`INSERT OR IGNORE INTO `+tenant(\"settings\")+` (Key, Value) VALUES(?, ?)`, \"DeletedSize\", 0); err != nil {\n\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t}\n\n\tif _, err := db.Exec(`UPDATE `+tenant(\"settings\")+` SET Value = Value + ? WHERE Key = ?`, v, \"DeletedSize\"); err != nil {\n\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t}\n}\n"
  },
  {
    "path": "internal/storage/structs.go",
    "content": "package storage\n\nimport (\n\t\"net/mail\"\n\t\"time\"\n)\n\n// Message data excluding physical attachments\n//\n// swagger:model Message\ntype Message struct {\n\t// Database ID\n\tID string\n\t// Message ID\n\tMessageID string\n\t// From address\n\tFrom *mail.Address\n\t// To addresses\n\tTo []*mail.Address\n\t// Cc addresses\n\tCc []*mail.Address\n\t// Bcc addresses\n\tBcc []*mail.Address\n\t// ReplyTo addresses\n\tReplyTo []*mail.Address\n\t// Return-Path\n\tReturnPath string\n\t// Message subject\n\tSubject string\n\t// List-Unsubscribe header information\n\tListUnsubscribe ListUnsubscribe\n\t// Message RFC3339Nano date & time (if set), else date & time received\n\t// ([extended RFC3339](https://tools.ietf.org/html/rfc3339#section-5.6) format with optional nano seconds)\n\tDate time.Time\n\t// Message tags\n\tTags []string\n\t// Username used for authentication (if provided) with the SMTP or Send API\n\tUsername string\n\t// Message body text\n\tText string\n\t// Message body HTML\n\tHTML string\n\t// Message size in bytes\n\tSize uint64\n\t// Inline message attachments\n\tInline []Attachment\n\t// Message attachments\n\tAttachments []Attachment\n}\n\n// Attachment struct for inline images and attachments\n//\n// swagger:model Attachment\ntype Attachment struct {\n\t// Attachment part ID\n\tPartID string\n\t// File name\n\tFileName string\n\t// Content type\n\tContentType string\n\t// Content ID\n\tContentID string\n\t// Size in bytes\n\tSize uint64\n\t// File checksums\n\tChecksums struct {\n\t\t// MD5 checksum hash of file\n\t\tMD5 string\n\t\t// SHA1 checksum hash of file\n\t\tSHA1 string\n\t\t// SHA256 checksum hash of file\n\t\tSHA256 string\n\t}\n}\n\n// MessageSummary struct for frontend messages\n//\n// swagger:model MessageSummary\ntype MessageSummary struct {\n\t// Database ID\n\tID string\n\t// Message ID\n\tMessageID string\n\t// Read status\n\tRead bool\n\t// From address\n\tFrom *mail.Address\n\t// To address\n\tTo []*mail.Address\n\t// Cc addresses\n\tCc []*mail.Address\n\t// Bcc addresses\n\tBcc []*mail.Address\n\t// Reply-To address\n\tReplyTo []*mail.Address\n\t// Email subject\n\tSubject string\n\t// Received RFC3339Nano date & time ([extended RFC3339](https://tools.ietf.org/html/rfc3339#section-5.6) format with optional nano seconds)\n\tCreated time.Time\n\t// Username used for authentication (if provided) with the SMTP or Send API\n\tUsername string\n\t// Message tags\n\tTags []string\n\t// Message size in bytes (total)\n\tSize uint64\n\t// Whether the message has any attachments\n\tAttachments int\n\t// Message snippet includes up to 250 characters\n\tSnippet string\n}\n\n// MailboxStats struct for quick mailbox total/read lookups\ntype MailboxStats struct {\n\tTotal  uint64\n\tUnread uint64\n\tTags   []string\n}\n\n// Metadata struct for storing message metadata\ntype Metadata struct {\n\tFrom     *mail.Address   `json:\"From,omitempty\"`\n\tTo       []*mail.Address `json:\"To,omitempty\"`\n\tCc       []*mail.Address `json:\"Cc,omitempty\"`\n\tBcc      []*mail.Address `json:\"Bcc,omitempty\"`\n\tReplyTo  []*mail.Address `json:\"ReplyTo,omitempty\"`\n\tUsername string          `json:\"Username,omitempty\"`\n}\n\n// ListUnsubscribe contains a summary of List-Unsubscribe & List-Unsubscribe-Post headers\n// including validation of the link structure\ntype ListUnsubscribe struct {\n\t// List-Unsubscribe header value\n\tHeader string\n\t// Detected links, maximum one email and one HTTP(S) link\n\tLinks []string\n\t// Validation errors (if any)\n\tErrors string\n\t// List-Unsubscribe-Post value (if set)\n\tHeaderPost string\n}\n"
  },
  {
    "path": "internal/storage/tagfilters.go",
    "content": "package storage\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"strings\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/axllent/mailpit/internal/tools\"\n\t\"github.com/leporo/sqlf\"\n)\n\n// TagFilter struct\ntype TagFilter struct {\n\t// Match is the user-defined match\n\tMatch string\n\t// SQL represents the SQL equivalent of Match\n\tSQL *sqlf.Stmt\n\t// Tags to add on match\n\tTags []string\n}\n\nvar tagFilters = []TagFilter{}\n\n// LoadTagFilters loads tag filters from the config and pre-generates the SQL query\nfunc LoadTagFilters() {\n\ttagFilters = []TagFilter{}\n\n\tfor _, t := range config.TagFilters {\n\t\tmatch := strings.TrimSpace(t.Match)\n\t\tif match == \"\" {\n\t\t\tlogger.Log().Warnf(\"[tags] ignoring tag item with missing 'match'\")\n\t\t\tcontinue\n\t\t}\n\t\tif len(t.Tags) == 0 {\n\t\t\tlogger.Log().Warnf(\"[tags] ignoring tag items with missing 'tags' array\")\n\t\t\tcontinue\n\t\t}\n\n\t\tvalidTags := []string{}\n\t\tfor _, tag := range t.Tags {\n\t\t\ttagName := tools.CleanTag(tag)\n\t\t\tif !config.ValidTagRegexp.MatchString(tagName) || len(tagName) == 0 {\n\t\t\t\tlogger.Log().Warnf(\"[tags] invalid tag (%s) - can only contain spaces, letters, numbers, - & _\", tagName)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidTags = append(validTags, tagName)\n\t\t}\n\n\t\tif len(validTags) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\ttagFilters = append(tagFilters, TagFilter{Match: match, Tags: validTags, SQL: searchQueryBuilder(match, \"\")})\n\t}\n}\n\n// TagFilterMatches returns a slice of matching tags from a message\nfunc tagFilterMatches(id string) []string {\n\ttags := []string{}\n\n\tif len(tagFilters) == 0 {\n\t\treturn tags\n\t}\n\n\tfor _, f := range tagFilters {\n\t\tvar matchID string\n\t\tq := f.SQL.Clone().Where(\"ID = ?\", id)\n\t\tif err := q.QueryAndClose(context.Background(), db, func(row *sql.Rows) {\n\t\t\tvar ignore sql.NullString\n\n\t\t\tif err := row.Scan(&ignore, &matchID, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore); err != nil {\n\t\t\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t\t\t\treturn\n\t\t\t}\n\t\t}); err != nil {\n\t\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t\t\treturn tags\n\t\t}\n\t\tif matchID == id {\n\t\t\ttags = append(tags, f.Tags...)\n\t\t}\n\t}\n\n\treturn tags\n}\n"
  },
  {
    "path": "internal/storage/tags.go",
    "content": "package storage\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"database/sql\"\n\t\"fmt\"\n\t\"regexp\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/axllent/mailpit/internal/tools\"\n\t\"github.com/axllent/mailpit/server/websockets\"\n\t\"github.com/leporo/sqlf\"\n)\n\nvar (\n\taddressPlusRe = regexp.MustCompile(`(?U)^(.*){1,}\\+(.*)@`)\n\taddTagMutex   sync.RWMutex\n)\n\n// SetMessageTags will set the tags for a given database ID, removing any not in the array\nfunc SetMessageTags(id string, tags []string) ([]string, error) {\n\tapplyTags := []string{}\n\tfor _, t := range tags {\n\t\tt = tools.CleanTag(t)\n\t\tif t != \"\" && config.ValidTagRegexp.MatchString(t) && !tools.InArray(t, applyTags) {\n\t\t\tapplyTags = append(applyTags, t)\n\t\t}\n\t}\n\n\ttagNames := []string{}\n\tcurrentTags := getMessageTags(id)\n\torigTagCount := len(currentTags)\n\n\tfor _, t := range applyTags {\n\t\tif t == \"\" || !config.ValidTagRegexp.MatchString(t) || tools.InArray(t, currentTags) {\n\t\t\tcontinue\n\t\t}\n\n\t\tname, err := addMessageTag(id, t)\n\t\tif err != nil {\n\t\t\treturn []string{}, err\n\t\t}\n\n\t\ttagNames = append(tagNames, name)\n\t}\n\n\tif origTagCount > 0 {\n\t\tcurrentTags = getMessageTags(id)\n\n\t\tfor _, t := range currentTags {\n\t\t\tif !tools.InArray(t, applyTags) {\n\t\t\t\tif err := deleteMessageTag(id, t); err != nil {\n\t\t\t\t\treturn []string{}, err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\td := struct {\n\t\tID   string\n\t\tTags []string\n\t}{ID: id, Tags: applyTags}\n\n\twebsockets.Broadcast(\"update\", d)\n\n\treturn tagNames, nil\n}\n\n// AddMessageTag adds a tag to a message\nfunc addMessageTag(id, name string) (string, error) {\n\t// prevent two identical tags being added at the same time\n\taddTagMutex.Lock()\n\n\tvar tagID int\n\tvar foundName sql.NullString\n\n\tq := sqlf.From(tenant(\"tags\")).\n\t\tSelect(\"ID\").To(&tagID).\n\t\tSelect(\"Name\").To(&foundName).\n\t\tWhere(\"Name = ?\", name)\n\n\t// if tag exists - add tag to message\n\tif err := q.QueryRowAndClose(context.TODO(), db); err == nil {\n\t\taddTagMutex.Unlock()\n\t\t// check message does not already have this tag\n\t\tvar exists int\n\n\t\tif err := sqlf.From(tenant(\"message_tags\")).\n\t\t\tSelect(\"COUNT(ID)\").To(&exists).\n\t\t\tWhere(\"ID = ?\", id).\n\t\t\tWhere(\"TagID = ?\", tagID).\n\t\t\tQueryRowAndClose(context.Background(), db); err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tif exists > 0 {\n\t\t\t// already exists\n\t\t\treturn foundName.String, nil\n\t\t}\n\n\t\tlogger.Log().Debugf(\"[tags] adding tag \\\"%s\\\" to %s\", name, id)\n\n\t\t_, err := sqlf.InsertInto(tenant(\"message_tags\")).\n\t\t\tSet(\"ID\", id).\n\t\t\tSet(\"TagID\", tagID).\n\t\t\tExecAndClose(context.TODO(), db)\n\n\t\treturn foundName.String, err\n\t}\n\n\t// new tag, add to the database\n\tif _, err := sqlf.InsertInto(tenant(\"tags\")).\n\t\tSet(\"Name\", name).\n\t\tExecAndClose(context.TODO(), db); err != nil {\n\t\taddTagMutex.Unlock()\n\t\treturn name, err\n\t}\n\n\taddTagMutex.Unlock()\n\n\t// add tag to the message\n\treturn addMessageTag(id, name)\n}\n\n// DeleteMessageTag deletes a tag from a message\nfunc deleteMessageTag(id, name string) error {\n\tif _, err := sqlf.DeleteFrom(tenant(\"message_tags\")).\n\t\tWhere(tenant(\"message_tags.ID\")+\" = ?\", id).\n\t\tWhere(tenant(\"message_tags.Key\")+` IN (SELECT Key FROM `+tenant(\"message_tags\")+` LEFT JOIN `+tenant(\"tags\")+` ON TagID=`+tenant(\"tags.ID\")+` WHERE Name = ?)`, name).\n\t\tExecAndClose(context.TODO(), db); err != nil {\n\t\treturn err\n\t}\n\n\treturn pruneUnusedTags()\n}\n\n// GetAllTags returns all used tags\nfunc GetAllTags() []string {\n\tvar tags = []string{}\n\tvar name string\n\n\tif err := sqlf.\n\t\tSelect(`DISTINCT Name`).\n\t\tFrom(tenant(\"tags\")).To(&name).\n\t\tOrderBy(\"Name\").\n\t\tQueryAndClose(context.TODO(), db, func(_ *sql.Rows) {\n\t\t\ttags = append(tags, name)\n\t\t}); err != nil {\n\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t}\n\n\treturn tags\n}\n\n// GetAllTagsCount returns all used tags with their total messages\nfunc GetAllTagsCount() map[string]int64 {\n\tvar tags = make(map[string]int64)\n\tvar name string\n\tvar total float64 // use float64 for rqlite compatibility\n\n\tif err := sqlf.\n\t\tSelect(`Name`).To(&name).\n\t\tSelect(`COUNT(`+tenant(\"message_tags.TagID\")+`) as total`).To(&total).\n\t\tFrom(tenant(\"tags\")).\n\t\tLeftJoin(tenant(\"message_tags\"), tenant(\"tags.ID\")+\" = \"+tenant(\"message_tags.TagID\")).\n\t\tGroupBy(tenant(\"message_tags.TagID\")).\n\t\tOrderBy(\"Name\").\n\t\tQueryAndClose(context.TODO(), db, func(_ *sql.Rows) {\n\t\t\ttags[name] = int64(total)\n\t\t}); err != nil {\n\t\tlogger.Log().Errorf(\"[db] %s\", err.Error())\n\t}\n\n\treturn tags\n}\n\n// RenameTag renames a tag\nfunc RenameTag(from, to string) error {\n\tto = tools.CleanTag(to)\n\tif to == \"\" || !config.ValidTagRegexp.MatchString(to) {\n\t\treturn fmt.Errorf(\"invalid tag name: %s\", to)\n\t}\n\n\tif from == to {\n\t\treturn nil // ignore\n\t}\n\n\tvar id, existsID int\n\n\tq := sqlf.From(tenant(\"tags\")).\n\t\tSelect(`ID`).To(&id).\n\t\tWhere(`Name = ?`, from).\n\t\tLimit(1)\n\terr := q.QueryRowAndClose(context.Background(), db)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"tag not found: %s\", from)\n\t}\n\n\t// check if another tag by this name already exists\n\tq = sqlf.From(tenant(\"tags\")).\n\t\tSelect(\"ID\").To(&existsID).\n\t\tWhere(`Name = ?`, to).\n\t\tWhere(`ID != ?`, id).\n\t\tLimit(1)\n\terr = q.QueryRowAndClose(context.Background(), db)\n\tif err == nil || existsID != 0 {\n\t\treturn fmt.Errorf(\"tag already exists: %s\", to)\n\t}\n\n\tq = sqlf.Update(tenant(\"tags\")).\n\t\tSet(\"Name\", to).\n\t\tWhere(\"ID = ?\", id)\n\t_, err = q.ExecAndClose(context.Background(), db)\n\n\treturn err\n}\n\n// DeleteTag deleted a tag and removed all references to the tag\nfunc DeleteTag(tag string) error {\n\tvar id int\n\n\tq := sqlf.From(tenant(\"tags\")).\n\t\tSelect(`ID`).To(&id).\n\t\tWhere(`Name = ?`, tag).\n\t\tLimit(1)\n\terr := q.QueryRowAndClose(context.Background(), db)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"tag not found: %s\", tag)\n\t}\n\n\t// delete all references\n\tq = sqlf.DeleteFrom(tenant(\"message_tags\")).\n\t\tWhere(`TagID = ?`, id)\n\t_, err = q.ExecAndClose(context.Background(), db)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error deleting tag references: %s\", err.Error())\n\t}\n\n\t// delete tag\n\tq = sqlf.DeleteFrom(tenant(\"tags\")).\n\t\tWhere(`ID = ?`, id)\n\t_, err = q.ExecAndClose(context.Background(), db)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error deleting tag: %s\", err.Error())\n\t}\n\n\treturn nil\n}\n\n// PruneUnusedTags will delete all unused tags from the database\nfunc pruneUnusedTags() error {\n\tq := sqlf.From(tenant(\"tags\")).\n\t\tSelect(tenant(\"tags.ID\")+\", \"+tenant(\"tags.Name\")+\", COUNT(\"+tenant(\"message_tags.ID\")+\") as COUNT\").\n\t\tLeftJoin(tenant(\"message_tags\"), tenant(\"tags.ID\")+\" = \"+tenant(\"message_tags.TagID\")).\n\t\tGroupBy(tenant(\"tags.ID\"))\n\n\ttoDel := []int{}\n\n\tif err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {\n\t\tvar n string\n\t\tvar id int\n\t\tvar c int\n\n\t\tif err := row.Scan(&id, &n, &c); err != nil {\n\t\t\tlogger.Log().Errorf(\"[tags] %s\", err.Error())\n\t\t\treturn\n\t\t}\n\n\t\tif c == 0 {\n\t\t\tlogger.Log().Debugf(\"[tags] deleting unused tag \\\"%s\\\"\", n)\n\t\t\ttoDel = append(toDel, id)\n\t\t}\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\tif len(toDel) > 0 {\n\t\tfor _, id := range toDel {\n\t\t\tif _, err := sqlf.DeleteFrom(tenant(\"tags\")).\n\t\t\t\tWhere(\"ID = ?\", id).\n\t\t\t\tExecAndClose(context.TODO(), db); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// Find tags set via --tags in raw message, useful for matching all headers etc.\n// This function is largely superseded by the database searching, however this\n// includes literally everything and is kept for backwards compatibility.\n// Returns a comma-separated string.\nfunc findTagsInRawMessage(message *[]byte) []string {\n\ttags := []string{}\n\tif len(tagFilters) == 0 {\n\t\treturn tags\n\t}\n\n\tstr := bytes.ToLower(*message)\n\tfor _, t := range tagFilters {\n\t\tif bytes.Contains(str, []byte(t.Match)) {\n\t\t\ttags = append(tags, t.Tags...)\n\t\t}\n\t}\n\n\treturn tags\n}\n\n// Returns tags found in email plus addresses (eg: test+tagname@example.com)\nfunc (d Metadata) tagsFromPlusAddresses() []string {\n\ttags := []string{}\n\tfor _, c := range d.To {\n\t\tmatches := addressPlusRe.FindAllStringSubmatch(c.Address, 1)\n\t\tif len(matches) == 1 {\n\t\t\ttags = append(tags, strings.Split(matches[0][2], \"+\")...)\n\t\t}\n\t}\n\tfor _, c := range d.Cc {\n\t\tmatches := addressPlusRe.FindAllStringSubmatch(c.Address, 1)\n\t\tif len(matches) == 1 {\n\t\t\ttags = append(tags, strings.Split(matches[0][2], \"+\")...)\n\t\t}\n\t}\n\tfor _, c := range d.Bcc {\n\t\tmatches := addressPlusRe.FindAllStringSubmatch(c.Address, 1)\n\t\tif len(matches) == 1 {\n\t\t\ttags = append(tags, strings.Split(matches[0][2], \"+\")...)\n\t\t}\n\t}\n\tmatches := addressPlusRe.FindAllStringSubmatch(d.From.Address, 1)\n\tif len(matches) == 1 {\n\t\ttags = append(tags, strings.Split(matches[0][2], \"+\")...)\n\t}\n\n\treturn tools.SetTagCasing(tags)\n}\n\n// Get message tags from the database for a given database ID\n// Used when parsing a raw email.\nfunc getMessageTags(id string) []string {\n\ttags := []string{}\n\tvar name string\n\n\tif err := sqlf.\n\t\tSelect(`Name`).To(&name).\n\t\tFrom(tenant(\"Tags\")).\n\t\tLeftJoin(tenant(\"message_tags\"), tenant(\"Tags.ID\")+\"=\"+tenant(\"message_tags.TagID\")).\n\t\tWhere(tenant(\"message_tags.ID\")+` = ?`, id).\n\t\tOrderBy(\"Name\").\n\t\tQueryAndClose(context.TODO(), db, func(_ *sql.Rows) {\n\t\t\ttags = append(tags, name)\n\t\t}); err != nil {\n\t\tlogger.Log().Errorf(\"[tags] %s\", err.Error())\n\t\treturn tags\n\t}\n\n\treturn tags\n}\n\n// SortedUniqueTags will return a unique slice of normalised tags\nfunc sortedUniqueTags(s []string) []string {\n\ttags := []string{}\n\tadded := make(map[string]bool)\n\n\tif len(s) == 0 {\n\t\treturn tags\n\t}\n\n\tfor _, p := range s {\n\t\tw := tools.CleanTag(p)\n\t\tif w == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tlc := strings.ToLower(w)\n\t\tif _, exists := added[lc]; exists {\n\t\t\tcontinue\n\t\t}\n\t\tif config.ValidTagRegexp.MatchString(w) {\n\t\t\tadded[lc] = true\n\t\t\ttags = append(tags, w)\n\t\t} else {\n\t\t\tlogger.Log().Debugf(\"[tags] ignoring invalid tag: %s\", w)\n\t\t}\n\t}\n\n\tsort.Strings(tags)\n\n\treturn tags\n}\n"
  },
  {
    "path": "internal/storage/tags_test.go",
    "content": "package storage\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/leporo/sqlf\"\n)\n\nfunc TestTags(t *testing.T) {\n\n\tfor _, tenantID := range []string{\"\", \"MyServer 3\", \"host.example.com\"} {\n\t\ttenantID = config.DBTenantID(tenantID)\n\n\t\tsetup(tenantID)\n\n\t\tif tenantID == \"\" {\n\t\t\tt.Log(\"Testing tags\")\n\t\t} else {\n\t\t\tt.Logf(\"Testing tags (tenant %s)\", tenantID)\n\t\t}\n\n\t\tids := []string{}\n\n\t\tfor range 10 {\n\t\t\tid, err := Store(&testMimeEmail, nil)\n\t\t\tif err != nil {\n\t\t\t\tt.Log(\"error \", err)\n\t\t\t\tt.Fail()\n\t\t\t}\n\t\t\tids = append(ids, id)\n\t\t}\n\n\t\tfor i := range 10 {\n\t\t\tif _, err := SetMessageTags(ids[i], []string{fmt.Sprintf(\"Tag-%d\", i)}); err != nil {\n\t\t\t\tt.Log(\"error \", err)\n\t\t\t\tt.Fail()\n\t\t\t}\n\t\t}\n\n\t\tfor i := range 10 {\n\t\t\tmessage, err := GetMessage(ids[i])\n\t\t\tif err != nil {\n\t\t\t\tt.Log(\"error \", err)\n\t\t\t\tt.Fail()\n\t\t\t}\n\n\t\t\tif len(message.Tags) != 1 || message.Tags[0] != fmt.Sprintf(\"Tag-%d\", i) {\n\t\t\t\tt.Fatal(\"Message tags do not match\")\n\t\t\t}\n\t\t}\n\n\t\tif err := DeleteAllMessages(); err != nil {\n\t\t\tt.Log(\"error \", err)\n\t\t\tt.Fail()\n\t\t}\n\n\t\t// test 20 tags\n\t\tid, err := Store(&testMimeEmail, nil)\n\t\tif err != nil {\n\t\t\tt.Log(\"error \", err)\n\t\t\tt.Fail()\n\t\t}\n\t\tnewTags := []string{}\n\t\tfor i := range 20 {\n\t\t\t// pad number with 0 to ensure they are returned alphabetically\n\t\t\tnewTags = append(newTags, fmt.Sprintf(\"AnotherTag %02d\", i))\n\t\t}\n\t\tif _, err := SetMessageTags(id, newTags); err != nil {\n\t\t\tt.Log(\"error \", err)\n\t\t\tt.Fail()\n\t\t}\n\t\treturnedTags := getMessageTags(id)\n\t\tassertEqual(t, strings.Join(newTags, \"|\"), strings.Join(returnedTags, \"|\"), \"Message tags do not match\")\n\n\t\t// remove first tag\n\t\tif err := deleteMessageTag(id, newTags[0]); err != nil {\n\t\t\tt.Log(\"error \", err)\n\t\t\tt.Fail()\n\t\t}\n\t\treturnedTags = getMessageTags(id)\n\t\tassertEqual(t, strings.Join(newTags[1:], \"|\"), strings.Join(returnedTags, \"|\"), \"Message tags do not match after deleting 1\")\n\n\t\t// remove all tags\n\t\tif err := deleteAllMessageTags(id); err != nil {\n\t\t\tt.Log(\"error \", err)\n\t\t\tt.Fail()\n\t\t}\n\t\treturnedTags = getMessageTags(id)\n\t\tassertEqual(t, \"\", strings.Join(returnedTags, \"|\"), \"Message tags should be empty\")\n\n\t\t// apply the same tag twice\n\t\tif _, err := SetMessageTags(id, []string{\"Duplicate Tag\", \"Duplicate Tag\"}); err != nil {\n\t\t\tt.Log(\"error \", err)\n\t\t\tt.Fail()\n\t\t}\n\t\treturnedTags = getMessageTags(id)\n\t\tassertEqual(t, \"Duplicate Tag\", strings.Join(returnedTags, \"|\"), \"Message tags should be duplicated\")\n\t\tif err := deleteAllMessageTags(id); err != nil {\n\t\t\tt.Log(\"error \", err)\n\t\t\tt.Fail()\n\t\t}\n\n\t\t// apply tag with invalid characters\n\t\tif _, err := SetMessageTags(id, []string{\"Dirty! \\\"Tag\\\"\"}); err != nil {\n\t\t\tt.Log(\"error \", err)\n\t\t\tt.Fail()\n\t\t}\n\t\treturnedTags = getMessageTags(id)\n\t\tassertEqual(t, \"Dirty Tag\", strings.Join(returnedTags, \"|\"), \"Dirty message tag did not clean as expected\")\n\t\tif err := deleteAllMessageTags(id); err != nil {\n\t\t\tt.Log(\"error \", err)\n\t\t\tt.Fail()\n\t\t}\n\n\t\t// Check deleted message tags also prune the tags database\n\t\tallTags := GetAllTags()\n\t\tassertEqual(t, \"\", strings.Join(allTags, \"|\"), \"Tags did not delete as expected\")\n\n\t\tif err := DeleteAllMessages(); err != nil {\n\t\t\tt.Log(\"error \", err)\n\t\t\tt.Fail()\n\t\t}\n\n\t\t// test 20 tags\n\t\tid, err = Store(&testTagEmail, nil)\n\t\tif err != nil {\n\t\t\tt.Log(\"error \", err)\n\t\t\tt.Fail()\n\t\t}\n\n\t\treturnedTags = getMessageTags(id)\n\t\tassertEqual(t, \"BccTag|CcTag|FromFag|ToTag|X-tag1|X-tag2\", strings.Join(returnedTags, \"|\"), \"Tags not detected correctly\")\n\t\tif err := deleteAllMessageTags(id); err != nil {\n\t\t\tt.Log(\"error \", err)\n\t\t\tt.Fail()\n\t\t}\n\n\t\tClose()\n\t}\n\n}\nfunc TestUsernameAutoTagging(t *testing.T) {\n\tsetup(\"\")\n\tdefer Close()\n\n\tusername := \"testuser\"\n\n\tt.Run(\"Auto-tagging enabled\", func(t *testing.T) {\n\t\tconfig.TagsUsername = true\n\t\tid, err := Store(&testTextEmail, &username)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Store failed: %v\", err)\n\t\t}\n\t\tmsg, err := GetMessage(id)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"GetMessage failed: %v\", err)\n\t\t}\n\t\tfound := slices.Contains(msg.Tags, username)\n\t\tif !found {\n\t\t\tt.Errorf(\"Expected username '%s' in tags, got %v\", username, msg.Tags)\n\t\t}\n\t})\n\n\tt.Run(\"Auto-tagging disabled\", func(t *testing.T) {\n\t\tconfig.TagsUsername = false\n\t\tid, err := Store(&testTextEmail, &username)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Store failed: %v\", err)\n\t\t}\n\t\tmsg, err := GetMessage(id)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"GetMessage failed: %v\", err)\n\t\t}\n\t\tfor _, tag := range msg.Tags {\n\t\t\tif tag == username {\n\t\t\t\tt.Errorf(\"Did not expect username '%s' in tags when disabled, got %v\", username, msg.Tags)\n\t\t\t}\n\t\t}\n\t})\n}\n\n// DeleteAllMessageTags deleted all tags from a message\nfunc deleteAllMessageTags(id string) error {\n\tif _, err := sqlf.DeleteFrom(tenant(\"message_tags\")).\n\t\tWhere(tenant(\"message_tags.ID\")+\" = ?\", id).\n\t\tExecAndClose(context.TODO(), db); err != nil {\n\t\treturn err\n\t}\n\n\treturn pruneUnusedTags()\n}\n"
  },
  {
    "path": "internal/storage/testdata/inline-attachment.eml",
    "content": "From: sender@example.com\nTo: recipient@example.com\nSubject: Test inline image proper\nMIME-Version: 1.0\nContent-Type: multipart/related; boundary=\"boundary123\"\n\n--boundary123\nContent-Type: text/html; charset=utf-8\n\n<html><body><img src=\"cid:test1@example.com\" alt=\"Test\"/></body></html>\n\n--boundary123\nContent-Type: image/png; name=\"test1.png\"\nContent-Disposition: inline; filename=\"test1.png\"\nContent-ID: <test1@example.com>\nContent-Transfer-Encoding: base64\n\niVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==\n\n--boundary123--\n"
  },
  {
    "path": "internal/storage/testdata/mime-attachment.eml",
    "content": "Delivered-To: recipient2@example.com\r\nReceived: by 2002:a0c:fe87:0:0:0:0:0 with SMTP id d7csp145570qvs;\r\n        Tue, 26 Jul 2022 20:42:36 -0700 (PDT)\r\nX-Received: by 2002:a17:902:f788:b0:16c:f48b:905e with SMTP id q8-20020a170902f78800b0016cf48b905emr19885972pln.60.1658893355881;\r\n        Tue, 26 Jul 2022 20:42:35 -0700 (PDT)\r\nARC-Seal: i=1; a=rsa-sha256; t=1658893355; cv=none;\r\n        d=google.com; s=arc-20160816;\r\n        b=WkNqsJS6Q7RhLY79RZAXgq+Moe0ZcMpGfkZMPq+v1YvG9yAao+QVeY+lN0vjM27H39\r\n         0QcXaTd4me7k0f96We657eNyjXSVaJyvvEYMA/Eu/bM51DrzsqywIfMq/O/xsA64mHph\r\n         o8LBjV3YjjfNY1uN3q/eLLd5ZLEiHulQSyKJwXxPs7FXaCiihK1iys4U/wEcVubANo0K\r\n         3DLhQ2NYrFOjN4jEyw8Agv3PjmLwgAFFisjt49Zm0N6sIDjgWLncXPQ0dA7MjKKE6pjQ\r\n         terzh43sjNeI6O+WQJ+aZ6nDxLzhgc+tk0sa290o4u7mjH8/qRx8/krqSPPlgGjLbdyo\r\n         Utlg==\r\nARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20160816;\r\n        h=subject:from:to:content-language:user-agent:mime-version:date\r\n         :message-id:dkim-signature;\r\n        bh=ofRVIgn/FP/zLSWstZbrzzKm87NpwtZOSgBYqfbXIl0=;\r\n        b=hnIfinlV6u631zlofA336KWFWzAQrScmCiXIxlBoBrZfgy0FsVJ07tRXSzqqeofkHU\r\n         k9pEKVJtD0FfkKzVdrAjetlBAbWCmbQf2u0AzaWqYVLk1rGSQj+UdpuIzMSuB5tX6sX5\r\n         XgGvkQC6cYoSd/pRGcxmrA6+jnW531pGvaQzxyv3rpcnYrOT+LBgxaaFVn3fEeUC+AWs\r\n         ZQHfciTV9hRCrmu2JWo47Z8RDr9SV3TLU/Mbf8G/p+PiaxhfxYarcTEoiV8+PuD9g6Et\r\n         tm1PAqdGq7NAWezv943ueamREZHWiD9+h1gSOro/BmdpWmigEhKFovxRlbAzwsZtW7xo\r\n         uSfA==\r\nARC-Authentication-Results: i=1; mx.google.com;\r\n       dkim=pass header.i=@gmail.com header.s=20210112 header.b=mywi6bMa;\r\n       spf=pass (google.com: domain of sender2@example.com designates 209.85.220.41 as permitted sender) smtp.mailfrom=sender2@example.com;\r\n       dmarc=pass (p=NONE sp=QUARANTINE dis=NONE) header.from=gmail.com\r\nReturn-Path: <sender2@example.com>\r\nReceived: from mail-sor-f41.google.com (mail-sor-f41.google.com. [209.85.220.41])\r\n        by mx.google.com with SMTPS id 11-20020aa7914b000000b0052ab192de4fsor8543241pfi.101.2022.07.26.20.42.35\r\n        for <recipient2@example.com>\r\n        (Google Transport Security);\r\n        Tue, 26 Jul 2022 20:42:35 -0700 (PDT)\r\nReceived-SPF: pass (google.com: domain of sender2@example.com designates 209.85.220.41 as permitted sender) client-ip=209.85.220.41;\r\nAuthentication-Results: mx.google.com;\r\n       dkim=pass header.i=@gmail.com header.s=20210112 header.b=mywi6bMa;\r\n       spf=pass (google.com: domain of sender2@example.com designates 209.85.220.41 as permitted sender) smtp.mailfrom=sender2@example.com;\r\n       dmarc=pass (p=NONE sp=QUARANTINE dis=NONE) header.from=gmail.com\r\nDKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n        d=gmail.com; s=20210112;\r\n        h=message-id:date:mime-version:user-agent:content-language:to:from\r\n         :subject;\r\n        bh=ofRVIgn/FP/zLSWstZbrzzKm87NpwtZOSgBYqfbXIl0=;\r\n        b=mywi6bMa68lM9RENvBG2mjVlMvGhyZCrh3z9gE57KY0ZK0RLLPFxzAVOXtJpaTGQ0M\r\n         C4W33O+7h5cvgFkLQJHc5YCemxEjCE5Dz5/uH4iSBYowkvn7Gu4TudNZtkNw8TGxH/Lf\r\n         lKJiaqtdnm8YdLWCzG1M/scBbbjZxDrTLddshu/Q1ireNliVwl9WdN25zXQLxsEqHFXc\r\n         5rVjyruB7cnshL8m14LYi+m5iN3H+o42oGzVce3+wQ31s+Bo/LBezb0qD8TRfTjnhp8u\r\n         77RU61IOSMbuwQWNQCywxCnoZolZpR9qRgzd5rg73dGpXHIyNfBsYyb5vr28+fp93Ayo\r\n         LXyw==\r\nX-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n        d=1e100.net; s=20210112;\r\n        h=x-gm-message-state:message-id:date:mime-version:user-agent\r\n         :content-language:to:from:subject;\r\n        bh=ofRVIgn/FP/zLSWstZbrzzKm87NpwtZOSgBYqfbXIl0=;\r\n        b=BvDmv5WC7f4WSVvuypzr9WNT7AUCQeEexvjmGur1rfkZcmqr62punbNEvcyk6T5Iy7\r\n         8XstlNbijtU9zT3qm5LBTEw1e7q8VACWVVHUbI5uE4NhqXbY6vfN4bxrDzRO/P+Ntr90\r\n         BwH1dYSBLpYOmFGX6GlrOCg0X1MZgzGI92YakpQitGBjhKnWvvQ4NlX7Ivk6W6W2aHt5\r\n         xkIVmZNdC13evcdFUOrQxcfFAkIe3kSR8eGVt++yoHlCt/fFv/QQjf5L9fEbteuA8h2V\r\n         pnfH4fN5z+GF3rpeSl1VebfW8NtPy/iHAze6dlodAVM0jtaom8MtHSXfquCea/2giq0o\r\n         YXQQ==\r\nX-Gm-Message-State: AJIora/WUqr3biShTHQBjSlCKazFbrLxeYpxmr1VF0TpBUbjnJrcLT77\r\n\tpdFYYiNICxragxqhNqXvw7/elR8u6B8=\r\nX-Google-Smtp-Source: AGRyM1tai6X1Bx130Y1yHG5w2e0r8wx6bbI+H+YppWmQoT28TV3dSoYCqmeQK5VViW8WuvdOpQzhPQ==\r\nX-Received: by 2002:a62:29c3:0:b0:52b:f774:7242 with SMTP id p186-20020a6229c3000000b0052bf7747242mr12504553pfp.67.1658893354675;\r\n        Tue, 26 Jul 2022 20:42:34 -0700 (PDT)\r\nReturn-Path: <sender2@example.com>\r\nReceived: from [192.168.1.2] ([8.8.8.8])\r\n        by smtp.gmail.com with ESMTPSA id oj16-20020a17090b4d9000b001f291c9d3bdsm387578pjb.48.2022.07.26.20.42.32\r\n        for <recipient2@example.com>\r\n        (version=TLS1_3 cipher=TLS_AES_128_GCM_SHA256 bits=128/128);\r\n        Tue, 26 Jul 2022 20:42:33 -0700 (PDT)\r\nContent-Type: multipart/mixed; boundary=\"------------ae0qIOkrNQLQHe1YyfTsUXrk\"\r\nMessage-ID: <33af2ac1-c33d-9738-35e3-a6daf90bbd89@gmail.com>\r\nDate: Wed, 27 Jul 2022 15:42:29 +1200\r\nMIME-Version: 1.0\r\nUser-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:91.0) Gecko/20100101\r\n Thunderbird/91.11.0\r\nContent-Language: en-NZ\r\nTo: \"Recipient Ross\" <recipient2@example.com>\r\nFrom: Sender Smith <sender2@example.com>\r\nSubject: inline + attachment\r\n\r\nThis is a multi-part message in MIME format.\r\n--------------ae0qIOkrNQLQHe1YyfTsUXrk\r\nContent-Type: multipart/alternative;\r\n boundary=\"------------GGc8vauWscgVN0JHIav4AOeV\"\r\n\r\n--------------GGc8vauWscgVN0JHIav4AOeV\r\nContent-Type: text/plain; charset=UTF-8; format=flowed\r\nContent-Transfer-Encoding: 7bit\r\n\r\nMessage with inline image and attachment:\r\n\r\n\r\n\r\n\r\n--------------GGc8vauWscgVN0JHIav4AOeV\r\nContent-Type: multipart/related;\r\n boundary=\"------------z0ttbxz8BplvjsfeE7Zogcgs\"\r\n\r\n--------------z0ttbxz8BplvjsfeE7Zogcgs\r\nContent-Type: text/html; charset=UTF-8\r\nContent-Transfer-Encoding: 7bit\r\n\r\n<html>\r\n  <head>\r\n\r\n    <meta http-equiv=\"content-type\" content=\"text/html; charset=UTF-8\">\r\n  </head>\r\n  <body>\r\n    <h1>Message with inline image and attachment:</h1>\r\n    <br>\r\n    <p><img src=\"cid:part1.845LaYlX.wtWMpWwa@gmail.com\"></p>\r\n    <br>\r\n    <br>\r\n  </body>\r\n</html>\r\n--------------z0ttbxz8BplvjsfeE7Zogcgs\r\nContent-Type: image/jpeg; name=\"inline-image.jpg\"\r\nContent-Disposition: inline; filename=\"inline-image.jpg\"\r\nContent-Id: <part1.845LaYlX.wtWMpWwa@gmail.com>\r\nContent-Transfer-Encoding: base64\r\n\r\n/9j/4AAQSkZJRgABAQEA+gD6AAD/4RnuRXhpZgAASUkqAAgAAAAGABoBBQABAAAAVgAAABsB\r\nBQABAAAAXgAAACgBAwABAAAAAgAAADEBAgANAAAAZgAAADIBAgAUAAAAdAAAAGmHBAABAAAA\r\niAAAAJoAAAD6AAAAAQAAAPoAAAABAAAAR0lNUCAyLjEwLjE4AAAyMDIyOjA3OjI3IDE1OjQw\r\nOjU2AAEAAaADAAEAAAABAAAAAAAAAAgAAAEEAAEAAAAAAQAAAQEEAAEAAADlAAAAAgEDAAMA\r\nAAAAAQAAAwEDAAEAAAAGAAAABgEDAAEAAAAGAAAAFQEDAAEAAAADAAAAAQIEAAEAAAAGAQAA\r\nAgIEAAEAAADgGAAAAAAAAAgACAAIAP/Y/+AAEEpGSUYAAQEAAAEAAQAA/9sAQwAIBgYHBgUI\r\nBwcHCQkICgwUDQwLCwwZEhMPFB0aHx4dGhwcICQuJyAiLCMcHCg3KSwwMTQ0NB8nOT04Mjwu\r\nMzQy/9sAQwEJCQkMCwwYDQ0YMiEcITIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIy\r\nMjIyMjIyMjIyMjIyMjIyMjIy/8AAEQgA5QEAAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAA\r\nAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQci\r\ncRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldY\r\nWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrC\r\nw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEA\r\nAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXET\r\nIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZX\r\nWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5\r\nusLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/aAAwDAQACEQMRAD8A8Kvp\r\nZBqFyBI3+tbv7mq/nSf89H/76NS3/wDyEbn/AK6v/M1XoAf50n/PR/8Avo0edJ/z0f8A76NM\r\nooAf50n/AD0f/vo0edJ/z0f/AL6NMooAf50n/PR/++jR50n/AD0f/vo0yigB/nSf89H/AO+j\r\nR50n/PR/++jTKKAH+dJ/z0f/AL6NHnSf89H/AO+jTKKAH+dJ/wA9H/76NHnSf89H/wC+jTKK\r\nAH+dJ/z0f/vo0edJ/wA9H/76NMooAf50n/PR/wDvo0edJ/z0f/vo0yigB/nSf89H/wC+jR50\r\nn/PR/wDvo0yigB/nSf8APR/++jR50n/PR/8Avo0yigB/nSf89H/76NHnSf8APR/++jTKKAH+\r\ndJ/z0f8A76NHnSf89H/76NMooAf50n/PR/8Avo0edJ/z0f8A76NMooAf50n/AD0f/vo0edJ/\r\nz0f/AL6NMooAf50n/PR/++jUtvLIbhP3jdfWq9S23/Hwn1oAff8A/IRuf+ur/wAzVerF/wD8\r\nhG5/66v/ADNV6ACiiigAooooAKKKvaZo97q83l2kRbH3nPCr9TQBRqza6de3rBba1llJ/uoS\r\nPzr0XSPBFhYqsl3/AKTP1O77o+grp44kiQJGiqo6BRilcqx5dB4G1qZQzRww+0knP6Zq6Ph3\r\nfkc3cAP0NekBacFpXCyPL5fh/q6ZMb20g7Ycgn8xWNeeH9W0/m5sJlX+8q7l/MZFe1Yp23PU\r\nUXCx4CRg4PWivZtU8K6XqynzbcRynpLGMEf4151rvhDUNF3S48+1HSVB0HuO1O4rHPUUUUxB\r\nRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABUtt/x8J9aiqW2/4+E+tAD7//AJCNz/11f+ZqvVi/\r\n/wCQjc/9dX/mar0AFFFFABRRV3SdNl1bUorOIHLn5m/ur3NAF3w74em126PJS2jP7yTH6D3r\r\n1Wx0+3061S3tYxHGo6Dqfc+ppdP0+DTrOO1t0CxoPzPrVsLUspIaBTgKcBSMQgyacYuTsgbS\r\nV2AFKWVfvMBVZ5mPA4FRV3U8A3rNnLPFpfCi558Q/ioFxF/e/SqRFJW/1Gn3Zl9bmaiMj/dY\r\nGnGNXUqwBU8EHvWTVmG6kjIDfMPesKmBa1g7msMUn8SOF8YeCvsqyajpifuessIH3fce3tXB\r\n19Dxsk6HGCCMEGvJfHHhn+xr8Xdqh+x3BJx/zzb0+npXE007M6dGro5KiiigQUUUUAFFFFAB\r\nRRRQAUUUUAFFFFABUtt/x8J9aiqW2/4+E+tAD7//AJCNz/11f+ZqvVi//wCQjc/9dX/mar0A\r\nFFFFABXp/gPSBZ6Ub2Rf31zyD6J2rza0tmvL2C2T70sioPxOK90t4EtreOCMYSNQqj2FJjQ8\r\nCnAUoFLjjNIoY7BBnv2qsSWOTT3bc2abXs4egqUddzzK1ZzlpsMIpuKkxSYrpMCPFJipcUhW\r\ngCLFLinbacQMUXGEMrRsCp5qfVLCLxBodzZthXdSFJGdr9j+dVtvNdH4X8O6hq9x5kKFbYEq\r\n8pIwDjIGOp7VyYulGUObZo6MPUaly9D5vngktriSCVdskbFGHoRxUddj8S9KOmeLJWKbPPXc\r\nR/tDg1x1eUdwUUUUAFFFFABRRRQAUUUUAFFFFABUtt/x8J9aiqW2/wCPhPrQA+//AOQjc/8A\r\nXV/5mq9WL/8A5CNz/wBdX/mar0AFFFFAHQeCrf7R4qtc9I9zn8B/9evYgteUfD0Z8UD/AK4t\r\n/MV62FpMpDAtNl4THrU4FQzjkCt8LHmqoyxEuWmytikxUmKTFe0eUNVQWAPStnT9GifMl0zJ\r\nGBxkdT2rKT5WBxnBrYiv4pSBIWTGO/WsqjlbQ1p8vUrf2QjygpLuQnnA6Cm3mmukm2GMGPqG\r\nB7VdguorOV5UBdHJOM1pfbYZLBp1jDKp4U9fpWTnNM1UItHNNpcyQLJgEt/CPStDw/4YuNYv\r\nUVwyWw5kkHYY7VZs3N3E0SRkvghVUZrvfDERt9CRTC8boCjbh3qKteUYvuVTpRkzIk8OaHZK\r\nimyEgzgs7nPHWt9ZrTTbIwWMSRREbgVIHNZmoWjtKWkk+TNVJ3BjADfIBgCuNty3Z0pKOyPH\r\nfjrbIbrTr6MZEhdWb3wD/jXjte5/G6Ajwrpc2zC/bCoP/ACf6V4ZWMlZlIKKKKQwooooAKKK\r\nKACiiigAooooAKltv+PhPrUVS23/AB8J9aAH3/8AyEbn/rq/8zVerF//AMhG5/66v/M1XoAK\r\nKKKAOs+Hf/I0j/rg/wDMV66BXkfw5GfFQ/64P/MV7AFpMpDQKhmX5h9KthahnHzD6V04P+KY\r\nYr+GVdtJtqUik2169zzCPbRipNtJj2pDHwFPM2yY2txk54rsrLRbO60MuC6OWxuQ5H1xXFAV\r\n1fhfWWjLWM7ARsPlPTFc9dS5bxN6Mo3szpfDWi2ujWDXMsgkuJerD+EelbIv4RamON/mA71z\r\n1leH+0WgWZDCyErkdx1FX7mOCKETg5JGQM1wTTcryO2NkrIp3zvIMAkilhsW8qNpY8KTwWpu\r\nk3huZZVwvmGQKgbniumv41+xkuOgxSleLsNWep4T8eJh/wAI1p1vGf3a3u7HvsavA690+OgA\r\n0DTsf8/f/sjV4XWctxoKKKKkYUUUUAFFFFABRRRQAUUUUAFS23/Hwn1qKpbb/j4T60APv/8A\r\nkI3P/XV/5mq9WL//AJCNz/11f+ZqvQAUUUUAdf8ADcZ8Vj/rg/8AMV7GBXjvw1/5Gwf9cH/m\r\nK9lApMpDQK6PRtJs7rTHubm1WTDlS7SFccexrBC10GmnOitGHP8ArCSvboKqm2paEzSa1MPU\r\nNGmto3ukANrnhgeme1Ze2urkWa6tTabj5Z7HoKzYfD17PfC1jEZYjcG3fLj1zXq06ya95nnV\r\nKVn7qMXbSYrv18G2K6hDH57GNR+8Vj976EdKrXHhKxVZY4bpmuMkjJCoo/WhYmmH1eZxOK19\r\nP8N6rqCiS3tmEeNwkc7Rj8a3rLwSs5tpPtIMf/Lcj19FrvIo40iMSNtVRjA7VlVxSXwGlPDN\r\n/GeO3kV7Y3bJNuWQD7yngj1BFdLokMuvwG3mlYRwxAYHGTnjNdRLZi9gntAgaOThn9RV+zht\r\nrVUiRUj2qFAHcCsp4jmjtqbRo8st9Cvo2j2+kWaKQrygHdMRyfb6U7U7qPycI+T6VoyFfLJO\r\nCK52+hYuzDkY6VzX5ndm1rLQ8a+OZz4e07/r8/8AZGrwqvdfjmhXw5puf+fz/wBkavCqUtwQ\r\nUUUVIwooooAKKKKACiiigAooooAKltv+PhPrUVS23/Hwn1oAff8A/IRuf+ur/wAzVerF/wD8\r\nhG5/66v/ADNV6ACiiigDsfhmM+LR/wBcH/mK9pA9q8Y+GAz4vH/Xu/8AMV7YFpMpDAtdBo0b\r\nyWbIoHL1iBa6LRbIz2G9WwfMxj8qqGjFLYq31nNbucNgn0NLpl9JZXqyy/eUEc9wetdENJDK\r\nWlb7p6GvN/Geo2mj6wtzaTyvGMLOhB2qw6YJ49jitedWsZ8p3cN9balczNbXWGhkCurDGDUe\r\nvRGKRZcKoK87f6ivIY/FSW+pBbOdY45pd88uM47598fzrrYL+PU7x7m0k8y2VRGWD7mOB3J/\r\n+vSi9RvY6HTb50jmVZ2U9QM8Vpab9ta88/zsofvgnPBrk2bY2FYgVp6VqkkE0ccrnygcnBrS\r\nS7EpnWahdTQ4KI8YUYDYwKx/t7iTcxywz171HquryTjasmU9M1kyTuUDDkdCamMdBtnS2niB\r\nypimAZTwMdqrXN9Is2VYkDpXPQys04GSBntXQXpgFkrx5M7cnNDSTBO55T8b7o3PhvTiVx/p\r\nn/sjV4ZXs/xkZj4e08MMf6V/7Ia8YrKe5S2CiiipGFFFFABRRRQAUUUUAFFFFABUtt/x8J9a\r\niqW2/wCPhPrQA+//AOQjc/8AXV/5mq9WL/8A5CNz/wBdX/mar0AFFFFAHafC4Z8YD/r3f+Yr\r\n28LXiPwsGfGI/wCvd/5ivcQtJlIQCtrSJntYfOXs/rWQFrS0+JZFCyNhA2TzVQ31JnsalzrU\r\nphVXQHBzuBxxXE+PbFtT8MSm2TaYQZNuOvOSa7a4tLaZCUYKQOnasm4sblonjClkKnj2rSya\r\nJu0fPeh2k95qEcSwvJErZkwei969l8O6HHp0t0kUmLWaJGQf7eTn9MVx/gGKP7VqUiBdkM5V\r\nNvpXoFtKIwoAG1aIx0uDfQiubOXI2gmoRBOvO0gfSt17y3nA+VY2xzjvVe5vjJbiIbdq9MVa\r\nkxcqMh5WAxzmoRdyQsQD1qUqXJJXAqnOQh5q0Sy9bT7ZFfNbq3CmMMcE4rkknXGPTpVxb0hN\r\npNKUbgmcf8aJVk0PTwp/5ev/AGU14tXq3xVmMukWYz/y8f8AsprymsJqzNI7BRRRUDCiiigA\r\nooooAKKKKACiiigAqW2/4+E+tRVLbf8AHwn1oAff/wDIRuf+ur/zNV6sX/8AyEbn/rq/8zVe\r\ngAooooA7f4VDPjIf9e7/AMxXugSvDfhOM+NB/wBez/zFe8haTKQwLVi1hkeQbAcd8HFMC1ct\r\n7Vpo8xk+YD0zxirpO0iKivE1RJaWsbMw3SbRhSaoX2ofabaTyT5cxQhT2BxxTLizuNmZFC49\r\n+azyH8woo5xya0sTc4H4R2jNZ6x9oxs89U7cMOv8xXof9kPKxSJenfNcF8LNraBeMZMu92xO\r\nenQV6JHqbW7KGTaAMfLSV7aDdr6mXNp8tvJiQ4pgcBdpXntWjqN9HchmQ5fqN1YBv1ltZJ4x\r\n80edyN1UinfuFux0X2eNdF3sV8xmOOegrlrhV8/aeeeayLnxc0rRWkEg8yUgZP8ACPWifV4l\r\nnK7wzDqc0Qku4mWp48MSlMBfbncDSoZpF3LG2WHTFJ9nuFH+qfn2ra5Fjh/iSSdItM/8/H/s\r\nprzSvSviQrrpNpuBH7//ANlNea1z1fiNIbBRRRWZQUUUUAFFFFABRRRQAUUUUAFS23/Hwn1q\r\nKpbb/j4T60APv/8AkI3P/XV/5mq9WL//AJCNz/11f+ZqvQAUUUUAd38JBnxsP+vZ/wCa176E\r\nrwT4QjPjgf8AXs/81r6AC0mNDAtK0kkSHYwANShKZKVBwQM4qofEKewC7kMe1iWGOgNMndVs\r\n5SSQ2w9PpUe1euePSq2oOi6ZdEFsiJ+/sa3aMrnIfCcQR+G7p2ALNcn8eBXbPLklWPToK4T4\r\nXrjwzKe5nauxYEZOOKUVoOT1HT3MUUZZto+uBXl/iHXLe/vJ7m3UKbYBWkQkCX29/wD9dL4p\r\nlmfUEjlMxluSERRjEanrx61yt3EzxSWdkxkhSQyMW6t129PasJy5nyouMbK7LVn5kU39otcD\r\npuKgbio980yz8S/YroC2hE0av8pmXcSc0nh/SbnUtMvorcZkBXPPIHpio5vDt2LpbSCJ0lVg\r\nHBOR0+8KSiPRbHsOi6o99aeZJGI27hcdfwzWgGRuST9K5Lwnb3Vgn2S7jCsBhZFbKt+nFdOU\r\n4yDXQkrGbbucF8YWjOgWGwAf6V/7Ka8cr134tf8AIBsf+vn/ANlNeRVlNWZcXdBRRRUjCiii\r\ngAooooAKKKKACiiigAqW2/4+E+tRVLbf8fCfWgB9/wD8hG5/66v/ADNV6sX/APyEbn/rq/8A\r\nM1XoAKKKKAO/+Dwz45H/AF7SfzWvoQCvnv4O/wDI9D/r1k/mtfQopMaHAVlancJDcqrSKpK5\r\nwTWstcx4s8Cax4hvobqza2RFi2/vnIOcnsAa0pW5tSKl7aE322L/AJ6of+BVT1e8i/sa92yL\r\nu8h8Yb/ZNY//AAqTxIQMS2GfQyt/8TTJfg94mkidRLp+WUgfv2/+JrpfJb4jJKXYq/DW4jj8\r\nL7WdQfObgmuua8h7yoPxFcpp3wV8U2tuY3n00nOflnb/AOJq4Pg74nx/rdOP/bdv/iamHJyq\r\n7HJSvsUtU06W+1NZgyEI2UYEcMeh/DrR4e8I21gJzcspY/MMkVeHwd8UA583T8f9d2/+Jp3/\r\nAAqDxQP+Wun8/wDTw3/xNTyU07plc0mrWOO8A3ezxRqluNojkLMPwavQJLS3ll807d2c5rnd\r\nP+B3iy0vjM82llDnOJ3zz/wGtgfCPxLggyaePTEzf/E0oONtWEk76GiixooAI470/wAxcY3C\r\nsxfhJ4lHWSw/7/t/8TTv+FSeI/8AnrY/9/m/+Jqrx7k2ZxvxbYHQrEA/8vP/ALKa8ir1P4oe\r\nCdW8MaJZXWovbFJbjy1EMhY52k9wPSvLKxna+hpHYKKKKgoKKKKACiiigAooooAKKKKACpbb\r\n/j4T61FUtt/x8J9aAH3/APyEbn/rq/8AM1Xqxf8A/IRuf+ur/wAzVegAooooA7/4Pf8AI9D/\r\nAK9ZP5rX0KOeK+evg9/yPQ/69pP5rX0bb+Wh3Fhu+vSkMtWduExJJ97sPStJZKz1nTH3x+dP\r\nFwn98fnQI0RLTxLWcLhP74/Oni5T++PzoA0RLTxL71mi6T++Pzpwu0/vr+dAGmJfenCWsz7X\r\nH/fX86X7XH/fH50Aafmil8wVmfbI/wDnoPzpReR/89B+dAGnvFLuHrWYL2P/AJ6L+dKL2L/n\r\nov50AeU/tIkHwhpH/X//AO02r5pr6M/aHuEm8J6UFYHF9nj/AHGr5zpgFFFFABRRRQAUUUUA\r\nFFFFABRRRQAVLbf8fCfWoqltv+PhPrQA+/8A+Qjc/wDXV/5mq9WL/wD5CNz/ANdX/mar0AFF\r\nFFAEtvc3FpL5ttPLDJjG+Nypx9RVv+3tY/6C19/4EP8A41n0UAaP9v6z/wBBa/8A/Al/8aP7\r\nf1n/AKC1/wD+BL/41nUUAaP9v6z/ANBe/wD/AAJf/Gj+39Z/6C9//wCBL/41nUUAaP8AwkGs\r\n/wDQXv8A/wACX/xo/wCEg1r/AKC9/wD+BL/41nUUAaP/AAkGtf8AQXv/APwJf/Gj/hINa/6C\r\n9/8A+BL/AONZ1FAGj/wkGtf9Be//APAl/wDGj/hINa/6C9//AOBL/wCNZ1FAGj/wkGtf9Be/\r\n/wDAl/8AGj/hINa/6C9//wCBL/41nUUAWrrU7++RUu765uEU5Cyys4B9eTVWiigAooooAKKK\r\nKACiiigAooooAKKKKACpbb/j4T61FUtt/wAfCfWgDobvw35l5O/2vG6Rjjy/f61D/wAIx/0+\r\nf+Qv/r0UUAH/AAjH/T5/5C/+vR/wjH/T5/5C/wDr0UUAH/CMf9Pn/kL/AOvR/wAIx/0+f+Qv\r\n/r0UUAH/AAjH/T5/5C/+vR/wjH/T5/5C/wDr0UUAH/CMf9Pn/kL/AOvR/wAIx/0+f+Qv/r0U\r\nUAH/AAjH/T5/5C/+vR/wjH/T5/5C/wDr0UUAH/CMf9Pn/kL/AOvR/wAIx/0+f+Qv/r0UUAH/\r\nAAjH/T5/5C/+vR/wjH/T5/5C/wDr0UUAH/CMf9Pn/kL/AOvR/wAIx/0+f+Qv/r0UUAH/AAjH\r\n/T5/5C/+vR/wjH/T5/5C/wDr0UUAH/CMf9Pn/kL/AOvR/wAIx/0+f+Qv/r0UUAH/AAjH/T5/\r\n5C/+vR/wjH/T5/5C/wDr0UUAH/CMf9Pn/kL/AOvR/wAIx/0+f+Qv/r0UUAH/AAjH/T5/5C/+\r\nvR/wjH/T5/5C/wDr0UUAH/CMf9Pn/kL/AOvR/wAIx/0+f+Qv/r0UUAH/AAjH/T5/5C/+vT4f\r\nDWyVW+15wf8Ann/9eiigD//Z/+ICsElDQ19QUk9GSUxFAAEBAAACoGxjbXMEMAAAbW50clJH\r\nQiBYWVogB+YABwAbAAMAKAAQYWNzcEFQUEwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPbW\r\nAAEAAAAA0y1sY21zAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\r\nAAAAAAANZGVzYwAAASAAAABAY3BydAAAAWAAAAA2d3RwdAAAAZgAAAAUY2hhZAAAAawAAAAs\r\nclhZWgAAAdgAAAAUYlhZWgAAAewAAAAUZ1hZWgAAAgAAAAAUclRSQwAAAhQAAAAgZ1RSQwAA\r\nAhQAAAAgYlRSQwAAAhQAAAAgY2hybQAAAjQAAAAkZG1uZAAAAlgAAAAkZG1kZAAAAnwAAAAk\r\nbWx1YwAAAAAAAAABAAAADGVuVVMAAAAkAAAAHABHAEkATQBQACAAYgB1AGkAbAB0AC0AaQBu\r\nACAAcwBSAEcAQm1sdWMAAAAAAAAAAQAAAAxlblVTAAAAGgAAABwAUAB1AGIAbABpAGMAIABE\r\nAG8AbQBhAGkAbgAAWFlaIAAAAAAAAPbWAAEAAAAA0y1zZjMyAAAAAAABDEIAAAXe///zJQAA\r\nB5MAAP2Q///7of///aIAAAPcAADAblhZWiAAAAAAAABvoAAAOPUAAAOQWFlaIAAAAAAAACSf\r\nAAAPhAAAtsRYWVogAAAAAAAAYpcAALeHAAAY2XBhcmEAAAAAAAMAAAACZmYAAPKnAAANWQAA\r\nE9AAAApbY2hybQAAAAAAAwAAAACj1wAAVHwAAEzNAACZmgAAJmcAAA9cbWx1YwAAAAAAAAAB\r\nAAAADGVuVVMAAAAIAAAAHABHAEkATQBQbWx1YwAAAAAAAAABAAAADGVuVVMAAAAIAAAAHABz\r\nAFIARwBC/9sAQwAFAwQEBAMFBAQEBQUFBgcMCAcHBwcPCwsJDBEPEhIRDxERExYcFxMUGhUR\r\nERghGBodHR8fHxMXIiQiHiQcHh8e/9sAQwEFBQUHBgcOCAgOHhQRFB4eHh4eHh4eHh4eHh4e\r\nHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4e/8AAEQgBDQEsAwEiAAIRAQMR\r\nAf/EAB0AAQABBQEBAQAAAAAAAAAAAAAFAwQGBwgCAQn/xABUEAABAwMABAURAwkEBwkAAAAB\r\nAAIDBAURBhIhMQcTQVGxCBQWIjQ1U2FlcXN0gZKksuIjkdMVGDJCUlaTocEXRqLCJTNEYnKC\r\n0TdFVGN1lNLw8f/EABoBAQADAQEBAAAAAAAAAAAAAAABAgMEBQb/xAApEQEAAgICAgEDBAID\r\nAAAAAAAAAQIDERIxBCEFIkFREzJSgRQkM6Gx/9oADAMBAAIRAxEAPwDkS/VE4vleBPKAKmTA\r\n1z+0VZdc1Hh5ffKub/39uHrUnzFWKCr1zUeHl98p1zUeHl98qkiCr1zUeHl98p1zUeHl98qk\r\niCr1zUeHl98p1zUeHl98qkiCr1zUeHl98p1zUeHl98qkiCr1zUeHl98p1zUeHl98qkiCr1zU\r\neHl98p1zUeHl98qkiCr1zUeHl98p1zUeHl98qkiCr1zUeHl98p1zUeHl98qkiCr1zUeHl98p\r\n1zUeHl98qkiCr1zUeHl98p1zUeHl98qkiCr1zUeHl98p1zUeHl98qkiCr1zUeHl98p1zUeHl\r\n98qkiCr1zUeHl98p1zUeHl98qkiCr1zUeHl98p1zUeHl98qkiCr1zUeHl98p1zUeHl98qkiC\r\nr1zUeHl98p1zUeHl98qkiCr1zUeHl98p1zUeHl98qkiCr1zUeHl98qYsk85pXZmkPbn9Y8wU\r\nEpmx9yO9IegKEwtL/wB/bh61J8xVir6/9/bh61J8xVipQIiICIiAiIgIi+sa57wxjS5zjgAD\r\nJJQfEWcaNcGOkl3DJqmJttp3bdaozrkeJg2/fhbBs3BFo7StBuEtVcJMDWBfxbM+IN2/zKja\r\n0VmWhkXUVJoZorTNDY9H7ccbjJA15+92SpNlptgGG26jA8UDf+ijkng5KRdXVWjGj1W7WqbH\r\nbZXYxrOpWZ+/GVBXXgt0Prw8soH0Ujv16aUtx5mnLf5JyRwlzci2xpHwL3GAPmsVwjrGjaIZ\r\nxxb8cwduJ8+FrS8Wq5WerNJc6KakmH6sjcZ8YO4jxhTvaJjSyREUoEREBERAREQEREBERARE\r\nQEREBERAUzY+5HekPQFDKZsfcjvSHoCSmFpf+/tw9ak+YqxV9f8Av7cPWpPmKsUQIiICIiAi\r\nLKeDvQ6r0suerl0FvhINRPj/AAt53H+W88gIWuhuid20oreJoIgyBhHHVD9jIx/U+ILe+hmg\r\n1k0aia+CEVFbjtqqUAu/5f2R5vblT1mtdFabfFQW+nZBTxDDWt6SeUnnKvmtCpNmkRp4DV7D\r\nV6DV6DVCzyGhewF9AA3rw+ogZvfnzbVpjxXyTqkTKl8lKRu06VA1eg1WhuEY3RuPnXwXFvLC\r\nfvXVHxnlTG+H/jCfNwfyXoarO92a2XuhdRXWiiqoHcjxtaecHeD4wqkdwp3fpB7POFeQyRSj\r\n7ORrvMdqwyeLmxe71mGlM2PJ+223PvCRwU11kZJc7Fxldbxlz4sZlhHP/vN8Y2j+a1iu1g3K\r\n0xwz8GTTHNpJo5T6rm5fWUjBsI5XsHPzj2jx5RK81/DSCIisoIiICIiAiIgIiICIiAiIgIiI\r\nCmbH3I70h6AoZTNj7kd6Q9ASUwtL/wB/bh61J8xVir6/9/bh61J8xViiBERAREQSWjNmq7/e\r\n6a1UTftJnYLsZDG8rj4gF0/o3ZaOxWeC2UMerDC3GTve7lcfGVg/AJo02gsT79Ux4qq/ZESN\r\nrYQf8xGfMAtnAKky0rGngNXsNXoNXoNVVnkNVKeZsYwBrO6EqZtXtGb+U8ytTtXueB8XziMm\r\nbr8PK8vz+M8Mff5U5pXyfpH2cipqqWheMbdi+hpWtI41jUPHtabTuZ28OC8qpjYvJGVdDwvr\r\nch2QSCOUL6QgCESkaG4yscGTfaM5+UKahcyZmsxwc0rFTkHIKvaGrfBIC0+cchXj+b8XTLHL\r\nH6n/AKl6Pjefan039w0hw8aDN0euwvdshLbZXPOu1o2QS7yPEDtI5to5lrBdnX+1UOlejFZa\r\n6gfZVMZYTjJjfva4eMHBXHd3t9TarpVW2sZqVFLK6KQeMHGzxL5yazWZrbuHsbiY3HS1RERA\r\niIgIiICIiAiIgIiICIiApmx9yO9IegKGUzY+5HekPQElMLS/9/bh61J8xVir6/8Af24etSfM\r\nVYogREQFfWG3S3a90Vshzr1U7YsgZwCcE+wbfYrFbA4AqFlZwgxyvGetKaScefYz/Okph0JQ\r\n0sFHRw0lPGGQwxtjjaBsa0DACuAF9DV7DVk1eQFTqH8XHs/SO5XAb4lY1DteUnkGwL0vjPFj\r\nPm3bqPbi87yJw4/XcrfC+Y2KphfMFfWPnFMjkXwhVhGXZwM4GVXobfVVtSynpoXOe44GzZ51\r\nEzEe5WiJmfSx1QvJZg5V3PTSxSGNzTrDfs3Kv+TahsLJZWtjZIMs1jglRyiPunjKLLSvrG5O\r\nCqxZk4AyhYWnaMFW2hSezmXloIcpS0Wi5Xep63tlFPVS7MtjYTjJwM8yy+Pgm0qdqh7aKN5c\r\nW6rqgZyP/o3c6yv5GPH6vbTSmHJf3WGIaNvqHXOGmhYZH1MjYxGN7nE4GPaVpXqo9HTatM6e\r\n6tiMYuERZM0twRNHhpz49Ut+4rvPgv4PaLRS3y1F2ioq25cbxrJeL1jCG7g0kZB5cjxcy566\r\nuqxw1Oi5v9NHxcbaxk+7lP2bh7S9p86+a+Qy0zZeVI/v8vb8THfHj43cZoiLgdIiIgIiICIi\r\nAiIgIiICIiApmx9yO9IegKGUzY+5HekPQElMLS/9/bh61J8xVir6/wDf24etSfMVYogREQFu\r\nDqZYg65XubG1sMTR7XOP9Fp9bn6l8Zqr9/wQdMiielq9t2Bq9BqqBq9Bqo0UZe1icfEo7Ck6\r\nsfYHxlWOovpvhqRGGbfmXg/KW3liPxCiWr4WqqWr5gr13mPVFG507WsIDjs2nAWe26ens+sa\r\nUyumdsa+XaADyhYHGSx4c04IUrR3ioiJ4/7Zp5Hci5fIxzd1ePkinaaZUurrlJRVTMPJyXtb\r\ngEHnyFK1lk69g60kbF9m37EjYdg51jE93455nBMc+tkareTxqS0bvk8taIqyXWjAOq7dhcmT\r\nHeI5V9adVMtJnU+9vkFjpaG4QmSmJY9vauLgdoG0q1nssMt0oouLw+plDDh2wA7M43//AIvF\r\n2rZH3cvMuvGDqtYNwblZ3adFdIK6C33iAw00gLXU7XjacneR5ktktjiLWntNa1vutY6ZroPo\r\nrQaK6PTOjw2qnAEshB2kZxs9qib5cmiqjMQPGMxgDnWfV8Dm0bWzuBYR25zgZxtWFXaCjpZO\r\nO1WuO8DmXjRkm9ptb3MvSmsVrEV6XFxvTnxRySs+2LcapcRyeJae6pCCW8cC2kHXDO1pqfjm\r\njlGq5p6QPuWxamcSRcYY8uz2uN6xThcoHVHApppI53F8RZppNU8uBuUWiIrJuZl+eKIi5Wgi\r\nIgIiICIiAiIgIiICIiApmx9yO9IegKGUzY+5HekPQElMLS/9/bh61J8xVir6/wDf24etSfMV\r\nYogREQFunqXNtVf/APgg6ZFpZbr6lgZq9IP+Cn6ZFE9Jr23kGleg1e2sKqBqo0WtW37H2qyL\r\nVJ1bfsParLVX0/xM/wCv/cvA+S/5v6UC1edXaq5btXzUXqcnnaUdVfcKrhfCPEo2l8hkfFIJ\r\nIyWuG4hbN4KzQ1dzbG9jZmvY5zmPZjVdgbMDZjK1lhTOi97q7JcoqmB7tQOGu0frDlC5vJxT\r\nkpMV7dPj5Yx3iZ6bRo9DrBPpdT1VTAaaNhL5YT+hI7ORs5As+uN1pIJGNEOyP9E49iwu8V4q\r\nrYy401QBUSR6zASCSfMpbR+o/Kloo3zzMkEjS0Ybqua7lBHOF4GWL2iJtPXp7ePjWZiv3SV0\r\nu8FdTtEZdsdtbjaCsWukMs8mGsJ8Q3q/vxp7TG4wgyPG8jnVDQuukq7bTuYYRUzTGMBxyTy7\r\nPZ0KtaaryhM2iZ4yuKaz1VNRRTTs4trnYDCNoHOsC6omqjj4ItKYITkfkuYFw2Z7XC3rc4WG\r\nhc6Rus5rMLQHVCtYOCXStzBs/J02PNhZVtyiV5jXT880RFgsIiICIiAiIgIiICIiAiIgKZsf\r\ncjvSHoChlM2PuR3pD0BJTC0v/f24etSfMVYq+v8A39uHrUnzFWKIEREBbu6lMZq9IfR0/TIt\r\nIrePUnjNXpD6On6ZFE9Jr23uGr2AvQavYYqNEpopZ6O83CSlrn1DY2wukbxONYuBAA2g86q6\r\nZ6J09A1k1lZXSxgZmZM0F7PHsA2b1KcGE8tJfqieKPjHCjeMZ3ZLdqkxc5qe8Grnbx2zVLHE\r\njZ7F6fhZ8mOPU+vw4fKwUyd9/lqmSN7HFj2lrhvBGCvOqsj01D6m+S14jLWVGHeLIGCB/wBF\r\nG2q11dzr4qGii4yolJ1G5AzgZO0+IL6GmWLUi8vBvjmt5rCNLV81fEsxpNAb5NepbZJG2IxR\r\nukMxBMbgP2SBtO3crWXQnSOK3y101vdDDGCcSuDHuA3kNJz/ACUf5GL+ULf4+X+MsYDV9DMn\r\nYpQWS5fktt0FI80j5DG2TZtPm3+1bq4L9DYbRaIam5W5jq+eQPfxjQ4xAZ1QBjtd+3x4WXke\r\nXTDXfbTx/GvltrppVrrrSUAMlPUshccslexwAPiO5SGi2kdwoLnRjjXyQxyufxetgEu3lb40\r\nvt8Vxj4iuizSPGo4F2zz451hM3B5ZKnSynNHGYLVHEeuGGVxc5w3FpOd/wDRcVPOxZKzGSun\r\nbbxMuO0TSdo+azX3SizG40tQQ/jXMghj7QO1cEucT7Qtl6JaM0Nkp2SajZK8xhs85z2ztpJA\r\nO4ZPIrvRy009ptcVFTa5hjBxrHJOTnar+rYHQluuWDnC8vN5E3+mOnfTFFZ5T2stIZjFR5Dw\r\nB+sOdaH6oBw/sj0s1R2pts2PuWzr1LMJjFlxYtWcPOTwQaVnGf8ARsu3/lVa11VeZfnuiIsF\r\nhERAREQEREBERAREQEREBTNj7kd6Q9AUMpmx9yO9IegJKYWl/wC/tw9ak+YqxV9f+/tw9ak+\r\nYqxRAiIgLefUmDNZpF6On6ZFoxb26kcZrNI/R0/TIonpNe2/WsXsNXtrFUaxUaJnQgEXh2JD\r\nH9i7bz7RsUpdo9SXjSC4FRmiQYLm4v2NERz94WZPFtqKNzWxScYc6ud2V2YJ1Vz5Y3LGae50\r\nsbDTPgYI3Ah7Xt1g/POCsv4L4qGktVWYooBVtndqZYC8xcm3eRtKw65W9sVPUVkkUgjhYXu1\r\nGFxAG/AG0qItGmDYLZPWWuYyCOmfNFs2yNxs2Hdt2EHxc4XRa0TSaxPbGtdWi0trW68PguLg\r\nMt40lu0drnkKjb5c623XbWn4qpaCXNa4lzMkc3OsU0J4QYanRylq9IxTR1Q13ue6MMxGckSB\r\noJJ2ENGzJO4K/wBMdIbRW1/EUkFVFOImva+dmpxgI3ap2gjxgLCsxNum9t6TWi+lLXVckFbT\r\nmXILmuaMah8Y5VJV+nMNPNE6GFz94kDjjO1aytVxkpK8VB1Q3cQRlT1lqbZXVbmzBm06ziSR\r\nsWl8dYnemdbzMabLfPDeYWTMqB1oGBzgG7c82edWkt6oaakbGynbHNsGMZB5N68UEdJLazFB\r\nVingaQY9VvKRzLD7xLC2rkhZUiVoJDXAYz7FjWsT6aWnUMvotJJONlMxjawEYycABTTrjS1t\r\nBJLSzNfqjJA3hagnqzxZYTkDdtXu23Z8MoEc2BnB242K84fvCsZGaXK50vGCN7ScjbyYWt+H\r\n9kA4HNLDE/P+i5tmdv6KyS5xzSTSSHJaDtcNo2rXPDQ9w4KNKgX/APdsw37xqpx9HL24MREX\r\nK0EREBERAREQEREBERAREQFM2PuR3pD0BQymbH3I70h6AkphaX/v7cPWpPmKsVfX/v7cPWpP\r\nmKsUQIiIC311IQzWaSejpumRaFW/Oo+GazSX0dN0yKJ6THboRrQvYaF7DQvQCq0T2gIpReni\r\nrLQwwOALhkZyMLY1HZYXwsk1Q5pIcBrcnsWsdGY3Or34AIbESc82Qspp71WwFpglH2TcBuNh\r\nHjW+OszX0ytMRPtL6UXGkstC+ejpG1szntiipg/Be9xwBnGwZ38y5C0y0hqdD9MqynqaEUcF\r\nW41E8ERMnFB2zAeTz52cmq0bsAdKuvk8lW58scbi55djG3J5lzr1U+jkrJaTSCIAwyl0cm7t\r\nCcnG7OCSfaQptWa+0RaJYrZ9M2s0npbzWUrpo6cNkbSA9o5zDqsOOXADitx6H3pumFXW6RMf\r\nMJY5NVzJg04BA3AbQNnKTnxbhz/wW2qsqbxHcpaFs9spsSVOs3OWA7xy7NXJA36pHKunNCtG\r\nbfZr1XSRbKOppomBowO3a55LvbrBWx77RbU+n2reCS/Ab4gqcUuHB8TzG48x3qSuVqe5urEN\r\nY7SPMo0WutzqNjceUEBdkWjTCaztl9JpLm0R0cpd2jdXAOPaoKarAmMhdkZyMqLkjmpxiQHK\r\ntJpJHFx3YUVrEdJmZlOVId/r4pOMj1cnHJ518tJ1qsENa85zh25Y4yslA4rXOrzZUpb6ji52\r\nvBwcK011CsTtsq6XTjrNHQBoD97iNmNi1Hw0080XBTpMXfo/k+Ujn3LPaesaacOac7MlYLw5\r\n1jJOCrSRuRrG3yj+S5taiYa724RREXI1EREBERAREQEREBERAREQFM2PuR3pD0BQymbH3I70\r\nh6AkphaX/v7cPWpPmKsVfX/v7cPWpPmKsUQIiIC3/wBR0M1mkvo6bpkWgF0D1GwzW6Tejpum\r\nVRPSY7dEhq9hqqNYqjWDmVWitaA/rhwZntmFp82QsyoLbQx0HFvd278Eu5QeZYbEXxuDmEgj\r\nlWUWKndV05kfOGkYzgZwuzHE/pb393Je0fq60tLtZ5IHl8B1+UEHatbcN1JNLwdXR9XCHNhY\r\nJWufsGQQRk+cD7lu6Y2u3z8U9+tMWgaxGcHlWnOqmuFRDwQXaGl+2imlhbKWfqN1wdY+0AbP\r\n2lE3nS8V9sB6nuJvYNBXOY3DzJEBjY4a5zn78fetpW+VsUccYGWRtDQScnAGFg/Uw243Dgji\r\n1o2xyMrphEQf027Dt8eSfZhZ/JbJYyWRFxcOTCvSY4wrbe05+UKGpp2RmBkcrBgPBIz51RZc\r\nnUAlih1Drt1Sc5yFjctPVxv7Zrhg8yqwBjH6s21xTjCeUra6zF8pOrk5UfNERGX4weZZLaaN\r\nlXdYaZrA8PeATjOxedMbdFR1ckVOO0BIBxvV4vqdK6+7CpZGNfhwCqx1DHAHOHDcvEkDJpHh\r\nx3DZhRk4fC/DSV0RqWU7hlEFyc2LUJwsN4YKwycHV+aDsNFJ0K+E8urktKxfhOlc/QK+ZP8A\r\nscnQqzWOMnL3DlNEReW6xERAREQEREBERAREQEREBTNj7kd6Q9AUMpmx9yO9IegJKYWl/wC/\r\ntw9ak+YqxV9f+/tw9ak+YqxRAiIgLoXqLxmt0n9HTdMq56XRPUUjNdpT6Ol6ZVE9Jjt0i1i9\r\ntYqjWKo1iqu9UEMr5vsYhI5o1sHdhScVzlfA6N7hHkY7XkVrQObDPrOwBjG1SzauzPlkBpWD\r\nUGNYkdA3ldeG0cNTDmyVnnuJY7UzyyPzI4kj9ZYFw9VXF8Et9/WzFG3f+1Kwf1Wz7o2Gd7hC\r\nwMa4YbloBK1d1RMTKLgZvLca0kz4G6zhjA41h2fcr2mOMorvav1Pz56Lgd0faMxMcJZAXbc/\r\nbP2+Zbct1bRxzDt45nvA2kYBK1RwBR1svBLYnuYA0QPDRjWBbxj9qy+ogbsEExb+0CNxUcYm\r\nuk8piWRaUsglaTG0REjOM7FhEz4pWGRmHAEtJG9pGwg+1SEjatzcGp1gNyw3S1twsVRPpBbt\r\nWtJaBV0WvjjAB+m3ncAN3KB4gq/sja02izILZf4bVWazJftWYJI/Vyoy76XwXO5T00TzK9jS\r\nZDyNOd3nWl9ONNnySm40cc9G2eEMEJI7VzXHLjyjacY8XtVho9pPJb9H5qhrm1NdW1IwxwJI\r\nad7tnJv9pG9Yx5Mct69I974w242sYxzjrDJ5MqjJPG5ri7B5liuibG3avDZr1SMicS4B8n2p\r\naNgGqB2oJz4zg7hhbIp9HKJzAXVRe3k4sZH3rsx563jak45j0xqOcta4Z2FYxwmSNOgd7wP9\r\njk6Fs1+jtDgAcfnzhYpwtaNin4NdIKkPcBHQyOwR4le2SupVjHO3GyIi850iIiAiIgIiICIi\r\nAiIgIiICmbH3I70h6AoZTNj7kd6Q9ASUwtL/AN/bh61J8xVir6/9/bh61J8xViiBERAXR3UR\r\ntzXaVejpemVc4rpHqHhmu0r9FS9MqiUx26Zaxe2t8SqNYqjWeJVWUHxgxkOaXDmHKrFrRE9x\r\nMQZndnOxSdU/iYg47tbCsXyxyOIfgjkK7MH7XNm/cRTASh73uON2dwWuuqZrS7gkr2F22SaF\r\noGM/rg/0WwnTMbuaCOYhat6qCoa/gwkaA0ZrIRnZnlP9FpePUq0n2yHgTrHt4JNHYGA8W2mO\r\nXA873Z6Ssm651XuMbSGndnesV4II9Xgv0fbsz1m07ufJyskew5zrecKaREQraZ2g9K9M7Xo6\r\n2OOumk4yb9GOIAu8+M537NmVpXTLTOo0r0rprVQOdTUjJRx3GHOdUgnWG7ALVNcMjKu3CW4i\r\nCJ9fKXtBDdYxxHIB1juIDgBgcud+FrW6uhgNJb4IYYqySjjjlkjY4uPGfaGXOduWuaPMCMbl\r\nw5slrWmn2dOOnrkjJb1bn3uWerpjNREkspSe1bt3EjeN+MeLPLm80nudTa4m261uEIni1pRH\r\nHqHadbVOwZAGzG0b/FiHvlmks1TTPnEjqcta+MkDbuOOXnW3r5wdVlRGdIaKpbPKKXjma7A4\r\nS9oD2wPOMjkIURi9+vsnlGmoNG6s0t2pJJ6xzIx27jEA55z+qdbZnz7F1ZoJcHTWRuqZS7AL\r\ni8tJ8/a7Nu/cFomxcG8t1uXHVrn0cfFt1mRjDg7VHbDOQRs5POtycHtuuNnhdQXCcVceqeKn\r\nbEIyRnc4DZ7fvXThr73plefTMG1JAIdtPIcLFeGGrdJwYaSB2SDbpQNviWU6jHM2gjHMsP4X\r\nARwYaRjGzrCXoW9ojUs4mdw4rREXA6RERAREQEREBERAREQEREBTNj7kd6Q9AUMpmx9yO9Ie\r\ngJKYWl/7+3D1qT5irFX1/wC/tw9ak+YqxRAiIgLpXqGBmv0s9FSdMq5qXS3ULd36WeipOmVR\r\nKYdRtavYC+N869tUaW2jNJpGU9sEkj2sHGAZJxzrHG3Cnx3VH74V3wvaOXrSXQ00Fjt8lbUC\r\npjeWMLW4Azk5cQFplvA9wlFxB0XnA5CJoj/mXp+JFJx/VaIcWeLc/UNu/lKm3GohP/MFqvqn\r\nKyObg/pmRStJ/KEZIa/ORqP5la/2O8J2COxOpJzvbPEM/wCNROlHAdwr1tuZDTaH1kj+MBLe\r\nuYQMYPO9Xz1xxSdWiVcXLlG4bO4Kq6nj4N7BG6ZuRQx57YcyyGS40oPbTsHneFpy0cCXCtDb\r\nIIZdEqpr2MALRUw7/fV4OBfhS1duiFX/AO5iP+dXpTFxj64Vtz3PpNad2mS+VIhbPHJSO7eQ\r\nB426v6LfMTnKiKDg9jl0kgvVYY8Ok13syMsyBsG39Xd7AqZ4GOFPYW6IVg2ctVFs/wAaqjga\r\n4Uhg9iddu/8AFRb/AH1hPjYeXLlDWMuTjx0xrqpqakoJLBS0WpquE8r9R293aDdjZu5+hbN4\r\nMLg648H9nqamZskj6YNe7GMkEt258y1vphwFcLlykpn02hVZJqMIOtVw5GTu2v8A6qX0Y4Fe\r\nFOkssVPU6GVUUsb3DVbVRbic5/TxylUrx/UmN+lrRPCPTZTKCiYG41RhoZv5AFeZZyOH3rXr\r\nuB7hM4trhonVh2e2HXMZ2e+vsfBBwmau3ROsBJ5amPZ/iWuqfyhnq34bCEjRsLgsT4X3t/sx\r\n0iAI20EvL4lGf2PcJWQOxes27z1zF/8ANY5wi8F/CDa9Bb3crho3V01HTUkkk0r6mNwa0Dac\r\nBxP8lFuGp+pMRbfTmFERec6hERAREQEREBERAREQEREBTNj7kd6Q9AUMpmx9yO9IegJKYWl/\r\n7+3D1qT5irFX1/7+3D1qT5irFECIiAuleoX7v0s9FSdMq5qXSvUMd36WeipOmVJIdStKv7ZR\r\nvqpP2Yx+k7/oreipjM4OfsYP5qfp9VjA1g1WgbAFVKSpBFTxCKJoa0cyumS+NRTZSPGqjZkE\r\ns2bxqo2ZRTZlUbMiEqyXxqq2Y86iWzeNVBP40Es2bnXoStKihP416E/jUiVEjTyr6Ht51Fio\r\nXrrhBJZHOvuQo4TjG9fRUeNNiQWvOqV/7AdOP/Rp/lWbNqPGtf8AVIz63ALps3O+zz/Kmx+W\r\niIikEREBERAREQEREBERAREQFM2PuR3pD0BQymbH3I70h6AkphaX/v7cPWpPmKsVfX/v7cPW\r\npPmKsUQIiIC231OPCfZeDWqvUt5oLhVtr2QtjFK1h1dQvznWcP2huWpEQdjx9VjoWwYGj+kG\r\nz/y4fxFWb1XGhbf7v6Q/w4fxFxkijQ7PHVdaF/u9pF7kP4i9Dqu9Ch/d7SL+HD+IuLkTQ7TH\r\nVe6FD+72kX8OH8Reh1X+hQ/u7pF/Dh/EXFSJodrfng6F/u7pF/Dh/EX0dWFoX+7ukX8OH8Rc\r\nUImh2x+eHoX+7ukX8OH8RPzxNC/3d0i/hw/iLidE0O2R1Ymhn7u6Rfw4fxF9HVi6Gfu7pF/D\r\nh/EXEqJodt/nj6Gfu7pF/Dh/ET88fQ393dIv4cP4i4kRNDtz88jQ393dIv4cP4ixvhR6qjRX\r\nSzg7v2jVJYr5DPcqKSmjklZEGNLhgE4eTj2LkdFOgREQEREBERAREQEREBERAREQFM2PuR3p\r\nD0BQymbH3I70h6AkphaX/v7cPWpPmKsVn920H4661c35U1ded7sdb5xlxP7StuwLyr8P9SIY\r\nSizbsC8q/D/UnYF5V+H+pBhKLNuwLyr8P9SdgXlX4f6kGEos27AvKvw/1J2BeVfh/qQYSizb\r\nsC8q/D/UnYF5V+H+pBhKLNuwLyr8P9SdgXlX4f6kGEos27AvKvw/1J2BeVfh/qQYSizbsC8q\r\n/D/UnYF5V+H+pBhKLNuwLyr8P9SdgXlX4f6kGEos27AvKvw/1J2BeVfh/qQYSizbsC8q/D/U\r\nnYF5V+H+pBhKLNuwLyr8P9SdgXlX4f6kGEos27AvKvw/1J2BeVfh/qQYSizbsC8q/D/UnYF5\r\nV+H+pBhKLNuwLyr8P9SdgXlX4f6kGEos27AvKvw/1J2BeVfh/qQYSizbsC8q/D/UnYF5V+H+\r\npBhKLNuwLyr8P9SdgXlX4f6kGEqZsfcjvSHoCnewLyr8P9SkLboZxMDmflLWy7OeIxyD/eRM\r\nP//Z\r\n\r\n--------------z0ttbxz8BplvjsfeE7Zogcgs--\r\n\r\n--------------GGc8vauWscgVN0JHIav4AOeV--\r\n--------------ae0qIOkrNQLQHe1YyfTsUXrk\r\nContent-Type: application/pdf; name=\"Sample PDF.pdf\"\r\nContent-Disposition: attachment; filename=\"Sample PDF.pdf\"\r\nContent-Transfer-Encoding: base64\r\n\r\nJVBERi0xLjQKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSL0ZpbHRlci9GbGF0\r\nZURlY29kZT4+CnN0cmVhbQp4nIXMvQoCMRAE4D5PMbXgOpvbXBIIVwha2B0sWIidP2AheI2v\r\n74mVWFjNwAwfRfEMDxCcW6pJIoqpFEznsF/g/t4kp1jtJ6drWHtQzfM7FRODn7DaKmoPvxwa\r\nlXFYamNHY2I/HH0XNh7Gv2akSQfLUfKX2ZhZWAe/fZwRLyqxKJYKZW5kc3RyZWFtCmVuZG9i\r\nagoKMyAwIG9iagoxMjkKZW5kb2JqCgo1IDAgb2JqCjw8L0xlbmd0aCA2IDAgUi9GaWx0ZXIv\r\nRmxhdGVEZWNvZGUvTGVuZ3RoMSAxOTk0MD4+CnN0cmVhbQp4nO08DXhU1ZX3zXkvCTckzIQM\r\nP0KSm0CE1CFJg4AglZkkkzASkjgZ/mzZ8jLzkgwk88aZSSIiiu0ianWxWqNFy1prsWJrKbUa\r\nlNq6pboW2e1W3V3bda1K1+1ulrq71O0iPPbc+978hUAR8Ge/3Twmc3/OPf/n3PMOaCLWr5Hx\r\nZAsB4g72qdGKgvw8QshLhEhFwYEEWzSl5y9w/Gv8GF3R7r7BwboQIYBz8lh378aum34ZW0yI\r\nfDshObU9mhq66L6XFxBC9+P+/B5cmG1EcnF+FOcze/oS14aVzjmE5E/B+exePajel2sgvXw3\r\nzif1qddGX7fVyzhfjXMWUfu0/h/FWnB+LSGL34zq8cTV5KaThCz9HN+PxrRo17vyazi/nhCl\r\nB9ckfPjPeBzm8LkNZCUnN28c+T/6I80jw+QgPs+R3eQBaRfOunD5Glx50LaXbCX9uPIT6aB0\r\nq20Oru0i75KXEXIbOQi7ZSJdSebiKiGvKTZyVAqQJxDHQqlYWpibIxO5VX5C9svD8jvyIbJA\r\njsuH5HVyXJoLDykrlV34WQg/tRWRF0kZGZbeIHHyNPwW5sJ+uVEuJG/AIdhNfoNU0N5IYzt5\r\nmGxCXoolndxo22Tz48oLyiGyAx8d9w9JO6WXkbunpS+SV8l9INuWkp3SqyjXQfIe+SIEbDei\r\nX861dSH/LyCuQ3h+B4nLRHlVosSwXYJryD3S6hS/S2CO8qp43iU3IuUAeThnOKc4dwZS4Rrb\r\nJf1EGsm5mzxIXobPwTXwK2mrPEP+lryUbDc1AOvIdsS9g5/J6ZI2ouz82cSx2wblddJu8lt5\r\nXW4n4v4plwhpPmHzo0RdZD9+BnPsKNPl0la4FTnluyXkUO6Vcg2eRwy5m1FqQnSYR9bjaBN5\r\nnOwlc2CIbEdMQt6cBcp7ePIB+U2Uebt0h+09cggaSRXpko+grkkxIUOEPJWbo8hgk4iL2ffY\r\nKn2hPe6rVrO/XFM+xzVqyuy5bA9p31OwkQ2fPNm+Wp6mrNmjTN8DlXl75MoZb55u8805rmXt\r\nq9meE95GC6t3XSOudazGIZ/hMq57G8UeJ7pHqcQ/vnV7WLCH3Wa/bcai2+zaIkwLNtJlDMld\r\nysOYjXLJRe7x8vsk530pT7nRJpOaA6+MfJrYXxl5ZaR2oqPcUVnuKO+SyfE4TDv+G2Mot/AP\r\n/xHLqcL433bybfkhtOoscoN7ccF4W2G+rbSsNG+cLZfayspK62l+aZnslIjz68VfmXKPQ76H\r\nfKXyy47bZ5fS/LJpuaRi2tTCOblTiytm2//hwMjxkcOOooX4g6QPHz08Yj/y3hH7847JC2tR\r\nwFx74b/h0PpaU7F3QpUkrX2yrKqmqq0K1krOamlGRY6zeFKZVCo5i+XyiotnzSuV5tbNn3fp\r\nxTVStTTv0plz6ybJS+Mvff6b3x/cdd1bf2e8bryz/ndbNo3EvrN/245Nb/1Mmvz78C+Vh3+6\r\nYP6WgaBWNvWS15587de1NT/3Nt1yQ+T6silzfvzY84cv5rrbhHLPQf+jpJLsd8+aWpY/eVwh\r\neXRyzr5CB7u57Onp+2YMO26fPJ5MhikF4/LyyyCv2Hux/fjIS6+M1NU5uIg1Bw4fPX50xP78\r\nEfsRx0LHwqKFte5IbUltaW1ZLastr61YMstd4i51l7mZu9xd0V7SXtpe1s7ay9sr2mdFZ20t\r\n2Va6rWwb21a+teLOWQ/OendWafJo8lDywLrSdWXr2LryaGm0LMqi5VtKt5RtYVvKp6yV1kpC\r\nZ6ilz0gLHDPmFaISL5536fy55ag11GfuvCtQhZNsz77x7Zv0r+4bHl6y/5ZvHzzxvmR75N51\r\nTwa0Z6/+z3dtc7s2dcZfe6Kq5cRNu7vU5x764Y+LbvxSdfXuWbOO81viaYywEtRVBbnafXFO\r\n0bgpE0hOSa5z/LYSBsPT9k+15xLHhLy8nHZH3oT26VPyLmqagYqqO378+Ai6Aypq8eLDRxcf\r\nGKlzFKEruCfWzmyfGZ1558wH8fnRzDdmnpw5DuUQfDodMxyC76zBXKfYlKu8P/7Cd5/dF+vf\r\nvmtfbPCOXfv2Ldmz8brH4NbrB37/1onP2XZ+/YFnHz6xzbbzoft/9I0T2+R1j3d3Xs8Tu408\r\nbayUH0YZ7GQ6ucI97aJ9pLB4n5I3XHi79EPYX+Ioym+eLJM8W1MJZ73uKI+ew4cPHz0wYj9w\r\npNY9bl3pltIHS39dKktrK1McEYfdhuqWTEaFFaT4vn2LvrfpJXLy5Eubvme77JG77nqEf751\r\n4vEcujukGvuNP+CzX5X+9eA77xzEj7h4MDMTaWNOMcZyxVPkXpuUR5pk5ASVN1LrLrArbqVd\r\nWadElXeVHGntxLmOGc8NYwL+7xEu2zXoyzeibMVkGom6ZxKnNO7mvFsU56OSsm+89MyUfUXD\r\n42+fPs1py3PmkWW2ogne6Yj66MgBYR2M1JHDdvRh+9EjDu7DVUtKoiUPlvy85N0SZQlZIi2x\r\nLXEumaa4cmvyasa5qE50SbfpTn3auLXXoN2c5SJIFzjR9RhqhKBeckUs58o3Ht87/tBT61/o\r\nDP58g3HUeEGqOv6WlDts++YtO/YV2v7k6mdfuPTSxz/lki6TqDRRajBeP3DvE4/v5DK9iHXS\r\nHcqrJI9MJIvcU6R77OSecTcV2WkeZjdlasESB5k+Ti4WfjaC+QQVdZQnO3f+BGeZc4nz887v\r\nOhXkzmHmjxmV5XUyJpVLJMcM6W7jjh077jAuk/7yfUkyTr5v/EypOfHXd227+a5db//q9bdO\r\nfMukr+wR9B2k2u3kxJGHPJudcup1nHiRUKLDzHc1OKzdu26iJGgKPZRXlotvTHN3H5XmSWXG\r\nm8ZBo176c2mvNGT0GO2GqtS8PyhNwdzmkibvMu41thg3GEPCH7j8M5D+OFLlLsq5R7bdQ26S\r\nv5OnSLkwnciUy/3KAZOs/Ujt3gn5SHhiuRPTPH5mvAhXn4ja2k/s+Zny6m5j6e4TC4io9Wxr\r\nvuqu/+yuz09Y/HtSlidqnr/50/+akq6AjJW5JeiFhOSliyKM/j6jJLNMGlU2UaxoupRisk1+\r\nh2zKPUiexvHTtoXkOXmEXCO/TF7Em+ZFeQeXCXPITulSaUj6J5tsi9getx2BKRZGSmaj3s1o\r\ntZOvcg5kp20SfvOa5yLpihTd+1I8SCQfZ5J1SibfsMaA649YYxnHe62xgrXuD61xDlJ80Rpz\r\nK79sjfOxtjhsjQuKviYlq+RCcunEndbYTvIn/sIaO4g88XWkKMlYN0u1E9+0xhLBbGaNbSTP\r\nOcsaA67XWGMZx15rrJApzs9a4xxS7Ixb4zxS4dxmjfPJIuej1rigcpHzbWtcSHouL7HGdjLp\r\n8q3W2EHyLr+/QY9ujIW7exJsdrCK1dXWzmWdG1l9OBFPxDS1z8V8kWA18/T2Mj+HijO/Ftdi\r\nA1qomp5ydD4/GlAH+tbrkW5Wr/ac5mCjtl5d2Y8lixrp1uJMjWksHGHR/s7ecJCF9D41HEnC\r\ndKiReL2ub8iYZgxXarF4WI+wuuq588zlDIAuPYJUEyhETyIRXVRTE8L1gf7quN4fC2pdeqxb\r\nq45oiSYBxnngUqQEZ7PjmsY6tV59sKqanQXH1ay5d2O0J87CfVE9ltBCrCum9zFPTBuwWEnS\r\nEBrqNzWUSYbSNHWUTGUmayk10zln/KGnGuSsbclGUQ7HqcoSMTWk9amxDUzvGo2F0nYt1heO\r\nC/WH46xHi2lIqzumRlB0F8qOYuEx1Bjq2cUSOlMjG1kUDYYH9M4EaiyMKlBZEJmmCJno0ZJ6\r\nCgb1viiCc4BED2JHLWuROGqvQqikogqRhZgaj+vBsIr0aEgP9vdpkYSa4Px0hXvRSLM5RnGA\r\ndehdiUFUf0WV4ARfdmN6qD+oCTShMAoW7uxPaJwHmnXAhWYO9vaHOCeD4USP3p9AZvrCFiFO\r\nIWaqEtH2xxGei+NifRqXmgoHife4Mmi4OM0aPcbiGtoBocPIqiX+KNKcOUQb5YpOUFN1gtBg\r\nDzrWKQe4Gbr6YxEkqImDIZ3FdReL93eu14IJvsLl69J70dm4QEE9EgpzOeKLKA0gOrVTH9CE\r\nBKYXCQZSThDRE2iGuLnKrRJNe4C5x+I9am8v7dQsrSEbGCVqlpx6BP0ixvr0mDam2CyxMap1\r\nqUio2mQqe7dP3YjRgsdD4a4wdzS1N4GuhwNEqoZCQnJTdTxA1Rjy1d+rxignFNLi4e6IYKPb\r\njFU8xD1UDSKSOD+R5Cc+mhJHSZGAUJjaOzYC60ySjzQ2ZC/Su5GFM9yccnFiGu/MCFg+iHNF\r\ncrskw0NDn9Ni4tCgHgvFWUUqDis47eQGreBhWyFUhpZpseKlU8NI4lj70QZcJwN6OMWYdm0C\r\nI4ap0SiGl9rZq/ENU3bEzAc0bZQeNcF61Dhi1CJZOuFel/buEOuPhCyG06xSwZwp4ZmsGtd7\r\neVQLs3EjqayXZw+MlSRgVA1uULtRMIzDiE65q34wp8oihQkLWdR6uzhTS72sqa01wDramgKr\r\nPH4v83Wwdn/bSl+jt5FVeDpwXuFiq3yBpW0rAgwh/J7WwBrW1sQ8rWvYMl9ro4t5V7f7vR0d\r\ntM3PfMvbW3xeXPO1NrSsaPS1NrN6PNfaFmAtvuW+ACINtImjFiqft4MjW+71NyzFqafe1+IL\r\nrHHRJl+gFXEic37mYe0ef8DXsKLF42ftK/ztbR1exNGIaFt9rU1+pOJd7kUhEFFDW/sav695\r\nacCFhwK46KIBv6fRu9zjX+ZiiKwNRfYzAVKNXCIO5l3JD3cs9bS0sHpfoCPg93qWc1iunebW\r\ntuVe2tS2orXRE/C1tbJ6L4riqW/xmryhKA0tHt9yF2v0LPc0c3GSRDiYKU5aHZQfaPa2ev2e\r\nFhfraPc2+PgA9ejzexsCAhJ1j5poEew2tLV2eK9agQsIlyThoquWegUJFMCDfxoEZ0L8VhSX\r\n4wm0+QMpVlb5Orwu5vH7OrhFmvxtyC63Z1uT8IAVqE9uvFaLX24jvnaqdyAUP20J2Oj1tCDC\r\nDs4GLtAsWPQu77VBLZrgvm0Ft5kaRRo1c6dLeK2ZBNCFmyMYuOaaGOK1hJElbh0zu6UvbH4d\r\nu8zUK9IHejfeRGbqDQ1omAHjPJXoMarzZDIYjotIxyuwTzfvPBZXe5EYnuJRJKAwV6q9eCye\r\nYjMroGjyMozGwnhkMBZOYDJhaj+uxsLXWddwzLqmhAQsLQGnkk4OJv8xLR7FWyo8oPVurEbY\r\nGL/LBCfhCNZqfZboQn3BxKJkqZBg3QJ5SE9QrOiqGaWi4jrv0ulsa9kLUwdRsw5i51IH0XQd\r\nxM6xDqKn1kFWkg8KTPHknTFGgZouWOj51EosWSvRT0atRE07fGi1EjUD9rxqJXoBayWarpXY\r\nOdZKNKsuOIdaiZ6uVmJnXyvRjFopM3yzyiW8zzFJXKhyiVrlEjuvcolmsSveGy90yUQjOjvv\r\nkole0JKJWiUTO/eSiY4umdi5lEx0zJKJfZCSiQY8K5df2cbZ9iw9p+qIpiU/n+qIJqsjdj7V\r\nEc2sjtg5VUd0zOqInU91xJ01K1BShQ89beHDPkDhQ89c+LCzKHyoKHyya4c/XtAkkvBuUTTQ\r\navyqPmPnqmYwvCFcE8YMcm11tCdaY6WxUZ0z0kB0EiUbSYyESTfpIQnCyGwSJFX4XUdq8ZmL\r\no06EYKQeYRIkjp8Y0YhK+ogLV30kgvDVOPKQXnwY8adwxcVMw28Nzwzg7xBC0rOgOj9FNYCU\r\nBpAW/+vZCEJzPlQ888EoNuJoPZ5bSfoRIoiwqsCmiROqkIghlgj+jiJMJ+INIxzD8zpSV8Xe\r\naDwdAkscOdLx2XCa3bFXVwoO44hXF1TrkM+5ZF4W9NgYusQJU9aEZQkuewI5X0Rq8AlZ8AMI\r\nX41wOn7HUBpNnI0JuasRh4ZnmjKwJfWQtMWpFud7XLeasI+GWtLJIMJya1wYHXNMzbizEWF6\r\nxMkw7kUF3wlhT66BmDjBPYBjHRilldFypH2oP8uHTicNxWcs2U2bqTjK1Nqp3kzJnPN46FlF\r\nyIWPy7HtnZY5jDtUjBJihXtZn9D1BlzT0QJ/jBcuWbvA1yewpb0/LHjqEXuaJVe3oBKxrO6y\r\n7G5ay6Rm+pjpzy7Bly6sHxHno1aEmRR0xJqwfCxseYEqcJiaphbOhOBitD8FBRz3QxN7EgOH\r\nNnk3fVkT8Wr6XkWGl1QIy/GzIfEdF3wF8YxqyUdFFATRQ/sEloTYSeqnC0e9ViTNTvGYpsDz\r\nCuc/gf5rej+nmNYJX4mKqAkhhaA4neQmJCRICF/rxN2E2DVp0DNQcFnRHETO+gUWUyeDwgd6\r\nRNZJWJrpE2uZEiVliGV5pcltv9ChK8M6fNwn7GnammZkkDiedp1GDldKzhqRQZjAbMaDiTts\r\naTXb+meWOqk5k9toyqMTgq+016UlGhT66DsrCslo6BJZO2JJqGVQDInfnIZLfHNNrEeIoMBn\r\nwiTtx/2418psSQsFBe2Q4DhscbpIRGfA4k5FjLrIDGkbZOaitAZOzQQRhE9Y0RDPgk3GSlpj\r\nmTkg8xwTMquCcypyc7avmdow7xL1DPbUxS3HLNv3ie90/jgbWyTETcRvTtWSqDpLU2c6y3Wy\r\n0bpbTOpc512Cx5DlSb3CT2OpFZNTrtNQhs0zvS55g6riRgyLnNErZjQlUUhwyu0VydBGd9a9\r\nalJK5lBVeI/pu0kao/UT/6MyJbmklgRpD1OFjc6eg2w6o/UxFm8uy9694lz4NNmcpqwTE3lW\r\nFXkljTe5Ek95ZDJeRt8empXnNCFFktKgkCokzleMcR9WpOQefYLiXvK2rcjwMjNmWkbdL50i\r\n3vUMXvutOEj6yQDuhsfQmEauFXqOWJEcxce8vVSRUbXUiUy7mzwnV+iYkdIjMjwT33GLR014\r\n0un8JJnrxsrdIXETRITdM/U1llZphuYybXiusRoXWTN5V6ejLRlJvHLoTdUeMetENsao8OgN\r\n+Lvbsph5H3Kvoqms+mFmqtNL1WnFSMK6D7tSmlpKvIJOG2nFGafThrMAWYV1pF/s+XCNYR3n\r\nx52VOGvE1UZhF4/Y4fsVIhpX4ZhjbCMrBC4Thx9/c9xrcIXjZmLOZ8sQvhVx8bNeslrQ8CK2\r\nDuSsDccc93JcbcFvrwXHTzTgygqc83Ez4VWoSa8VTwVE7PBznBeT0wCup6lmc+UTFJOcLceZ\r\nH/EvtXY9iNsn8HH+XaI+4uNWi09Tc36BneuIY+Y4G5CjFjHjqyvwux3hOoQ+PUJmk9tWIUMT\r\n7puyeAUHpiVMjhrwux1pc4hm5CsgtMApBSxIl7Ajl6dRnOdUlwkok7M2y8p8nMZSbenS5IPr\r\nf2WKcoeQvwUfJuQP4EpA2MaD+JN4k77TLDBwvqnQxgohn0fooU1QqBdwXItcny0pj/NnWKVB\r\n6IvbjXPeKCh5hEY6xpQkiS3TOmN5B01RaBbyeYWmWgR0B+rRi/C+1Irpjz4ha4OlaxOn6fem\r\nT7RkaLdByMgtexVS9Vo+5RG6y5aC22mV4D8thWkBj/W7IUNnaeu3WtZN8hMQlANjaGWViEWv\r\ngPIIW3ekYqRJxO9yi/MVKQ9L54AVln+2pTjL1m8yjpJwZ5M7TFxJ2tkWbBT+1GJx2JHShglB\r\nz4DXzF1evNeC4j0nkcrb2Td3ZtWYrkYz605XRq7NrATMLNwsYPtGwaVXzbcl885Kv+tk1m5j\r\nvWEn347NWj5Z9aarDzN3m+9EmVVvSNTnZg0YT1UluqgD9VRlMih203d61Oqd6FnveZyyKu5+\r\nV4pW8i5K4zLrSlVUC5xafAxtnv6Goqe8GUbFfW9SGRTjhFWZcPn6LVi+ft2ot+Fk/+dUG7Ax\r\nbZCUZazKIVP/MWHvqPUuFRYa5vVktYU3RpLvZWmdcA2YfbW+UVZPex/HtoiM7ipwHXRncB4S\r\nuqbE7NFxmlTkq2SP6+PvOl3ovuwnqR9Es/pBoyuvD68fRMfsB7GPuB9Ez6oflF3JBzN4Svc6\r\nkpBn10Edq8NCP7a+Ejulr0T/v6+U0VdKdxj+d/aVaNYN+/H1legYb2ufhL4SHbOvlJboo+kr\r\n0TP0Cz6avhIlH7SvlP5bpwvZV0rHW3Zf6XS37+m7S+b7uVlJfNK6S5Rkd5fG7m58NN0legbt\r\nsgwNfrK7TFT42KnVzEffZaKf4C4THdVlSr/rfpRdJvpHu0zsI+sy0Q/QZWIfWpeJCh2sRKxX\r\nCm5NbXtw/6PrHdExbf5x9Y7oKb0j9rH1juhpe0fpHtCH3zuiH6B3dCa8H27vKJlZT3+jnNrx\r\noefQ8cns0lzIjg89r47Pqe9s59bxoRkdnzP1HS5EhyZxCn43SXcaqKDDZ9Xn8W+uaoReNuCn\r\nRvAWElVTtahfo7iWXY2d+d+cif9mWfycvIFcPdb/m2bYtsV98n0DjhXDf1fCH+rgv4bgvUL4\r\nvQFHDfjPSviPQvj3IXi3En53m0f5nQFHhuDfhmDkGPzrMfgXA367CP65Ht4x4J/q4DeHO5Tf\r\nDMFhBDzcAW+/VaO8fQzeqoE3Dfi1AW/UwT8Ww+tD8A8G/KoIfrkZXnsG/t6Av0Xwv90Mr77S\r\nrLy6GV5phpd/MU152YBfTIO/MeDnBvy1AX9lwKEheOlgqfKSAQdL4Wd18KIBz291KM9Ph59O\r\nggMG/MSAvzDgOQN+bMCPDHjWgB8asN+AZwx42gH7bq5U9hkw/NQzyrABTz25VnnqGXhqi/zk\r\nDyqVJ9e6T8KTbvkHlfCEAd8fgr0GfM+APQZ814DHQ/CdQvj2Y5XKt0Pw2O4i5bFK2F0EjyLT\r\njx6DbxnwiAG7DPhmETxswDceKlS+UQcPFcLXQ/Aggjw4BH9uwM6vjVd2GvC18fDA/VOVB0Jw\r\n/w67cv9U2GGHr1K4z4B7hwqUew0YKoB78NA9Q/CVuwuVr8yGuwvhrmPw5TufUb5swJ3b1yp3\r\nPgN3bpG3/1mlsn0tbHfLf1YJdxhw+5eqldsN+FI13IZi3uaBW2/JV24thlvyYRsubAvBzaip\r\nmythqwP+1IAvfsGhfNGALzjgJgO2GHCjAe6TN2zerNxgwObNcH0INgWcyqZKuM6AjQZcWwiD\r\n42GAQr8BiWMQPwaxY3DNMYgaoBsQMaC3HDYYsN5Rr6zvgLABPZuhGyddBmgGhAwIGtBpgLoI\r\n1h2DPxkPaw34rAFXG7BmNVXWHIPVFFZNmqqsqoOVBqxAyivqIeCEDsmudEwBfzFcdeVE5SoD\r\n2vOhzYDW5Xal1YDldmgxYBnuLDPgSp9duXIi+EoKFJ8dlhZAswFNQ+AdgkYDGmxzlIZjUP8M\r\neJaB24AlBlzxmSLlimL4zOIJymeKYPHlBcpi98kJcHkBLDJgoQGXLShWLjsGC+bblQXFMH9e\r\nvjLfDvPy4dJSmFsAdZ/OV+oM+HQ+1NbkK7UFUJMP1XPGKdV2mDMOXHVwyacqlUtC8KmqIuVT\r\nlVBVBLNnVSqzPTCrEi6uzFcungCV+TDTgBkGVEyAcpSzvAhYCMqOQSmKUBqCkgKYjhqcbsC0\r\nY3BRPUzFyVQDpoRgMmpqsgGT8NCkqeA0oNiAiQYUIUCRAQ6U1VEP9s0wIQSFBhSMn6QUGDAe\r\nocdPgnwDqB3GGZCHYHkG5BZDTghk3JTRA5yAq2CADee2OSDZgRggDUuhrXdIl/xv+CEfNwNn\r\n/Cn5H+T5xf0KZW5kc3RyZWFtCmVuZG9iagoKNiAwIG9iago3MjA5CmVuZG9iagoKNyAwIG9i\r\nago8PC9UeXBlL0ZvbnREZXNjcmlwdG9yL0ZvbnROYW1lL0JBQUFBQStEZWphVnVTYW5zCi9G\r\nbGFncyA0Ci9Gb250QkJveFstMTAyMCAtNDYyIDE3OTIgMTIzMl0vSXRhbGljQW5nbGUgMAov\r\nQXNjZW50IDkyOAovRGVzY2VudCAtMjM1Ci9DYXBIZWlnaHQgMTIzMgovU3RlbVYgODAKL0Zv\r\nbnRGaWxlMiA1IDAgUgo+PgplbmRvYmoKCjggMCBvYmoKPDwvTGVuZ3RoIDI2NS9GaWx0ZXIv\r\nRmxhdGVEZWNvZGU+PgpzdHJlYW0KeJxdkE1uwyAQhfecgmW6iMCO7TSSZalyFMmLtFXdHgDD\r\n2EGKAWG88O3LT9pKXYC+YeYN84a03blT0pF3q3kPDo9SCQuLXi0HPMAkFcpyLCR3jyjefGYG\r\nEa/tt8XB3KlR1zUiHz63OLvh3YvQAzwh8mYFWKkmvPtqex/3qzF3mEE5TFHTYAGj73Nl5pXN\r\nQKJq3wmflm7be8lfwedmAOcxztIoXAtYDONgmZoA1ZQ2uL5cGgRK/MudkmIY+Y1ZX5n5SkrL\r\nQ+M5j1xlgQ+Jz4GLyEcauEzvbeAqcRn4mPrEmufIRRH4lLiKszx+DVOFtf24xXy11juNu40W\r\ngzmp4Hf9Rpugiucbox+AAgplbmRzdHJlYW0KZW5kb2JqCgo5IDAgb2JqCjw8L1R5cGUvRm9u\r\ndC9TdWJ0eXBlL1RydWVUeXBlL0Jhc2VGb250L0JBQUFBQStEZWphVnVTYW5zCi9GaXJzdENo\r\nYXIgMAovTGFzdENoYXIgOQovV2lkdGhzWzYwMCA2MzQgNjEyIDk3NCA2MzQgMjc3IDYxNSA2\r\nMDMgNzcwIDU3NSBdCi9Gb250RGVzY3JpcHRvciA3IDAgUgovVG9Vbmljb2RlIDggMCBSCj4+\r\nCmVuZG9iagoKMTAgMCBvYmoKPDwvRjEgOSAwIFIKPj4KZW5kb2JqCgoxMSAwIG9iago8PC9G\r\nb250IDEwIDAgUgovUHJvY1NldFsvUERGL1RleHRdCj4+CmVuZG9iagoKMSAwIG9iago8PC9U\r\neXBlL1BhZ2UvUGFyZW50IDQgMCBSL1Jlc291cmNlcyAxMSAwIFIvTWVkaWFCb3hbMCAwIDU5\r\nNSA4NDJdL0dyb3VwPDwvUy9UcmFuc3BhcmVuY3kvQ1MvRGV2aWNlUkdCL0kgdHJ1ZT4+L0Nv\r\nbnRlbnRzIDIgMCBSPj4KZW5kb2JqCgo0IDAgb2JqCjw8L1R5cGUvUGFnZXMKL1Jlc291cmNl\r\ncyAxMSAwIFIKL01lZGlhQm94WyAwIDAgNTk1IDg0MiBdCi9LaWRzWyAxIDAgUiBdCi9Db3Vu\r\ndCAxPj4KZW5kb2JqCgoxMiAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgNCAwIFIKL09w\r\nZW5BY3Rpb25bMSAwIFIgL1hZWiBudWxsIG51bGwgMF0KL0xhbmcoZW4tTlopCj4+CmVuZG9i\r\nagoKMTMgMCBvYmoKPDwvQ3JlYXRvcjxGRUZGMDA1NzAwNzIwMDY5MDA3NDAwNjUwMDcyPgov\r\nUHJvZHVjZXI8RkVGRjAwNEMwMDY5MDA2MjAwNzIwMDY1MDA0RjAwNjYwMDY2MDA2OTAwNjMw\r\nMDY1MDAyMDAwMzUwMDJFMDAzMT4KL0NyZWF0aW9uRGF0ZShEOjIwMTYwNjE2MTM0NDU4KzEy\r\nJzAwJyk+PgplbmRvYmoKCnhyZWYKMCAxNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDgz\r\nNTggMDAwMDAgbiAKMDAwMDAwMDAxOSAwMDAwMCBuIAowMDAwMDAwMjE5IDAwMDAwIG4gCjAw\r\nMDAwMDg1MDEgMDAwMDAgbiAKMDAwMDAwMDIzOSAwMDAwMCBuIAowMDAwMDA3NTMzIDAwMDAw\r\nIG4gCjAwMDAwMDc1NTQgMDAwMDAgbiAKMDAwMDAwNzc0NyAwMDAwMCBuIAowMDAwMDA4MDgx\r\nIDAwMDAwIG4gCjAwMDAwMDgyNzEgMDAwMDAgbiAKMDAwMDAwODMwMyAwMDAwMCBuIAowMDAw\r\nMDA4NjAwIDAwMDAwIG4gCjAwMDAwMDg2OTcgMDAwMDAgbiAKdHJhaWxlcgo8PC9TaXplIDE0\r\nL1Jvb3QgMTIgMCBSCi9JbmZvIDEzIDAgUgovSUQgWyA8Nzg2RkVDMTY2OUIxOURDMTJBNEU2\r\nODQzN0YxQjIzRTE+Cjw3ODZGRUMxNjY5QjE5REMxMkE0RTY4NDM3RjFCMjNFMT4gXQovRG9j\r\nQ2hlY2tzdW0gLzkzRjFCMUZBQjVENzc2Q0JFNDc2MzA1QzdENUVCRUUxCj4+CnN0YXJ0eHJl\r\nZgo4ODcyCiUlRU9GCg==\r\n\r\n--------------ae0qIOkrNQLQHe1YyfTsUXrk--\r\n"
  },
  {
    "path": "internal/storage/testdata/mixed-attachment.eml",
    "content": "From: sender@example.com\nTo: recipient@example.com\nSubject: Test mixed attachments\nMIME-Version: 1.0\nContent-Type: multipart/mixed; boundary=\"boundary111\"\n\n--boundary111\nContent-Type: text/html; charset=utf-8\n\n<html><body><img src=\"cid:inline@example.com\" alt=\"Inline\"/><p>Document attached</p></body></html>\n\n--boundary111\nContent-Type: image/png; name=\"inline.png\"\nContent-Disposition: inline; filename=\"inline.png\"\nContent-ID: <inline@example.com>\nContent-Transfer-Encoding: base64\n\niVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==\n\n--boundary111\nContent-Type: application/pdf; name=\"document.pdf\"\nContent-Disposition: attachment; filename=\"document.pdf\"\nContent-Transfer-Encoding: base64\n\nJVBERi0xLjQKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSL0ZpbHRlci9GbGF0ZURlY29kZT4+CnN0cmVhbQo=\n\n--boundary111--\n"
  },
  {
    "path": "internal/storage/testdata/plain-text.eml",
    "content": "Delivered-To: recipient@example.com\r\nReceived: by 2002:a0c:fe87:0:0:0:0:0 with SMTP id d7csp146390qvs;\r\n        Tue, 26 Jul 2022 20:45:20 -0700 (PDT)\r\nX-Received: by 2002:a17:90a:1943:b0:1ef:8146:f32f with SMTP id 3-20020a17090a194300b001ef8146f32fmr2327371pjh.112.1658893508159;\r\n        Tue, 26 Jul 2022 20:45:08 -0700 (PDT)\r\nARC-Seal: i=1; a=rsa-sha256; t=1658893507; cv=none;\r\n        d=google.com; s=arc-20160816;\r\n        b=KrXcumoy4Oldq3Ny6ZLUfED4+/+4ndNbrM3uw1COEhqCVWWv7lLfFeNHTyxJQJLBK3\r\n         tVgmPBX2XRmX+531CFRNquUDrqhsvc4kgIq0ExWPz99wG2vgsKWQ2x89AIfQ8sEYMwxY\r\n         HOwErTH6XQuJ45YE+5Lt4pjMP+7NqnJ1NTRQyc7FB/c1Wt1JdTWscgaJGqUMnIFSbCPG\r\n         xi0xpJnrIkh4giARIhabCRmVoo1g8BfzYrmy8uHtbIcDDuCJ8tN2lMLscwfw3u8hZWm6\r\n         e1nAx4iDYyShdMZPPoUVoMHDf9P39DKwhdfb/xP/cQ6ulv7ECzVSp5DM8aLpfjw6SU9G\r\n         JYJA==\r\nARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20160816;\r\n        h=content-disposition:mime-version:message-id:subject:to:from:date\r\n         :dkim-signature;\r\n        bh=8shE8duj4atyKhQhO1qlS4/NgHN4ubjWq86U+mmAH9M=;\r\n        b=TGK9vlNQRpyHvcpQonLjrFuLubL2mo9vT15CPwtC6ltsrYccKUozKiyb+id79dPatM\r\n         y2unMpJqJFB4rZnASRm20Ck9dFRulM8bowO4l9BWKAUti9+u7bmLYbOPQCgDmJRA88ij\r\n         YTkSKE8TuFMZQMJTkyZZTwE3F/Vrv84fAekWzGlwFoV3D6r6t1D5EUYUoR4xCVZdpMo1\r\n         Ic0bEqgmRXl44uEqyVNpIC0w86Hzz84zl2V+nca+gxfObMzbJheDkOwVKkNNmr0ja936\r\n         QZK+aO9s9VQGtqmjWtWhc1OWO50Bc5vE/krLFvZM6+vbMBEuDE5rkfHdf5mSD9Ix4xWl\r\n         6/Rg==\r\nARC-Authentication-Results: i=1; mx.google.com;\r\n       dkim=pass header.i=@gmail.com header.s=20210112 header.b=fpxRepVP;\r\n       spf=pass (google.com: domain of sender@example.com designates 209.85.220.41 as permitted sender) smtp.mailfrom=sender@example.com;\r\n       dmarc=pass (p=NONE sp=QUARANTINE dis=NONE) header.from=gmail.com\r\nReturn-Path: <sender@example.com>\r\nReceived: from mail-sor-f41.google.com (mail-sor-f41.google.com. [209.85.220.41])\r\n        by mx.google.com with SMTPS id t3-20020a17090a2f8300b001f25e258dfasor335081pjd.34.2022.07.26.20.45.07\r\n        for <recipient@example.com>\r\n        (Google Transport Security);\r\n        Tue, 26 Jul 2022 20:45:07 -0700 (PDT)\r\nReceived-SPF: pass (google.com: domain of sender@example.com designates 209.85.220.41 as permitted sender) client-ip=209.85.220.41;\r\nAuthentication-Results: mx.google.com;\r\n       dkim=pass header.i=@gmail.com header.s=20210112 header.b=fpxRepVP;\r\n       spf=pass (google.com: domain of sender@example.com designates 209.85.220.41 as permitted sender) smtp.mailfrom=sender@example.com;\r\n       dmarc=pass (p=NONE sp=QUARANTINE dis=NONE) header.from=gmail.com\r\nDKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n        d=gmail.com; s=20210112;\r\n        h=date:from:to:subject:message-id:mime-version:content-disposition;\r\n        bh=8shE8duj4atyKhQhO1qlS4/NgHN4ubjWq86U+mmAH9M=;\r\n        b=fpxRepVPdRgZF9VI4rCzO4n1l9+OHrm254/c1PaNcNnC1+0Rr78o1ASLvDKoQY4INc\r\n         gRN1kJIk+ozQumJSfQPEIe+rHbJxe+wzjbYhEfUwBUnFHZykqvYWl6Xmjwg61IhxwwWk\r\n         b3Gp/ODHkdQrm5QqIFACEn1fQmqkk4XBlcKMYEU/NOswGDOFULfbrhDcBWmR/gp2kHmT\r\n         DkqRA9UJ1Cc6GO9lG+McRi8uLNaTymuLwzBydVV0bZOQTLxHQcQBTfUFrp/fwjHc9V19\r\n         l9uQcn5rOOsh3vR37NGpv8WPi7BORLRFGjMVD0DZ7CtJwTDHz4EVvdLijt6YbUV9ecp1\r\n         df3Q==\r\nX-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n        d=1e100.net; s=20210112;\r\n        h=x-gm-message-state:date:from:to:subject:message-id:mime-version\r\n         :content-disposition;\r\n        bh=8shE8duj4atyKhQhO1qlS4/NgHN4ubjWq86U+mmAH9M=;\r\n        b=Z8ndxERf1NU67swjZ7cSjkSTTaa2YzhtrRyJkg0vnRxi87af7ECZNT+Zaxuxmxmqvb\r\n         5T3IN2ymjPu1Y52EqRdZQpnzS/E5OjHbA6AYSn5qneNXNDxqJwp5qVSXuyB265QOo/9M\r\n         bGp4fqfi8Qe5pmgkzyTqyrigWFOzcl23sCGXqvnrD8+0e+/n1dqo2tYk4v2KpSoAUxF0\r\n         SNwHocpTDBDxOMEulUkQpqNlyZsgqNGdRhZmUN+2tQnpCQULd4B7+pydyWBCp9o8J1W4\r\n         0IqmhJiNT8pB8MVzyUsWNG+WX9GBh8PK6XndOjmp2WvYh0LcUKeEYQ6zBsIdDFNEkMD1\r\n         dU9w==\r\nX-Gm-Message-State: AJIora+ZXWhiNwKn6ik6LuIUHc1hskP3Nneo2J0m0wSC9wwGXI1RPi1a\r\n\tMl5Ex/pAryQwTi7MXqbUQkCIrEe5kU0=\r\nX-Google-Smtp-Source: AGRyM1v7CWOR6/X4d18Wv11XTnkfT25QfmsqBowwGsebQlPqhR1ogD3bo1sZRs/OSAHP7AjywIebfw==\r\nX-Received: by 2002:a17:90a:5e0b:b0:1f0:5565:ee6e with SMTP id w11-20020a17090a5e0b00b001f05565ee6emr2290528pjf.128.1658893506447;\r\n        Tue, 26 Jul 2022 20:45:06 -0700 (PDT)\r\nReturn-Path: <sender@example.com>\r\nReceived: from localhost.localhost ([8.8.8.8])\r\n        by smtp.gmail.com with ESMTPSA id s7-20020a170902ea0700b0016a3f9e4865sm12488166plg.148.2022.07.26.20.45.04\r\n        for <recipient@example.com>\r\n        (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256);\r\n        Tue, 26 Jul 2022 20:45:06 -0700 (PDT)\r\nDate: Wed, 27 Jul 2022 15:44:41 +1200\r\nFrom: Sender Smith <sender@example.com>\r\nTo: Recipient Ross <recipient@example.com>\r\nSubject: Plain text message\r\nMessage-ID: <20220727034441.7za34h6ljuzfpmj6@localhost.localhost>\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=us-ascii\r\nContent-Disposition: inline\r\n\r\nLorem ipsum dolor sit amet, consectetur adipiscing elit. Cras non massa lacinia, \r\nfringilla ex vel, ornare nulla. Suspendisse dapibus commodo sapien, non \r\nhendrerit diam feugiat sit amet. Nulla lorem quam, laoreet vitae nisl volutpat, \r\nmollis bibendum felis. In eget ultricies justo. Donec vitae hendrerit tortor, at \r\nposuere libero. Fusce a gravida nibh. Nulla ac odio ex.\r\n\r\nAliquam sem turpis, cursus vitae condimentum at, scelerisque pulvinar lectus. \r\nCras tempor nisl ut arcu interdum, et luctus arcu cursus. Maecenas mollis \r\nsagittis commodo. Mauris ac lorem nec ex interdum consequat. Morbi congue \r\nultrices ullamcorper. Aenean ex tortor, dapibus quis dapibus iaculis, iaculis \r\neget felis. Vestibulum purus ante, efficitur in turpis ac, tristique laoreet \r\norci. Nulla facilisi. Praesent mollis orci posuere elementum laoreet. \r\nPellentesque enim nibh, varius at ante id, consequat posuere ante.\r\n\r\nCras maximus venenatis nulla nec cursus. Morbi convallis, enim eget viverra \r\nvulputate, ipsum arcu tincidunt tortor, ut cursus dui enim commodo quam. Donec \r\net vulputate quam. Vivamus non posuere erat. Nam commodo pellentesque \r\ncondimentum. Vivamus condimentum eros at odio dictum feugiat. Ut imperdiet \r\ntempor luctus. Aenean varius libero ac faucibus dictum. Aliquam sed finibus \r\nmassa. Morbi dolor lorem, feugiat quis neque et, suscipit posuere ex. Sed auctor \r\net augue at finibus. Vestibulum interdum mi ac justo porta aliquam. Curabitur \r\nnec enim sit amet enim aliquet accumsan. Etiam accumsan tellus tortor, interdum \r\nsodales odio finibus eu. Integer eget ante eu nisi lobortis pulvinar et vel \r\nipsum. Cras condimentum posuere vulputate.\r\n\r\nCras nulla felis, blandit vitae egestas quis, fringilla ut dolor. Phasellus est \r\naugue, feugiat eu risus quis, posuere ultrices libero. Phasellus non nunc eget \r\njusto sollicitudin tincidunt. Praesent pretium dui id felis bibendum sodales. \r\nPhasellus eget dictum libero, auctor tempor nibh. Suspendisse posuere libero \r\nvenenatis elit imperdiet porttitor. In condimentum dictum luctus. Nullam in \r\nnulla vitae augue blandit posuere. Vestibulum consectetur ultricies tincidunt. \r\nVivamus dolor quam, pharetra sed eros sed, hendrerit ultrices diam. Vestibulum \r\nvulputate tellus eget tellus lacinia, a pulvinar velit vulputate. Suspendisse \r\nmauris odio, scelerisque eget turpis sed, tincidunt ultrices magna. Nunc arcu \r\narcu, commodo et porttitor quis, accumsan viverra purus. Fusce id libero iaculis \r\nlorem tristique commodo porttitor id ipsum. Vestibulum odio dui, tincidunt eget \r\nlectus vel, tristique lacinia libero. Aliquam dapibus ac felis vitae cursus.\r\n"
  },
  {
    "path": "internal/storage/testdata/regular-attachment.eml",
    "content": "From: sender@example.com\nTo: recipient@example.com\nSubject: Test regular attachment\nMIME-Version: 1.0\nContent-Type: multipart/mixed; boundary=\"boundary789\"\n\n--boundary789\nContent-Type: text/html; charset=utf-8\n\n<html><body><p>Message with regular attachment</p></body></html>\n\n--boundary789\nContent-Type: application/pdf; name=\"document.pdf\"\nContent-Disposition: attachment; filename=\"document.pdf\"\nContent-Transfer-Encoding: base64\n\nJVBERi0xLjQKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSL0ZpbHRlci9GbGF0ZURlY29kZT4+CnN0cmVhbQo=\n\n--boundary789--\n"
  },
  {
    "path": "internal/storage/testdata/tags.eml",
    "content": "Date: Wed, 27 Jul 2022 15:44:41 +1200\r\nFrom: Sender Smith <sender+FromFag@example.com>\r\nTo: Recipient Ross <recipient+ToTag@example.com>\r\nCc: Recipient Ross <cc+CcTag@example.com>\r\nBcc: <bcc+BccTag@example.com>\r\nSubject: Plain text message\r\nX-Tags: X-tag1, X-tag2\r\nMessage-ID: <20220727034441.7za34h6ljuzfpmj3@localhost.localhost>\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=us-ascii\r\nContent-Disposition: inline\r\n\r\nLorem ipsum dolor sit amet, consectetur adipiscing elit. Cras non massa lacinia, \r\nfringilla ex vel, ornare nulla. Suspendisse dapibus commodo sapien, non \r\nhendrerit diam feugiat sit amet. Nulla lorem quam, laoreet vitae nisl volutpat, \r\nmollis bibendum felis. In eget ultricies justo. Donec vitae hendrerit tortor, at \r\nposuere libero. Fusce a gravida nibh. Nulla ac odio ex.\r\n\r\nAliquam sem turpis, cursus vitae condimentum at, scelerisque pulvinar lectus. \r\nCras tempor nisl ut arcu interdum, et luctus arcu cursus. Maecenas mollis \r\nsagittis commodo. Mauris ac lorem nec ex interdum consequat. Morbi congue \r\nultrices ullamcorper. Aenean ex tortor, dapibus quis dapibus iaculis, iaculis \r\neget felis. Vestibulum purus ante, efficitur in turpis ac, tristique laoreet \r\norci. Nulla facilisi. Praesent mollis orci posuere elementum laoreet. \r\nPellentesque enim nibh, varius at ante id, consequat posuere ante.\r\n\r\nCras maximus venenatis nulla nec cursus. Morbi convallis, enim eget viverra \r\nvulputate, ipsum arcu tincidunt tortor, ut cursus dui enim commodo quam. Donec \r\net vulputate quam. Vivamus non posuere erat. Nam commodo pellentesque \r\ncondimentum. Vivamus condimentum eros at odio dictum feugiat. Ut imperdiet \r\ntempor luctus. Aenean varius libero ac faucibus dictum. Aliquam sed finibus \r\nmassa. Morbi dolor lorem, feugiat quis neque et, suscipit posuere ex. Sed auctor \r\net augue at finibus. Vestibulum interdum mi ac justo porta aliquam. Curabitur \r\nnec enim sit amet enim aliquet accumsan. Etiam accumsan tellus tortor, interdum \r\nsodales odio finibus eu. Integer eget ante eu nisi lobortis pulvinar et vel \r\nipsum. Cras condimentum posuere vulputate.\r\n\r\nCras nulla felis, blandit vitae egestas quis, fringilla ut dolor. Phasellus est \r\naugue, feugiat eu risus quis, posuere ultrices libero. Phasellus non nunc eget \r\njusto sollicitudin tincidunt. Praesent pretium dui id felis bibendum sodales. \r\nPhasellus eget dictum libero, auctor tempor nibh. Suspendisse posuere libero \r\nvenenatis elit imperdiet porttitor. In condimentum dictum luctus. Nullam in \r\nnulla vitae augue blandit posuere. Vestibulum consectetur ultricies tincidunt. \r\nVivamus dolor quam, pharetra sed eros sed, hendrerit ultrices diam. Vestibulum \r\nvulputate tellus eget tellus lacinia, a pulvinar velit vulputate. Suspendisse \r\nmauris odio, scelerisque eget turpis sed, tincidunt ultrices magna. Nunc arcu \r\narcu, commodo et porttitor quis, accumsan viverra purus. Fusce id libero iaculis \r\nlorem tristique commodo porttitor id ipsum. Vestibulum odio dui, tincidunt eget \r\nlectus vel, tristique lacinia libero. Aliquam dapibus ac felis vitae cursus.\r\n"
  },
  {
    "path": "internal/storage/utils.go",
    "content": "package storage\n\nimport (\n\t\"net/mail\"\n\t\"os\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/axllent/mailpit/internal/html2text\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/axllent/mailpit/internal/tools\"\n\t\"github.com/jhillyerd/enmime/v2\"\n)\n\nvar (\n\t// for stats to prevent import cycle\n\tmu sync.RWMutex\n\t// StatsDeleted for counting the number of messages deleted\n\tStatsDeleted uint64\n)\n\n// AddTempFile adds a file to the slice of files to delete on exit\nfunc AddTempFile(s string) {\n\ttemporaryFiles = append(temporaryFiles, s)\n}\n\n// DeleteTempFiles will delete files added via AddTempFiles\nfunc deleteTempFiles() {\n\tfor _, f := range temporaryFiles {\n\t\tif err := os.Remove(f); err == nil {\n\t\t\tlogger.Log().Debugf(\"removed temporary file: %s\", f)\n\t\t}\n\t}\n}\n\n// Return a header field as a []*mail.Address, or \"null\" is not found/empty\nfunc addressToSlice(env *enmime.Envelope, key string) []*mail.Address {\n\tdata, err := env.AddressList(key)\n\tif err != nil || data == nil {\n\t\treturn []*mail.Address{}\n\t}\n\n\treturn data\n}\n\n// Generate the search text based on some header fields (to, from, subject etc)\n// and either the stripped HTML body (if exists) or text body\nfunc createSearchText(env *enmime.Envelope) string {\n\tvar b strings.Builder\n\n\tb.WriteString(env.GetHeader(\"From\") + \" \")\n\tb.WriteString(env.GetHeader(\"Subject\") + \" \")\n\tb.WriteString(env.GetHeader(\"To\") + \" \")\n\tb.WriteString(env.GetHeader(\"Cc\") + \" \")\n\tb.WriteString(env.GetHeader(\"Bcc\") + \" \")\n\tb.WriteString(env.GetHeader(\"Reply-To\") + \" \")\n\tb.WriteString(env.GetHeader(\"Return-Path\") + \" \")\n\n\th := html2text.Strip(env.HTML, true)\n\tif h != \"\" {\n\t\tb.WriteString(h + \" \")\n\t} else {\n\t\tb.WriteString(env.Text + \" \")\n\t}\n\t// add attachment filenames\n\tfor _, a := range env.Attachments {\n\t\tb.WriteString(a.FileName + \" \")\n\t}\n\n\td := cleanString(b.String())\n\n\treturn d\n}\n\n// CleanString removes unwanted characters from stored search text and search queries\nfunc cleanString(str string) string {\n\t// replace \\uFEFF with space, see https://github.com/golang/go/issues/42274#issuecomment-1017258184\n\tstr = strings.ReplaceAll(str, string('\\uFEFF'), \" \")\n\n\t// remove/replace new lines\n\tre := regexp.MustCompile(`(\\r?\\n|\\t|>|<|\"|\\,|;|\\(|\\))`)\n\tstr = re.ReplaceAllString(str, \" \")\n\n\t// remove duplicate whitespace and trim\n\treturn strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(str)), \" \"))\n}\n\n// LogMessagesDeleted logs the number of messages deleted\nfunc logMessagesDeleted(n int) {\n\tmu.Lock()\n\tStatsDeleted = StatsDeleted + tools.SafeUint64(n)\n\tmu.Unlock()\n}\n\n// IsFile returns whether a path is a file\nfunc isFile(path string) bool {\n\tinfo, err := os.Stat(path)\n\tif os.IsNotExist(err) || !info.Mode().IsRegular() {\n\t\treturn false\n\t}\n\n\treturn true\n}\n\n// Convert `%` to `%%` for SQL searches\nfunc escPercentChar(s string) string {\n\treturn strings.ReplaceAll(s, \"%\", \"%%\")\n}\n"
  },
  {
    "path": "internal/tools/argsparser.go",
    "content": "package tools\n\nimport \"strings\"\n\n// ArgsParser will split a string by new words and quotes phrases\nfunc ArgsParser(s string) []string {\n\targs := []string{}\n\tsb := &strings.Builder{}\n\tquoted := false\n\tfor _, r := range s {\n\t\tif r == '\"' {\n\t\t\tquoted = !quoted\n\t\t\tsb.WriteRune(r) // keep '\"' otherwise comment this line\n\t\t} else if !quoted && r == ' ' {\n\t\t\tv := strings.TrimSpace(strings.ReplaceAll(sb.String(), \"\\\"\", \"\"))\n\t\t\tif v != \"\" {\n\t\t\t\targs = append(args, v)\n\t\t\t}\n\t\t\tsb.Reset()\n\t\t} else {\n\t\t\tsb.WriteRune(r)\n\t\t}\n\t}\n\tif sb.Len() > 0 {\n\t\tv := strings.TrimSpace(strings.ReplaceAll(sb.String(), \"\\\"\", \"\"))\n\t\tif v != \"\" {\n\t\t\targs = append(args, v)\n\t\t}\n\t}\n\n\treturn args\n}\n"
  },
  {
    "path": "internal/tools/fs.go",
    "content": "package tools\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n)\n\n// IsFile returns whether a file exists and is readable\nfunc IsFile(path string) bool {\n\tf, err := os.Open(filepath.Clean(path))\n\tdefer func() { _ = f.Close() }()\n\treturn err == nil\n}\n\n// IsDir returns whether a path is a directory\nfunc IsDir(path string) bool {\n\tinfo, err := os.Stat(path)\n\tif err != nil || os.IsNotExist(err) || !info.IsDir() {\n\t\treturn false\n\t}\n\n\treturn true\n}\n"
  },
  {
    "path": "internal/tools/headers.go",
    "content": "// Package tools provides various methods for various things\npackage tools\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"net/mail\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/axllent/mailpit/internal/logger\"\n)\n\n// RemoveMessageHeaders scans a message for headers, if found them removes them.\n// It will only remove a single instance of any given message header.\nfunc RemoveMessageHeaders(msg []byte, headers []string) ([]byte, error) {\n\treader := bytes.NewReader(msg)\n\tm, err := mail.ReadMessage(reader)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treBlank := regexp.MustCompile(`^\\s+`)\n\n\tfor _, hdr := range headers {\n\t\t// case-insensitive\n\t\treHdr := regexp.MustCompile(`(?i)^` + regexp.QuoteMeta(hdr+\":\"))\n\n\t\t// header := []byte(hdr + \":\")\n\t\tif m.Header.Get(hdr) != \"\" {\n\t\t\tscanner := bufio.NewScanner(bytes.NewReader(msg))\n\t\t\tfound := false\n\t\t\thdr := []byte(\"\")\n\t\t\tfor scanner.Scan() {\n\t\t\t\tline := scanner.Bytes()\n\t\t\t\tif !found && reHdr.Match(line) {\n\t\t\t\t\t// add the first line starting with <header>:\n\t\t\t\t\thdr = append(hdr, line...)\n\t\t\t\t\thdr = append(hdr, []byte(\"\\r\\n\")...)\n\t\t\t\t\tfound = true\n\t\t\t\t} else if found && reBlank.Match(line) {\n\t\t\t\t\t// add any following lines starting with a whitespace (tab or space)\n\t\t\t\t\thdr = append(hdr, line...)\n\t\t\t\t\thdr = append(hdr, []byte(\"\\r\\n\")...)\n\t\t\t\t} else if found {\n\t\t\t\t\t// stop scanning, we have the full <header>\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif len(hdr) > 0 {\n\t\t\t\tlogger.Log().Debugf(\"[relay] removed %s header\", hdr)\n\t\t\t\tmsg = bytes.Replace(msg, hdr, []byte(\"\"), 1)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn msg, nil\n}\n\n// SetMessageHeader scans a message for a header and updates its value if found.\n// It does not consider multiple instances of the same header.\n// If not found it will add the header to the beginning of the message.\nfunc SetMessageHeader(msg []byte, header, value string) ([]byte, error) {\n\treader := bytes.NewReader(msg)\n\tm, err := mail.ReadMessage(reader)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif m.Header.Get(header) != \"\" {\n\t\treBlank := regexp.MustCompile(`^\\s+`)\n\t\treHdr := regexp.MustCompile(`(?i)^` + regexp.QuoteMeta(header+\":\"))\n\n\t\tscanner := bufio.NewScanner(bytes.NewReader(msg))\n\t\tfound := false\n\t\thdr := []byte(\"\")\n\t\tfor scanner.Scan() {\n\t\t\tline := scanner.Bytes()\n\t\t\tif !found && reHdr.Match(line) {\n\t\t\t\t// add the first line starting with <header>:\n\t\t\t\thdr = append(hdr, line...)\n\t\t\t\thdr = append(hdr, []byte(\"\\r\\n\")...)\n\t\t\t\tfound = true\n\t\t\t} else if found && reBlank.Match(line) {\n\t\t\t\t// add any following lines starting with a whitespace (tab or space)\n\t\t\t\thdr = append(hdr, line...)\n\t\t\t\thdr = append(hdr, []byte(\"\\r\\n\")...)\n\t\t\t} else if found {\n\t\t\t\t// stop scanning, we have the full <header>\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\treturn bytes.Replace(msg, hdr, []byte(header+\": \"+value+\"\\r\\n\"), 1), nil\n\t}\n\n\t// no header, so add one to beginning\n\treturn append([]byte(header+\": \"+value+\"\\r\\n\"), msg...), nil\n}\n\n// OverrideFromHeader scans a message for the From header and replaces it with a different email address.\nfunc OverrideFromHeader(msg []byte, address string) ([]byte, error) {\n\treader := bytes.NewReader(msg)\n\tm, err := mail.ReadMessage(reader)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif m.Header.Get(\"From\") != \"\" {\n\t\treBlank := regexp.MustCompile(`^\\s+`)\n\t\treHdr := regexp.MustCompile(`(?i)^` + regexp.QuoteMeta(\"From:\"))\n\n\t\tscanner := bufio.NewScanner(bytes.NewReader(msg))\n\t\tfound := false\n\t\thdr := []byte(\"\")\n\t\tfor scanner.Scan() {\n\t\t\tline := scanner.Bytes()\n\t\t\tif !found && reHdr.Match(line) {\n\t\t\t\t// add the first line starting with <header>:\n\t\t\t\thdr = append(hdr, line...)\n\t\t\t\thdr = append(hdr, []byte(\"\\r\\n\")...)\n\t\t\t\tfound = true\n\t\t\t} else if found && reBlank.Match(line) {\n\t\t\t\t// add any following lines starting with a whitespace (tab or space)\n\t\t\t\thdr = append(hdr, line...)\n\t\t\t\thdr = append(hdr, []byte(\"\\r\\n\")...)\n\t\t\t} else if found {\n\t\t\t\t// stop scanning, we have the full <header>\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif len(hdr) > 0 {\n\t\t\toriginalFrom := strings.TrimRight(string(hdr[5:]), \"\\r\\n\")\n\n\t\t\tfrom, err := mail.ParseAddress(originalFrom)\n\t\t\tif err != nil {\n\t\t\t\t// error parsing the from address, so just replace the whole line\n\t\t\t\tmsg = bytes.Replace(msg, hdr, []byte(\"From: \"+address+\"\\r\\n\"), 1)\n\t\t\t} else {\n\t\t\t\toriginalFrom = from.Address\n\t\t\t\t// replace the from email, but keep the original name\n\t\t\t\tfrom.Address = address\n\t\t\t\tmsg = bytes.Replace(msg, hdr, []byte(\"From: \"+from.String()+\"\\r\\n\"), 1)\n\t\t\t}\n\n\t\t\t// insert the original From header as X-Original-From\n\t\t\tmsg = append([]byte(\"X-Original-From: \"+originalFrom+\"\\r\\n\"), msg...)\n\n\t\t\tlogger.Log().Debugf(\"[relay] Replaced From email address with %s\", address)\n\t\t}\n\t} else {\n\t\t// no From header, so add one\n\t\tmsg = append([]byte(\"From: \"+address+\"\\r\\n\"), msg...)\n\t\tlogger.Log().Debugf(\"[relay] Added From email: %s\", address)\n\t}\n\n\treturn msg, nil\n}\n"
  },
  {
    "path": "internal/tools/html.go",
    "content": "package tools\n\nimport (\n\t\"fmt\"\n\n\t\"golang.org/x/net/html\"\n)\n\n// GetHTMLAttributeVal returns the value of an HTML Attribute, else an error.\n// Returns a blank value if the attribute is set but empty.\nfunc GetHTMLAttributeVal(e *html.Node, key string) (string, error) {\n\tfor _, a := range e.Attr {\n\t\tif a.Key == key {\n\t\t\treturn a.Val, nil\n\t\t}\n\t}\n\n\treturn \"\", fmt.Errorf(\"%s not found\", key)\n}\n\n// SetHTMLAttributeVal sets an attribute on a node.\nfunc SetHTMLAttributeVal(n *html.Node, key, val string) {\n\tfor i := range n.Attr {\n\t\ta := &n.Attr[i]\n\t\tif a.Key == key {\n\t\t\ta.Val = val\n\t\t\treturn\n\t\t}\n\t}\n\tn.Attr = append(n.Attr, html.Attribute{\n\t\tKey: key,\n\t\tVal: val,\n\t})\n}\n\n// WalkHTML traverses the entire HTML tree and calls fn on each node.\nfunc WalkHTML(n *html.Node, fn func(*html.Node)) {\n\tif n == nil {\n\t\treturn\n\t}\n\n\tfn(n)\n\n\t// Each node has a pointer to its first child and next sibling. To traverse\n\t// all children of a node, we need to start from its first child and then\n\t// traverse the next sibling until nil.\n\tfor c := n.FirstChild; c != nil; c = c.NextSibling {\n\t\tWalkHTML(c, fn)\n\t}\n}\n"
  },
  {
    "path": "internal/tools/listunsubscribeparser.go",
    "content": "package tools\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n)\n\n// ListUnsubscribeParser will attempt to parse a `List-Unsubscribe` header and return\n// a slide of addresses (mail & URLs)\nfunc ListUnsubscribeParser(v string) ([]string, error) {\n\tvar results = []string{}\n\tvar re = regexp.MustCompile(`(?mU)<(.*)>`)\n\tvar reJoins = regexp.MustCompile(`(?imUs)>(.*)<`)\n\tvar reValidJoinChars = regexp.MustCompile(`(?imUs)^(\\s+)?,(\\s+)?$`)\n\tvar reWrapper = regexp.MustCompile(`(?imUs)^<(.*)>$`)\n\tvar reMailTo = regexp.MustCompile(`^mailto:[a-zA-Z0-9]`)\n\tvar reHTTP = regexp.MustCompile(`^(?i)https?://[a-zA-Z0-9]`)\n\tvar reSpaces = regexp.MustCompile(`\\s`)\n\tvar reComments = regexp.MustCompile(`(?mUs)\\(.*\\)`)\n\tvar hasMailTo bool\n\tvar hasHTTP bool\n\n\tv = strings.TrimSpace(v)\n\n\tcomments := reComments.FindAllStringSubmatch(v, -1)\n\tfor _, c := range comments {\n\t\t// strip comments\n\t\tv = strings.ReplaceAll(v, c[0], \"\")\n\t\tv = strings.TrimSpace(v)\n\t}\n\n\tif !re.MatchString(v) {\n\t\treturn results, fmt.Errorf(\"\\\"%s\\\" no valid unsubscribe links found\", v)\n\t}\n\n\terrors := []string{}\n\n\tif !reWrapper.MatchString(v) {\n\t\treturn results, fmt.Errorf(\"\\\"%s\\\" should be enclosed in <>\", v)\n\t}\n\n\tmatches := re.FindAllStringSubmatch(v, -1)\n\n\tif len(matches) > 2 {\n\t\terrors = append(errors, fmt.Sprintf(\"\\\"%s\\\" should include a maximum of one email and one HTTP link\", v))\n\t} else {\n\t\tsplits := reJoins.FindAllStringSubmatch(v, -1)\n\t\tfor _, g := range splits {\n\t\t\tif !reValidJoinChars.MatchString(g[1]) {\n\t\t\t\treturn results, fmt.Errorf(\"\\\"%s\\\" <> should be split with a comma and optional spaces\", v)\n\t\t\t}\n\t\t}\n\n\t\tfor _, m := range matches {\n\t\t\tr := m[1]\n\t\t\tif reSpaces.MatchString(r) {\n\t\t\t\terrors = append(errors, fmt.Sprintf(\"\\\"%s\\\" should not contain spaces\", r))\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif reMailTo.MatchString(r) {\n\t\t\t\tif hasMailTo {\n\t\t\t\t\terrors = append(errors, fmt.Sprintf(\"\\\"%s\\\" should only contain one mailto:\", r))\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\thasMailTo = true\n\t\t\t} else if reHTTP.MatchString(r) {\n\t\t\t\tif hasHTTP {\n\t\t\t\t\terrors = append(errors, fmt.Sprintf(\"\\\"%s\\\" should only contain one HTTP link\", r))\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\thasHTTP = true\n\n\t\t\t} else {\n\t\t\t\terrors = append(errors, fmt.Sprintf(\"\\\"%s\\\" should start with either http(s):// or mailto:\", r))\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t_, err := url.ParseRequestURI(r)\n\t\t\tif err != nil {\n\t\t\t\terrors = append(errors, err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tresults = append(results, r)\n\t\t}\n\t}\n\n\tvar err error\n\tif len(errors) > 0 {\n\t\terr = fmt.Errorf(\"%s\", strings.Join(errors, \", \"))\n\t}\n\n\treturn results, err\n}\n"
  },
  {
    "path": "internal/tools/net.go",
    "content": "package tools\n\nimport (\n\t\"net\"\n\t\"net/url\"\n)\n\n// IsInternalIP checks if the given IP address is an internal IP address (e.g., loopback, private, link-local, or multicast).\n// IsLoopback — 127.0.0.0/8, ::1\n// IsPrivate — 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7\n// IsLinkLocalUnicast — 169.254.0.0/16, fe80::/10 (covers cloud metadata 169.254.169.254)\n// IsLinkLocalMulticast — 224.0.0.0/24, ff02::/16\n// IsUnspecified — 0.0.0.0, ::\n// IsMulticast — 224.0.0.0/4, ff00::/8\nfunc IsInternalIP(ip net.IP) bool {\n\treturn ip.IsLoopback() ||\n\t\tip.IsPrivate() ||\n\t\tip.IsLinkLocalUnicast() ||\n\t\tip.IsLinkLocalMulticast() ||\n\t\tip.IsUnspecified() ||\n\t\tip.IsMulticast()\n}\n\n// IsValidLinkURL checks if the provided string is a valid URL with http or https scheme and a non-empty hostname.\nfunc IsValidLinkURL(str string) bool {\n\tu, err := url.Parse(str)\n\treturn err == nil && (u.Scheme == \"http\" || u.Scheme == \"https\") && u.Hostname() != \"\"\n}\n"
  },
  {
    "path": "internal/tools/snippets.go",
    "content": "package tools\n\nimport (\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/axllent/mailpit/internal/html2text\"\n)\n\n// CreateSnippet returns a message snippet. It will use the HTML version (if it exists)\n// otherwise the text version.\nfunc CreateSnippet(text, html string) string {\n\ttext = strings.TrimSpace(text)\n\thtml = strings.TrimSpace(html)\n\tlimit := 200\n\tspaceRe := regexp.MustCompile(`\\s+`)\n\n\tif text == \"\" && html == \"\" {\n\t\treturn \"\"\n\t}\n\n\tif html != \"\" {\n\t\tdata := html2text.Strip(html, false)\n\n\t\tif len(data) <= limit {\n\t\t\treturn data\n\t\t}\n\n\t\treturn truncate(data, limit) + \"...\"\n\t}\n\n\tif text != \"\" {\n\t\t// replace \\uFEFF with space, see https://github.com/golang/go/issues/42274#issuecomment-1017258184\n\t\ttext = strings.ReplaceAll(text, string('\\uFEFF'), \" \")\n\t\ttext = strings.TrimSpace(spaceRe.ReplaceAllString(text, \" \"))\n\t\tif len(text) <= limit {\n\t\t\treturn text\n\t\t}\n\n\t\treturn truncate(text, limit) + \"...\"\n\t}\n\n\treturn \"\"\n}\n\n// Truncate a string allowing for multi-byte encoding.\n// Shamelessly borrowed from Tailscale.\n// See https://github.com/tailscale/tailscale/blob/main/util/truncate/truncate.go\nfunc truncate(s string, n int) string {\n\tif n >= len(s) {\n\t\treturn s\n\t}\n\n\t// Back up until we find the beginning of a UTF-8 encoding.\n\tfor n > 0 && s[n-1]&0xc0 == 0x80 { // 0x10... is a continuation byte\n\t\tn--\n\t}\n\n\t// If we're at the beginning of a multi-byte encoding, back up one more to\n\t// skip it. It's possible the value was already complete, but it's simpler\n\t// if we only have to check in one direction.\n\t//\n\t// Otherwise, we have a single-byte code (0x00... or 0x01...).\n\tif n > 0 && s[n-1]&0xc0 == 0xc0 { // 0x11... starts a multibyte encoding\n\t\tn--\n\t}\n\n\treturn s[:n]\n}\n"
  },
  {
    "path": "internal/tools/tags.go",
    "content": "package tools\n\nimport (\n\t\"regexp\"\n\t\"strings\"\n\n\t\"golang.org/x/text/cases\"\n\t\"golang.org/x/text/language\"\n)\n\nvar (\n\t// Invalid tag characters regex\n\ttagsInvalidChars = regexp.MustCompile(`[^a-zA-Z0-9\\-\\ \\_\\.@]`)\n\n\t// Regex to catch multiple spaces\n\tmultiSpaceRe = regexp.MustCompile(`(\\s+)`)\n\n\t// TagsTitleCase enforces TitleCase on all tags\n\tTagsTitleCase bool\n)\n\n// CleanTag returns a clean tag, trimming whitespace and replacing invalid characters.\n// If the tag is longer than 100 characters, it is truncated.\nfunc CleanTag(s string) string {\n\tt := strings.TrimSpace(\n\t\tmultiSpaceRe.ReplaceAllString(\n\t\t\ttagsInvalidChars.ReplaceAllString(s, \" \"),\n\t\t\t\" \",\n\t\t),\n\t)\n\n\tif len(t) > 100 {\n\t\treturn t[:100]\n\t}\n\n\treturn t\n}\n\n// SetTagCasing returns the slice of tags, title-casing if set\nfunc SetTagCasing(s []string) []string {\n\tif !TagsTitleCase {\n\t\treturn s\n\t}\n\n\ttitleTags := []string{}\n\n\tc := cases.Title(language.Und, cases.NoLower)\n\n\tfor _, t := range s {\n\t\ttitleTags = append(titleTags, c.String(t))\n\t}\n\n\treturn titleTags\n}\n"
  },
  {
    "path": "internal/tools/tools_test.go",
    "content": "package tools\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n)\n\nfunc TestArgsParser(t *testing.T) {\n\ttests := map[string][]string{}\n\ttests[\"this is a test\"] = []string{\"this\", \"is\", \"a\", \"test\"}\n\ttests[\"\\\"this is\\\" a test\"] = []string{\"this is\", \"a\", \"test\"}\n\ttests[\"!\\\"this is\\\" a test\"] = []string{\"!this is\", \"a\", \"test\"}\n\ttests[\"subject:this is a test\"] = []string{\"subject:this\", \"is\", \"a\", \"test\"}\n\ttests[\"subject:\\\"this is\\\" a test\"] = []string{\"subject:this is\", \"a\", \"test\"}\n\ttests[\"subject:\\\"this is\\\" \\\"a test\\\"\"] = []string{\"subject:this is\", \"a test\"}\n\ttests[\"subject:\\\"this 'is\\\" \\\"a test\\\"\"] = []string{\"subject:this 'is\", \"a test\"}\n\ttests[\"subject:\\\"this 'is a test\"] = []string{\"subject:this 'is a test\"}\n\ttests[\"\\\"this is a test\\\"=\\\"this is a test\\\"\"] = []string{\"this is a test=this is a test\"}\n\n\tfor search, expected := range tests {\n\t\tres := ArgsParser(search)\n\t\tif !reflect.DeepEqual(res, expected) {\n\t\t\tt.Log(\"Args parser error:\", res, \"!=\", expected)\n\t\t\tt.Fail()\n\t\t}\n\t}\n}\n\nfunc TestCleanTag(t *testing.T) {\n\ttests := map[string]string{}\n\ttests[\"this is a test\"] = \"this is a test\"\n\ttests[\"thiS IS a Test\"] = \"thiS IS a Test\"\n\ttests[\"thiS IS a Test :-)\"] = \"thiS IS a Test -\"\n\ttests[\"  thiS 99     IS a Test :-)\"] = \"thiS 99 IS a Test -\"\n\ttests[\"this_is-a test \"] = \"this_is-a test\"\n\ttests[\"this_is-a&^%%(*)@ test\"] = \"this_is-a @ test\"\n\ttests[\"this is a long tag title with more than 100 characters, which should get automatically truncated to 100 characters\"] = \"this is a long tag title with more than 100 characters which should get automatically truncated to 1\"\n\n\tfor search, expected := range tests {\n\t\tres := CleanTag(search)\n\t\tif res != expected {\n\t\t\tt.Log(\"CleanTags error:\", res, \"!=\", expected)\n\t\t\tt.Fail()\n\t\t}\n\t}\n}\n\nfunc TestSnippets(t *testing.T) {\n\ttests := map[string]string{}\n\ttests[\"this is a  test\"] = \"this is a test\"\n\ttests[\"thiS IS a Test\"] = \"thiS IS a Test\"\n\ttests[\"thiS IS a Test :-)\"] = \"thiS IS a Test :-)\"\n\ttests[\"<h1>This is a test.</h1> \"] = \"This is a test.\"\n\ttests[\"this_is-a     test \"] = \"this_is-a test\"\n\ttests[\"this_is-a&^%%(*)@ test\"] = \"this_is-a&^%%(*)@ test\"\n\ttests[\"<h1>Heading</h1><p>Paragraph</p>\"] = \"Heading Paragraph\"\n\ttests[`<h1>Heading</h1>\n\t\t<p>Paragraph</p>`] = \"Heading Paragraph\"\n\ttests[`<h1>Heading</h1><p>   <a href=\"https://github.com\">linked text</a></p>`] = \"Heading linked text\"\n\t// broken html\n\ttests[`<h1>Heading</h3><p>   <a href=\"https://github.com\">linked text.`] = \"Heading linked text.\"\n\t// truncation to 200 chars + ...\n\ttests[\"abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789\"] = \"abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmno...\"\n\n\tfor str, expected := range tests {\n\t\tres := CreateSnippet(str, str)\n\t\tif res != expected {\n\t\t\tt.Log(\"CreateSnippet error:\", res, \"!=\", expected)\n\t\t\tt.Fail()\n\t\t}\n\t}\n}\n\nfunc TestListUnsubscribeParser(t *testing.T) {\n\ttests := map[string]bool{}\n\n\t// should pass\n\ttests[\"<mailto:unsubscribe@example.com>\"] = true\n\ttests[\"<https://example.com>\"] = true\n\ttests[\"<HTTPS://EXAMPLE.COM>\"] = true\n\ttests[\"<mailto:unsubscribe@example.com>, <http://example.com>\"] = true\n\ttests[\"<mailto:unsubscribe@example.com>, <https://example.com>\"] = true\n\ttests[\"<https://example.com>, <mailto:unsubscribe@example.com>\"] = true\n\ttests[\"<https://example.com> , \t\t<mailto:unsubscribe@example.com>\"] = true\n\ttests[\"<https://example.com> ,<mailto:unsubscribe@example.com>\"] = true\n\ttests[\"<mailto:unsubscribe@example.com>,<https://example.com>\"] = true\n\ttests[`<https://example.com> ,\n\t\t <mailto:unsubscribe@example.com>`] = true\n\ttests[\"<mailto:unsubscribe@example.com?subject=unsubscribe%20me>\"] = true\n\ttests[\"(Use this command to get off the list) <mailto:unsubscribe@example.com?subject=unsubscribe%20me>\"] = true\n\ttests[\"<mailto:unsubscribe@example.com> (Use this command to get off the list)\"] = true\n\ttests[\"(Use this command to get off the list) <mailto:unsubscribe@example.com>, (Click this link to unsubscribe) <http://example.com>\"] = true\n\n\t// should fail\n\ttests[\"mailto:unsubscribe@example.com\"] = false                                                // no <>\n\ttests[\"<mailto::unsubscribe@example.com>\"] = false                                             // ::\n\ttests[\"https://example.com/\"] = false                                                          // no <>\n\ttests[\"mailto:unsubscribe@example.com, <https://example.com/>\"] = false                        // no <>\n\ttests[\"<MAILTO:unsubscribe@example.com>\"] = false                                              // capitals\n\ttests[\"<mailto:unsubscribe@example.com>, <mailto:test2@example.com>\"] = false                  // two emails\n\ttests[\"<http://exampl\\\\e2.com>, <http://example2.com>\"] = false                                // two links\n\ttests[\"<http://example.com>, <mailto:unsubscribe@example.com>, <http://example2.com>\"] = false // two links\n\ttests[\"<mailto:unsubscribe@example.com>, <example.com>\"] = false                               // no mailto || http(s)\n\ttests[\"<mailto: unsubscribe@example.com>, <unsubscribe@lol.com>\"] = false                      // space\n\ttests[\"<mailto:unsubscribe@example.com?subject=unsubscribe me>\"] = false                       // space\n\ttests[\"<http:///example.com>\"] = false                                                         // http:///\n\n\tfor search, expected := range tests {\n\t\t_, err := ListUnsubscribeParser(search)\n\t\thasError := err != nil\n\t\tif expected == hasError {\n\t\t\tif err != nil {\n\t\t\t\tt.Logf(\"ListUnsubscribeParser: %v\", err)\n\t\t\t} else {\n\t\t\t\tt.Logf(\"ListUnsubscribeParser: \\\"%s\\\" expected: %v\", search, expected)\n\t\t\t}\n\t\t\tt.Fail()\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/tools/unixsocket.go",
    "content": "package tools\n\nimport (\n\t\"fmt\"\n\t\"io/fs\"\n\t\"net\"\n\t\"os\"\n\t\"path\"\n\t\"regexp\"\n\t\"strconv\"\n)\n\n// UnixSocket returns a path and a FileMode if the address is in\n// the format of unix:<path>:<permission>\nfunc UnixSocket(address string) (string, fs.FileMode, bool) {\n\tre := regexp.MustCompile(`^unix:(.*):(\\d\\d\\d\\d?)$`)\n\n\tvar f fs.FileMode\n\n\tif !re.MatchString(address) {\n\t\treturn \"\", f, false\n\t}\n\n\tm := re.FindAllStringSubmatch(address, 1)\n\n\tmodeVal, err := strconv.ParseUint(m[0][2], 8, 32)\n\n\tif err != nil {\n\t\treturn \"\", f, false\n\t}\n\n\treturn path.Clean(m[0][1]), fs.FileMode(modeVal), true\n}\n\n// PrepareSocket returns an error if an active socket file already exists\nfunc PrepareSocket(address string) error {\n\taddress = path.Clean(address)\n\tif _, err := os.Stat(address); os.IsNotExist(err) {\n\t\t// does not exist, OK\n\t\treturn nil\n\t}\n\n\tif _, err := net.Dial(\"unix\", address); err == nil {\n\t\t// socket is listening\n\t\treturn fmt.Errorf(\"socket already in use: %s\", address)\n\t}\n\n\treturn os.Remove(address)\n}\n"
  },
  {
    "path": "internal/tools/utils.go",
    "content": "package tools\n\nimport (\n\t\"fmt\"\n\t\"regexp\"\n\t\"strings\"\n)\n\n// Plural returns a singular or plural of a word together with the total\nfunc Plural(total int, singular, plural string) string {\n\tif total == 1 {\n\t\treturn fmt.Sprintf(\"%d %s\", total, singular)\n\t}\n\n\treturn fmt.Sprintf(\"%d %s\", total, plural)\n}\n\n// InArray tests if a string is within an array. It is not case sensitive.\nfunc InArray(k string, arr []string) bool {\n\tfor _, v := range arr {\n\t\tif strings.EqualFold(v, k) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\n// Normalize will remove any extra spaces, remove newlines, and trim leading and trailing spaces\nfunc Normalize(s string) string {\n\tnlRe := regexp.MustCompile(`\\r?\\r`)\n\tre := regexp.MustCompile(`\\s+`)\n\n\ts = nlRe.ReplaceAllString(s, \" \")\n\ts = re.ReplaceAllString(s, \" \")\n\n\treturn strings.TrimSpace(s)\n}\n\n// SafeUint64 converts an int or int64 to uint64, ensuring it does not exceed the maximum value for uint64.\nfunc SafeUint64(i any) uint64 {\n\tswitch v := i.(type) {\n\tcase int:\n\t\tif v < 0 {\n\t\t\treturn 0\n\t\t}\n\t\treturn uint64(v)\n\tcase int64:\n\t\tif v < 0 {\n\t\t\treturn 0\n\t\t}\n\t\treturn uint64(v)\n\tdefault:\n\t\t// only accepts int or int64\n\t\treturn 0\n\t}\n}\n"
  },
  {
    "path": "main.go",
    "content": "// Package main is the entrypoint\npackage main\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/axllent/mailpit/cmd\"\n\tsendmail \"github.com/axllent/mailpit/sendmail/cmd\"\n)\n\nfunc main() {\n\t// if the command executable contains \"send\" in the name (eg: sendmail), then run the sendmail command\n\tif strings.Contains(strings.ToLower(filepath.Base(os.Args[0])), \"send\") {\n\t\tsendmail.Run()\n\t} else {\n\t\t// else run mailpit\n\t\tcmd.Execute()\n\t}\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"mailpit\",\n  \"version\": \"0.0.0\",\n  \"type\": \"module\",\n  \"private\": true,\n  \"scripts\": {\n    \"build\": \"MINIFY=true node esbuild.config.mjs\",\n    \"watch\": \"WATCH=true node esbuild.config.mjs\",\n    \"package\": \"MINIFY=true node esbuild.config.mjs\",\n    \"update-caniemail\": \"wget -O internal/htmlcheck/caniemail-data.json https://www.caniemail.com/api/data.json\",\n    \"lint\": \"eslint --max-warnings 0 && prettier -c .\",\n    \"lint-fix\": \"eslint --fix && prettier --write .\"\n  },\n  \"dependencies\": {\n    \"axios\": \"^1.13.5\",\n    \"bootstrap\": \"^5.2.0\",\n    \"bootstrap-icons\": \"^1.9.1\",\n    \"bootstrap5-tags\": \"^1.6.1\",\n    \"color-hash\": \"^2.0.2\",\n    \"dayjs\": \"^1.11.10\",\n    \"dompurify\": \"^3.1.6\",\n    \"highlight.js\": \"^11.11.1\",\n    \"ical.js\": \"^2.0.1\",\n    \"mitt\": \"^3.0.1\",\n    \"modern-screenshot\": \"^4.4.30\",\n    \"rapidoc\": \"^9.3.4\",\n    \"timezones-list\": \"^3.0.3\",\n    \"vue\": \"^3.2.13\",\n    \"vue-css-donut-chart\": \"^2.0.0\",\n    \"vue-router\": \"^4.2.4\"\n  },\n  \"devDependencies\": {\n    \"@eslint/compat\": \"^2.0.2\",\n    \"@eslint/js\": \"^10.0.1\",\n    \"@popperjs/core\": \"^2.11.5\",\n    \"@types/bootstrap\": \"^5.2.7\",\n    \"@types/tinycon\": \"^0.6.3\",\n    \"@vue/compiler-sfc\": \"^3.2.37\",\n    \"esbuild\": \"^0.27.2\",\n    \"esbuild-plugin-vue-next\": \"^0.1.4\",\n    \"esbuild-sass-plugin\": \"^3.0.0\",\n    \"eslint\": \"^10.0.1\",\n    \"eslint-config-prettier\": \"^10.1.5\",\n    \"eslint-plugin-vue\": \"^10.2.0\",\n    \"globals\": \"^17.3.0\",\n    \"prettier\": \"^3.5.3\"\n  },\n  \"prettier\": {\n    \"tabWidth\": 4,\n    \"useTabs\": true,\n    \"printWidth\": 120\n  }\n}\n"
  },
  {
    "path": "sendmail/cmd/cmd.go",
    "content": "// Package cmd is the sendmail cli\npackage cmd\n\n/**\n * Bare bones sendmail drop-in replacement borrowed from MailHog\n *\n * It uses a bit of a hack for flag parsing in order to be compatible\n * with the cobra sendmail subcommand, as sendmail uses `-bc` which\n * is not POSIX compatible.\n *\n * The -bs command-line switch causes sendmail to run a single SMTP session in the\n * foreground over its standard input and output, and then exit. The SMTP session\n * is exactly like a network SMTP session. Usually, one or more messages are\n * submitted to sendmail for delivery.\n */\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/mail\"\n\t\"os\"\n\t\"os/user\"\n\t\"path\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/mneis/go-telnet\"\n\tflag \"github.com/spf13/pflag\"\n)\n\nvar (\n\t// SMTPAddr address\n\tSMTPAddr = \"localhost:1025\"\n\t// FromAddr email address\n\tFromAddr string\n\t// UseB - used to set from `-bs`\n\tUseB bool\n\t// UseS - used to set from `-bs`\n\tUseS bool\n)\n\nfunc init() {\n\t// ensure only valid characters are used, ie: windows\n\tre := regexp.MustCompile(`[^a-zA-Z\\-\\.\\_]`)\n\thost, err := os.Hostname()\n\tif err != nil {\n\t\thost = \"localhost\"\n\t} else {\n\t\thost = re.ReplaceAllString(host, \"-\")\n\t}\n\n\tusername := \"nobody\"\n\tuser, err := user.Current()\n\tif err == nil && user != nil && len(user.Username) > 0 {\n\t\tusername = re.ReplaceAllString(user.Username, \"-\")\n\t}\n\n\tif FromAddr == \"\" {\n\t\tFromAddr = username + \"@\" + host\n\t}\n}\n\n// Run the Mailpit sendmail replacement.\nfunc Run() {\n\tvar recipients []string\n\n\t// defaults from env vars if provided\n\tif len(os.Getenv(\"MP_SENDMAIL_SMTP_ADDR\")) > 0 {\n\t\tSMTPAddr = os.Getenv(\"MP_SENDMAIL_SMTP_ADDR\")\n\t}\n\tif len(os.Getenv(\"MP_SENDMAIL_FROM\")) > 0 {\n\t\tFromAddr = os.Getenv(\"MP_SENDMAIL_FROM\")\n\t}\n\n\tflag.StringVarP(&FromAddr, \"from\", \"f\", FromAddr, \"SMTP sender\")\n\tflag.StringVarP(&SMTPAddr, \"smtp-addr\", \"S\", SMTPAddr, \"SMTP server address\")\n\tflag.BoolVarP(&UseB, \"long-b\", \"b\", false, \"Handle SMTP commands on standard input (use as -bs)\")\n\tflag.BoolVarP(&UseS, \"long-s\", \"s\", false, \"Handle SMTP commands on standard input (use as -bs)\")\n\tflag.BoolP(\"verbose\", \"v\", false, \"Ignored\")\n\tflag.BoolP(\"long-i\", \"i\", false, \"Ignored\")\n\tflag.BoolP(\"long-o\", \"o\", false, \"Ignored\")\n\tflag.BoolP(\"long-t\", \"t\", false, \"Ignored\")\n\tflag.StringP(\"from-name\", \"F\", \"\", \"Ignored\")\n\tflag.StringP(\"bits\", \"B\", \"\", \"Ignored\")\n\tflag.StringP(\"errors\", \"e\", \"\", \"Ignored\")\n\n\t// set the default help\n\tflag.Usage = func() {\n\t\tfmt.Println(HelpTemplate(os.Args[0:1]))\n\t}\n\n\tvar showHelp bool\n\t// avoid 'pflag: help requested' error\n\tflag.BoolVarP(&showHelp, \"help\", \"h\", false, \"\")\n\n\tflag.Parse()\n\n\t// allow recipients to be passed as an argument\n\trecipients = flag.Args()\n\n\t// if run via `mailpit sendmail ...` then remove `sendmail` from \"recipients\"\n\tif len(recipients) > 0 && recipients[0] == \"sendmail\" {\n\t\trecipients = recipients[1:]\n\t}\n\n\tif showHelp {\n\t\tflag.Usage()\n\t\tos.Exit(0)\n\t}\n\n\t// ensure -bs is set\n\tif UseB && !UseS || !UseB && UseS {\n\t\tfmt.Printf(\"error: use -bs\")\n\t\tos.Exit(1)\n\t}\n\n\tsocketAddr, isSocket := socketAddress(SMTPAddr)\n\n\t// handles `sendmail -bs`\n\t// telnet directly to SMTP\n\tif UseB && UseS {\n\t\tvar caller = telnet.StandardCaller\n\t\tswitch isSocket {\n\t\tcase true:\n\t\t\tif err := telnet.DialToAndCallUnix(socketAddr, caller); err != nil {\n\t\t\t\tfmt.Println(err)\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\t\tdefault:\n\t\t\tif err := telnet.DialToAndCall(SMTPAddr, caller); err != nil {\n\t\t\t\tfmt.Println(err)\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\t\t}\n\n\t\treturn\n\t}\n\n\tbody, err := io.ReadAll(os.Stdin)\n\tif err != nil {\n\t\tfmt.Fprintln(os.Stderr, \"error reading stdin\")\n\t\tos.Exit(11)\n\t}\n\n\tmsg, err := mail.ReadMessage(bytes.NewReader(body))\n\tif err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"error parsing message body: %si\\n\", err)\n\t\tos.Exit(11)\n\t}\n\n\taddresses := []string{}\n\n\tif len(recipients) > 0 {\n\t\taddresses = recipients\n\t} else {\n\t\t// get all recipients in To, Cc and Bcc\n\t\tif to, err := msg.Header.AddressList(\"To\"); err == nil {\n\t\t\tfor _, a := range to {\n\t\t\t\taddresses = append(addresses, a.Address)\n\t\t\t}\n\t\t}\n\t\tif cc, err := msg.Header.AddressList(\"Cc\"); err == nil {\n\t\t\tfor _, a := range cc {\n\t\t\t\taddresses = append(addresses, a.Address)\n\t\t\t}\n\t\t}\n\t\tif bcc, err := msg.Header.AddressList(\"Bcc\"); err == nil {\n\t\t\tfor _, a := range bcc {\n\t\t\t\taddresses = append(addresses, a.Address)\n\t\t\t}\n\t\t}\n\t}\n\n\tfrom, err := mail.ParseAddress(FromAddr)\n\tif err != nil {\n\t\tfmt.Fprintln(os.Stderr, \"invalid from address\")\n\t\tos.Exit(11)\n\t}\n\n\tif err := Send(SMTPAddr, from.Address, addresses, body); err != nil {\n\t\tfmt.Fprintln(os.Stderr, \"error sending mail\")\n\t\tlogger.Log().Fatal(err)\n\t}\n}\n\n// HelpTemplate returns a string of the help\nfunc HelpTemplate(args []string) string {\n\treturn fmt.Sprintf(`A sendmail command replacement for Mailpit (%s)\n\nUsage: %s [flags] [recipients] < message\n\nSee: https://github.com/axllent/mailpit\n\nFlags:\n  -S  string  SMTP server address (default \"localhost:1025\")\n  -f  string  Set the envelope sender address (default \"%s\")\n  -bs         Handle SMTP commands on standard input\n  -t          Ignored\n  -i          Ignored\n  -o          Ignored\n  -v          Ignored\n  -F  string  Ignored\n  -B  string  Ignored\n  -e  string  Ignored\n`, config.Version, strings.Join(args, \" \"), FromAddr)\n}\n\n// SocketAddress returns a path and a FileMode if the address is in\n// the format of unix:<path>\nfunc socketAddress(address string) (string, bool) {\n\tre := regexp.MustCompile(`^unix:(.*)$`)\n\n\tif !re.MatchString(address) {\n\t\treturn \"\", false\n\t}\n\n\tm := re.FindAllStringSubmatch(address, 1)\n\n\treturn path.Clean(m[0][1]), true\n}\n"
  },
  {
    "path": "sendmail/cmd/smtp.go",
    "content": "// Package cmd is a wrapper library to send mail\npackage cmd\n\nimport (\n\t\"crypto/tls\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/mail\"\n\t\"net/smtp\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/axllent/mailpit/internal/logger\"\n)\n\n// Send is a wrapper for smtp.SendMail() which also supports sending via unix sockets.\n// Unix sockets must be set as unix:/path/to/socket\n// It does not support authentication.\nfunc Send(addr string, from string, to []string, msg []byte) error {\n\tsocketPath, isSocket := socketAddress(addr)\n\n\tfromAddress, err := mail.ParseAddress(from)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid from address: %s\", from)\n\t}\n\n\tif len(to) == 0 {\n\t\treturn fmt.Errorf(\"no To addresses specified\")\n\t}\n\n\tif !isSocket {\n\t\treturn sendMail(addr, nil, fromAddress.Address, to, msg)\n\t}\n\n\tconn, err := net.Dial(\"unix\", socketPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error connecting to %s\", addr)\n\t}\n\n\tclient, err := smtp.NewClient(conn, \"\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Set the sender\n\tif err := client.Mail(fromAddress.Address); err != nil {\n\t\tfmt.Fprintln(os.Stderr, \"error sending mail\")\n\t\tlogger.Log().Fatal(err)\n\t}\n\n\t// Set the recipient\n\tfor _, a := range to {\n\t\tif err := client.Rcpt(a); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\twc, err := client.Data()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, err = wc.Write(msg)\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = wc.Close()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc sendMail(addr string, a smtp.Auth, from string, to []string, msg []byte) error {\n\tif err := validateLine(from); err != nil {\n\t\treturn err\n\t}\n\n\tfor _, recipient := range to {\n\t\tif err := validateLine(recipient); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tc, err := smtp.Dial(addr)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer func() { _ = c.Close() }()\n\n\t// Use the local hostname for EHLO/HELO as required by RFC 5321.\n\t// Fall back to \"localhost\" if the hostname cannot be determined.\n\tlocalHostname, err := os.Hostname()\n\tif err != nil {\n\t\tlocalHostname = \"localhost\"\n\t}\n\tif err = c.Hello(localHostname); err != nil {\n\t\treturn err\n\t}\n\n\tif ok, _ := c.Extension(\"STARTTLS\"); ok {\n\t\tconfig := &tls.Config{ServerName: addr, InsecureSkipVerify: true} // #nosec\n\t\tif err = c.StartTLS(config); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif a != nil {\n\t\tif ok, _ := c.Extension(\"AUTH\"); !ok {\n\t\t\treturn errors.New(\"smtp: server doesn't support AUTH\")\n\t\t}\n\t\tif err = c.Auth(a); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err = c.Mail(from); err != nil {\n\t\treturn err\n\t}\n\n\tfor _, addr := range to {\n\t\tif err = c.Rcpt(addr); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tw, err := c.Data()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, err = w.Write(msg)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = w.Close()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn c.Quit()\n}\n\n// validateLine checks to see if a line has CR or LF as per RFC 5321.\nfunc validateLine(line string) error {\n\tif strings.ContainsAny(line, \"\\n\\r\") {\n\t\treturn errors.New(\"smtp: A line must not contain CR or LF\")\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "sendmail/main.go",
    "content": "package main\n\nimport \"github.com/axllent/mailpit/sendmail/cmd\"\n\nfunc main() {\n\tcmd.Run()\n}\n"
  },
  {
    "path": "server/apiv1/api.go",
    "content": "// Package apiv1 handles all the API responses\npackage apiv1\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"github.com/araddon/dateparse\"\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n)\n\n// FourOFour returns a basic 404 message\nfunc fourOFour(w http.ResponseWriter) {\n\tw.Header().Set(\"Referrer-Policy\", \"no-referrer\")\n\tw.Header().Set(\"Content-Security-Policy\", config.ContentSecurityPolicy)\n\tw.WriteHeader(http.StatusNotFound)\n\tw.Header().Set(\"Content-Type\", \"text/plain\")\n\t_, _ = fmt.Fprint(w, \"404 page not found\")\n}\n\n// HTTPError returns a basic error message (400 response)\nfunc httpError(w http.ResponseWriter, msg string) {\n\tw.Header().Set(\"Referrer-Policy\", \"no-referrer\")\n\tw.Header().Set(\"Content-Security-Policy\", config.ContentSecurityPolicy)\n\tw.WriteHeader(http.StatusBadRequest)\n\tw.Header().Set(\"Content-Type\", \"text/plain\")\n\t_, _ = fmt.Fprint(w, msg)\n}\n\n// httpJSONError returns a basic error message (400 response) in JSON format\nfunc httpJSONError(w http.ResponseWriter, msg string) {\n\tw.Header().Set(\"Referrer-Policy\", \"no-referrer\")\n\tw.Header().Set(\"Content-Security-Policy\", config.ContentSecurityPolicy)\n\tw.WriteHeader(http.StatusBadRequest)\n\te := struct{ Error string }{Error: msg}\n\n\tw.Header().Add(\"Content-Type\", \"application/json\")\n\tif err := json.NewEncoder(w).Encode(e); err != nil {\n\t\thttpError(w, err.Error())\n\t}\n}\n\n// Get the start and limit based on query params. Defaults to 0, 50\nfunc getStartLimit(req *http.Request) (start int, beforeTS int64, limit int) {\n\tstart = 0\n\tlimit = 50\n\tbeforeTS = 0 // timestamp\n\n\ts := req.URL.Query().Get(\"start\")\n\tif n, err := strconv.Atoi(s); err == nil && n > 0 {\n\t\tstart = n\n\t}\n\n\tl := req.URL.Query().Get(\"limit\")\n\tif n, err := strconv.Atoi(l); err == nil && n > -1 {\n\t\tlimit = n\n\t}\n\n\tb := req.URL.Query().Get(\"before\")\n\tif b != \"\" {\n\t\tt, err := dateparse.ParseLocal(b)\n\t\tif err != nil {\n\t\t\tlogger.Log().Warnf(\"ignoring invalid before: date \\\"%s\\\"\", b)\n\t\t} else {\n\t\t\tbeforeTS = t.UnixMilli()\n\t\t}\n\t}\n\n\treturn start, beforeTS, limit\n}\n\n// GetOptions returns a blank response\nfunc GetOptions(w http.ResponseWriter, _ *http.Request) {\n\n\tw.Header().Set(\"Content-Type\", \"text/plain\")\n\t_, _ = w.Write([]byte(\"\"))\n}\n"
  },
  {
    "path": "server/apiv1/application.go",
    "content": "package apiv1\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/internal/smtpd/chaos\"\n\t\"github.com/axllent/mailpit/internal/stats\"\n)\n\n// AppInfo returns some basic details about the running app including the latest release (unless disabled).\nfunc AppInfo(w http.ResponseWriter, _ *http.Request) {\n\t// swagger:route GET /api/v1/info application AppInformation\n\t//\n\t// # Get application information\n\t//\n\t// Returns basic runtime information, message totals and latest release version.\n\t//\n\t//\tProduces:\n\t//\t- application/json\n\t//\n\t//\tSchemes: http, https\n\t//\n\t//\tResponses:\n\t//\t\t200: AppInfoResponse\n\t//\t\t400: ErrorResponse\n\n\tw.Header().Add(\"Content-Type\", \"application/json\")\n\tif err := json.NewEncoder(w).Encode(stats.Load(true)); err != nil {\n\t\thttpError(w, err.Error())\n\t}\n}\n\n// WebUIConfig returns configuration settings for the web UI.\nfunc WebUIConfig(w http.ResponseWriter, _ *http.Request) {\n\t// swagger:route GET /api/v1/webui application WebUIConfigurationResponse\n\t//\n\t// # Get web UI configuration\n\t//\n\t// Returns configuration settings for the web UI.\n\t// Intended for web UI only!\n\t//\n\t//\tProduces:\n\t//\t - application/json\n\t//\n\t//\tSchemes: http, https\n\t//\n\t//\tResponses:\n\t//\t  200: WebUIConfigurationResponse\n\t//\t  400: ErrorResponse\n\n\tconf := webUIConfigurationResponse{}\n\n\tconf.Body.Label = config.Label\n\tconf.Body.MessageRelay.Enabled = config.ReleaseEnabled\n\tif config.ReleaseEnabled {\n\t\tconf.Body.MessageRelay.SMTPServer = fmt.Sprintf(\"%s:%d\", config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)\n\t\tconf.Body.MessageRelay.ReturnPath = config.SMTPRelayConfig.ReturnPath\n\t\tconf.Body.MessageRelay.AllowedRecipients = config.SMTPRelayConfig.AllowedRecipients\n\t\tconf.Body.MessageRelay.BlockedRecipients = config.SMTPRelayConfig.BlockedRecipients\n\t\tconf.Body.MessageRelay.OverrideFrom = config.SMTPRelayConfig.OverrideFrom\n\t\tconf.Body.MessageRelay.PreserveMessageIDs = config.SMTPRelayConfig.PreserveMessageIDs\n\n\t\t// DEPRECATED 2024/03/12\n\t\tconf.Body.MessageRelay.RecipientAllowlist = config.SMTPRelayConfig.AllowedRecipients\n\t}\n\n\tconf.Body.SpamAssassin = config.EnableSpamAssassin != \"\"\n\tconf.Body.ChaosEnabled = chaos.Enabled\n\tconf.Body.DuplicatesIgnored = config.IgnoreDuplicateIDs\n\tconf.Body.HideDeleteAllButton = config.HideDeleteAllButton\n\n\tw.Header().Add(\"Content-Type\", \"application/json\")\n\tif err := json.NewEncoder(w).Encode(conf.Body); err != nil {\n\t\thttpError(w, err.Error())\n\t}\n}\n"
  },
  {
    "path": "server/apiv1/chaos.go",
    "content": "package apiv1\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\n\t\"github.com/axllent/mailpit/internal/smtpd/chaos\"\n)\n\n// GetChaos returns the current Chaos triggers\nfunc GetChaos(w http.ResponseWriter, _ *http.Request) {\n\t// swagger:route GET /api/v1/chaos testing getChaos\n\t//\n\t// # Get Chaos triggers\n\t//\n\t// Returns the current Chaos triggers configuration.\n\t// This API route will return an error if Chaos is not enabled at runtime.\n\t//\n\t//\tProduces:\n\t//\t  - application/json\n\t//\n\t//\tSchemes: http, https\n\t//\n\t//\tResponses:\n\t//\t  200: ChaosResponse\n\t//\t  400: ErrorResponse\n\n\tif !chaos.Enabled {\n\t\thttpError(w, \"Chaos is not enabled\")\n\t\treturn\n\t}\n\n\tconf := chaos.Config\n\n\tw.Header().Add(\"Content-Type\", \"application/json\")\n\tif err := json.NewEncoder(w).Encode(conf); err != nil {\n\t\thttpError(w, err.Error())\n\t}\n}\n\n// SetChaos sets the Chaos configuration.\nfunc SetChaos(w http.ResponseWriter, r *http.Request) {\n\t// swagger:route PUT /api/v1/chaos testing setChaosParams\n\t//\n\t// # Set Chaos triggers\n\t//\n\t// Set the Chaos triggers configuration and return the updated values.\n\t// This API route will return an error if Chaos is not enabled at runtime.\n\t//\n\t// If any triggers are omitted from the request, then those are reset to their\n\t// default values with a 0% probability (ie: disabled).\n\t// Setting a blank `{}` will reset all triggers to their default values.\n\t//\n\t//\tConsumes:\n\t//\t  - application/json\n\t//\n\t//\tProduces:\n\t//\t  - application/json\n\t//\n\t//\tSchemes: http, https\n\t//\n\t//\tResponses:\n\t//\t  200: ChaosResponse\n\t//\t  400: ErrorResponse\n\n\tif !chaos.Enabled {\n\t\thttpError(w, \"Chaos is not enabled\")\n\t\treturn\n\t}\n\n\tdata := chaos.Triggers{}\n\n\tdecoder := json.NewDecoder(r.Body)\n\n\terr := decoder.Decode(&data)\n\tif err != nil {\n\t\thttpError(w, err.Error())\n\t\treturn\n\t}\n\n\tif err := chaos.SetFromStruct(data); err != nil {\n\t\thttpError(w, err.Error())\n\t\treturn\n\t}\n\n\tconf := chaos.Config\n\n\tw.Header().Add(\"Content-Type\", \"application/json\")\n\tif err := json.NewEncoder(w).Encode(conf); err != nil {\n\t\thttpError(w, err.Error())\n\t}\n}\n"
  },
  {
    "path": "server/apiv1/message.go",
    "content": "package apiv1\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/mail\"\n\t\"net/url\"\n\n\t\"github.com/axllent/mailpit/internal/storage\"\n\t\"github.com/gorilla/mux\"\n)\n\n// GetMessage (method: GET) returns the Message as JSON\nfunc GetMessage(w http.ResponseWriter, r *http.Request) {\n\t// swagger:route GET /api/v1/message/{ID} message GetMessageParams\n\t//\n\t// # Get message summary\n\t//\n\t// Returns the summary of a message, marking the message as read.\n\t//\n\t// The ID can be set to `latest` to return the latest message.\n\t//\n\t//\tProduces:\n\t//\t  - application/json\n\t//\n\t//\tSchemes: http, https\n\t//\n\t//\tResponses:\n\t//\t  200: Message\n\t//    400: ErrorResponse\n\t//    404: NotFoundResponse\n\n\tvars := mux.Vars(r)\n\n\tid := vars[\"id\"]\n\n\tif id == \"latest\" {\n\t\tvar err error\n\t\tid, err = storage.LatestID(r)\n\t\tif err != nil {\n\t\t\tw.WriteHeader(404)\n\t\t\t_, _ = fmt.Fprint(w, err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tmsg, err := storage.GetMessage(id)\n\tif err != nil {\n\t\tfourOFour(w)\n\t\treturn\n\t}\n\n\tw.Header().Add(\"Content-Type\", \"application/json\")\n\tif err := json.NewEncoder(w).Encode(msg); err != nil {\n\t\thttpError(w, err.Error())\n\t}\n}\n\n// GetHeaders (method: GET) returns the message headers as JSON\nfunc GetHeaders(w http.ResponseWriter, r *http.Request) {\n\t// swagger:route GET /api/v1/message/{ID}/headers message GetHeadersParams\n\t//\n\t// # Get message headers\n\t//\n\t// Returns the message headers as an array. Note that header keys are returned alphabetically.\n\t//\n\t// The ID can be set to `latest` to return the latest message headers.\n\t//\n\t//\tProduces:\n\t//\t  - application/json\n\t//\n\t//\tSchemes: http, https\n\t//\n\t//\tResponses:\n\t//\t  200: MessageHeadersResponse\n\t//    400: ErrorResponse\n\t//    404: NotFoundResponse\n\n\tvars := mux.Vars(r)\n\n\tid := vars[\"id\"]\n\n\tif id == \"latest\" {\n\t\tvar err error\n\t\tid, err = storage.LatestID(r)\n\t\tif err != nil {\n\t\t\tw.WriteHeader(404)\n\t\t\t_, _ = fmt.Fprint(w, err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tdata, err := storage.GetMessageRaw(id)\n\tif err != nil {\n\t\tfourOFour(w)\n\t\treturn\n\t}\n\n\treader := bytes.NewReader(data)\n\tm, err := mail.ReadMessage(reader)\n\tif err != nil {\n\t\thttpError(w, err.Error())\n\t\treturn\n\t}\n\n\tw.Header().Add(\"Content-Type\", \"application/json\")\n\tif err := json.NewEncoder(w).Encode(m.Header); err != nil {\n\t\thttpError(w, err.Error())\n\t}\n}\n\n// DownloadAttachment (method: GET) returns the attachment data\nfunc DownloadAttachment(w http.ResponseWriter, r *http.Request) {\n\t// swagger:route GET /api/v1/message/{ID}/part/{PartID} message AttachmentParams\n\t//\n\t// # Get message attachment\n\t//\n\t// This will return the attachment part using the appropriate Content-Type.\n\t//\n\t// The ID can be set to `latest` to reference the latest message.\n\t//\n\t//\tProduces:\n\t//\t  - application/*\n\t//\t  - image/*\n\t//\t  - text/*\n\t//\n\t//\tSchemes: http, https\n\t//\n\t//\tResponses:\n\t//\t  200: BinaryResponse\n\t//    400: ErrorResponse\n\t//    404: NotFoundResponse\n\n\tvars := mux.Vars(r)\n\n\tid := vars[\"id\"]\n\tpartID := vars[\"partID\"]\n\n\tif id == \"latest\" {\n\t\tvar err error\n\t\tid, err = storage.LatestID(r)\n\t\tif err != nil {\n\t\t\tw.WriteHeader(404)\n\t\t\t_, _ = fmt.Fprint(w, err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\ta, err := storage.GetAttachmentPart(id, partID)\n\tif err != nil {\n\t\tfourOFour(w)\n\t\treturn\n\t}\n\tfileName := a.FileName\n\tif fileName == \"\" {\n\t\tfileName = a.ContentID\n\t}\n\n\tw.Header().Add(\"Content-Type\", a.ContentType)\n\tw.Header().Set(\"Content-Disposition\", \"inline; filename=\\\"\"+url.PathEscape(fileName)+\"\\\"\")\n\t_, _ = w.Write(a.Content)\n}\n\n// DownloadRaw (method: GET) returns the full email source as plain text\nfunc DownloadRaw(w http.ResponseWriter, r *http.Request) {\n\t// swagger:route GET /api/v1/message/{ID}/raw message DownloadRawParams\n\t//\n\t// # Get message source\n\t//\n\t// Returns the full email source as plain text.\n\t//\n\t// The ID can be set to `latest` to return the latest message source.\n\t//\n\t//\tProduces:\n\t//\t  - text/plain\n\t//\n\t//\tSchemes: http, https\n\t//\n\t//\tResponses:\n\t//\t  200: TextResponse\n\t//    400: ErrorResponse\n\t//    404: NotFoundResponse\n\n\tvars := mux.Vars(r)\n\n\tid := vars[\"id\"]\n\tdl := r.FormValue(\"dl\")\n\n\tif id == \"latest\" {\n\t\tvar err error\n\t\tid, err = storage.LatestID(r)\n\t\tif err != nil {\n\t\t\tw.WriteHeader(404)\n\t\t\t_, _ = fmt.Fprint(w, err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tdata, err := storage.GetMessageRaw(id)\n\tif err != nil {\n\t\tfourOFour(w)\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"text/plain; charset=utf-8\")\n\tif dl == \"1\" {\n\t\tw.Header().Set(\"Content-Disposition\", \"attachment; filename=\\\"\"+id+\".eml\\\"\")\n\t}\n\t_, _ = w.Write(data)\n}\n"
  },
  {
    "path": "server/apiv1/messages.go",
    "content": "package apiv1\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/axllent/mailpit/internal/storage\"\n\t\"github.com/axllent/mailpit/internal/tools\"\n)\n\n// MessagesSummary is a summary of a list of messages\ntype MessagesSummary struct {\n\t// Total number of messages in mailbox\n\tTotal uint64 `json:\"total\"`\n\n\t// Total number of unread messages in mailbox\n\tUnread uint64 `json:\"unread\"`\n\n\t// Legacy - now undocumented in API specs but left for backwards compatibility.\n\t// Removed from API documentation 2023-07-12\n\t// swagger:ignore\n\tCount uint64 `json:\"count\"`\n\n\t// Total number of messages matching current query\n\tMessagesCount uint64 `json:\"messages_count\"`\n\n\t// Total number of unread messages matching current query\n\tMessagesUnreadCount uint64 `json:\"messages_unread\"`\n\n\t// Pagination offset\n\tStart int `json:\"start\"`\n\n\t// All current tags\n\tTags []string `json:\"tags\"`\n\n\t// Messages summary\n\t// in: body\n\tMessages []storage.MessageSummary `json:\"messages\"`\n}\n\n// GetMessages returns a paginated list of messages as JSON\nfunc GetMessages(w http.ResponseWriter, r *http.Request) {\n\t// swagger:route GET /api/v1/messages messages GetMessagesParams\n\t//\n\t// # List messages\n\t//\n\t// Returns messages from the mailbox ordered from newest to oldest.\n\t//\n\t//\tProduces:\n\t//\t  - application/json\n\t//\n\t//\tSchemes: http, https\n\t//\n\t//\tResponses:\n\t//\t  200: MessagesSummaryResponse\n\t//    400: ErrorResponse\n\n\tstart, beforeTS, limit := getStartLimit(r)\n\n\tmessages, err := storage.List(start, beforeTS, limit)\n\tif err != nil {\n\t\thttpError(w, err.Error())\n\t\treturn\n\t}\n\n\tstats := storage.StatsGet()\n\n\tvar res MessagesSummary\n\n\tres.Start = start\n\tres.Messages = messages\n\tres.Count = uint64(len(messages)) // legacy - now undocumented in API specs\n\tres.Total = stats.Total\n\tres.Unread = stats.Unread\n\tres.Tags = stats.Tags\n\tres.MessagesCount = stats.Total\n\tres.MessagesUnreadCount = stats.Unread\n\n\tw.Header().Add(\"Content-Type\", \"application/json\")\n\tif err := json.NewEncoder(w).Encode(res); err != nil {\n\t\thttpError(w, err.Error())\n\t}\n}\n\n// SetReadStatus (method: PUT) will update the status to Read/Unread for all provided IDs.\nfunc SetReadStatus(w http.ResponseWriter, r *http.Request) {\n\t// swagger:route PUT /api/v1/messages messages SetReadStatusParams\n\t//\n\t// # Set read status\n\t//\n\t// You can optionally provide an array of IDs or a search string.\n\t// If neither IDs nor search is provided then all mailbox messages are updated.\n\t//\n\t//\tConsumes:\n\t//\t  - application/json\n\t//\n\t//\tProduces:\n\t//\t  - text/plain\n\t//\n\t//\tSchemes: http, https\n\t//\n\t//\tResponses:\n\t//\t  200: OKResponse\n\t//    400: ErrorResponse\n\n\tdecoder := json.NewDecoder(r.Body)\n\n\tvar data struct {\n\t\tRead   bool\n\t\tIDs    []string\n\t\tSearch string\n\t}\n\n\terr := decoder.Decode(&data)\n\tif err != nil {\n\t\thttpError(w, err.Error())\n\t\treturn\n\t}\n\n\tids := data.IDs\n\tsearch := data.Search\n\n\tif len(ids) > 0 && search != \"\" {\n\t\thttpError(w, \"You may specify either IDs or a search query, not both\")\n\t\treturn\n\t}\n\n\tif search != \"\" {\n\t\terr := storage.SetSearchReadStatus(search, r.URL.Query().Get(\"tz\"), data.Read)\n\t\tif err != nil {\n\t\t\thttpError(w, err.Error())\n\t\t\treturn\n\t\t}\n\t} else if len(ids) == 0 {\n\t\tif data.Read {\n\t\t\terr := storage.MarkAllRead()\n\t\t\tif err != nil {\n\t\t\t\thttpError(w, err.Error())\n\t\t\t\treturn\n\t\t\t}\n\t\t} else {\n\t\t\terr := storage.MarkAllUnread()\n\t\t\tif err != nil {\n\t\t\t\thttpError(w, err.Error())\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t} else {\n\t\tif data.Read {\n\t\t\tif err := storage.MarkRead(ids); err != nil {\n\t\t\t\thttpError(w, err.Error())\n\t\t\t\treturn\n\t\t\t}\n\t\t} else {\n\t\t\tif err := storage.MarkUnread(ids); err != nil {\n\t\t\t\thttpError(w, err.Error())\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n\n\tw.Header().Add(\"Content-Type\", \"text/plain\")\n\t_, _ = w.Write([]byte(\"ok\"))\n}\n\n// DeleteMessages (method: DELETE) deletes all messages matching IDS.\nfunc DeleteMessages(w http.ResponseWriter, r *http.Request) {\n\t// swagger:route DELETE /api/v1/messages messages DeleteMessagesParams\n\t//\n\t// # Delete messages\n\t//\n\t// Delete individual or all messages. If no IDs are provided then all messages are deleted.\n\t//\n\t//\tConsumes:\n\t//\t  - application/json\n\t//\n\t//\tProduces:\n\t//\t  - text/plain\n\t//\n\t//\tSchemes: http, https\n\t//\n\t//\tResponses:\n\t//\t  200: OKResponse\n\t//    400: ErrorResponse\n\n\tdecoder := json.NewDecoder(r.Body)\n\tvar data struct {\n\t\tIDs []string\n\t}\n\terr := decoder.Decode(&data)\n\tif err != nil || len(data.IDs) == 0 {\n\t\tif err := storage.DeleteAllMessages(); err != nil {\n\t\t\thttpError(w, err.Error())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tif err := storage.DeleteMessages(data.IDs); err != nil {\n\t\t\thttpError(w, err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tw.Header().Add(\"Content-Type\", \"text/plain\")\n\t_, _ = w.Write([]byte(\"ok\"))\n}\n\n// Search returns the latest messages as JSON\nfunc Search(w http.ResponseWriter, r *http.Request) {\n\t// swagger:route GET /api/v1/search messages SearchParams\n\t//\n\t// # Search messages\n\t//\n\t// Returns messages matching [a search](https://mailpit.axllent.org/docs/usage/search-filters/), sorted by received date (descending).\n\t//\n\t//\tProduces:\n\t//\t  - application/json\n\t//\n\t//\tSchemes: http, https\n\t//\n\t//\tResponses:\n\t//\t  200: MessagesSummaryResponse\n\t//    400: ErrorResponse\n\n\tsearch := strings.TrimSpace(r.URL.Query().Get(\"query\"))\n\tif search == \"\" {\n\t\thttpError(w, \"Error: no search query\")\n\t\treturn\n\t}\n\n\tstart, beforeTS, limit := getStartLimit(r)\n\n\tmessages, results, err := storage.Search(search, r.URL.Query().Get(\"tz\"), start, beforeTS, limit)\n\tif err != nil {\n\t\thttpError(w, err.Error())\n\t\treturn\n\t}\n\n\tstats := storage.StatsGet()\n\n\tvar res MessagesSummary\n\n\tres.Start = start\n\tres.Messages = messages\n\tres.Count = tools.SafeUint64(len(messages)) // legacy - now undocumented in API specs\n\tres.Total = stats.Total                     // total messages in mailbox\n\tres.MessagesCount = tools.SafeUint64(results)\n\tres.Unread = stats.Unread\n\tres.Tags = stats.Tags\n\n\tunread, err := storage.SearchUnreadCount(search, r.URL.Query().Get(\"tz\"), beforeTS)\n\tif err != nil {\n\t\thttpError(w, err.Error())\n\t\treturn\n\t}\n\n\tres.MessagesUnreadCount = tools.SafeUint64(unread)\n\n\tw.Header().Add(\"Content-Type\", \"application/json\")\n\tif err := json.NewEncoder(w).Encode(res); err != nil {\n\t\thttpError(w, err.Error())\n\t}\n}\n\n// DeleteSearch will delete all messages matching a search\nfunc DeleteSearch(w http.ResponseWriter, r *http.Request) {\n\t// swagger:route DELETE /api/v1/search messages DeleteSearchParams\n\t//\n\t// # Delete messages by search\n\t//\n\t// Delete all messages matching [a search](https://mailpit.axllent.org/docs/usage/search-filters/).\n\t//\n\t//\tProduces:\n\t//\t  - application/json\n\t//\n\t//\tSchemes: http, https\n\t//\n\t//\tResponses:\n\t//\t  200: OKResponse\n\t//    400: ErrorResponse\n\n\tsearch := strings.TrimSpace(r.URL.Query().Get(\"query\"))\n\tif search == \"\" {\n\t\thttpError(w, \"Error: no search query\")\n\t\treturn\n\t}\n\n\tif err := storage.DeleteSearch(search, r.URL.Query().Get(\"tz\")); err != nil {\n\t\thttpError(w, err.Error())\n\t\treturn\n\t}\n\n\tw.Header().Add(\"Content-Type\", \"text/plain\")\n\t_, _ = w.Write([]byte(\"ok\"))\n}\n"
  },
  {
    "path": "server/apiv1/other.go",
    "content": "package apiv1\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/internal/htmlcheck\"\n\t\"github.com/axllent/mailpit/internal/linkcheck\"\n\t\"github.com/axllent/mailpit/internal/spamassassin\"\n\t\"github.com/axllent/mailpit/internal/storage\"\n\t\"github.com/gorilla/mux\"\n\t\"github.com/jhillyerd/enmime/v2\"\n)\n\n// HTMLCheck returns a summary of the HTML client support\nfunc HTMLCheck(w http.ResponseWriter, r *http.Request) {\n\t// swagger:route GET /api/v1/message/{ID}/html-check other HTMLCheckParams\n\t//\n\t// # HTML check\n\t//\n\t// Returns the summary of the message HTML checker.\n\t//\n\t// The ID can be set to `latest` to return the latest message.\n\t//\n\t//\tProduces:\n\t//\t  - application/json\n\t//\n\t//\tSchemes: http, https\n\t//\n\t//\tResponses:\n\t//\t  200: HTMLCheckResponse\n\t//    400: ErrorResponse\n\t//    404: NotFoundResponse\n\n\tvars := mux.Vars(r)\n\tid := vars[\"id\"]\n\n\tif id == \"latest\" {\n\t\tvar err error\n\t\tid, err = storage.LatestID(r)\n\t\tif err != nil {\n\t\t\tfourOFour(w)\n\t\t\treturn\n\t\t}\n\t}\n\n\traw, err := storage.GetMessageRaw(id)\n\tif err != nil {\n\t\tfourOFour(w)\n\t\treturn\n\t}\n\n\te := bytes.NewReader(raw)\n\n\tparser := enmime.NewParser(enmime.DisableCharacterDetection(true))\n\n\tmsg, err := parser.ReadEnvelope(e)\n\tif err != nil {\n\t\thttpError(w, err.Error())\n\t\treturn\n\t}\n\n\tif msg.HTML == \"\" {\n\t\thttpError(w, \"message does not contain HTML\")\n\t\treturn\n\t}\n\n\tchecks, err := htmlcheck.RunTests(msg.HTML)\n\tif err != nil {\n\t\thttpError(w, err.Error())\n\t\treturn\n\t}\n\n\tw.Header().Add(\"Content-Type\", \"application/json\")\n\tif err := json.NewEncoder(w).Encode(checks); err != nil {\n\t\thttpError(w, err.Error())\n\t}\n}\n\n// LinkCheck returns a summary of links in the email\nfunc LinkCheck(w http.ResponseWriter, r *http.Request) {\n\t// swagger:route GET /api/v1/message/{ID}/link-check other LinkCheckParams\n\t//\n\t// # Link check\n\t//\n\t// Returns the summary of the message Link checker.\n\t//\n\t// The ID can be set to `latest` to return the latest message.\n\t//\n\t//\tProduces:\n\t//\t  - application/json\n\t//\n\t//\tSchemes: http, https\n\t//\n\t//\tResponses:\n\t//\t  200: LinkCheckResponse\n\t//    400: ErrorResponse\n\t//    404: NotFoundResponse\n\n\tif config.DemoMode {\n\t\thttpError(w, \"this functionality has been disabled for demonstration purposes\")\n\t\treturn\n\t}\n\n\tvars := mux.Vars(r)\n\tid := vars[\"id\"]\n\n\tif id == \"latest\" {\n\t\tvar err error\n\t\tid, err = storage.LatestID(r)\n\t\tif err != nil {\n\t\t\tfourOFour(w)\n\t\t\treturn\n\t\t}\n\t}\n\n\tmsg, err := storage.GetMessage(id)\n\tif err != nil {\n\t\tfourOFour(w)\n\t\treturn\n\t}\n\n\tf := r.URL.Query().Get(\"follow\")\n\tfollowRedirects := f == \"true\" || f == \"1\"\n\n\tsummary, err := linkcheck.RunTests(msg, followRedirects)\n\tif err != nil {\n\t\thttpError(w, err.Error())\n\t\treturn\n\t}\n\n\tw.Header().Add(\"Content-Type\", \"application/json\")\n\tif err := json.NewEncoder(w).Encode(summary); err != nil {\n\t\thttpError(w, err.Error())\n\t}\n}\n\n// SpamAssassinCheck returns a summary of SpamAssassin results (if enabled)\nfunc SpamAssassinCheck(w http.ResponseWriter, r *http.Request) {\n\t// swagger:route GET /api/v1/message/{ID}/sa-check other SpamAssassinCheckParams\n\t//\n\t// # SpamAssassin check\n\t//\n\t// Returns the SpamAssassin summary (if enabled) of the message.\n\t//\n\t// The ID can be set to `latest` to return the latest message.\n\t//\n\t//\tProduces:\n\t//\t  - application/json\n\t//\n\t//\tSchemes: http, https\n\t//\n\t//\tResponses:\n\t//\t  200: SpamAssassinResponse\n\t//    400: ErrorResponse\n\t//    404: NotFoundResponse\n\n\tvars := mux.Vars(r)\n\tid := vars[\"id\"]\n\n\tif id == \"latest\" {\n\t\tvar err error\n\t\tid, err = storage.LatestID(r)\n\t\tif err != nil {\n\t\t\tw.WriteHeader(404)\n\t\t\t_, _ = fmt.Fprint(w, err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tmsg, err := storage.GetMessageRaw(id)\n\tif err != nil {\n\t\tfourOFour(w)\n\t\treturn\n\t}\n\n\tsummary, err := spamassassin.Check(msg)\n\tif err != nil {\n\t\thttpError(w, err.Error())\n\t\treturn\n\t}\n\n\tw.Header().Add(\"Content-Type\", \"application/json\")\n\tif err := json.NewEncoder(w).Encode(summary); err != nil {\n\t\thttpError(w, err.Error())\n\t}\n}\n"
  },
  {
    "path": "server/apiv1/release.go",
    "content": "package apiv1\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/mail\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/axllent/mailpit/internal/smtpd\"\n\t\"github.com/axllent/mailpit/internal/storage\"\n\t\"github.com/axllent/mailpit/internal/tools\"\n\t\"github.com/gorilla/mux\"\n\t\"github.com/lithammer/shortuuid/v4\"\n)\n\n// ReleaseMessage (method: POST) will release a message via a pre-configured external SMTP server.\nfunc ReleaseMessage(w http.ResponseWriter, r *http.Request) {\n\t// swagger:route POST /api/v1/message/{ID}/release message ReleaseMessageParams\n\t//\n\t// # Release message\n\t//\n\t// Release a message via a pre-configured external SMTP server. This is only enabled if message relaying has been configured.\n\t//\n\t// The ID can be set to `latest` to reference the latest message.\n\t//\n\t//\tConsumes:\n\t//\t  - application/json\n\t//\n\t//\tProduces:\n\t//\t  - text/plain\n\t//\n\t//\tSchemes: http, https\n\t//\n\t//\tResponses:\n\t//\t  200: OKResponse\n\t//    400: ErrorResponse\n\t//    404: NotFoundResponse\n\n\tif config.DemoMode {\n\t\thttpError(w, \"this functionality has been disabled for demonstration purposes\")\n\t\treturn\n\t}\n\n\tvars := mux.Vars(r)\n\n\tid := vars[\"id\"]\n\n\tmsg, err := storage.GetMessageRaw(id)\n\tif err != nil {\n\t\tfourOFour(w)\n\t\treturn\n\t}\n\n\tdecoder := json.NewDecoder(r.Body)\n\n\tvar data struct {\n\t\tTo []string\n\t}\n\n\tif err := decoder.Decode(&data); err != nil {\n\t\thttpError(w, err.Error())\n\t\treturn\n\t}\n\n\tblocked := []string{}\n\tnotAllowed := []string{}\n\n\tfor _, to := range data.To {\n\t\taddress, err := mail.ParseAddress(to)\n\n\t\tif err != nil {\n\t\t\thttpError(w, \"Invalid email address: \"+to)\n\t\t\treturn\n\t\t}\n\n\t\tif config.SMTPRelayConfig.AllowedRecipientsRegexp != nil && !config.SMTPRelayConfig.AllowedRecipientsRegexp.MatchString(address.Address) {\n\t\t\tnotAllowed = append(notAllowed, to)\n\t\t\tcontinue\n\t\t}\n\n\t\tif config.SMTPRelayConfig.BlockedRecipientsRegexp != nil && config.SMTPRelayConfig.BlockedRecipientsRegexp.MatchString(address.Address) {\n\t\t\tblocked = append(blocked, to)\n\t\t\tcontinue\n\t\t}\n\t}\n\n\tif len(notAllowed) > 0 {\n\t\taddr := tools.Plural(len(notAllowed), \"Address\", \"Addresses\")\n\t\thttpError(w, \"Failed: \"+addr+\" do not match the allowlist: \"+strings.Join(notAllowed, \", \"))\n\t\treturn\n\t}\n\n\tif len(blocked) > 0 {\n\t\taddr := tools.Plural(len(blocked), \"Address\", \"Addresses\")\n\t\thttpError(w, \"Failed: \"+addr+\" found on blocklist: \"+strings.Join(blocked, \", \"))\n\t\treturn\n\t}\n\n\tif len(data.To) == 0 {\n\t\thttpError(w, \"No valid addresses found\")\n\t\treturn\n\t}\n\n\treader := bytes.NewReader(msg)\n\tm, err := mail.ReadMessage(reader)\n\tif err != nil {\n\t\thttpError(w, err.Error())\n\t\treturn\n\t}\n\n\tfromAddresses, err := m.Header.AddressList(\"From\")\n\tif err != nil {\n\t\thttpError(w, \"Failed: unable to parse From header: \"+err.Error())\n\t\treturn\n\t}\n\n\tif len(fromAddresses) == 0 {\n\t\thttpError(w, \"No From header found\")\n\t\treturn\n\t}\n\n\tfrom := fromAddresses[0].Address\n\n\t// if sender is used, then change from to the sender\n\tif senders, err := m.Header.AddressList(\"Sender\"); err == nil {\n\t\tfrom = senders[0].Address\n\t}\n\n\tmsg, err = tools.RemoveMessageHeaders(msg, []string{\"Bcc\"})\n\tif err != nil {\n\t\thttpError(w, err.Error())\n\t\treturn\n\t}\n\n\t// set the Return-Path and SMTP from\n\tif config.SMTPRelayConfig.ReturnPath != \"\" {\n\t\tif m.Header.Get(\"Return-Path\") != \"<\"+config.SMTPRelayConfig.ReturnPath+\">\" {\n\t\t\tmsg, err = tools.RemoveMessageHeaders(msg, []string{\"Return-Path\"})\n\t\t\tif err != nil {\n\t\t\t\thttpError(w, err.Error())\n\t\t\t\treturn\n\t\t\t}\n\t\t\tmsg = append([]byte(\"Return-Path: <\"+config.SMTPRelayConfig.ReturnPath+\">\\r\\n\"), msg...)\n\t\t}\n\n\t\tfrom = config.SMTPRelayConfig.ReturnPath\n\t}\n\n\t// update message date\n\tmsg, err = tools.SetMessageHeader(msg, \"Date\", time.Now().Format(time.RFC1123Z))\n\tif err != nil {\n\t\thttpError(w, err.Error())\n\t\treturn\n\t}\n\n\tif !config.SMTPRelayConfig.PreserveMessageIDs {\n\t\t// replace the Message-ID header with unique ID\n\t\tuid := shortuuid.New() + \"@mailpit\"\n\t\tmsg, err = tools.SetMessageHeader(msg, \"Message-ID\", \"<\"+uid+\">\")\n\t\tif err != nil {\n\t\t\thttpError(w, err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tif err := smtpd.Relay(from, data.To, msg); err != nil {\n\t\tlogger.Log().Errorf(\"[smtp] error sending message: %s\", err.Error())\n\t\thttpError(w, \"SMTP error: \"+err.Error())\n\t\treturn\n\t}\n\n\tw.Header().Add(\"Content-Type\", \"text/plain\")\n\t_, _ = w.Write([]byte(\"ok\"))\n}\n"
  },
  {
    "path": "server/apiv1/send.go",
    "content": "package apiv1\n\nimport (\n\t\"bytes\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/mail\"\n\t\"strings\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/internal/smtpd\"\n\t\"github.com/axllent/mailpit/internal/tools\"\n\t\"github.com/jhillyerd/enmime/v2\"\n)\n\n// SendMessageHandler handles HTTP requests to send a new message\nfunc SendMessageHandler(w http.ResponseWriter, r *http.Request) {\n\t// swagger:route POST /api/v1/send message SendMessageParams\n\t//\n\t// # Send a message\n\t//\n\t// Send a message via the HTTP API.\n\t//\n\t//\tConsumes:\n\t//\t  - application/json\n\t//\n\t//\tProduces:\n\t//\t  - application/json\n\t//\n\t//\tSchemes: http, https\n\t//\n\t//\tResponses:\n\t//\t  200: SendMessageResponse\n\t//\t  400: JSONErrorResponse\n\n\tif config.DemoMode {\n\t\thttpJSONError(w, \"this functionality has been disabled for demonstration purposes\")\n\t\treturn\n\t}\n\n\tdecoder := json.NewDecoder(r.Body)\n\n\tdata := sendMessageParams{}\n\n\tif err := decoder.Decode(&data.Body); err != nil {\n\t\thttpJSONError(w, err.Error())\n\t\treturn\n\t}\n\n\tvar httpAuthUser *string\n\tif user, _, ok := r.BasicAuth(); ok {\n\t\thttpAuthUser = &user\n\t}\n\n\tid, err := data.Send(r.RemoteAddr, httpAuthUser)\n\n\tif err != nil {\n\t\thttpJSONError(w, err.Error())\n\t\treturn\n\t}\n\n\tw.Header().Add(\"Content-Type\", \"application/json\")\n\tif err := json.NewEncoder(w).Encode(struct{ ID string }{ID: id}); err != nil {\n\t\thttpError(w, err.Error())\n\t}\n}\n\n// Send will validate the message structure and attempt to send to Mailpit.\n// It returns a sending summary or an error.\nfunc (d sendMessageParams) Send(remoteAddr string, httpAuthUser *string) (string, error) {\n\tip, _, err := net.SplitHostPort(remoteAddr)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"error parsing request RemoteAddr: %s\", err.Error())\n\t}\n\n\tipAddr := &net.IPAddr{IP: net.ParseIP(ip)}\n\n\taddresses := []string{}\n\n\tmsg := enmime.Builder().\n\t\tFrom(d.Body.From.Name, d.Body.From.Email).\n\t\tSubject(d.Body.Subject).\n\t\tText([]byte(d.Body.Text))\n\n\tif d.Body.HTML != \"\" {\n\t\tmsg = msg.HTML([]byte(d.Body.HTML))\n\t}\n\n\tif len(d.Body.To) > 0 {\n\t\tfor _, a := range d.Body.To {\n\t\t\tif _, err := mail.ParseAddress(a.Email); err == nil {\n\t\t\t\tmsg = msg.To(a.Name, a.Email)\n\t\t\t\taddresses = append(addresses, a.Email)\n\t\t\t} else {\n\t\t\t\treturn \"\", fmt.Errorf(\"invalid To address: %s\", a.Email)\n\t\t\t}\n\t\t}\n\t}\n\n\tif len(d.Body.Cc) > 0 {\n\t\tfor _, a := range d.Body.Cc {\n\t\t\tif _, err := mail.ParseAddress(a.Email); err == nil {\n\t\t\t\tmsg = msg.CC(a.Name, a.Email)\n\t\t\t\taddresses = append(addresses, a.Email)\n\t\t\t} else {\n\t\t\t\treturn \"\", fmt.Errorf(\"invalid Cc address: %s\", a.Email)\n\t\t\t}\n\t\t}\n\t}\n\n\tif len(d.Body.Bcc) > 0 {\n\t\tfor _, e := range d.Body.Bcc {\n\t\t\tif _, err := mail.ParseAddress(e); err == nil {\n\t\t\t\tmsg = msg.BCC(\"\", e)\n\t\t\t\taddresses = append(addresses, e)\n\t\t\t} else {\n\t\t\t\treturn \"\", fmt.Errorf(\"invalid Bcc address: %s\", e)\n\t\t\t}\n\t\t}\n\t}\n\n\tif len(d.Body.ReplyTo) > 0 {\n\t\tfor _, a := range d.Body.ReplyTo {\n\t\t\tif _, err := mail.ParseAddress(a.Email); err == nil {\n\t\t\t\tmsg = msg.ReplyTo(a.Name, a.Email)\n\t\t\t} else {\n\t\t\t\treturn \"\", fmt.Errorf(\"invalid Reply-To address: %s\", a.Email)\n\t\t\t}\n\t\t}\n\t}\n\n\trestrictedHeaders := []string{\"To\", \"From\", \"Cc\", \"Bcc\", \"Reply-To\", \"Date\", \"Subject\", \"Content-Type\", \"Mime-Version\"}\n\n\tif len(d.Body.Tags) > 0 {\n\t\tmsg = msg.Header(\"X-Tags\", strings.Join(d.Body.Tags, \", \"))\n\t\trestrictedHeaders = append(restrictedHeaders, \"X-Tags\")\n\t}\n\n\tif len(d.Body.Headers) > 0 {\n\t\tfor k, v := range d.Body.Headers {\n\t\t\t// check header isn't in \"restricted\" headers\n\t\t\tif tools.InArray(k, restrictedHeaders) {\n\t\t\t\treturn \"\", fmt.Errorf(\"cannot overwrite header: \\\"%s\\\"\", k)\n\t\t\t}\n\t\t\tmsg = msg.Header(k, v)\n\t\t}\n\t}\n\n\tif len(d.Body.Attachments) > 0 {\n\t\tfor _, a := range d.Body.Attachments {\n\t\t\t// workaround: split string because JS readAsDataURL() returns the base64 string\n\t\t\t// with the mime type prefix eg: data:image/png;base64,<base64String>\n\t\t\tparts := strings.Split(a.Content, \",\")\n\t\t\tcontent := parts[len(parts)-1]\n\t\t\tb, err := base64.StdEncoding.DecodeString(content)\n\t\t\tif err != nil {\n\t\t\t\treturn \"\", fmt.Errorf(\"error decoding base64 attachment \\\"%s\\\": %s\", a.Filename, err.Error())\n\t\t\t}\n\t\t\tcontentType := http.DetectContentType(b)\n\t\t\tif a.ContentType != \"\" {\n\t\t\t\tcontentType = a.ContentType\n\t\t\t}\n\t\t\tif a.ContentID != \"\" {\n\t\t\t\tmsg = msg.AddInline(b, contentType, a.Filename, a.ContentID)\n\t\t\t} else {\n\t\t\t\tmsg = msg.AddAttachment(b, contentType, a.Filename)\n\t\t\t}\n\t\t}\n\t}\n\n\tpart, err := msg.Build()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"error building message: %s\", err.Error())\n\t}\n\n\tvar buff bytes.Buffer\n\n\tif err := part.Encode(io.Writer(&buff)); err != nil {\n\t\treturn \"\", fmt.Errorf(\"error building message: %s\", err.Error())\n\t}\n\n\treturn smtpd.SaveToDatabase(ipAddr, d.Body.From.Email, addresses, buff.Bytes(), httpAuthUser)\n}\n"
  },
  {
    "path": "server/apiv1/structs.go",
    "content": "package apiv1\n\nimport (\n\t\"github.com/axllent/mailpit/internal/storage\"\n)\n\n// The following structs & aliases are provided for easy import\n// and understanding of the JSON structure.\n\n// MessageSummary - summary of a single message\ntype MessageSummary = storage.MessageSummary\n\n// Message data\ntype Message = storage.Message\n\n// Attachment summary\ntype Attachment = storage.Attachment\n"
  },
  {
    "path": "server/apiv1/swagger-config.yml",
    "content": "consumes:\n  - application/json\ninfo:\n  description:  |-\n    OpenAPI 2.0 documentation for [Mailpit](https://github.com/axllent/mailpit).\n  title: Mailpit API\n  contact:\n    name: GitHub\n    url: https://github.com/axllent/mailpit\n  license:\n    name: MIT license\n    url: https://github.com/axllent/mailpit/blob/develop/LICENSE\n  version: \"v1\"\npaths: {}\nproduces:\n  - application/json\nschemes:\n  - http\nswagger: \"2.0\"\n"
  },
  {
    "path": "server/apiv1/swaggerParams.go",
    "content": "// Package apiv1 provides the API v1 endpoints for Mailpit.\n//\n// These structs are for the purpose of defining swagger HTTP parameters in go-swagger\n// in order to generate a spec file. They are lowercased to avoid exporting them as public types.\n//\n//nolint:unused\npackage apiv1\n\nimport \"github.com/axllent/mailpit/internal/smtpd/chaos\"\n\n// swagger:parameters setChaosParams\ntype setChaosParams struct {\n\t// in: body\n\tBody chaos.Triggers\n}\n\n// swagger:parameters AttachmentParams\ntype attachmentParams struct {\n\t// Message database ID or \"latest\"\n\t//\n\t// in: path\n\t// required: true\n\tID string\n\n\t// Attachment part ID\n\t//\n\t// in: path\n\t// required: true\n\tPartID string\n}\n\n// swagger:parameters DownloadRawParams\ntype downloadRawParams struct {\n\t// Message database ID or \"latest\"\n\t//\n\t// in: path\n\t// required: true\n\tID string\n}\n\n// swagger:parameters GetMessageParams\ntype getMessageParams struct {\n\t// Message database ID or \"latest\"\n\t//\n\t// in: path\n\t// required: true\n\tID string\n}\n\n// swagger:parameters GetHeadersParams\ntype getHeadersParams struct {\n\t// Message database ID or \"latest\"\n\t//\n\t// in: path\n\t// required: true\n\tID string\n}\n\n// swagger:parameters GetMessagesParams\ntype getMessagesParams struct {\n\t// Pagination offset\n\t//\n\t// in: query\n\t// name: start\n\t// required: false\n\t// default: 0\n\t// type: integer\n\tStart int `json:\"start\"`\n\n\t// Limit number of results\n\t//\n\t// in: query\n\t// name: limit\n\t// required: false\n\t// default: 50\n\t// type: integer\n\tLimit int `json:\"limit\"`\n}\n\n// swagger:parameters SetReadStatusParams\ntype setReadStatusParams struct {\n\t// in: body\n\tBody struct {\n\t\t// Read status\n\t\t//\n\t\t// required: false\n\t\t// default: false\n\t\t// example: true\n\t\tRead bool\n\n\t\t// Optional array of message database IDs\n\t\t//\n\t\t// required: false\n\t\t// default: []\n\t\t// example: [\"4oRBnPtCXgAqZniRhzLNmS\", \"hXayS6wnCgNnt6aFTvmOF6\"]\n\t\tIDs []string\n\n\t\t// Optional messages matching a search\n\t\t//\n\t\t// required: false\n\t\t// example: tag:backups\n\t\tSearch string\n\t}\n\n\t// Optional [timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` & `after:` searches (eg: \"Pacific/Auckland\").\n\t//\n\t// in: query\n\t// required: false\n\t// type string\n\tTZ string `json:\"tz\"`\n}\n\n// swagger:parameters DeleteMessagesParams\ntype deleteMessagesParams struct {\n\t// Delete request\n\t// in: body\n\tBody struct {\n\t\t// Array of message database IDs\n\t\t//\n\t\t// required: false\n\t\t// example: [\"4oRBnPtCXgAqZniRhzLNmS\", \"hXayS6wnCgNnt6aFTvmOF6\"]\n\t\tIDs []string\n\t}\n}\n\n// swagger:parameters SearchParams\ntype searchParams struct {\n\t// Search query\n\t//\n\t// in: query\n\t// required: true\n\t// type: string\n\tQuery string `json:\"query\"`\n\n\t// Pagination offset\n\t//\n\t// in: query\n\t// required: false\n\t// default: 0\n\t// type integer\n\tStart string `json:\"start\"`\n\n\t// Limit results\n\t//\n\t// in: query\n\t// required: false\n\t// default: 50\n\t// type integer\n\tLimit string `json:\"limit\"`\n\n\t// Optional [timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` & `after:` searches (eg: \"Pacific/Auckland\").\n\t//\n\t// in: query\n\t// required: false\n\t// type string\n\tTZ string `json:\"tz\"`\n}\n\n// swagger:parameters DeleteSearchParams\ntype deleteSearchParams struct {\n\t// Search query\n\t//\n\t// in: query\n\t// required: true\n\t// type: string\n\tQuery string `json:\"query\"`\n\n\t// [Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` & `after:` searches (eg: \"Pacific/Auckland\").\n\t//\n\t// in: query\n\t// required: false\n\t// type string\n\tTZ string `json:\"tz\"`\n}\n\n// swagger:parameters HTMLCheckParams\ntype htmlCheckParams struct {\n\t// Message database ID or \"latest\"\n\t//\n\t// in: path\n\t// description: Message database ID or \"latest\"\n\t// required: true\n\tID string\n}\n\n// swagger:parameters LinkCheckParams\ntype linkCheckParams struct {\n\t// Message database ID or \"latest\"\n\t//\n\t// in: path\n\t// required: true\n\tID string\n\n\t// Follow redirects\n\t//\n\t// in: query\n\t// required: false\n\t// default: false\n\tFollow string `json:\"follow\"`\n}\n\n// swagger:parameters ReleaseMessageParams\ntype releaseMessageParams struct {\n\t// Message database ID\n\t//\n\t// in: path\n\t// description: Message database ID\n\t// required: true\n\tID string\n\n\t// in: body\n\tBody struct {\n\t\t// Array of email addresses to relay the message to\n\t\t//\n\t\t// required: true\n\t\t// example: [\"user1@example.com\", \"user2@example.com\"]\n\t\tTo []string\n\t}\n}\n\n// swagger:parameters SendMessageParams\ntype sendMessageParams struct {\n\t// in: body\n\t// Body SendRequest\n\tBody struct {\n\t\t// \"From\" recipient\n\t\t// required: true\n\t\tFrom struct {\n\t\t\t// Optional name\n\t\t\t// example: John Doe\n\t\t\tName string\n\t\t\t// Email address\n\t\t\t// example: john@example.com\n\t\t\t// required: true\n\t\t\tEmail string\n\t\t}\n\n\t\t// \"To\" recipients\n\t\tTo []struct {\n\t\t\t// Optional name\n\t\t\t// example: Jane Doe\n\t\t\tName string\n\t\t\t// Email address\n\t\t\t// example: jane@example.com\n\t\t\t// required: true\n\t\t\tEmail string\n\t\t}\n\n\t\t// Cc recipients\n\t\tCc []struct {\n\t\t\t// Optional name\n\t\t\t// example: Manager\n\t\t\tName string\n\t\t\t// Email address\n\t\t\t// example: manager@example.com\n\t\t\t// required: true\n\t\t\tEmail string\n\t\t}\n\n\t\t// Bcc recipients email addresses only\n\t\t// example: [\"jack@example.com\"]\n\t\tBcc []string\n\n\t\t// Optional Reply-To recipients\n\t\tReplyTo []struct {\n\t\t\t// Optional name\n\t\t\t// example: Secretary\n\t\t\tName string\n\t\t\t// Email address\n\t\t\t// example: secretary@example.com\n\t\t\t// required: true\n\t\t\tEmail string\n\t\t}\n\n\t\t// Subject\n\t\t// example: Mailpit message via the HTTP API\n\t\tSubject string\n\n\t\t// Message body (text)\n\t\t// example: Mailpit is awesome!\n\t\tText string\n\n\t\t// Message body (HTML)\n\t\t// example: <div style=\"text-align:center\"><p style=\"font-family: arial; font-size: 24px;\">Mailpit is <b>awesome</b>!</p><p><img src=\"cid:mailpit-logo\" /></p></div>\n\t\tHTML string\n\n\t\t// Attachments\n\t\tAttachments []struct {\n\t\t\t// Base64-encoded string of the file content\n\t\t\t// required: true\n\t\t\t// example: iVBORw0KGgoAAAANSUhEUgAAAEEAAAA8CAMAAAAOlSdoAAAACXBIWXMAAAHrAAAB6wGM2bZBAAAAS1BMVEVHcEwRfnUkZ2gAt4UsSF8At4UtSV4At4YsSV4At4YsSV8At4YsSV4At4YsSV4sSV4At4YsSV4At4YtSV4At4YsSV4At4YtSV8At4YsUWYNAAAAGHRSTlMAAwoXGiktRE5dbnd7kpOlr7zJ0d3h8PD8PCSRAAACWUlEQVR42pXT4ZaqIBSG4W9rhqQYocG+/ys9Y0Z0Br+x3j8zaxUPewFh65K+7yrIMeIY4MT3wPfEJCidKXEMnLaVkxDiELiMz4WEOAZSFghxBIypCOlKiAMgXfIqTnBgSm8CIQ6BImxEUxEckClVQiHGj4Ba4AQHikAIClwTE9KtIghAhUJwoLkmLnCiAHJLRKgIMsEtVUKbBUIwoAg2C4QgQBE6l4VCnApBgSKYLLApCnCa0+96AEMW2BQcmC+Pr3nfp7o5Exy49gIADcIqUELGfeA+bp93LmAJp8QJoEcN3C7NY3sbVANixMyI0nku20/n5/ZRf3KI2k6JEDWQtxcbdGuAqu3TAXG+/799Oyyas1B1MnMiA+XyxHp9q0PUKGPiRAau1fZbLRZV09wZcT8/gHk8QQAxXn8VgaDqcUmU6O/r28nbVwXAqca2mRNtPAF5+zoP2MeN9Fy4NgC6RfcbgE7XITBRYTtOE3U3C2DVff7pk+PkUxgAbvtnPXJaD6DxulMLwOhPS/M3MQkgg1ZFrIXnmfaZoOfpKiFgzeZD/WuKqQEGrfJYkyWf6vlG3xUgTuscnkNkQsb599q124kdpMUjCa/XARHs1gZymVtGt3wLkiFv8rUgTxitYCex5EVGec0Y9VmoDTFBSQte2TfXGXlf7hbdaUM9Sk7fisEN9qfBBTK+FZcvM9fQSdkl2vj4W2oX/bRogO3XasiNH7R0eW7fgRM834ImTg+Lg6BEnx4vz81rhr+MYPBBQg1v8GndEOrthxaCTxNAOut8WKLGZQl+MPz88Q9tAO/hVuSeqQAAAABJRU5ErkJggg==\n\t\t\tContent string\n\t\t\t// Filename\n\t\t\t// required: true\n\t\t\t// example: mailpit.png\n\t\t\tFilename string\n\t\t\t// Optional Content Type for the the attachment.\n\t\t\t// If this field is not set (or empty) then the content type is automatically detected.\n\t\t\t// required: false\n\t\t\t// example: image/png\n\t\t\tContentType string\n\t\t\t// Optional Content-ID (`cid`) for attachment.\n\t\t\t// If this field is set then the file is attached inline.\n\t\t\t// required: false\n\t\t\t// example: mailpit-logo\n\t\t\tContentID string\n\t\t}\n\n\t\t// Mailpit tags\n\t\t// example: [\"Tag 1\",\"Tag 2\"]\n\t\tTags []string\n\n\t\t// Optional headers in {\"key\":\"value\"} format\n\t\t// example: {\"X-IP\":\"1.2.3.4\"}\n\t\tHeaders map[string]string\n\t}\n}\n\n// swagger:parameters SetTagsParams\ntype setTagsParams struct {\n\t// in: body\n\tBody struct {\n\t\t// Array of tag names to set\n\t\t//\n\t\t// required: true\n\t\t// example: [\"Tag 1\", \"Tag 2\"]\n\t\tTags []string\n\n\t\t// Array of message database IDs\n\t\t//\n\t\t// required: true\n\t\t// example: [\"4oRBnPtCXgAqZniRhzLNmS\", \"hXayS6wnCgNnt6aFTvmOF6\"]\n\t\tIDs []string\n\t}\n}\n\n// swagger:parameters RenameTagParams\ntype renameTagParams struct {\n\t// The url-encoded tag name to rename\n\t//\n\t// in: path\n\t// required: true\n\t// type: string\n\tTag string\n\n\t// in: body\n\tBody struct {\n\t\t// New name\n\t\t//\n\t\t// required: true\n\t\t// example: New name\n\t\tName string\n\t}\n}\n\n// swagger:parameters DeleteTagParams\ntype deleteTagParams struct {\n\t// The url-encoded tag name to delete\n\t//\n\t// in: path\n\t// required: true\n\tTag string\n}\n\n// swagger:parameters GetMessageHTMLParams\ntype getMessageHTMLParams struct {\n\t// Message database ID or \"latest\"\n\t//\n\t// in: path\n\t// required: true\n\tID string\n\n\t// If this is route is to be embedded in an iframe, set embed to `1` in the URL to add `target=\"_blank\"` and `rel=\"noreferrer noopener\"` to all links.\n\t//\n\t// In addition, a small script will be added to the end of the document to post (postMessage()) the height of the document back to the parent window for optional iframe height resizing.\n\t//\n\t// Note that this will also *transform* the message into a full HTML document (if it isn't already), so this option is useful for viewing but not programmatic testing.\n\t//\n\t// in: query\n\t// required: false\n\t// type: string\n\tEmbed string `json:\"embed\"`\n}\n\n// swagger:parameters GetMessageTextParams\ntype getMessageTextParams struct {\n\t// Message database ID or \"latest\"\n\t//\n\t// in: path\n\t// required: true\n\tID string\n}\n\n// swagger:parameters SpamAssassinCheckParams\ntype spamAssassinCheckParams struct {\n\t// Message database ID or \"latest\"\n\t//\n\t// in: path\n\t// required: true\n\tID string\n}\n\n// swagger:parameters ThumbnailParams\ntype thumbnailParams struct {\n\t// Message database ID or \"latest\"\n\t//\n\t// in: path\n\t// required: true\n\tID string\n\n\t// Attachment part ID\n\t//\n\t// in: path\n\t// required: true\n\tPartID string\n}\n"
  },
  {
    "path": "server/apiv1/swaggerResponses.go",
    "content": "// Package apiv1 provides the API v1 endpoints for Mailpit.\n//\n// These structs are for the purpose of defining swagger HTTP responses in go-swagger\n// in order to generate a spec file. They are lowercased to avoid exporting them as public types.\n//\n//nolint:unused\npackage apiv1\n\nimport (\n\t\"github.com/axllent/mailpit/internal/smtpd/chaos\"\n\t\"github.com/axllent/mailpit/internal/stats\"\n)\n\n// Binary data response which inherits the attachment's content type.\n// swagger:response BinaryResponse\ntype binaryResponse string\n\n// Plain text response\n// swagger:response TextResponse\ntype textResponse string\n\n// HTML response\n// swagger:response HTMLResponse\ntype htmlResponse string\n\n// Server error will return with a 400 status code\n// with the error message in the body\n// swagger:response ErrorResponse\ntype errorResponse string\n\n// Not found error will return a 404 status code\n// swagger:response NotFoundResponse\ntype notFoundResponse string\n\n// Plain text \"ok\" response\n// swagger:response OKResponse\ntype okResponse string\n\n// Plain JSON array response\n// swagger:response ArrayResponse\ntype arrayResponse []string\n\n// JSON error response\n// swagger:response JSONErrorResponse\ntype jsonErrorResponse struct {\n\t// A JSON-encoded error response\n\t//\n\t// in: body\n\tBody struct {\n\t\t// Error message\n\t\t// example: invalid format\n\t\tError string\n\t}\n}\n\n// Web UI configuration response\n// swagger:response WebUIConfigurationResponse\ntype webUIConfigurationResponse struct {\n\t// Web UI configuration settings\n\t//\n\t// in: body\n\tBody struct {\n\t\t// Optional label to identify this Mailpit instance\n\t\tLabel string\n\t\t// Message Relay information\n\t\tMessageRelay struct {\n\t\t\t// Whether message relaying (release) is enabled\n\t\t\tEnabled bool\n\t\t\t// The configured SMTP server address\n\t\t\tSMTPServer string\n\t\t\t// Enforced Return-Path (if set) for relay bounces\n\t\t\tReturnPath string\n\t\t\t// Only allow relaying to these recipients (regex)\n\t\t\tAllowedRecipients string\n\t\t\t// Block relaying to these recipients (regex)\n\t\t\tBlockedRecipients string\n\t\t\t// Overrides the \"From\" address for all relayed messages\n\t\t\tOverrideFrom string\n\t\t\t// Preserve the original Message-IDs when relaying messages\n\t\t\tPreserveMessageIDs bool\n\n\t\t\t// DEPRECATED 2024/03/12\n\t\t\t// swagger:ignore\n\t\t\tRecipientAllowlist string\n\t\t}\n\n\t\t// Whether SpamAssassin is enabled\n\t\tSpamAssassin bool\n\n\t\t// Whether Chaos support is enabled at runtime\n\t\tChaosEnabled bool\n\n\t\t// Whether messages with duplicate IDs are ignored\n\t\tDuplicatesIgnored bool\n\n\t\t// Whether the delete button should be hidden\n\t\tHideDeleteAllButton bool\n\t}\n}\n\n// Application information\n// swagger:response AppInfoResponse\ntype appInfoResponse struct {\n\t// Application information\n\t//\n\t// in: body\n\tBody stats.AppInformation\n}\n\n// Response for the Chaos triggers configuration\n// swagger:response ChaosResponse\ntype chaosResponse struct {\n\t// The current Chaos triggers\n\t//\n\t// in: body\n\tBody chaos.Triggers\n}\n\n// Message headers\n// swagger:model MessageHeadersResponse\ntype messageHeadersResponse map[string][]string\n\n// Summary of messages\n// swagger:response MessagesSummaryResponse\ntype messagesSummaryResponse struct {\n\t// The messages summary\n\t// in: body\n\tBody MessagesSummary\n}\n\n// Confirmation message for HTTP send API\n// swagger:response SendMessageResponse\ntype sendMessageResponse struct {\n\t// Response for sending messages via the HTTP API\n\t//\n\t// in: body\n\tBody struct {\n\t\t// Database ID\n\t\t// example: iAfZVVe2UQfNSG5BAjgYwa\n\t\tID string\n\t}\n}\n"
  },
  {
    "path": "server/apiv1/tags.go",
    "content": "package apiv1\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\n\t\"github.com/axllent/mailpit/internal/storage\"\n\t\"github.com/axllent/mailpit/server/websockets\"\n\t\"github.com/gorilla/mux\"\n)\n\n// GetAllTags (method: GET) will get all tags currently in use\nfunc GetAllTags(w http.ResponseWriter, _ *http.Request) {\n\t// swagger:route GET /api/v1/tags tags GetAllTags\n\t//\n\t// # Get all current tags\n\t//\n\t// Returns a JSON array of all unique message tags.\n\t//\n\t//\tProduces:\n\t//\t  - application/json\n\t//\n\t//\tSchemes: http, https\n\t//\n\t//\tResponses:\n\t//\t  200: ArrayResponse\n\t//    400: ErrorResponse\n\n\tw.Header().Add(\"Content-Type\", \"application/json\")\n\tif err := json.NewEncoder(w).Encode(storage.GetAllTags()); err != nil {\n\t\thttpError(w, err.Error())\n\t}\n}\n\n// SetMessageTags (method: PUT) will set the tags for all provided IDs\nfunc SetMessageTags(w http.ResponseWriter, r *http.Request) {\n\t// swagger:route PUT /api/v1/tags tags SetTagsParams\n\t//\n\t// # Set message tags\n\t//\n\t// This will overwrite any existing tags for selected message database IDs. To remove all tags from a message, pass an empty tags array.\n\t//\n\t//\tConsumes:\n\t//\t  - application/json\n\t//\n\t//\tProduces:\n\t//\t  - text/plain\n\t//\n\t//\tSchemes: http, https\n\t//\n\t//\tResponses:\n\t//\t  200: OKResponse\n\t//    400: ErrorResponse\n\n\tdecoder := json.NewDecoder(r.Body)\n\n\tvar data struct {\n\t\tTags []string\n\t\tIDs  []string\n\t}\n\n\terr := decoder.Decode(&data)\n\tif err != nil {\n\t\thttpError(w, err.Error())\n\t\treturn\n\t}\n\n\tids := data.IDs\n\n\tif len(ids) > 0 {\n\t\tfor _, id := range ids {\n\t\t\tif _, err := storage.SetMessageTags(id, data.Tags); err != nil {\n\t\t\t\thttpError(w, err.Error())\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n\n\tw.Header().Add(\"Content-Type\", \"text/plain\")\n\t_, _ = w.Write([]byte(\"ok\"))\n}\n\n// RenameTag (method: PUT) used to rename a tag\nfunc RenameTag(w http.ResponseWriter, r *http.Request) {\n\t// swagger:route PUT /api/v1/tags/{Tag} tags RenameTagParams\n\t//\n\t// # Rename a tag\n\t//\n\t// Renames an existing tag.\n\t//\n\t//\tProduces:\n\t//\t  - text/plain\n\t//\n\t//\tSchemes: http, https\n\t//\n\t//\tResponses:\n\t//\t  200: OKResponse\n\t//    400: ErrorResponse\n\n\tvars := mux.Vars(r)\n\n\ttag := vars[\"tag\"]\n\n\tdecoder := json.NewDecoder(r.Body)\n\n\tvar data struct {\n\t\tName string\n\t}\n\n\terr := decoder.Decode(&data)\n\tif err != nil {\n\t\thttpError(w, err.Error())\n\t\treturn\n\t}\n\n\tif err := storage.RenameTag(tag, data.Name); err != nil {\n\t\thttpError(w, err.Error())\n\t\treturn\n\t}\n\n\twebsockets.Broadcast(\"prune\", nil)\n\n\tw.Header().Add(\"Content-Type\", \"text/plain\")\n\t_, _ = w.Write([]byte(\"ok\"))\n}\n\n// DeleteTag (method: DELETE) used to delete a tag\nfunc DeleteTag(w http.ResponseWriter, r *http.Request) {\n\t// swagger:route DELETE /api/v1/tags/{Tag} tags DeleteTagParams\n\t//\n\t// # Delete a tag\n\t//\n\t// Deletes a tag. This will not delete any messages with the tag, but will remove the tag from any messages containing the tag.\n\t//\n\t//\tProduces:\n\t//\t  - text/plain\n\t//\n\t//\tSchemes: http, https\n\t//\n\t//\tResponses:\n\t//\t  200: OKResponse\n\t//    400: ErrorResponse\n\n\tvars := mux.Vars(r)\n\n\ttag := vars[\"tag\"]\n\n\tif err := storage.DeleteTag(tag); err != nil {\n\t\thttpError(w, err.Error())\n\t\treturn\n\t}\n\n\twebsockets.Broadcast(\"prune\", nil)\n\n\tw.Header().Add(\"Content-Type\", \"text/plain\")\n\t_, _ = w.Write([]byte(\"ok\"))\n}\n"
  },
  {
    "path": "server/apiv1/testing.go",
    "content": "package apiv1\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/axllent/mailpit/internal/storage\"\n\t\"github.com/axllent/mailpit/internal/tools\"\n\t\"github.com/gorilla/mux\"\n\t\"golang.org/x/net/html\"\n\t\"golang.org/x/net/html/atom\"\n)\n\n// GetMessageHTML (method: GET) returns a rendered version of a message's HTML part\nfunc GetMessageHTML(w http.ResponseWriter, r *http.Request) {\n\t// swagger:route GET /view/{ID}.html testing GetMessageHTMLParams\n\t//\n\t// # Render message HTML part\n\t//\n\t// Renders just the message's HTML part which can be used for UI integration testing.\n\t// Attached inline images are modified to link to the API provided they exist.\n\t// Note that is the message does not contain a HTML part then an 404 error is returned.\n\t//\n\t// The ID can be set to `latest` to return the latest message.\n\t//\n\t//\tProduces:\n\t//\t- text/html\n\t//\n\t//\tSchemes: http, https\n\t//\n\t//\tResponses:\n\t//\t\t200: HTMLResponse\n\t//\t\t400: ErrorResponse\n\t//      404: NotFoundResponse\n\n\tvars := mux.Vars(r)\n\n\tid := vars[\"id\"]\n\n\tif id == \"latest\" {\n\t\tvar err error\n\t\tid, err = storage.LatestID(r)\n\t\tif err != nil {\n\t\t\tw.WriteHeader(404)\n\t\t\t_, _ = fmt.Fprint(w, err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tmsg, err := storage.GetMessage(id)\n\tif err != nil {\n\t\tw.WriteHeader(404)\n\t\t_, _ = fmt.Fprint(w, \"Message not found\")\n\t\treturn\n\t}\n\tif msg.HTML == \"\" {\n\t\tw.WriteHeader(404)\n\t\t_, _ = fmt.Fprint(w, \"This message does not contain a HTML part\")\n\t\treturn\n\t}\n\n\thtmlStr := linkInlineImages(msg)\n\n\t// If embed=1 is set, then we will add target=\"_blank\" and rel=\"noreferrer noopener\" to all links\n\tif r.URL.Query().Get(\"embed\") == \"1\" {\n\t\tdoc, err := html.Parse(strings.NewReader(htmlStr))\n\t\tif err != nil {\n\t\t\tlogger.Log().Error(err.Error())\n\t\t} else {\n\t\t\t// Walk the entire HTML tree.\n\t\t\ttools.WalkHTML(doc, func(n *html.Node) {\n\t\t\t\tif n.Type == html.ElementNode && n.DataAtom == atom.A {\n\t\t\t\t\t// Set attributes on all anchors with external links.\n\t\t\t\t\ttools.SetHTMLAttributeVal(n, \"target\", \"_blank\")\n\t\t\t\t\ttools.SetHTMLAttributeVal(n, \"rel\", \"noreferrer noopener\")\n\t\t\t\t}\n\t\t\t})\n\n\t\t\tb := bytes.Buffer{}\n\t\t\t_ = html.Render(&b, doc)\n\t\t\thtmlStr = b.String()\n\n\t\t\tnonce := r.Header.Get(\"mp-nonce\")\n\n\t\t\tjs := `<script nonce=\"` + nonce + `\">\n\t\t\t\tif (typeof window.parent == \"object\") {\n\t\t\t\t\twindow.addEventListener('load', function () {\n\t\t\t\t\t\twindow.parent.postMessage({ messageHeight: document.body.scrollHeight}, \"*\")\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t</script>`\n\n\t\t\thtmlStr = strings.ReplaceAll(htmlStr, \"</body>\", js+\"</body>\")\n\t\t}\n\t}\n\n\tw.Header().Add(\"Content-Type\", \"text/html; charset=utf-8\")\n\t_, _ = w.Write([]byte(htmlStr))\n}\n\n// GetMessageText (method: GET) returns a message's text part\nfunc GetMessageText(w http.ResponseWriter, r *http.Request) {\n\t// swagger:route GET /view/{ID}.txt testing GetMessageTextParams\n\t//\n\t// # Render message text part\n\t//\n\t// Renders just the message's text part which can be used for UI integration testing.\n\t//\n\t// The ID can be set to `latest` to return the latest message.\n\t//\n\t//\tProduces:\n\t//\t- text/plain\n\t//\n\t//\tSchemes: http, https\n\t//\n\t//\tResponses:\n\t//\t\t200: TextResponse\n\t//\t\t400: ErrorResponse\n\t//      404: NotFoundResponse\n\n\tvars := mux.Vars(r)\n\n\tid := vars[\"id\"]\n\n\tif id == \"latest\" {\n\t\tvar err error\n\t\tid, err = storage.LatestID(r)\n\t\tif err != nil {\n\t\t\tw.WriteHeader(404)\n\t\t\t_, _ = fmt.Fprint(w, err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tmsg, err := storage.GetMessage(id)\n\tif err != nil {\n\t\tw.WriteHeader(404)\n\t\t_, _ = fmt.Fprint(w, \"Message not found\")\n\t\treturn\n\t}\n\n\tw.Header().Add(\"Content-Type\", \"text/plain; charset=utf-8\")\n\t_, _ = w.Write([]byte(msg.Text))\n}\n\n// This will rewrite all inline image paths to API URLs\nfunc linkInlineImages(msg *storage.Message) string {\n\thtml := msg.HTML\n\n\tfor _, a := range msg.Inline {\n\t\tif a.ContentID != \"\" {\n\t\t\tre := regexp.MustCompile(`(?i)(=[\"\\']?)(cid:` + regexp.QuoteMeta(a.ContentID) + `)([\"|\\'|\\\\s|\\\\/|>|;])`)\n\t\t\tu := config.Webroot + \"api/v1/message/\" + msg.ID + \"/part/\" + a.PartID\n\t\t\tmatches := re.FindAllStringSubmatch(html, -1)\n\t\t\tfor _, m := range matches {\n\t\t\t\thtml = strings.ReplaceAll(html, m[0], m[1]+u+m[3])\n\t\t\t}\n\t\t}\n\t}\n\n\tfor _, a := range msg.Attachments {\n\t\tif a.ContentID != \"\" {\n\t\t\tre := regexp.MustCompile(`(?i)(=[\"\\']?)(cid:` + regexp.QuoteMeta(a.ContentID) + `)([\"|\\'|\\\\s|\\\\/|>|;])`)\n\t\t\tu := config.Webroot + \"api/v1/message/\" + msg.ID + \"/part/\" + a.PartID\n\t\t\tmatches := re.FindAllStringSubmatch(html, -1)\n\t\t\tfor _, m := range matches {\n\t\t\t\thtml = strings.ReplaceAll(html, m[0], m[1]+u+m[3])\n\t\t\t}\n\t\t}\n\t}\n\n\treturn html\n}\n"
  },
  {
    "path": "server/apiv1/thumbnails.go",
    "content": "package apiv1\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"image\"\n\t\"image/color\"\n\t\"image/draw\"\n\t\"image/jpeg\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/axllent/mailpit/internal/storage\"\n\t\"github.com/gorilla/mux\"\n\t\"github.com/jhillyerd/enmime/v2\"\n\t\"github.com/kovidgoyal/imaging\"\n)\n\nvar (\n\tthumbWidth  = 180\n\tthumbHeight = 120\n)\n\n// Thumbnail returns a thumbnail image for an attachment (images only)\nfunc Thumbnail(w http.ResponseWriter, r *http.Request) {\n\t// swagger:route GET /api/v1/message/{ID}/part/{PartID}/thumb message ThumbnailParams\n\t//\n\t// # Get an attachment image thumbnail\n\t//\n\t// This will return a cropped 180x120 JPEG thumbnail of an image attachment.\n\t// If the image is smaller than 180x120 then the image is padded. If the attachment is not an image then a blank image is returned.\n\t//\n\t// The ID can be set to `latest` to return the latest message.\n\t//\n\t//\tProduces:\n\t//\t  - image/jpeg\n\t//\n\t//\tSchemes: http, https\n\t//\n\t//\tResponses:\n\t//\t  200: BinaryResponse\n\t//    400: ErrorResponse\n\n\tvars := mux.Vars(r)\n\n\tid := vars[\"id\"]\n\tpartID := vars[\"partID\"]\n\n\ta, err := storage.GetAttachmentPart(id, partID)\n\tif err != nil {\n\t\thttpError(w, err.Error())\n\t\treturn\n\t}\n\n\tfileName := a.FileName\n\tif fileName == \"\" {\n\t\tfileName = a.ContentID\n\t}\n\n\tif !strings.HasPrefix(a.ContentType, \"image/\") {\n\t\tblankImage(a, w)\n\t\treturn\n\t}\n\n\tbuf := bytes.NewBuffer(a.Content)\n\n\timg, err := imaging.Decode(buf, imaging.AutoOrientation(true))\n\tif err != nil {\n\t\t// it's not an image, return default\n\t\tlogger.Log().Warnf(\"[image] %s\", err.Error())\n\t\tblankImage(a, w)\n\t\treturn\n\t}\n\n\tvar b bytes.Buffer\n\tfoo := bufio.NewWriter(&b)\n\n\tvar temp image.Image\n\tif img.Bounds().Dx() < thumbWidth || img.Bounds().Dy() < thumbHeight {\n\t\ttemp = imaging.Fit(img, thumbWidth, thumbHeight, imaging.Lanczos)\n\t} else {\n\t\ttemp = imaging.Fill(img, thumbWidth, thumbHeight, imaging.Center, imaging.Lanczos)\n\t}\n\tdstImageFill := imaging.Clone(temp)\n\n\t// create white image and paste image over the top\n\t// preventing black backgrounds for transparent GIF/PNG images\n\tdst := imaging.New(thumbWidth, thumbHeight, color.White)\n\t// paste the original over the top\n\tdst = imaging.OverlayCenter(dst, dstImageFill, 1.0)\n\n\tif err := jpeg.Encode(foo, dst, &jpeg.Options{Quality: 70}); err != nil {\n\t\tlogger.Log().Warnf(\"[image] %s\", err.Error())\n\t\tblankImage(a, w)\n\t\treturn\n\t}\n\n\tw.Header().Add(\"Content-Type\", \"image/jpeg\")\n\tw.Header().Set(\"Content-Disposition\", \"filename=\\\"\"+fileName+\"\\\"\")\n\t_, _ = w.Write(b.Bytes())\n}\n\n// Return a blank image instead of an error when file or image not supported\nfunc blankImage(a *enmime.Part, w http.ResponseWriter) {\n\trect := image.Rect(0, 0, thumbWidth, thumbHeight)\n\timg := image.NewRGBA(rect)\n\tbackground := color.RGBA{255, 255, 255, 255}\n\tdraw.Draw(img, img.Bounds(), &image.Uniform{background}, image.Point{}, draw.Src)\n\tvar b bytes.Buffer\n\tfoo := bufio.NewWriter(&b)\n\tdstImageFill := imaging.Fill(img, thumbWidth, thumbHeight, imaging.Center, imaging.Lanczos)\n\n\tif err := jpeg.Encode(foo, dstImageFill, &jpeg.Options{Quality: 70}); err != nil {\n\t\tlogger.Log().Warnf(\"[image] %s\", err.Error())\n\t}\n\n\tfileName := a.FileName\n\tif fileName == \"\" {\n\t\tfileName = a.ContentID\n\t}\n\n\tw.Header().Add(\"Content-Type\", \"image/jpeg\")\n\tw.Header().Set(\"Content-Disposition\", \"filename=\\\"\"+fileName+\"\\\"\")\n\t_, _ = w.Write(b.Bytes())\n}\n"
  },
  {
    "path": "server/cors.go",
    "content": "package server\n\nimport (\n\t\"net/http\"\n\t\"net/url\"\n\t\"sort\"\n\t\"strings\"\n\n\t\"github.com/axllent/mailpit/internal/logger\"\n)\n\nvar (\n\t// AccessControlAllowOrigin CORS policy - set with flags/env\n\tAccessControlAllowOrigin string\n\n\t// CorsAllowOrigins are optional allowed origins by hostname, set via setCORSOrigins().\n\tcorsAllowOrigins = make(map[string]bool)\n)\n\n// equalASCIIFold reports whether s and t, interpreted as UTF-8 strings, are equal\n// under Unicode case folding, ignoring any difference in length.\nfunc asciiFoldString(s string) string {\n\tb := make([]byte, len(s))\n\tfor i := range s {\n\t\tb[i] = toLowerASCIIFold(s[i])\n\t}\n\treturn string(b)\n}\n\n// toLowerASCIIFold returns the Unicode case-folded equivalent of the ASCII character c.\n// It is equivalent to the Unicode 13.0.0 function foldCase(c, CaseFoldingMapping).\nfunc toLowerASCIIFold(c byte) byte {\n\tif 'A' <= c && c <= 'Z' {\n\t\treturn c + 'a' - 'A'\n\t}\n\treturn c\n}\n\n// CorsOriginAccessControl checks if the request origin is allowed based on the configured CORS origins.\nfunc corsOriginAccessControl(r *http.Request) bool {\n\torigin := r.Header[\"Origin\"]\n\n\tif len(origin) != 0 {\n\t\tu, err := url.Parse(origin[0])\n\t\tif err != nil {\n\t\t\tlogger.Log().Errorf(\"[cors] origin parse error: %v\", err)\n\t\t\treturn false\n\t\t}\n\n\t\t_, allAllowed := corsAllowOrigins[\"*\"]\n\t\t// allow same origin, or if \"*\" is defined as an origin\n\t\tif asciiFoldString(u.Host) == asciiFoldString(r.Host) || allAllowed {\n\t\t\treturn true\n\t\t}\n\n\t\t// match on full host:port so that example.com:8080 is not admitted\n\t\t// by an allowlist entry for example.com (standard port 80/443).\n\t\toriginHostFold := asciiFoldString(u.Host)\n\t\tif corsAllowOrigins[originHostFold] {\n\t\t\treturn true\n\t\t}\n\n\t\tlogger.Log().Warnf(\"[cors] blocking request from unauthorized origin: %s\", u.Host)\n\n\t\treturn false\n\t}\n\n\treturn true\n}\n\n// SetCORSOrigins sets the allowed CORS origins from a comma-separated string.\n// Origins are matched on the full host:port, so example.com and example.com:8080\n// are treated as distinct origins.\nfunc setCORSOrigins() {\n\tcorsAllowOrigins = make(map[string]bool)\n\n\thosts := extractOrigins(AccessControlAllowOrigin)\n\tfor _, host := range hosts {\n\t\tcorsAllowOrigins[asciiFoldString(host)] = true\n\t}\n\n\tif _, wildCard := corsAllowOrigins[\"*\"]; wildCard {\n\t\t// reset to just wildcard\n\t\tcorsAllowOrigins = make(map[string]bool)\n\t\tcorsAllowOrigins[\"*\"] = true\n\t\tlogger.Log().Info(\"[cors] all origins are allowed due to wildcard \\\"*\\\"\")\n\t} else {\n\t\tkeys := make([]string, 0)\n\t\tfor k := range corsAllowOrigins {\n\t\t\tkeys = append(keys, k)\n\t\t}\n\t\tsort.Strings(keys)\n\t\tlogger.Log().Infof(\"[cors] allowed API origins: %v\", strings.Join(keys, \", \"))\n\t}\n}\n\n// extractOrigins extracts and returns a sorted list of origins from a comma-separated string.\nfunc extractOrigins(str string) []string {\n\torigins := make([]string, 0)\n\ts := strings.TrimSpace(str)\n\tif s == \"\" {\n\t\treturn origins\n\t}\n\n\thosts := strings.FieldsFunc(s, func(r rune) bool {\n\t\treturn r == ',' || r == ' '\n\t})\n\n\tfor _, host := range hosts {\n\t\th := strings.TrimSpace(host)\n\t\tif h != \"\" {\n\t\t\tif h == \"*\" {\n\t\t\t\treturn []string{\"*\"}\n\t\t\t}\n\n\t\t\tif !strings.HasPrefix(h, \"http://\") && !strings.HasPrefix(h, \"https://\") {\n\t\t\t\th = \"http://\" + h\n\t\t\t}\n\n\t\t\tu, err := url.Parse(h)\n\t\t\tif err != nil || u.Hostname() == \"\" || strings.Contains(h, \"*\") {\n\t\t\t\tlogger.Log().Warnf(\"[cors] invalid CORS origin \\\"%s\\\", ignoring\", h)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// Store host:port so port differences are respected.\n\t\t\t// u.Host equals u.Hostname() when no port is present.\n\t\t\torigins = append(origins, u.Host)\n\t\t}\n\t}\n\n\tsort.Strings(origins)\n\n\treturn origins\n}\n"
  },
  {
    "path": "server/cors_test.go",
    "content": "package server\n\nimport (\n\t\"net/http\"\n\t\"testing\"\n)\n\nfunc TestExtractOrigins(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    string\n\t\texpected []string\n\t}{\n\t\t{\n\t\t\tname:     \"empty string\",\n\t\t\tinput:    \"\",\n\t\t\texpected: []string{},\n\t\t},\n\t\t{\n\t\t\tname:     \"single hostname\",\n\t\t\tinput:    \"example.com\",\n\t\t\texpected: []string{\"example.com\"},\n\t\t},\n\t\t{\n\t\t\tname:     \"multiple hostnames comma separated\",\n\t\t\tinput:    \"example.com,foo.com\",\n\t\t\texpected: []string{\"example.com\", \"foo.com\"},\n\t\t},\n\t\t{\n\t\t\tname:     \"multiple hostnames space separated\",\n\t\t\tinput:    \"example.com foo.com\",\n\t\t\texpected: []string{\"example.com\", \"foo.com\"},\n\t\t},\n\t\t{\n\t\t\tname:     \"wildcard\",\n\t\t\tinput:    \"*\",\n\t\t\texpected: []string{\"*\"},\n\t\t},\n\t\t{\n\t\t\tname:     \"mixed protocols\",\n\t\t\tinput:    \"http://example.com,https://foo.com:8080\",\n\t\t\texpected: []string{\"example.com\", \"foo.com:8080\"},\n\t\t},\n\t\t{\n\n\t\t\tname:     \"embedded wildcard\",\n\t\t\tinput:    \"http://example.com,*,https://test\",\n\t\t\texpected: []string{\"*\"},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := extractOrigins(tt.input)\n\n\t\t\tif len(got) != len(tt.expected) {\n\t\t\t\tt.Errorf(\"expected %d origins, got %d\", len(tt.expected), len(got))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tfor i := range got {\n\t\t\t\tif got[i] != tt.expected[i] {\n\t\t\t\t\tt.Errorf(\"expected origin %q, got %q\", tt.expected[i], got[i])\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCorsOriginAccessControl(t *testing.T) {\n\t// Setup allowed origins\n\tAccessControlAllowOrigin = \"example.com,foo.com,bar.com\"\n\tsetCORSOrigins()\n\n\ttests := []struct {\n\t\tname   string\n\t\torigin string\n\t\thost   string\n\t\tallow  bool\n\t}{\n\t\t{\"no origin header\", \"\", \"example.com\", true},\n\t\t// example.com:1234 must NOT be admitted by an allowlist entry for example.com (different port)\n\t\t{\"allowed origin\", \"http://example.com:1234\", \"mailpit.local\", false},\n\t\t{\"allowed origin\", \"http://example.com:1234\", \"example.com\", false},\n\t\t{\"allowed origin\", \"http://example.com:1234\", \"example.com:1234\", true},\n\t\t{\"not allowed origin\", \"http://notallowed.com\", \"mailpit.local\", false},\n\t\t{\"allowed by hostname\", \"http://foo.com\", \"mailpit.local\", true},\n\t\t{\"ascii fold: allowed origin uppercase\", \"HTTP://EXAMPLE.COM\", \"mailpit.local\", true},\n\t\t{\"ascii fold: allowed by hostname uppercase\", \"HTTP://FOO.COM\", \"mailpit.local\", true},\n\t\t{\"ascii fold: host uppercase\", \"http://example.com\", \"MAILPIT.LOCAL\", true},\n\t\t{\"ascii fold: not allowed origin uppercase\", \"HTTP://NOTALLOWED.COM\", \"mailpit.local\", false},\n\t\t{\"ascii fold: mixed case\", \"HtTp://ExAmPlE.CoM\", \"mailpit.local\", true},\n\t\t{\"non-ascii: allowed origin (unicode hostname)\", \"http://exámple.com\", \"mailpit.local\", false},\n\t\t{\"non-ascii: allowed by hostname (unicode)\", \"http://föö.com\", \"mailpit.local\", false},\n\t\t{\"non-ascii: host uppercase (unicode)\", \"http://exámple.com\", \"MAILPIT.LOCAL\", false},\n\t\t{\"non-ascii: mixed case (unicode)\", \"HtTp://ExÁmPlE.CoM\", \"mailpit.local\", false},\n\t}\n\n\t// Add wildcard test\n\tAccessControlAllowOrigin = \"*\"\n\tsetCORSOrigins()\n\treqWildcard := &http.Request{Header: http.Header{\"Origin\": {\"http://any.com\"}}, Host: \"mailpit.local\"}\n\tif !corsOriginAccessControl(reqWildcard) {\n\t\tt.Error(\"Wildcard origin should be allowed\")\n\t}\n\n\t// Reset to specific hosts\n\tAccessControlAllowOrigin = \"example.com,foo.com,bar.com\"\n\tsetCORSOrigins()\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\treq := &http.Request{Header: http.Header{}, Host: tt.host}\n\t\t\tif tt.origin != \"\" {\n\t\t\t\treq.Header.Set(\"Origin\", tt.origin)\n\t\t\t}\n\t\t\tallowed := corsOriginAccessControl(req)\n\t\t\tif allowed != tt.allow {\n\t\t\t\tt.Errorf(\"expected allowed=%v, got %v for origin=%q host=%q\", tt.allow, allowed, tt.origin, tt.host)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "server/embed.go",
    "content": "package server\n\nimport (\n\t\"embed\"\n\t\"net/http\"\n\t\"path\"\n\t\"strings\"\n\n\t\"github.com/axllent/mailpit/config\"\n)\n\nvar (\n\t//go:embed ui\n\tdistFS embed.FS\n)\n\n// EmbedController is a simple controller to return a file from the embedded filesystem.\n//\n// This controller is replaces Go's default http.FileServer which, as of Go v1.23, removes\n// the Content-Encoding header from error responses, breaking pages such as 404's while\n// using gzip compression middleware.\nfunc embedController(w http.ResponseWriter, r *http.Request) {\n\tp := r.URL.Path\n\n\tif strings.HasSuffix(p, \"/\") {\n\t\tp = p + \"index.html\"\n\t}\n\n\tp = strings.TrimPrefix(p, config.Webroot) // server webroot config\n\tp = path.Join(\"ui\", p)                    // add go:embed path to path prefix\n\n\tb, err := distFS.ReadFile(p)\n\tif err != nil {\n\t\thttp.Error(w, \"File not found\", http.StatusNotFound)\n\t\treturn\n\t}\n\n\t// ensure any HTML files have the correct nonce\n\tif strings.HasSuffix(p, \".html\") {\n\t\tnonce := r.Header.Get(\"mp-nonce\")\n\t\tb = []byte(strings.ReplaceAll(string(b), \"%%NONCE%%\", nonce))\n\t}\n\n\t// allow browser cache except for ?dev queries and HTML files\n\tif r.URL.RawQuery != \"dev\" && !strings.HasSuffix(p, \".html\") {\n\t\tw.Header().Set(\"Cache-Control\", \"max-age=31536000, public, immutable\")\n\t}\n\n\tw.Header().Set(\"Content-Type\", contentType(p))\n\t_, _ = w.Write(b)\n}\n\n// ContentType supports only a few content types, limited to this application's needs.\nfunc contentType(p string) string {\n\tswitch {\n\tcase strings.HasSuffix(p, \".html\"):\n\t\treturn \"text/html; charset=utf-8\"\n\tcase strings.HasSuffix(p, \".css\"):\n\t\treturn \"text/css; charset=utf-8\"\n\tcase strings.HasSuffix(p, \".js\"):\n\t\treturn \"application/javascript; charset=utf-8\"\n\tcase strings.HasSuffix(p, \".json\"):\n\t\treturn \"application/json\"\n\tcase strings.HasSuffix(p, \".svg\"):\n\t\treturn \"image/svg+xml\"\n\tcase strings.HasSuffix(p, \".ico\"):\n\t\treturn \"image/x-icon\"\n\tcase strings.HasSuffix(p, \".png\"):\n\t\treturn \"image/png\"\n\tcase strings.HasSuffix(p, \".jpg\"):\n\t\treturn \"image/jpeg\"\n\tcase strings.HasSuffix(p, \".gif\"):\n\t\treturn \"image/gif\"\n\tcase strings.HasSuffix(p, \".woff\"):\n\t\treturn \"font/woff\"\n\tcase strings.HasSuffix(p, \".woff2\"):\n\t\treturn \"font/woff2\"\n\tdefault:\n\t\treturn \"text/plain\"\n\t}\n}\n"
  },
  {
    "path": "server/handlers/k8healthz.go",
    "content": "package handlers\n\nimport \"net/http\"\n\n// HealthzHandler is a liveness probe\nfunc HealthzHandler(w http.ResponseWriter, _ *http.Request) {\n\tw.WriteHeader(http.StatusOK)\n}\n"
  },
  {
    "path": "server/handlers/k8sready.go",
    "content": "package handlers\n\nimport (\n\t\"net/http\"\n\t\"sync/atomic\"\n\n\t\"github.com/axllent/mailpit/internal/storage\"\n)\n\n// ReadyzHandler is a ready probe that signals k8s to be able to retrieve traffic\nfunc ReadyzHandler(isReady *atomic.Value) http.HandlerFunc {\n\treturn func(w http.ResponseWriter, _ *http.Request) {\n\t\tif isReady == nil || !isReady.Load().(bool) || storage.Ping() != nil {\n\t\t\thttp.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)\n\t\t\treturn\n\t\t}\n\t\tw.WriteHeader(http.StatusOK)\n\t}\n}\n"
  },
  {
    "path": "server/handlers/messages.go",
    "content": "package handlers\n\nimport (\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/internal/storage\"\n)\n\n// RedirectToLatestMessage (method: GET) redirects the web UI to the latest message\nfunc RedirectToLatestMessage(w http.ResponseWriter, r *http.Request) {\n\tvar messages []storage.MessageSummary\n\tvar err error\n\n\tsearch := strings.TrimSpace(r.URL.Query().Get(\"query\"))\n\tif search != \"\" {\n\t\tmessages, _, err = storage.Search(search, \"\", 0, 0, 1)\n\t\tif err != nil {\n\t\t\thttpError(w, err.Error())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tmessages, err = storage.List(0, 0, 1)\n\t\tif err != nil {\n\t\t\thttpError(w, err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\turi := config.Webroot\n\n\tif len(messages) == 1 {\n\t\turi, err = url.JoinPath(uri, \"/view/\"+messages[0].ID)\n\t\tif err != nil {\n\t\t\thttpError(w, err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\thttp.Redirect(w, r, uri, http.StatusFound)\n}\n"
  },
  {
    "path": "server/handlers/proxy.go",
    "content": "// Package handlers contains a specific handlers\npackage handlers\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"encoding/base64\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/axllent/mailpit/internal/storage\"\n\t\"github.com/axllent/mailpit/internal/tools\"\n)\n\nconst (\n\t// maxProxyBodySize is the maximum number of bytes read from a proxied\n\t// response body (fonts, images, CSS). Prevents OOM on oversized responses.\n\tmaxProxyBodySize = 50 * 1024 * 1024 // 50 MB\n)\n\nvar (\n\tlinkRe = regexp.MustCompile(`(?i)^https?:\\/\\/`)\n\n\turlRe = regexp.MustCompile(`(?mU)url\\(('|\")?(https?:\\/\\/[^)'\"]+)('|\")?\\)`)\n\n\tassetsMutex sync.Mutex\n\n\tassets = map[string]MessageAssets{}\n)\n\n// MessageAssets represents assets linked in a message\ntype MessageAssets struct {\n\tID string\n\t// Created timestamp so we can expire old entries\n\tCreated time.Time\n\t// Assets found in the message\n\tAssets []string\n}\n\nfunc init() {\n\t// Start a goroutine to clean up old asset entries every minute\n\tgo func() {\n\t\tfor {\n\t\t\ttime.Sleep(time.Minute)\n\t\t\tassetsMutex.Lock()\n\t\t\tnow := time.Now()\n\t\t\tfor id, entry := range assets {\n\t\t\t\tif now.Sub(entry.Created) > time.Minute {\n\t\t\t\t\tlogger.Log().Debugf(\"[proxy] cleaning up assets for message %s\", id)\n\t\t\t\t\tdelete(assets, id)\n\t\t\t\t}\n\t\t\t}\n\t\t\tassetsMutex.Unlock()\n\t\t}\n\t}()\n}\n\n// ProxyHandler is used to proxy assets for printing.\n// It accepts a base64-encoded message-id:url string as the `data` query parameter.\nfunc ProxyHandler(w http.ResponseWriter, r *http.Request) {\n\tencoded := strings.TrimSpace(r.URL.Query().Get(\"data\"))\n\tif encoded == \"\" {\n\t\tlogger.Log().Warn(\"[proxy] Data missing\")\n\t\thttpError(w, \"Error: Data missing\")\n\t\treturn\n\t}\n\n\tdecoded, err := base64.StdEncoding.DecodeString(encoded)\n\tif err != nil {\n\t\tlogger.Log().Warnf(\"[proxy] Data parameter corrupted: %s\", err.Error())\n\t\thttpError(w, \"Error: invalid request\")\n\t\treturn\n\t}\n\n\tparts := strings.SplitN(string(decoded), \":\", 2)\n\tif len(parts) != 2 {\n\t\tlogger.Log().Warnf(\"[proxy] Invalid data parameter: %s\", string(decoded))\n\t\thttpError(w, \"Error: invalid request\")\n\t\treturn\n\t}\n\n\tid := parts[0]\n\turi := parts[1]\n\n\tlinks, err := getAssets(id)\n\tif err != nil {\n\t\thttpError(w, \"Error: invalid request\")\n\t\treturn\n\t}\n\n\tif !tools.InArray(uri, links) {\n\t\tlogger.Log().Warnf(\"[proxy] URL %s not found in message %s\", uri, id)\n\t\thttpError(w, \"Error: invalid request\")\n\t\treturn\n\t}\n\n\tif !linkRe.MatchString(uri) || !tools.IsValidLinkURL(uri) {\n\t\tlogger.Log().Warnf(\"[proxy] invalid URL %s\", uri)\n\t\thttpError(w, \"Error: invalid URL\")\n\t\treturn\n\t}\n\n\tdialer := &net.Dialer{\n\t\tTimeout:   10 * time.Second,\n\t\tKeepAlive: 30 * time.Second,\n\t}\n\n\ttr := &http.Transport{\n\t\tDialContext: safeDialContext(dialer),\n\t}\n\n\tif config.AllowUntrustedTLS {\n\t\ttr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec\n\t}\n\n\tclient := &http.Client{\n\t\tTimeout:   10 * time.Second,\n\t\tTransport: tr,\n\t\tCheckRedirect: func(req *http.Request, via []*http.Request) error {\n\t\t\tif len(via) >= 3 {\n\t\t\t\treturn errors.New(\"too many redirects\")\n\t\t\t}\n\t\t\tif !tools.IsValidLinkURL(req.URL.String()) {\n\t\t\t\treturn fmt.Errorf(\"blocked redirect to invalid URL: %s\", req.URL)\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t}\n\n\treq, err := http.NewRequest(\"GET\", uri, nil)\n\tif err != nil {\n\t\tlogger.Log().Warnf(\"[proxy] %s\", err.Error())\n\t\thttpError(w, \"Error: invalid request\")\n\t\treturn\n\t}\n\n\t// use requesting useragent\n\treq.Header.Set(\"User-Agent\", r.UserAgent())\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\tlogger.Log().Warnf(\"[proxy] %s\", err.Error())\n\t\thttpError(w, \"Error: invalid request\")\n\t\treturn\n\t}\n\n\tdefer func() { _ = resp.Body.Close() }()\n\n\tif resp.StatusCode < 200 || resp.StatusCode >= 300 {\n\t\tlogger.Log().Warnf(\"[proxy] received status code %d for %s\", resp.StatusCode, uri)\n\t\thttpError(w, \"Error: invalid request\")\n\t\treturn\n\t}\n\n\tct := strings.ToLower(resp.Header.Get(\"content-type\"))\n\tif !supportedProxyContentType(ct) {\n\t\tlogger.Log().Warnf(\"[proxy] blocking unsupported content-type %s for %s\", ct, uri)\n\t\thttpError(w, \"Error: invalid request\")\n\t\treturn\n\t}\n\n\tlimitedBody := io.LimitReader(resp.Body, maxProxyBodySize+1)\n\tbody, err := io.ReadAll(limitedBody)\n\tif err != nil {\n\t\tlogger.Log().Warnf(\"[proxy] %s\", err.Error())\n\t\thttpError(w, \"Error: invalid request\")\n\t\treturn\n\t}\n\tif int64(len(body)) > maxProxyBodySize {\n\t\tlogger.Log().Warnf(\"[proxy] response body for %s exceeds %d bytes, blocking\", uri, maxProxyBodySize)\n\t\thttpError(w, \"Error: response too large\")\n\t\treturn\n\t}\n\n\t// relay common headers\n\tw.Header().Set(\"content-type\", ct)\n\tif resp.Header.Get(\"last-modified\") != \"\" {\n\t\tw.Header().Set(\"last-modified\", resp.Header.Get(\"last-modified\"))\n\t}\n\tif resp.Header.Get(\"content-disposition\") != \"\" {\n\t\tw.Header().Set(\"content-disposition\", resp.Header.Get(\"content-disposition\"))\n\t}\n\tif resp.Header.Get(\"cache-control\") != \"\" {\n\t\tw.Header().Set(\"cache-control\", resp.Header.Get(\"cache-control\"))\n\t}\n\n\t// replace CSS url() values with proxy address, eg: fonts & images\n\tif strings.HasPrefix(resp.Header.Get(\"content-type\"), \"text/css\") {\n\t\tvar re = regexp.MustCompile(`(?mi)(url\\((\\'|\\\")?([^\\)\\'\\\"]+)(\\'|\\\")?\\))`)\n\t\tbody = re.ReplaceAllFunc(body, func(s []byte) []byte {\n\t\t\tparts := re.FindStringSubmatch(string(s))\n\n\t\t\t// don't resolve inline `data:..`\n\t\t\tif strings.HasPrefix(parts[3], \"data:\") {\n\t\t\t\treturn []byte(parts[3])\n\t\t\t}\n\n\t\t\taddress, err := absoluteURL(parts[3], uri)\n\t\t\tif err != nil {\n\t\t\t\tlogger.Log().Errorf(\"[proxy] %s\", err.Error())\n\t\t\t\treturn []byte(parts[3])\n\t\t\t}\n\n\t\t\t// store asset address against message ID\n\t\t\tif result, ok := assets[id]; ok {\n\t\t\t\tif !tools.InArray(address, result.Assets) {\n\t\t\t\t\tassetsMutex.Lock()\n\t\t\t\t\tresult.Assets = append(result.Assets, address)\n\t\t\t\t\tassets[id] = result\n\t\t\t\t\tassetsMutex.Unlock()\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// encode with base64 to handle any special characters and group message ID with URL\n\t\t\tencoded := base64.StdEncoding.EncodeToString([]byte(id + \":\" + address))\n\n\t\t\treturn []byte(\"url(\" + parts[2] + config.Webroot + \"proxy?data=\" + encoded + parts[4] + \")\")\n\t\t})\n\t}\n\n\tlogger.Log().Debugf(\"[proxy] %s (%d)\", uri, resp.StatusCode)\n\n\t// relay status code - WriteHeader must come after Header.Set()\n\tw.WriteHeader(resp.StatusCode)\n\n\tif _, err := w.Write(body); err != nil {\n\t\tlogger.Log().Warnf(\"[proxy] %s\", err.Error())\n\t}\n}\n\n// GetAssets retrieves and parses the message to return linked assets.\n// Linked CSS files are appended to the assets list via the ProxyHandler when proxying CSS files.\nfunc getAssets(id string) ([]string, error) {\n\tassetsMutex.Lock()\n\tdefer assetsMutex.Unlock()\n\n\tresult, ok := assets[id]\n\tif ok {\n\t\t// return cached assets\n\t\treturn result.Assets, nil\n\t}\n\n\tmsg, err := storage.GetMessage(id)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tlinks := []string{}\n\n\treader := strings.NewReader(msg.HTML)\n\n\t// load the HTML document\n\tdoc, err := goquery.NewDocumentFromReader(reader)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// css & font links\n\tdoc.Find(\"link\").Each(func(_ int, s *goquery.Selection) {\n\t\tif href, exists := s.Attr(\"href\"); exists {\n\t\t\tif linkRe.MatchString(href) && !tools.InArray(href, links) {\n\t\t\t\tlinks = append(links, href)\n\t\t\t}\n\t\t}\n\t})\n\n\t// images\n\tdoc.Find(\"img\").Each(func(_ int, s *goquery.Selection) {\n\t\tif src, exists := s.Attr(\"src\"); exists {\n\t\t\tif linkRe.MatchString(src) && !tools.InArray(src, links) {\n\t\t\t\tlinks = append(links, src)\n\t\t\t}\n\t\t}\n\t})\n\n\t// background=\"<>\" links\n\tdoc.Find(\"[background]\").Each(func(_ int, s *goquery.Selection) {\n\t\tif bg, exists := s.Attr(\"background\"); exists {\n\t\t\tif linkRe.MatchString(bg) && !tools.InArray(bg, links) {\n\t\t\t\tlinks = append(links, bg)\n\t\t\t}\n\t\t}\n\t})\n\n\t// url(<>) links in style blocks\n\tmatches := urlRe.FindAllStringSubmatch(msg.HTML, -1)\n\tfor _, match := range matches {\n\t\tif len(match) >= 3 {\n\t\t\tlink := match[2]\n\t\t\tif linkRe.MatchString(link) && !tools.InArray(link, links) {\n\t\t\t\tlinks = append(links, link)\n\t\t\t}\n\t\t}\n\t}\n\n\tr := MessageAssets{}\n\tr.ID = id\n\tr.Created = time.Now()\n\tr.Assets = links\n\tassets[id] = r\n\n\treturn links, nil\n}\n\n// AbsoluteURL will return a full URL regardless whether it is relative or absolute.\n// This is used to replace relative CSS url(...) links when proxying.\nfunc absoluteURL(link, baseURL string) (string, error) {\n\t// scheme relative links, eg <script src=\"//example.com/script.js\">\n\tif len(link) > 1 && link[0:2] == \"//\" {\n\t\tbase, err := url.Parse(baseURL)\n\t\tif err != nil {\n\t\t\treturn link, err\n\t\t}\n\t\tlink = base.Scheme + \":\" + link\n\t}\n\n\tu, err := url.Parse(link)\n\tif err != nil {\n\t\treturn link, err\n\t}\n\n\t// remove hashes\n\tu.Fragment = \"\"\n\n\tbase, err := url.Parse(baseURL)\n\tif err != nil {\n\t\treturn link, err\n\t}\n\n\tresult := base.ResolveReference(u)\n\n\t// ensure link is HTTP(S)\n\tif result.Scheme != \"http\" && result.Scheme != \"https\" {\n\t\treturn link, fmt.Errorf(\"invalid URL: %s\", result.String())\n\t}\n\n\treturn result.String(), nil\n}\n\n// HTTPError returns a basic error message (400 response)\nfunc httpError(w http.ResponseWriter, msg string) {\n\tw.Header().Set(\"Referrer-Policy\", \"no-referrer\")\n\tw.Header().Set(\"Content-Security-Policy\", config.ContentSecurityPolicy)\n\tw.WriteHeader(http.StatusBadRequest)\n\tw.Header().Set(\"Content-Type\", \"text/plain\")\n\t_, _ = fmt.Fprint(w, msg)\n}\n\n// SupportedProxyContentType checks if the content-type is supported for proxying.\n// This is limited to fonts, images and css only.\nfunc supportedProxyContentType(ct string) bool {\n\tct = strings.ToLower(ct)\n\n\ttypes := []string{\n\t\t\"font/otf\",\n\t\t\"font/ttf\",\n\t\t\"font/woff\",\n\t\t\"font/woff2\",\n\t\t\"image/apng\",\n\t\t\"image/avif\",\n\t\t\"image/bmp\",\n\t\t\"image/gif\",\n\t\t\"image/jpeg\",\n\t\t\"image/jpg\",\n\t\t\"image/png\",\n\t\t\"image/tiff\",\n\t\t\"image/svg+xml\",\n\t\t\"image/webp\",\n\t\t\"text/css\",\n\t}\n\n\tfor _, t := range types {\n\t\tif strings.HasPrefix(ct, t) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\n// SafeDialContext is a custom dialer that checks if the resolved IP addresses are internal before allowing the connection.\nfunc safeDialContext(dialer *net.Dialer) func(ctx context.Context, network, address string) (net.Conn, error) {\n\treturn func(ctx context.Context, network, address string) (net.Conn, error) {\n\t\thost, port, err := net.SplitHostPort(address)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tips, err := net.DefaultResolver.LookupIPAddr(ctx, host)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif !config.AllowInternalHTTPRequests {\n\t\t\tfor _, ip := range ips {\n\t\t\t\tif tools.IsInternalIP(ip.IP) {\n\t\t\t\t\treturn nil, fmt.Errorf(\"blocked request to %s (%s): private/reserved address\", host, ip)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port))\n\t}\n}\n"
  },
  {
    "path": "server/server.go",
    "content": "// Package server is the HTTP daemon\npackage server\n\nimport (\n\t\"bytes\"\n\t\"compress/gzip\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"os\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"text/template\"\n\t\"time\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/internal/auth\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/axllent/mailpit/internal/pop3\"\n\t\"github.com/axllent/mailpit/internal/prometheus\"\n\t\"github.com/axllent/mailpit/internal/snakeoil\"\n\t\"github.com/axllent/mailpit/internal/stats\"\n\t\"github.com/axllent/mailpit/internal/storage\"\n\t\"github.com/axllent/mailpit/internal/tools\"\n\t\"github.com/axllent/mailpit/server/apiv1\"\n\t\"github.com/axllent/mailpit/server/handlers\"\n\t\"github.com/axllent/mailpit/server/websockets\"\n\t\"github.com/gorilla/mux\"\n\t\"github.com/lithammer/shortuuid/v4\"\n)\n\nvar (\n\t// htmlPreviewRouteRe is a regexp to match the HTML preview route\n\thtmlPreviewRouteRe *regexp.Regexp\n)\n\n// skipUIAuthKey is a private context key used to signal that UI basic-auth\n// should be bypassed for a specific request. This avoids mutating the global\n// auth.UICredentials pointer (which is a data race under concurrent load).\ntype contextKey int\n\nconst skipUIAuthKey contextKey = iota\n\n// Listen will start the httpd\nfunc Listen() {\n\tsetCORSOrigins()\n\n\tisReady := &atomic.Value{}\n\tisReady.Store(false)\n\tstats.Track()\n\n\twebsockets.MessageHub = websockets.NewHub()\n\n\t// set allowed websocket origins from configuration\n\t// websockets.SetAllowedOrigins(AccessControlAllowWSOrigins)\n\n\tgo websockets.MessageHub.Run()\n\n\tgo pop3.Run()\n\n\tr := apiRoutes()\n\n\t// kubernetes probes\n\tr.HandleFunc(config.Webroot+\"livez\", handlers.HealthzHandler)\n\tr.HandleFunc(config.Webroot+\"readyz\", handlers.ReadyzHandler(isReady))\n\n\t// proxy handler for screenshots\n\tr.HandleFunc(config.Webroot+\"proxy\", middleWareFunc(handlers.ProxyHandler)).Methods(\"GET\")\n\n\t// virtual filesystem for /dist/ & some individual files\n\tr.PathPrefix(config.Webroot + \"dist/\").Handler(middleWareFunc(embedController))\n\tr.PathPrefix(config.Webroot + \"api/\").Handler(middleWareFunc(embedController))\n\tr.Path(config.Webroot + \"favicon.ico\").Handler(middleWareFunc(embedController))\n\tr.Path(config.Webroot + \"favicon.svg\").Handler(middleWareFunc(embedController))\n\tr.Path(config.Webroot + \"mailpit.svg\").Handler(middleWareFunc(embedController))\n\tr.Path(config.Webroot + \"notification.png\").Handler(middleWareFunc(embedController))\n\n\t// redirect to webroot if no trailing slash\n\tif config.Webroot != \"/\" {\n\t\tredirect := strings.TrimRight(config.Webroot, \"/\")\n\t\tr.HandleFunc(redirect, middleWareFunc(addSlashToWebroot)).Methods(\"GET\")\n\t}\n\n\t// UI shortcut\n\tr.HandleFunc(config.Webroot+\"view/latest\", middleWareFunc(handlers.RedirectToLatestMessage)).Methods(\"GET\")\n\n\t// frontend testing\n\tr.HandleFunc(config.Webroot+\"view/{id}.html\", middleWareFunc(apiv1.GetMessageHTML)).Methods(\"GET\")\n\tr.HandleFunc(config.Webroot+\"view/{id}.txt\", middleWareFunc(apiv1.GetMessageText)).Methods(\"GET\")\n\n\t// web UI via virtual index.html\n\tr.PathPrefix(config.Webroot + \"view/\").Handler(middleWareFunc(index)).Methods(\"GET\")\n\tr.Path(config.Webroot + \"search\").Handler(middleWareFunc(index)).Methods(\"GET\")\n\tr.Path(config.Webroot).Handler(middleWareFunc(index)).Methods(\"GET\")\n\n\tif auth.UICredentials != nil {\n\t\tlogger.Log().Info(\"[http] enabling basic authentication\")\n\t}\n\n\t// Mark the application here as ready\n\tisReady.Store(true)\n\n\tserver := &http.Server{\n\t\tAddr:         config.HTTPListen,\n\t\tReadTimeout:  30 * time.Second,\n\t\tWriteTimeout: 30 * time.Second,\n\t\tHandler:      r,\n\t}\n\n\t// add temporary self-signed certificates to get deleted afterwards\n\tfor _, keyPair := range snakeoil.Certificates() {\n\t\tstorage.AddTempFile(keyPair.Public)\n\t\tstorage.AddTempFile(keyPair.Private)\n\t}\n\n\tif config.UITLSCert != \"\" && config.UITLSKey != \"\" {\n\t\tlogger.Log().Infof(\"[http] starting on %s (TLS)\", config.HTTPListen)\n\t\tlogger.Log().Infof(\"[http] accessible via https://%s%s\", logger.CleanHTTPIP(config.HTTPListen), config.Webroot)\n\t\tif err := server.ListenAndServeTLS(config.UITLSCert, config.UITLSKey); err != nil {\n\t\t\tstorage.Close()\n\t\t\tlogger.Log().Fatal(err)\n\t\t}\n\n\t} else {\n\t\tsocketAddr, perm, isSocket := tools.UnixSocket(config.HTTPListen)\n\n\t\tif isSocket {\n\t\t\tif err := tools.PrepareSocket(socketAddr); err != nil {\n\t\t\t\tstorage.Close()\n\t\t\t\tlogger.Log().Fatal(err)\n\t\t\t}\n\n\t\t\t// delete the Unix socket file on exit\n\t\t\tstorage.AddTempFile(socketAddr)\n\n\t\t\tln, err := net.Listen(\"unix\", socketAddr)\n\t\t\tif err != nil {\n\t\t\t\tstorage.Close()\n\t\t\t\tlogger.Log().Fatal(err)\n\t\t\t}\n\n\t\t\tif err := os.Chmod(socketAddr, perm); err != nil {\n\t\t\t\tstorage.Close()\n\t\t\t\tlogger.Log().Fatal(err)\n\t\t\t}\n\n\t\t\tlogger.Log().Infof(\"[http] starting on %s\", config.HTTPListen)\n\n\t\t\tif err := server.Serve(ln); err != nil {\n\t\t\t\tstorage.Close()\n\t\t\t\tlogger.Log().Fatal(err)\n\t\t\t}\n\n\t\t} else {\n\t\t\tlogger.Log().Infof(\"[http] starting on %s\", config.HTTPListen)\n\t\t\tlogger.Log().Infof(\"[http] accessible via http://%s%s\", logger.CleanHTTPIP(config.HTTPListen), config.Webroot)\n\t\t\tif err := server.ListenAndServe(); err != nil {\n\t\t\t\tstorage.Close()\n\t\t\t\tlogger.Log().Fatal(err)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc apiRoutes() *mux.Router {\n\tr := mux.NewRouter()\n\n\t// API V1\n\tr.HandleFunc(config.Webroot+\"api/v1/messages\", middleWareFunc(apiv1.GetMessages)).Methods(\"GET\")\n\tr.HandleFunc(config.Webroot+\"api/v1/messages\", middleWareFunc(apiv1.SetReadStatus)).Methods(\"PUT\")\n\tr.HandleFunc(config.Webroot+\"api/v1/messages\", middleWareFunc(apiv1.DeleteMessages)).Methods(\"DELETE\")\n\tr.HandleFunc(config.Webroot+\"api/v1/search\", middleWareFunc(apiv1.Search)).Methods(\"GET\")\n\tr.HandleFunc(config.Webroot+\"api/v1/search\", middleWareFunc(apiv1.DeleteSearch)).Methods(\"DELETE\")\n\tr.HandleFunc(config.Webroot+\"api/v1/send\", sendAPIAuthMiddleware(apiv1.SendMessageHandler)).Methods(\"POST\")\n\tr.HandleFunc(config.Webroot+\"api/v1/tags\", middleWareFunc(apiv1.GetAllTags)).Methods(\"GET\")\n\tr.HandleFunc(config.Webroot+\"api/v1/tags\", middleWareFunc(apiv1.SetMessageTags)).Methods(\"PUT\")\n\tr.HandleFunc(config.Webroot+\"api/v1/tags/{tag}\", middleWareFunc(apiv1.RenameTag)).Methods(\"PUT\")\n\tr.HandleFunc(config.Webroot+\"api/v1/tags/{tag}\", middleWareFunc(apiv1.DeleteTag)).Methods(\"DELETE\")\n\tr.HandleFunc(config.Webroot+\"api/v1/message/{id}/part/{partID}\", middleWareFunc(apiv1.DownloadAttachment)).Methods(\"GET\")\n\tr.HandleFunc(config.Webroot+\"api/v1/message/{id}/part/{partID}/thumb\", middleWareFunc(apiv1.Thumbnail)).Methods(\"GET\")\n\tr.HandleFunc(config.Webroot+\"api/v1/message/{id}/headers\", middleWareFunc(apiv1.GetHeaders)).Methods(\"GET\")\n\tr.HandleFunc(config.Webroot+\"api/v1/message/{id}/raw\", middleWareFunc(apiv1.DownloadRaw)).Methods(\"GET\")\n\tr.HandleFunc(config.Webroot+\"api/v1/message/{id}/release\", middleWareFunc(apiv1.ReleaseMessage)).Methods(\"POST\")\n\tr.HandleFunc(config.Webroot+\"api/v1/message/{id}/html-check\", middleWareFunc(apiv1.HTMLCheck)).Methods(\"GET\")\n\tr.HandleFunc(config.Webroot+\"api/v1/message/{id}/link-check\", middleWareFunc(apiv1.LinkCheck)).Methods(\"GET\")\n\tif config.EnableSpamAssassin != \"\" {\n\t\tr.HandleFunc(config.Webroot+\"api/v1/message/{id}/sa-check\", middleWareFunc(apiv1.SpamAssassinCheck)).Methods(\"GET\")\n\t}\n\tr.HandleFunc(config.Webroot+\"api/v1/message/{id}\", middleWareFunc(apiv1.GetMessage)).Methods(\"GET\")\n\tr.HandleFunc(config.Webroot+\"api/v1/info\", middleWareFunc(apiv1.AppInfo)).Methods(\"GET\")\n\tr.HandleFunc(config.Webroot+\"api/v1/webui\", middleWareFunc(apiv1.WebUIConfig)).Methods(\"GET\")\n\tr.HandleFunc(config.Webroot+\"api/v1/swagger.json\", middleWareFunc(swaggerBasePath)).Methods(\"GET\")\n\n\t// Chaos\n\tr.HandleFunc(config.Webroot+\"api/v1/chaos\", middleWareFunc(apiv1.GetChaos)).Methods(\"GET\")\n\tr.HandleFunc(config.Webroot+\"api/v1/chaos\", middleWareFunc(apiv1.SetChaos)).Methods(\"PUT\")\n\n\t// Prometheus metrics (if enabled and using existing server)\n\tif prometheus.GetMode() == \"integrated\" {\n\t\tr.HandleFunc(config.Webroot+\"metrics\", middleWareFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprometheus.GetHandler().ServeHTTP(w, r)\n\t\t})).Methods(\"GET\")\n\t}\n\n\t// web UI websocket\n\tr.HandleFunc(config.Webroot+\"api/events\", middleWareFunc(apiWebsocket)).Methods(\"GET\")\n\n\t// return blank 200 response for OPTIONS requests for CORS\n\tr.PathPrefix(config.Webroot + \"api/v1/\").Handler(middleWareFunc(apiv1.GetOptions)).Methods(\"OPTIONS\")\n\n\treturn r\n}\n\n// BasicAuthResponse returns an basic auth response to the browser\nfunc basicAuthResponse(w http.ResponseWriter) {\n\tw.Header().Set(\"WWW-Authenticate\", `Basic realm=\"Login\"`)\n\tw.WriteHeader(http.StatusUnauthorized)\n\t_, _ = w.Write([]byte(\"Unauthorized.\\n\"))\n}\n\n// sendAPIAuthMiddleware handles authentication specifically for the send API endpoint.\n// It can use dedicated send API authentication or accept any credentials based on configuration.\n// It communicates skip-UI-auth intent via request context rather than mutating the global\n// auth.UICredentials pointer, which would be a data race under concurrent load.\nfunc sendAPIAuthMiddleware(fn http.HandlerFunc) http.HandlerFunc {\n\treturn func(w http.ResponseWriter, r *http.Request) {\n\t\t// If send API auth accept any is enabled, bypass all authentication.\n\t\tif config.SendAPIAuthAcceptAny {\n\t\t\tctx := context.WithValue(r.Context(), skipUIAuthKey, true)\n\t\t\tmiddleWareFunc(fn)(w, r.WithContext(ctx))\n\t\t\treturn\n\t\t}\n\n\t\t// If Send API credentials are configured, only accept those credentials.\n\t\tif auth.SendAPICredentials != nil {\n\t\t\tuser, pass, ok := r.BasicAuth()\n\n\t\t\tif !ok {\n\t\t\t\tbasicAuthResponse(w)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif !auth.SendAPICredentials.Match(user, pass) {\n\t\t\t\tbasicAuthResponse(w)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Valid Send API credentials — bypass UI auth via context flag.\n\t\t\tctx := context.WithValue(r.Context(), skipUIAuthKey, true)\n\t\t\tmiddleWareFunc(fn)(w, r.WithContext(ctx))\n\t\t\treturn\n\t\t}\n\n\t\t// No Send API credentials configured — fall back to UI auth.\n\t\tmiddleWareFunc(fn)(w, r)\n\t}\n}\n\ntype gzipResponseWriter struct {\n\tio.Writer\n\thttp.ResponseWriter\n}\n\nfunc (w gzipResponseWriter) Write(b []byte) (int, error) {\n\treturn w.Writer.Write(b)\n}\n\n// MiddleWareFunc http middleware adds optional basic authentication\n// and gzip compression.\nfunc middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {\n\treturn func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Referrer-Policy\", \"no-referrer\")\n\n\t\t// generate a new random nonce on every request\n\t\trandomNonce := shortuuid.New()\n\t\t// header used to pass nonce through to function\n\t\tr.Header.Set(\"mp-nonce\", randomNonce)\n\n\t\t// Prevent JavaScript XSS by adding a nonce for script-src\n\t\tcspHeader := strings.Replace(\n\t\t\tconfig.ContentSecurityPolicy,\n\t\t\t\"script-src 'self';\",\n\t\t\tfmt.Sprintf(\"script-src 'nonce-%s';\", randomNonce),\n\t\t\t1,\n\t\t)\n\n\t\tw.Header().Set(\"Content-Security-Policy\", cspHeader)\n\n\t\tif htmlPreviewRouteRe == nil {\n\t\t\thtmlPreviewRouteRe = regexp.MustCompile(`^` + regexp.QuoteMeta(config.Webroot) + `view/[a-zA-Z0-9]+\\.html$`)\n\t\t}\n\n\t\tif strings.HasPrefix(r.RequestURI, config.Webroot+\"api/\") || htmlPreviewRouteRe.MatchString(r.RequestURI) {\n\t\t\tif allowed := corsOriginAccessControl(r); !allowed {\n\t\t\t\thttp.Error(w, \"Blocked to to CORS violation\", http.StatusForbidden)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tw.Header().Set(\"Access-Control-Allow-Origin\", r.Header.Get(\"Origin\"))\n\t\t\tw.Header().Set(\"Access-Control-Allow-Methods\", \"GET, POST, DELETE, PUT, OPTIONS\")\n\t\t\tw.Header().Set(\"Access-Control-Allow-Headers\", \"*\")\n\t\t}\n\n\t\t// Check basic authentication headers if configured.\n\t\t// OPTIONS requests are skipped if CORS is enabled, since browsers omit credentials for preflight checks.\n\t\t// skipUIAuthKey in the request context allows sendAPIAuthMiddleware to bypass UI auth\n\t\t// for a specific request without touching the global auth.UICredentials pointer.\n\t\tskipUIAuth, _ := r.Context().Value(skipUIAuthKey).(bool)\n\t\tisCORSOptionsRequest := AccessControlAllowOrigin != \"\" && r.Method == http.MethodOptions\n\t\tif !skipUIAuth && !isCORSOptionsRequest && auth.UICredentials != nil {\n\t\t\tuser, pass, ok := r.BasicAuth()\n\n\t\t\tif !ok {\n\t\t\t\tbasicAuthResponse(w)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif !auth.UICredentials.Match(user, pass) {\n\t\t\t\tbasicAuthResponse(w)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\t// WebSocket upgrade requests must not be wrapped in a gzip writer:\n\t\t// gzipResponseWriter does not implement http.Hijacker, which the\n\t\t// WebSocket library requires to take over the raw TCP connection.\n\t\tisWebSocketUpgrade := strings.EqualFold(r.Header.Get(\"Upgrade\"), \"websocket\")\n\t\tif isWebSocketUpgrade || config.DisableHTTPCompression || !strings.Contains(r.Header.Get(\"Accept-Encoding\"), \"gzip\") {\n\t\t\tfn(w, r)\n\t\t\treturn\n\t\t}\n\n\t\tw.Header().Set(\"Content-Encoding\", \"gzip\")\n\t\tgz := gzip.NewWriter(w)\n\t\tdefer func() { _ = gz.Close() }()\n\t\tgzr := gzipResponseWriter{Writer: gz, ResponseWriter: w}\n\t\tfn(gzr, r)\n\t}\n}\n\n// Redirect to webroot\nfunc addSlashToWebroot(w http.ResponseWriter, r *http.Request) {\n\thttp.Redirect(w, r, config.Webroot, http.StatusFound)\n}\n\n// Websocket to broadcast changes.\n// Authentication and CORS are handled by middleWareFunc before this is reached.\nfunc apiWebsocket(w http.ResponseWriter, r *http.Request) {\n\twebsockets.ServeWs(websockets.MessageHub, w, r)\n\tstorage.BroadcastMailboxStats()\n}\n\n// Wrapper to artificially inject a basePath to the swagger.json if a webroot has been specified\nfunc swaggerBasePath(w http.ResponseWriter, _ *http.Request) {\n\tf, err := distFS.ReadFile(\"ui/api/v1/swagger.json\")\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\tif config.Webroot != \"/\" {\n\t\t// artificially inject a path at the start\n\t\treplacement := fmt.Sprintf(\"{\\n  \\\"basePath\\\": \\\"%s\\\",\", strings.TrimRight(config.Webroot, \"/\"))\n\n\t\tf = bytes.Replace(f, []byte(\"{\"), []byte(replacement), 1)\n\t}\n\n\tw.Header().Add(\"Content-Type\", \"application/json\")\n\t_, _ = w.Write(f)\n}\n\n// Just returns the default HTML template\nfunc index(w http.ResponseWriter, r *http.Request) {\n\n\tvar h = `<!DOCTYPE html>\n<html lang=\"en\" class=\"h-100\">\n\n<head>\n\t<meta charset=\"utf-8\">\n\t<meta name=\"viewport\" content=\"width=device-width,initial-scale=1.0\">\n\t<meta name=\"referrer\" content=\"no-referrer\">\n\t<meta name=\"robots\" content=\"noindex, nofollow, noarchive\">\n\t<link rel=\"icon\" href=\"{{ .Webroot }}favicon.svg\">\n\t<title>Mailpit</title>\n\t<link rel=stylesheet href=\"{{ .Webroot }}dist/app.css?{{ .Version }}\">\n</head>\n\n<body class=\"h-100\">\n\t<div class=\"container-fluid h-100 d-flex flex-column\" id=\"app\" data-webroot=\"{{ .Webroot }}\" data-version=\"{{ .Version }}\">\n\t\t<noscript class=\"alert alert-warning position-absolute top-50 start-50 translate-middle\">\n\t\t\tYou need a browser with JavaScript enabled to use Mailpit\n\t\t</noscript>\n\t</div>\n\n\t<script src=\"{{ .Webroot }}dist/app.js?{{ .Version }}\" nonce=\"{{ .Nonce }}\"></script>\n</body>\n\n</html>`\n\n\tt, err := template.New(\"index\").Parse(h)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\tdata := struct {\n\t\tWebroot string\n\t\tVersion string\n\t\tNonce   string\n\t}{\n\t\tWebroot: config.Webroot,\n\t\tVersion: config.Version,\n\t\tNonce:   r.Header.Get(\"mp-nonce\"),\n\t}\n\n\tbuff := new(bytes.Buffer)\n\n\terr = t.Execute(buff, data)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\tw.Header().Add(\"Content-Type\", \"text/html; charset=utf-8\")\n\t_, _ = w.Write(buff.Bytes())\n}\n"
  },
  {
    "path": "server/server_test.go",
    "content": "package server\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"net/url\"\n\t\"os\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/internal/auth\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/axllent/mailpit/internal/storage\"\n\t\"github.com/axllent/mailpit/server/apiv1\"\n\t\"github.com/jhillyerd/enmime/v2\"\n\t\"golang.org/x/crypto/bcrypt\"\n)\n\nvar (\n\tputDataStruct struct {\n\t\tRead bool\n\t\tIDs  []string\n\t}\n\n\t// Shared test message structure for consistency\n\ttestSendMessage = map[string]any{\n\t\t\"From\": map[string]string{\n\t\t\t\"Email\": \"test@example.com\",\n\t\t},\n\t\t\"To\": []map[string]string{\n\t\t\t{\"Email\": \"recipient@example.com\"},\n\t\t},\n\t\t\"Subject\": \"Test\",\n\t\t\"Text\":    \"Test message\",\n\t}\n)\n\nfunc TestAPIv1Messages(t *testing.T) {\n\tsetup()\n\tdefer storage.Close()\n\n\tr := apiRoutes()\n\n\tts := httptest.NewServer(r)\n\tdefer ts.Close()\n\n\tm, err := fetchMessages(ts.URL + \"/api/v1/messages\")\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t}\n\n\t// check count of empty database\n\tassertStatsEqual(t, ts.URL+\"/api/v1/messages\", 0, 0)\n\n\t// insert 100\n\tt.Log(\"Insert 100 messages\")\n\tinsertEmailData(t)\n\tassertStatsEqual(t, ts.URL+\"/api/v1/messages\", 100, 100)\n\n\tm, err = fetchMessages(ts.URL + \"/api/v1/messages\")\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t}\n\n\t// read first 10 messages\n\tt.Log(\"Read first 10 messages including raw & headers\")\n\tfor idx, msg := range m.Messages {\n\t\tif idx == 10 {\n\t\t\tbreak\n\t\t}\n\n\t\tif _, err := clientGet(ts.URL + \"/api/v1/message/\" + msg.ID); err != nil {\n\t\t\tt.Error(err.Error())\n\t\t}\n\n\t\t// get RAW\n\t\tif _, err := clientGet(ts.URL + \"/api/v1/message/\" + msg.ID + \"/raw\"); err != nil {\n\t\t\tt.Error(err.Error())\n\t\t}\n\n\t\t// get headers\n\t\tif _, err := clientGet(ts.URL + \"/api/v1/message/\" + msg.ID + \"/headers\"); err != nil {\n\t\t\tt.Error(err.Error())\n\t\t}\n\t}\n\n\t// 10 should be marked as read\n\tassertStatsEqual(t, ts.URL+\"/api/v1/messages\", 90, 100)\n\n\t// delete all\n\tt.Log(\"Delete all messages\")\n\t_, err = clientDelete(ts.URL+\"/api/v1/messages\", \"{}\")\n\tif err != nil {\n\t\tt.Errorf(\"Expected nil, received %s\", err.Error())\n\t}\n\tassertStatsEqual(t, ts.URL+\"/api/v1/messages\", 0, 0)\n}\n\nfunc TestAPIv1ToggleReadStatus(t *testing.T) {\n\tsetup()\n\tdefer storage.Close()\n\n\tr := apiRoutes()\n\n\tts := httptest.NewServer(r)\n\tdefer ts.Close()\n\n\tm, err := fetchMessages(ts.URL + \"/api/v1/messages\")\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t}\n\n\t// check count of empty database\n\tassertStatsEqual(t, ts.URL+\"/api/v1/messages\", 0, 0)\n\n\t// insert 100\n\tt.Log(\"Insert 100 messages\")\n\tinsertEmailData(t)\n\tassertStatsEqual(t, ts.URL+\"/api/v1/messages\", 100, 100)\n\n\tm, err = fetchMessages(ts.URL + \"/api/v1/messages\")\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t}\n\n\t// read first 10 IDs\n\tt.Log(\"Get first 10 IDs\")\n\tputIDs := []string{}\n\tfor idx, msg := range m.Messages {\n\t\tif idx == 10 {\n\t\t\tbreak\n\t\t}\n\n\t\t// store for later\n\t\tputIDs = append(putIDs, msg.ID)\n\t}\n\tassertStatsEqual(t, ts.URL+\"/api/v1/messages\", 100, 100)\n\n\t// mark first 10 as unread\n\tt.Log(\"Mark first 10 as read\")\n\tputData := putDataStruct\n\tputData.Read = true\n\tputData.IDs = putIDs\n\tj, err := json.Marshal(putData)\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t}\n\t_, err = clientPut(ts.URL+\"/api/v1/messages\", string(j))\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t}\n\tassertStatsEqual(t, ts.URL+\"/api/v1/messages\", 90, 100)\n\n\t// mark first 10 as read\n\tt.Log(\"Mark first 10 as unread\")\n\tputData.Read = false\n\tj, err = json.Marshal(putData)\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t}\n\t_, err = clientPut(ts.URL+\"/api/v1/messages\", string(j))\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t}\n\tassertStatsEqual(t, ts.URL+\"/api/v1/messages\", 100, 100)\n\n\t// mark all as read\n\tputData.Read = true\n\tputData.IDs = []string{}\n\tj, err = json.Marshal(putData)\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t}\n\n\tt.Log(\"Mark all read\")\n\t_, err = clientPut(ts.URL+\"/api/v1/messages\", string(j))\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t}\n\tassertStatsEqual(t, ts.URL+\"/api/v1/messages\", 0, 100)\n}\n\nfunc TestAPIv1Search(t *testing.T) {\n\tsetup()\n\tdefer storage.Close()\n\n\tr := apiRoutes()\n\n\tts := httptest.NewServer(r)\n\tdefer ts.Close()\n\n\t// insert 100\n\tt.Log(\"Insert 100 messages & tag\")\n\tinsertEmailData(t)\n\tassertStatsEqual(t, ts.URL+\"/api/v1/messages\", 100, 100)\n\n\t// search\n\tassertSearchEqual(t, ts.URL+\"/api/v1/search\", \"from-1@example.com\", 1)\n\tassertSearchEqual(t, ts.URL+\"/api/v1/search\", \"from:from-1@example.com\", 1)\n\tassertSearchEqual(t, ts.URL+\"/api/v1/search\", \"-from:from-1@example.com\", 99)\n\tassertSearchEqual(t, ts.URL+\"/api/v1/search\", \"-FROM:FROM-1@EXAMPLE.COM\", 99)\n\tassertSearchEqual(t, ts.URL+\"/api/v1/search\", \"to:from-1@example.com\", 0)\n\tassertSearchEqual(t, ts.URL+\"/api/v1/search\", \"from:@example.com\", 100)\n\tassertSearchEqual(t, ts.URL+\"/api/v1/search\", \"subject:\\\"Subject line\\\"\", 100)\n\tassertSearchEqual(t, ts.URL+\"/api/v1/search\", \"subject:\\\"SUBJECT LINE 17 END\\\"\", 1)\n\tassertSearchEqual(t, ts.URL+\"/api/v1/search\", \"!thisdoesnotexist\", 100)\n\tassertSearchEqual(t, ts.URL+\"/api/v1/search\", \"-ThisDoesNotExist\", 100)\n\tassertSearchEqual(t, ts.URL+\"/api/v1/search\", \"thisdoesnotexist\", 0)\n\tassertSearchEqual(t, ts.URL+\"/api/v1/search\", \"tag:\\\"Test tag 065\\\"\", 1)\n\tassertSearchEqual(t, ts.URL+\"/api/v1/search\", \"tag:\\\"TEST TAG 065\\\"\", 1)\n\tassertSearchEqual(t, ts.URL+\"/api/v1/search\", \"!tag:\\\"Test tag 023\\\"\", 99)\n}\n\nfunc TestAPIv1Send(t *testing.T) {\n\tsetup()\n\tdefer storage.Close()\n\n\tr := apiRoutes()\n\n\tts := httptest.NewServer(r)\n\tdefer ts.Close()\n\n\tjsonData := `{\n\t\t\"From\": {\n\t\t  \"Email\": \"john@example.com\",\n\t\t  \"Name\": \"John Doe\"\n\t\t},\n\t\t\"To\": [\n\t\t  {\n\t\t\t\"Email\": \"jane@example.com\",\n\t\t\t\"Name\": \"Jane Doe\"\n\t\t  }\n\t\t],\n\t\t\"Cc\": [\n\t\t  {\n\t\t\t\"Email\": \"manager1@example.com\",\n\t\t\t\"Name\": \"Manager 1\"\n\t\t  },\n\t\t  {\n\t\t\t\"Email\": \"manager2@example.com\",\n\t\t\t\"Name\": \"Manager 2\"\n\t\t  }\n\t\t],\n\t\t\"Bcc\": [\"jack@example.com\"],\n\t\t\"Headers\": {\n\t\t\t\"X-IP\": \"1.2.3.4\"\n\t\t},\n\t\t\"Subject\": \"Mailpit message via the HTTP API\",\n\t\t\"Text\": \"This is the text body\",\n\t\t\"HTML\": \"<p style=\\\"font-family: arial\\\">Mailpit is <b>awesome</b>!</p>\",\n\t\t\"Attachments\": [\n\t\t  {\n\t\t\t\"Content\": \"VGhpcyBpcyBhIHBsYWluIHRleHQgYXR0YWNobWVudA==\",\n\t\t\t\"Filename\": \"Attached File.txt\"\n\t\t  },\n\t\t  {\n\t\t\t\"Content\": \"iVBORw0KGgoAAAANSUhEUgAAAEEAAAA8CAMAAAAOlSdoAAAACXBIWXMAAAHrAAAB6wGM2bZBAAAAS1BMVEVHcEwRfnUkZ2gAt4UsSF8At4UtSV4At4YsSV4At4YsSV8At4YsSV4At4YsSV4sSV4At4YsSV4At4YtSV4At4YsSV4At4YtSV8At4YsUWYNAAAAGHRSTlMAAwoXGiktRE5dbnd7kpOlr7zJ0d3h8PD8PCSRAAACWUlEQVR42pXT4ZaqIBSG4W9rhqQYocG+/ys9Y0Z0Br+x3j8zaxUPewFh65K+7yrIMeIY4MT3wPfEJCidKXEMnLaVkxDiELiMz4WEOAZSFghxBIypCOlKiAMgXfIqTnBgSm8CIQ6BImxEUxEckClVQiHGj4Ba4AQHikAIClwTE9KtIghAhUJwoLkmLnCiAHJLRKgIMsEtVUKbBUIwoAg2C4QgQBE6l4VCnApBgSKYLLApCnCa0+96AEMW2BQcmC+Pr3nfp7o5Exy49gIADcIqUELGfeA+bp93LmAJp8QJoEcN3C7NY3sbVANixMyI0nku20/n5/ZRf3KI2k6JEDWQtxcbdGuAqu3TAXG+/799Oyyas1B1MnMiA+XyxHp9q0PUKGPiRAau1fZbLRZV09wZcT8/gHk8QQAxXn8VgaDqcUmU6O/r28nbVwXAqca2mRNtPAF5+zoP2MeN9Fy4NgC6RfcbgE7XITBRYTtOE3U3C2DVff7pk+PkUxgAbvtnPXJaD6DxulMLwOhPS/M3MQkgg1ZFrIXnmfaZoOfpKiFgzeZD/WuKqQEGrfJYkyWf6vlG3xUgTuscnkNkQsb599q124kdpMUjCa/XARHs1gZymVtGt3wLkiFv8rUgTxitYCex5EVGec0Y9VmoDTFBSQte2TfXGXlf7hbdaUM9Sk7fisEN9qfBBTK+FZcvM9fQSdkl2vj4W2oX/bRogO3XasiNH7R0eW7fgRM834ImTg+Lg6BEnx4vz81rhr+MYPBBQg1v8GndEOrthxaCTxNAOut8WKLGZQl+MPz88Q9tAO/hVuSeqQAAAABJRU5ErkJggg==\",\n\t\t\t\"Filename\": \"logo.png\",\n\t\t\t\"ContentID\": \"inline-cid\",\n\t\t\t\"ContentType\": \"overridden/type\"\n\t\t  }\n\t\t],\n\t\t\"ReplyTo\": [\n\t\t  {\n\t\t\t\"Email\": \"secretary@example.com\",\n\t\t\t\"Name\": \"Secretary\"\n\t\t  }\n\t\t],\n\t\t\"Tags\": [\n\t\t  \"Tag 1\",\n\t\t  \"Tag 2\"\n\t\t]\n\t  }`\n\n\tt.Log(\"Sending message via HTTP API\")\n\tb, err := clientPost(ts.URL+\"/api/v1/send\", jsonData)\n\tif err != nil {\n\t\tt.Errorf(\"Expected nil, received %s\", err.Error())\n\t}\n\n\tresp := struct {\n\t\tID string\n\t}{}\n\n\tif err := json.Unmarshal(b, &resp); err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tt.Logf(\"Fetching response for message %s\", resp.ID)\n\tmsg, err := fetchMessage(ts.URL + \"/api/v1/message/\" + resp.ID)\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t}\n\n\tt.Logf(\"Testing response for message %s\", resp.ID)\n\tassertEqual(t, `Mailpit message via the HTTP API`, msg.Subject, \"wrong subject\")\n\tassertEqual(t, `This is the text body`, msg.Text, \"wrong text\")\n\tassertEqual(t, `<p style=\"font-family: arial\">Mailpit is <b>awesome</b>!</p>`, msg.HTML, \"wrong HTML\")\n\tassertEqual(t, `\"John Doe\" <john@example.com>`, msg.From.String(), \"wrong HTML\")\n\tassertEqual(t, 1, len(msg.To), \"wrong To count\")\n\tassertEqual(t, `\"Jane Doe\" <jane@example.com>`, msg.To[0].String(), \"wrong To address\")\n\tassertEqual(t, 2, len(msg.Cc), \"wrong Cc count\")\n\tassertEqual(t, `\"Manager 1\" <manager1@example.com>`, msg.Cc[0].String(), \"wrong Cc address\")\n\tassertEqual(t, `\"Manager 2\" <manager2@example.com>`, msg.Cc[1].String(), \"wrong Cc address\")\n\tassertEqual(t, 1, len(msg.Bcc), \"wrong Bcc count\")\n\tassertEqual(t, `<jack@example.com>`, msg.Bcc[0].String(), \"wrong Bcc address\")\n\tassertEqual(t, 1, len(msg.ReplyTo), \"wrong Reply-To count\")\n\tassertEqual(t, `\"Secretary\" <secretary@example.com>`, msg.ReplyTo[0].String(), \"wrong Reply-To address\")\n\tassertEqual(t, 2, len(msg.Tags), \"wrong Tags count\")\n\tassertEqual(t, `Tag 1,Tag 2`, strings.Join(msg.Tags, \",\"), \"wrong Tags\")\n\tassertEqual(t, 1, len(msg.Attachments), \"wrong Attachment count\")\n\tassertEqual(t, `Attached File.txt`, msg.Attachments[0].FileName, \"wrong Attachment name\")\n\tassertEqual(t, `text/plain`, msg.Attachments[0].ContentType, \"wrong Content-Type\")\n\tassertEqual(t, 1, len(msg.Inline), \"wrong inline Attachment count\")\n\tassertEqual(t, `logo.png`, msg.Inline[0].FileName, \"wrong Attachment name\")\n\tassertEqual(t, `overridden/type`, msg.Inline[0].ContentType, \"wrong Content-Type\")\n\n\tattachmentBytes, err := clientGet(ts.URL + \"/api/v1/message/\" + resp.ID + \"/part/\" + msg.Attachments[0].PartID)\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t}\n\tassertEqual(t, `This is a plain text attachment`, string(attachmentBytes), \"wrong Attachment content\")\n}\n\nfunc TestSendAPIAuthMiddleware(t *testing.T) {\n\tsetup()\n\tdefer storage.Close()\n\n\t// Test 1: Send API with accept-any enabled (should bypass all auth)\n\tt.Run(\"SendAPIAuthAcceptAny\", func(t *testing.T) {\n\t\t// Set up UI auth and enable accept-any for send API\n\t\toriginalSendAPIAuthAcceptAny := config.SendAPIAuthAcceptAny\n\t\toriginalUICredentials := auth.UICredentials\n\t\tdefer func() {\n\t\t\tconfig.SendAPIAuthAcceptAny = originalSendAPIAuthAcceptAny\n\t\t\tauth.UICredentials = originalUICredentials\n\t\t}()\n\n\t\t// Enable accept-any for send API\n\t\tconfig.SendAPIAuthAcceptAny = true\n\n\t\t// Set up UI auth that would normally block requests\n\t\ttestHash, _ := bcrypt.GenerateFromPassword([]byte(\"testpass\"), bcrypt.DefaultCost)\n\t\tif err := auth.SetUIAuth(\"testuser:\" + string(testHash)); err != nil {\n\t\t\tt.Fatalf(\"Failed to set UI auth: %s\", err.Error())\n\t\t}\n\n\t\tr := apiRoutes()\n\t\tts := httptest.NewServer(r)\n\t\tdefer ts.Close()\n\n\t\t// Should succeed without any auth headers\n\t\tjsonData, _ := json.Marshal(testSendMessage)\n\t\t_, err := clientPost(ts.URL+\"/api/v1/send\", string(jsonData))\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Expected send to succeed with accept-any, got error: %s\", err.Error())\n\t\t}\n\t})\n\n\t// Test 2: Send API with dedicated credentials\n\tt.Run(\"SendAPIWithDedicatedCredentials\", func(t *testing.T) {\n\t\toriginalSendAPIAuthAcceptAny := config.SendAPIAuthAcceptAny\n\t\toriginalUICredentials := auth.UICredentials\n\t\toriginalSendAPICredentials := auth.SendAPICredentials\n\t\tdefer func() {\n\t\t\tconfig.SendAPIAuthAcceptAny = originalSendAPIAuthAcceptAny\n\t\t\tauth.UICredentials = originalUICredentials\n\t\t\tauth.SendAPICredentials = originalSendAPICredentials\n\t\t}()\n\n\t\tconfig.SendAPIAuthAcceptAny = false\n\n\t\t// Set up UI auth\n\t\tuiHash, _ := bcrypt.GenerateFromPassword([]byte(\"uipass\"), bcrypt.DefaultCost)\n\t\tif err := auth.SetUIAuth(\"uiuser:\" + string(uiHash)); err != nil {\n\t\t\tt.Fatalf(\"Failed to set UI auth: %s\", err.Error())\n\t\t}\n\n\t\t// Set up dedicated Send API auth\n\t\tsendHash, _ := bcrypt.GenerateFromPassword([]byte(\"sendpass\"), bcrypt.DefaultCost)\n\t\tif err := auth.SetSendAPIAuth(\"senduser:\" + string(sendHash)); err != nil {\n\t\t\tt.Fatalf(\"Failed to set Send API auth: %s\", err.Error())\n\t\t}\n\n\t\tr := apiRoutes()\n\t\tts := httptest.NewServer(r)\n\t\tdefer ts.Close()\n\n\t\tjsonData, _ := json.Marshal(testSendMessage)\n\n\t\t// Should succeed with correct Send API credentials\n\t\t_, err := clientPostWithAuth(ts.URL+\"/api/v1/send\", string(jsonData), \"senduser\", \"sendpass\")\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Expected send to succeed with correct Send API credentials, got error: %s\", err.Error())\n\t\t}\n\n\t\t// Should fail with wrong Send API credentials\n\t\t_, err = clientPostWithAuth(ts.URL+\"/api/v1/send\", string(jsonData), \"senduser\", \"wrongpass\")\n\t\tif err == nil {\n\t\t\tt.Error(\"Expected send to fail with wrong Send API credentials\")\n\t\t}\n\n\t\t// Should fail with UI credentials when Send API credentials are set\n\t\t_, err = clientPostWithAuth(ts.URL+\"/api/v1/send\", string(jsonData), \"uiuser\", \"uipass\")\n\t\tif err == nil {\n\t\t\tt.Error(\"Expected send to fail with UI credentials when Send API credentials are required\")\n\t\t}\n\t})\n\n\t// Test 3: Send API fallback to UI auth when no Send API auth is configured\n\tt.Run(\"SendAPIFallbackToUIAuth\", func(t *testing.T) {\n\t\toriginalSendAPIAuthAcceptAny := config.SendAPIAuthAcceptAny\n\t\toriginalUICredentials := auth.UICredentials\n\t\toriginalSendAPICredentials := auth.SendAPICredentials\n\t\tdefer func() {\n\t\t\tconfig.SendAPIAuthAcceptAny = originalSendAPIAuthAcceptAny\n\t\t\tauth.UICredentials = originalUICredentials\n\t\t\tauth.SendAPICredentials = originalSendAPICredentials\n\t\t}()\n\n\t\tconfig.SendAPIAuthAcceptAny = false\n\t\tauth.SendAPICredentials = nil\n\n\t\t// Set up only UI auth\n\t\tuiHash, _ := bcrypt.GenerateFromPassword([]byte(\"uipass\"), bcrypt.DefaultCost)\n\t\tif err := auth.SetUIAuth(\"uiuser:\" + string(uiHash)); err != nil {\n\t\t\tt.Fatalf(\"Failed to set UI auth: %s\", err.Error())\n\t\t}\n\n\t\tr := apiRoutes()\n\t\tts := httptest.NewServer(r)\n\t\tdefer ts.Close()\n\n\t\tjsonData, _ := json.Marshal(testSendMessage)\n\n\t\t// Should succeed with UI credentials when no Send API auth is configured\n\t\t_, err := clientPostWithAuth(ts.URL+\"/api/v1/send\", string(jsonData), \"uiuser\", \"uipass\")\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Expected send to succeed with UI credentials when no Send API auth configured, got error: %s\", err.Error())\n\t\t}\n\n\t\t// Should fail without any credentials\n\t\t_, err = clientPost(ts.URL+\"/api/v1/send\", string(jsonData))\n\t\tif err == nil {\n\t\t\tt.Error(\"Expected send to fail without credentials when UI auth is required\")\n\t\t}\n\t})\n\n\t// Test 4: Regular API endpoints should not be affected by Send API auth settings\n\tt.Run(\"RegularAPINotAffectedBySendAPIAuth\", func(t *testing.T) {\n\t\toriginalSendAPIAuthAcceptAny := config.SendAPIAuthAcceptAny\n\t\toriginalUICredentials := auth.UICredentials\n\t\toriginalSendAPICredentials := auth.SendAPICredentials\n\t\tdefer func() {\n\t\t\tconfig.SendAPIAuthAcceptAny = originalSendAPIAuthAcceptAny\n\t\t\tauth.UICredentials = originalUICredentials\n\t\t\tauth.SendAPICredentials = originalSendAPICredentials\n\t\t}()\n\n\t\t// Set up UI auth and Send API auth\n\t\tuiHash, _ := bcrypt.GenerateFromPassword([]byte(\"uipass\"), bcrypt.DefaultCost)\n\t\tif err := auth.SetUIAuth(\"uiuser:\" + string(uiHash)); err != nil {\n\t\t\tt.Fatalf(\"Failed to set UI auth: %s\", err.Error())\n\t\t}\n\n\t\tsendHash, _ := bcrypt.GenerateFromPassword([]byte(\"sendpass\"), bcrypt.DefaultCost)\n\t\tif err := auth.SetSendAPIAuth(\"senduser:\" + string(sendHash)); err != nil {\n\t\t\tt.Fatalf(\"Failed to set Send API auth: %s\", err.Error())\n\t\t}\n\n\t\tr := apiRoutes()\n\t\tts := httptest.NewServer(r)\n\t\tdefer ts.Close()\n\n\t\t// Regular API endpoint should require UI credentials, not Send API credentials\n\t\t_, err := clientGetWithAuth(ts.URL+\"/api/v1/messages\", \"uiuser\", \"uipass\")\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Expected regular API to work with UI credentials, got error: %s\", err.Error())\n\t\t}\n\n\t\t// Regular API endpoint should fail with Send API credentials\n\t\t_, err = clientGetWithAuth(ts.URL+\"/api/v1/messages\", \"senduser\", \"sendpass\")\n\t\tif err == nil {\n\t\t\tt.Error(\"Expected regular API to fail with Send API credentials\")\n\t\t}\n\t})\n}\n\nfunc setup() {\n\tlogger.NoLogging = true\n\tconfig.MaxMessages = 0\n\tconfig.Database = os.Getenv(\"MP_DATABASE\")\n\n\tif err := storage.InitDB(); err != nil {\n\t\tpanic(err)\n\t}\n\n\tif err := storage.DeleteAllMessages(); err != nil {\n\t\tpanic(err)\n\t}\n}\n\nfunc assertStatsEqual(t *testing.T, uri string, unread, total int) {\n\tm := apiv1.MessagesSummary{}\n\n\tdata, err := clientGet(uri)\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tif err := json.Unmarshal(data, &m); err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tassertEqual(t, uint64(unread), m.Unread, \"wrong unread count\")\n\tassertEqual(t, uint64(total), m.Total, \"wrong total count\")\n}\n\nfunc assertSearchEqual(t *testing.T, uri, query string, count int) {\n\tt.Logf(\"Test search: %s\", query)\n\tm := apiv1.MessagesSummary{}\n\n\tlimit := fmt.Sprintf(\"%d\", count)\n\n\tdata, err := clientGet(uri + \"?query=\" + url.QueryEscape(query) + \"&limit=\" + limit)\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tif err := json.Unmarshal(data, &m); err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tassertEqual(t, uint64(count), m.MessagesCount, \"wrong search results count\")\n}\n\nfunc insertEmailData(t *testing.T) {\n\tfor i := range 100 {\n\t\tmsg := enmime.Builder().\n\t\t\tFrom(fmt.Sprintf(\"From %d\", i), fmt.Sprintf(\"from-%d@example.com\", i)).\n\t\t\tSubject(fmt.Sprintf(\"Subject line %d end\", i)).\n\t\t\tText(fmt.Appendf(nil, \"This is the email body %d <jdsauk;dwqmdqw;>.\", i)).\n\t\t\tTo(fmt.Sprintf(\"To %d\", i), fmt.Sprintf(\"to-%d@example.com\", i))\n\n\t\tenv, err := msg.Build()\n\t\tif err != nil {\n\t\t\tt.Log(\"error \", err)\n\t\t\tt.Fail()\n\t\t}\n\n\t\tbuf := new(bytes.Buffer)\n\n\t\tif err := env.Encode(buf); err != nil {\n\t\t\tt.Log(\"error \", err)\n\t\t\tt.Fail()\n\t\t}\n\n\t\tbufBytes := buf.Bytes()\n\n\t\tid, err := storage.Store(&bufBytes, nil)\n\t\tif err != nil {\n\t\t\tt.Log(\"error \", err)\n\t\t\tt.Fail()\n\t\t}\n\n\t\tif _, err := storage.SetMessageTags(id, []string{fmt.Sprintf(\"Test tag %03d\", i)}); err != nil {\n\t\t\tt.Log(\"error \", err)\n\t\t\tt.Fail()\n\t\t}\n\t}\n}\n\nfunc fetchMessage(url string) (storage.Message, error) {\n\tm := storage.Message{}\n\n\tdata, err := clientGet(url)\n\tif err != nil {\n\t\treturn m, err\n\t}\n\n\tif err := json.Unmarshal(data, &m); err != nil {\n\t\treturn m, err\n\t}\n\n\treturn m, nil\n}\n\nfunc fetchMessages(url string) (apiv1.MessagesSummary, error) {\n\tm := apiv1.MessagesSummary{}\n\n\tdata, err := clientGet(url)\n\tif err != nil {\n\t\treturn m, err\n\t}\n\n\tif err := json.Unmarshal(data, &m); err != nil {\n\t\treturn m, err\n\t}\n\n\treturn m, nil\n}\n\nfunc clientGet(url string) ([]byte, error) {\n\tresp, err := http.Get(url)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"%s returned status %d\", url, resp.StatusCode)\n\t}\n\n\tdefer func() { _ = resp.Body.Close() }()\n\n\tdata, err := io.ReadAll(resp.Body)\n\n\treturn data, err\n}\n\nfunc clientDelete(url, body string) ([]byte, error) {\n\tclient := new(http.Client)\n\n\tb := strings.NewReader(body)\n\treq, err := http.NewRequest(\"DELETE\", url, b)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdefer func() { _ = resp.Body.Close() }()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"%s returned status %d\", url, resp.StatusCode)\n\t}\n\n\tdata, err := io.ReadAll(resp.Body)\n\n\treturn data, err\n}\n\nfunc clientPut(url, body string) ([]byte, error) {\n\tclient := new(http.Client)\n\n\tb := strings.NewReader(body)\n\treq, err := http.NewRequest(\"PUT\", url, b)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdefer func() { _ = resp.Body.Close() }()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"%s returned status %d\", url, resp.StatusCode)\n\t}\n\n\tdata, err := io.ReadAll(resp.Body)\n\n\treturn data, err\n}\n\nfunc clientPost(url, body string) ([]byte, error) {\n\tclient := new(http.Client)\n\n\tb := strings.NewReader(body)\n\treq, err := http.NewRequest(\"POST\", url, b)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdefer func() { _ = resp.Body.Close() }()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"%s returned status %d\", url, resp.StatusCode)\n\t}\n\n\tdata, err := io.ReadAll(resp.Body)\n\n\treturn data, err\n}\n\nfunc clientPostWithAuth(url, body, username, password string) ([]byte, error) {\n\tclient := new(http.Client)\n\n\tb := strings.NewReader(body)\n\treq, err := http.NewRequest(\"POST\", url, b)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treq.SetBasicAuth(username, password)\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdefer func() { _ = resp.Body.Close() }()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"%s returned status %d\", url, resp.StatusCode)\n\t}\n\n\tdata, err := io.ReadAll(resp.Body)\n\n\treturn data, err\n}\n\nfunc clientGetWithAuth(url, username, password string) ([]byte, error) {\n\tclient := new(http.Client)\n\n\treq, err := http.NewRequest(\"GET\", url, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treq.SetBasicAuth(username, password)\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdefer func() { _ = resp.Body.Close() }()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"%s returned status %d\", url, resp.StatusCode)\n\t}\n\n\tdata, err := io.ReadAll(resp.Body)\n\n\treturn data, err\n}\n\nfunc assertEqual(t *testing.T, a any, b any, message string) {\n\tif a == b {\n\t\treturn\n\t}\n\tmessage = fmt.Sprintf(\"%s: \\\"%v\\\" != \\\"%v\\\"\", message, a, b)\n\tt.Fatal(message)\n}\n"
  },
  {
    "path": "server/ui/api/v1/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n\t<meta charset=\"utf-8\">\n\t<meta name=\"viewport\" content=\"width=device-width,initial-scale=1.0\">\n\t<title>Mailpit API v1 documentation</title>\n\t<meta name=\"referrer\" content=\"no-referrer\">\n\t<meta name=\"robots\" content=\"noindex, nofollow, noarchive\">\n\t<link rel=\"icon\" href=\"../../favicon.svg\">\n\t<script src=\"../../dist/docs.js\" nonce=\"%%NONCE%%\"></script>\n</head>\n\n<body>\n\t<rapi-doc id=\"thedoc\" spec-url=\"swagger.json\" theme=\"light\" layout=\"column\" render-style=\"read\" load-fonts=\"false\"\n\t\tallow-authentication=\"false\" sort-tags=\"true\"\n\t\tregular-font=\"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'\"\n\t\tmono-font=\"Courier New, Courier, System, fixed-width\" font-size=\"large\" allow-spec-url-load=\"false\"\n\t\tallow-spec-file-load=\"false\" allow-server-selection=\"false\" allow-search=\"false\" allow-advanced-search=\"false\"\n\t\tshow-curl-before-try=\"true\" bg-color=\"#ffffff\" nav-bg-color=\"#e3e8ec\" nav-text-color=\"#212529\"\n\t\tnav-hover-bg-color=\"#fff\" header-color=\"#2c3e50\" primary-color=\"#2c3e50\" text-color=\"#212529\">\n\t\t<div slot=\"header\">Mailpit API v1 documentation</div>\n\t\t<a target='_blank' href=\"../../\" slot=\"logo\">\n\t\t\t<img src=\"../../mailpit.svg\" width=\"40\" alt=\"Mailpit\" />\n\t\t</a>\n\t</rapi-doc>\n</body>\n\n</html>\n"
  },
  {
    "path": "server/ui/api/v1/swagger.json",
    "content": "{\n  \"consumes\": [\n    \"application/json\"\n  ],\n  \"produces\": [\n    \"application/json\"\n  ],\n  \"schemes\": [\n    \"http\"\n  ],\n  \"swagger\": \"2.0\",\n  \"info\": {\n    \"description\": \"OpenAPI 2.0 documentation for [Mailpit](https://github.com/axllent/mailpit).\",\n    \"title\": \"Mailpit API\",\n    \"contact\": {\n      \"name\": \"GitHub\",\n      \"url\": \"https://github.com/axllent/mailpit\"\n    },\n    \"license\": {\n      \"name\": \"MIT license\",\n      \"url\": \"https://github.com/axllent/mailpit/blob/develop/LICENSE\"\n    },\n    \"version\": \"v1\"\n  },\n  \"paths\": {\n    \"/api/v1/chaos\": {\n      \"get\": {\n        \"description\": \"Returns the current Chaos triggers configuration.\\nThis API route will return an error if Chaos is not enabled at runtime.\",\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"schemes\": [\n          \"http\",\n          \"https\"\n        ],\n        \"tags\": [\n          \"testing\"\n        ],\n        \"summary\": \"Get Chaos triggers\",\n        \"operationId\": \"getChaos\",\n        \"responses\": {\n          \"200\": {\n            \"$ref\": \"#/responses/ChaosResponse\"\n          },\n          \"400\": {\n            \"$ref\": \"#/responses/ErrorResponse\"\n          }\n        }\n      },\n      \"put\": {\n        \"description\": \"Set the Chaos triggers configuration and return the updated values.\\nThis API route will return an error if Chaos is not enabled at runtime.\\n\\nIf any triggers are omitted from the request, then those are reset to their\\ndefault values with a 0% probability (ie: disabled).\\nSetting a blank `{}` will reset all triggers to their default values.\",\n        \"consumes\": [\n          \"application/json\"\n        ],\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"schemes\": [\n          \"http\",\n          \"https\"\n        ],\n        \"tags\": [\n          \"testing\"\n        ],\n        \"summary\": \"Set Chaos triggers\",\n        \"operationId\": \"setChaosParams\",\n        \"parameters\": [\n          {\n            \"name\": \"Body\",\n            \"in\": \"body\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/ChaosTriggers\"\n            }\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"$ref\": \"#/responses/ChaosResponse\"\n          },\n          \"400\": {\n            \"$ref\": \"#/responses/ErrorResponse\"\n          }\n        }\n      }\n    },\n    \"/api/v1/info\": {\n      \"get\": {\n        \"description\": \"Returns basic runtime information, message totals and latest release version.\",\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"schemes\": [\n          \"http\",\n          \"https\"\n        ],\n        \"tags\": [\n          \"application\"\n        ],\n        \"summary\": \"Get application information\",\n        \"operationId\": \"AppInformation\",\n        \"responses\": {\n          \"200\": {\n            \"$ref\": \"#/responses/AppInfoResponse\"\n          },\n          \"400\": {\n            \"$ref\": \"#/responses/ErrorResponse\"\n          }\n        }\n      }\n    },\n    \"/api/v1/message/{ID}\": {\n      \"get\": {\n        \"description\": \"Returns the summary of a message, marking the message as read.\\n\\nThe ID can be set to `latest` to return the latest message.\",\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"schemes\": [\n          \"http\",\n          \"https\"\n        ],\n        \"tags\": [\n          \"message\"\n        ],\n        \"summary\": \"Get message summary\",\n        \"operationId\": \"GetMessageParams\",\n        \"parameters\": [\n          {\n            \"type\": \"string\",\n            \"description\": \"Message database ID or \\\"latest\\\"\",\n            \"name\": \"ID\",\n            \"in\": \"path\",\n            \"required\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Message\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/Message\"\n            }\n          },\n          \"400\": {\n            \"$ref\": \"#/responses/ErrorResponse\"\n          },\n          \"404\": {\n            \"$ref\": \"#/responses/NotFoundResponse\"\n          }\n        }\n      }\n    },\n    \"/api/v1/message/{ID}/headers\": {\n      \"get\": {\n        \"description\": \"Returns the message headers as an array. Note that header keys are returned alphabetically.\\n\\nThe ID can be set to `latest` to return the latest message headers.\",\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"schemes\": [\n          \"http\",\n          \"https\"\n        ],\n        \"tags\": [\n          \"message\"\n        ],\n        \"summary\": \"Get message headers\",\n        \"operationId\": \"GetHeadersParams\",\n        \"parameters\": [\n          {\n            \"type\": \"string\",\n            \"description\": \"Message database ID or \\\"latest\\\"\",\n            \"name\": \"ID\",\n            \"in\": \"path\",\n            \"required\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"MessageHeadersResponse\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/MessageHeadersResponse\"\n            }\n          },\n          \"400\": {\n            \"$ref\": \"#/responses/ErrorResponse\"\n          },\n          \"404\": {\n            \"$ref\": \"#/responses/NotFoundResponse\"\n          }\n        }\n      }\n    },\n    \"/api/v1/message/{ID}/html-check\": {\n      \"get\": {\n        \"description\": \"Returns the summary of the message HTML checker.\\n\\nThe ID can be set to `latest` to return the latest message.\",\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"schemes\": [\n          \"http\",\n          \"https\"\n        ],\n        \"tags\": [\n          \"other\"\n        ],\n        \"summary\": \"HTML check\",\n        \"operationId\": \"HTMLCheckParams\",\n        \"parameters\": [\n          {\n            \"type\": \"string\",\n            \"description\": \"Message database ID or \\\"latest\\\"\",\n            \"name\": \"ID\",\n            \"in\": \"path\",\n            \"required\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"HTMLCheckResponse\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/HTMLCheckResponse\"\n            }\n          },\n          \"400\": {\n            \"$ref\": \"#/responses/ErrorResponse\"\n          },\n          \"404\": {\n            \"$ref\": \"#/responses/NotFoundResponse\"\n          }\n        }\n      }\n    },\n    \"/api/v1/message/{ID}/link-check\": {\n      \"get\": {\n        \"description\": \"Returns the summary of the message Link checker.\\n\\nThe ID can be set to `latest` to return the latest message.\",\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"schemes\": [\n          \"http\",\n          \"https\"\n        ],\n        \"tags\": [\n          \"other\"\n        ],\n        \"summary\": \"Link check\",\n        \"operationId\": \"LinkCheckParams\",\n        \"parameters\": [\n          {\n            \"type\": \"string\",\n            \"description\": \"Message database ID or \\\"latest\\\"\",\n            \"name\": \"ID\",\n            \"in\": \"path\",\n            \"required\": true\n          },\n          {\n            \"type\": \"string\",\n            \"default\": \"false\",\n            \"x-go-name\": \"Follow\",\n            \"description\": \"Follow redirects\",\n            \"name\": \"follow\",\n            \"in\": \"query\"\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"LinkCheckResponse\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/LinkCheckResponse\"\n            }\n          },\n          \"400\": {\n            \"$ref\": \"#/responses/ErrorResponse\"\n          },\n          \"404\": {\n            \"$ref\": \"#/responses/NotFoundResponse\"\n          }\n        }\n      }\n    },\n    \"/api/v1/message/{ID}/part/{PartID}\": {\n      \"get\": {\n        \"description\": \"This will return the attachment part using the appropriate Content-Type.\\n\\nThe ID can be set to `latest` to reference the latest message.\",\n        \"produces\": [\n          \"application/*\",\n          \"image/*\",\n          \"text/*\"\n        ],\n        \"schemes\": [\n          \"http\",\n          \"https\"\n        ],\n        \"tags\": [\n          \"message\"\n        ],\n        \"summary\": \"Get message attachment\",\n        \"operationId\": \"AttachmentParams\",\n        \"parameters\": [\n          {\n            \"type\": \"string\",\n            \"description\": \"Message database ID or \\\"latest\\\"\",\n            \"name\": \"ID\",\n            \"in\": \"path\",\n            \"required\": true\n          },\n          {\n            \"type\": \"string\",\n            \"description\": \"Attachment part ID\",\n            \"name\": \"PartID\",\n            \"in\": \"path\",\n            \"required\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"$ref\": \"#/responses/BinaryResponse\"\n          },\n          \"400\": {\n            \"$ref\": \"#/responses/ErrorResponse\"\n          },\n          \"404\": {\n            \"$ref\": \"#/responses/NotFoundResponse\"\n          }\n        }\n      }\n    },\n    \"/api/v1/message/{ID}/part/{PartID}/thumb\": {\n      \"get\": {\n        \"description\": \"This will return a cropped 180x120 JPEG thumbnail of an image attachment.\\nIf the image is smaller than 180x120 then the image is padded. If the attachment is not an image then a blank image is returned.\\n\\nThe ID can be set to `latest` to return the latest message.\",\n        \"produces\": [\n          \"image/jpeg\"\n        ],\n        \"schemes\": [\n          \"http\",\n          \"https\"\n        ],\n        \"tags\": [\n          \"message\"\n        ],\n        \"summary\": \"Get an attachment image thumbnail\",\n        \"operationId\": \"ThumbnailParams\",\n        \"parameters\": [\n          {\n            \"type\": \"string\",\n            \"description\": \"Message database ID or \\\"latest\\\"\",\n            \"name\": \"ID\",\n            \"in\": \"path\",\n            \"required\": true\n          },\n          {\n            \"type\": \"string\",\n            \"description\": \"Attachment part ID\",\n            \"name\": \"PartID\",\n            \"in\": \"path\",\n            \"required\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"$ref\": \"#/responses/BinaryResponse\"\n          },\n          \"400\": {\n            \"$ref\": \"#/responses/ErrorResponse\"\n          }\n        }\n      }\n    },\n    \"/api/v1/message/{ID}/raw\": {\n      \"get\": {\n        \"description\": \"Returns the full email source as plain text.\\n\\nThe ID can be set to `latest` to return the latest message source.\",\n        \"produces\": [\n          \"text/plain\"\n        ],\n        \"schemes\": [\n          \"http\",\n          \"https\"\n        ],\n        \"tags\": [\n          \"message\"\n        ],\n        \"summary\": \"Get message source\",\n        \"operationId\": \"DownloadRawParams\",\n        \"parameters\": [\n          {\n            \"type\": \"string\",\n            \"description\": \"Message database ID or \\\"latest\\\"\",\n            \"name\": \"ID\",\n            \"in\": \"path\",\n            \"required\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"$ref\": \"#/responses/TextResponse\"\n          },\n          \"400\": {\n            \"$ref\": \"#/responses/ErrorResponse\"\n          },\n          \"404\": {\n            \"$ref\": \"#/responses/NotFoundResponse\"\n          }\n        }\n      }\n    },\n    \"/api/v1/message/{ID}/release\": {\n      \"post\": {\n        \"description\": \"Release a message via a pre-configured external SMTP server. This is only enabled if message relaying has been configured.\\n\\nThe ID can be set to `latest` to reference the latest message.\",\n        \"consumes\": [\n          \"application/json\"\n        ],\n        \"produces\": [\n          \"text/plain\"\n        ],\n        \"schemes\": [\n          \"http\",\n          \"https\"\n        ],\n        \"tags\": [\n          \"message\"\n        ],\n        \"summary\": \"Release message\",\n        \"operationId\": \"ReleaseMessageParams\",\n        \"parameters\": [\n          {\n            \"type\": \"string\",\n            \"description\": \"Message database ID\",\n            \"name\": \"ID\",\n            \"in\": \"path\",\n            \"required\": true\n          },\n          {\n            \"name\": \"Body\",\n            \"in\": \"body\",\n            \"schema\": {\n              \"type\": \"object\",\n              \"required\": [\n                \"To\"\n              ],\n              \"properties\": {\n                \"To\": {\n                  \"description\": \"Array of email addresses to relay the message to\",\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"type\": \"string\"\n                  },\n                  \"example\": [\n                    \"user1@example.com\",\n                    \"user2@example.com\"\n                  ]\n                }\n              }\n            }\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"$ref\": \"#/responses/OKResponse\"\n          },\n          \"400\": {\n            \"$ref\": \"#/responses/ErrorResponse\"\n          },\n          \"404\": {\n            \"$ref\": \"#/responses/NotFoundResponse\"\n          }\n        }\n      }\n    },\n    \"/api/v1/message/{ID}/sa-check\": {\n      \"get\": {\n        \"description\": \"Returns the SpamAssassin summary (if enabled) of the message.\\n\\nThe ID can be set to `latest` to return the latest message.\",\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"schemes\": [\n          \"http\",\n          \"https\"\n        ],\n        \"tags\": [\n          \"other\"\n        ],\n        \"summary\": \"SpamAssassin check\",\n        \"operationId\": \"SpamAssassinCheckParams\",\n        \"parameters\": [\n          {\n            \"type\": \"string\",\n            \"description\": \"Message database ID or \\\"latest\\\"\",\n            \"name\": \"ID\",\n            \"in\": \"path\",\n            \"required\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"SpamAssassinResponse\",\n            \"schema\": {\n              \"$ref\": \"#/definitions/SpamAssassinResponse\"\n            }\n          },\n          \"400\": {\n            \"$ref\": \"#/responses/ErrorResponse\"\n          },\n          \"404\": {\n            \"$ref\": \"#/responses/NotFoundResponse\"\n          }\n        }\n      }\n    },\n    \"/api/v1/messages\": {\n      \"get\": {\n        \"description\": \"Returns messages from the mailbox ordered from newest to oldest.\",\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"schemes\": [\n          \"http\",\n          \"https\"\n        ],\n        \"tags\": [\n          \"messages\"\n        ],\n        \"summary\": \"List messages\",\n        \"operationId\": \"GetMessagesParams\",\n        \"parameters\": [\n          {\n            \"type\": \"integer\",\n            \"format\": \"int64\",\n            \"default\": 0,\n            \"x-go-name\": \"Start\",\n            \"description\": \"Pagination offset\",\n            \"name\": \"start\",\n            \"in\": \"query\"\n          },\n          {\n            \"type\": \"integer\",\n            \"format\": \"int64\",\n            \"default\": 50,\n            \"x-go-name\": \"Limit\",\n            \"description\": \"Limit number of results\",\n            \"name\": \"limit\",\n            \"in\": \"query\"\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"$ref\": \"#/responses/MessagesSummaryResponse\"\n          },\n          \"400\": {\n            \"$ref\": \"#/responses/ErrorResponse\"\n          }\n        }\n      },\n      \"put\": {\n        \"description\": \"You can optionally provide an array of IDs or a search string.\\nIf neither IDs nor search is provided then all mailbox messages are updated.\",\n        \"consumes\": [\n          \"application/json\"\n        ],\n        \"produces\": [\n          \"text/plain\"\n        ],\n        \"schemes\": [\n          \"http\",\n          \"https\"\n        ],\n        \"tags\": [\n          \"messages\"\n        ],\n        \"summary\": \"Set read status\",\n        \"operationId\": \"SetReadStatusParams\",\n        \"parameters\": [\n          {\n            \"name\": \"Body\",\n            \"in\": \"body\",\n            \"schema\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"IDs\": {\n                  \"description\": \"Optional array of message database IDs\",\n                  \"type\": \"array\",\n                  \"default\": [],\n                  \"items\": {\n                    \"type\": \"string\"\n                  },\n                  \"example\": [\n                    \"4oRBnPtCXgAqZniRhzLNmS\",\n                    \"hXayS6wnCgNnt6aFTvmOF6\"\n                  ]\n                },\n                \"Read\": {\n                  \"description\": \"Read status\",\n                  \"type\": \"boolean\",\n                  \"default\": false,\n                  \"example\": true\n                },\n                \"Search\": {\n                  \"description\": \"Optional messages matching a search\",\n                  \"type\": \"string\",\n                  \"example\": \"tag:backups\"\n                }\n              }\n            }\n          },\n          {\n            \"type\": \"string\",\n            \"x-go-name\": \"TZ\",\n            \"description\": \"Optional [timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` \\u0026 `after:` searches (eg: \\\"Pacific/Auckland\\\").\",\n            \"name\": \"tz\",\n            \"in\": \"query\"\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"$ref\": \"#/responses/OKResponse\"\n          },\n          \"400\": {\n            \"$ref\": \"#/responses/ErrorResponse\"\n          }\n        }\n      },\n      \"delete\": {\n        \"description\": \"Delete individual or all messages. If no IDs are provided then all messages are deleted.\",\n        \"consumes\": [\n          \"application/json\"\n        ],\n        \"produces\": [\n          \"text/plain\"\n        ],\n        \"schemes\": [\n          \"http\",\n          \"https\"\n        ],\n        \"tags\": [\n          \"messages\"\n        ],\n        \"summary\": \"Delete messages\",\n        \"operationId\": \"DeleteMessagesParams\",\n        \"parameters\": [\n          {\n            \"description\": \"Delete request\",\n            \"name\": \"Body\",\n            \"in\": \"body\",\n            \"schema\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"IDs\": {\n                  \"description\": \"Array of message database IDs\",\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"type\": \"string\"\n                  },\n                  \"example\": [\n                    \"4oRBnPtCXgAqZniRhzLNmS\",\n                    \"hXayS6wnCgNnt6aFTvmOF6\"\n                  ]\n                }\n              }\n            }\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"$ref\": \"#/responses/OKResponse\"\n          },\n          \"400\": {\n            \"$ref\": \"#/responses/ErrorResponse\"\n          }\n        }\n      }\n    },\n    \"/api/v1/search\": {\n      \"get\": {\n        \"description\": \"Returns messages matching [a search](https://mailpit.axllent.org/docs/usage/search-filters/), sorted by received date (descending).\",\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"schemes\": [\n          \"http\",\n          \"https\"\n        ],\n        \"tags\": [\n          \"messages\"\n        ],\n        \"summary\": \"Search messages\",\n        \"operationId\": \"SearchParams\",\n        \"parameters\": [\n          {\n            \"type\": \"string\",\n            \"x-go-name\": \"Query\",\n            \"description\": \"Search query\",\n            \"name\": \"query\",\n            \"in\": \"query\",\n            \"required\": true\n          },\n          {\n            \"type\": \"string\",\n            \"default\": \"0\",\n            \"x-go-name\": \"Start\",\n            \"description\": \"Pagination offset\",\n            \"name\": \"start\",\n            \"in\": \"query\"\n          },\n          {\n            \"type\": \"string\",\n            \"default\": \"50\",\n            \"x-go-name\": \"Limit\",\n            \"description\": \"Limit results\",\n            \"name\": \"limit\",\n            \"in\": \"query\"\n          },\n          {\n            \"type\": \"string\",\n            \"x-go-name\": \"TZ\",\n            \"description\": \"Optional [timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` \\u0026 `after:` searches (eg: \\\"Pacific/Auckland\\\").\",\n            \"name\": \"tz\",\n            \"in\": \"query\"\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"$ref\": \"#/responses/MessagesSummaryResponse\"\n          },\n          \"400\": {\n            \"$ref\": \"#/responses/ErrorResponse\"\n          }\n        }\n      },\n      \"delete\": {\n        \"description\": \"Delete all messages matching [a search](https://mailpit.axllent.org/docs/usage/search-filters/).\",\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"schemes\": [\n          \"http\",\n          \"https\"\n        ],\n        \"tags\": [\n          \"messages\"\n        ],\n        \"summary\": \"Delete messages by search\",\n        \"operationId\": \"DeleteSearchParams\",\n        \"parameters\": [\n          {\n            \"type\": \"string\",\n            \"x-go-name\": \"Query\",\n            \"description\": \"Search query\",\n            \"name\": \"query\",\n            \"in\": \"query\",\n            \"required\": true\n          },\n          {\n            \"type\": \"string\",\n            \"x-go-name\": \"TZ\",\n            \"description\": \"[Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` \\u0026 `after:` searches (eg: \\\"Pacific/Auckland\\\").\",\n            \"name\": \"tz\",\n            \"in\": \"query\"\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"$ref\": \"#/responses/OKResponse\"\n          },\n          \"400\": {\n            \"$ref\": \"#/responses/ErrorResponse\"\n          }\n        }\n      }\n    },\n    \"/api/v1/send\": {\n      \"post\": {\n        \"description\": \"Send a message via the HTTP API.\",\n        \"consumes\": [\n          \"application/json\"\n        ],\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"schemes\": [\n          \"http\",\n          \"https\"\n        ],\n        \"tags\": [\n          \"message\"\n        ],\n        \"summary\": \"Send a message\",\n        \"operationId\": \"SendMessageParams\",\n        \"parameters\": [\n          {\n            \"name\": \"Body\",\n            \"in\": \"body\",\n            \"schema\": {\n              \"type\": \"object\",\n              \"required\": [\n                \"From\"\n              ],\n              \"properties\": {\n                \"Attachments\": {\n                  \"description\": \"Attachments\",\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"type\": \"object\",\n                    \"required\": [\n                      \"Content\",\n                      \"Filename\"\n                    ],\n                    \"properties\": {\n                      \"Content\": {\n                        \"description\": \"Base64-encoded string of the file content\",\n                        \"type\": \"string\",\n                        \"example\": \"iVBORw0KGgoAAAANSUhEUgAAAEEAAAA8CAMAAAAOlSdoAAAACXBIWXMAAAHrAAAB6wGM2bZBAAAAS1BMVEVHcEwRfnUkZ2gAt4UsSF8At4UtSV4At4YsSV4At4YsSV8At4YsSV4At4YsSV4sSV4At4YsSV4At4YtSV4At4YsSV4At4YtSV8At4YsUWYNAAAAGHRSTlMAAwoXGiktRE5dbnd7kpOlr7zJ0d3h8PD8PCSRAAACWUlEQVR42pXT4ZaqIBSG4W9rhqQYocG+/ys9Y0Z0Br+x3j8zaxUPewFh65K+7yrIMeIY4MT3wPfEJCidKXEMnLaVkxDiELiMz4WEOAZSFghxBIypCOlKiAMgXfIqTnBgSm8CIQ6BImxEUxEckClVQiHGj4Ba4AQHikAIClwTE9KtIghAhUJwoLkmLnCiAHJLRKgIMsEtVUKbBUIwoAg2C4QgQBE6l4VCnApBgSKYLLApCnCa0+96AEMW2BQcmC+Pr3nfp7o5Exy49gIADcIqUELGfeA+bp93LmAJp8QJoEcN3C7NY3sbVANixMyI0nku20/n5/ZRf3KI2k6JEDWQtxcbdGuAqu3TAXG+/799Oyyas1B1MnMiA+XyxHp9q0PUKGPiRAau1fZbLRZV09wZcT8/gHk8QQAxXn8VgaDqcUmU6O/r28nbVwXAqca2mRNtPAF5+zoP2MeN9Fy4NgC6RfcbgE7XITBRYTtOE3U3C2DVff7pk+PkUxgAbvtnPXJaD6DxulMLwOhPS/M3MQkgg1ZFrIXnmfaZoOfpKiFgzeZD/WuKqQEGrfJYkyWf6vlG3xUgTuscnkNkQsb599q124kdpMUjCa/XARHs1gZymVtGt3wLkiFv8rUgTxitYCex5EVGec0Y9VmoDTFBSQte2TfXGXlf7hbdaUM9Sk7fisEN9qfBBTK+FZcvM9fQSdkl2vj4W2oX/bRogO3XasiNH7R0eW7fgRM834ImTg+Lg6BEnx4vz81rhr+MYPBBQg1v8GndEOrthxaCTxNAOut8WKLGZQl+MPz88Q9tAO/hVuSeqQAAAABJRU5ErkJggg==\"\n                      },\n                      \"ContentID\": {\n                        \"description\": \"Optional Content-ID (`cid`) for attachment.\\nIf this field is set then the file is attached inline.\",\n                        \"type\": \"string\",\n                        \"example\": \"mailpit-logo\"\n                      },\n                      \"ContentType\": {\n                        \"description\": \"Optional Content Type for the the attachment.\\nIf this field is not set (or empty) then the content type is automatically detected.\",\n                        \"type\": \"string\",\n                        \"example\": \"image/png\"\n                      },\n                      \"Filename\": {\n                        \"description\": \"Filename\",\n                        \"type\": \"string\",\n                        \"example\": \"mailpit.png\"\n                      }\n                    }\n                  }\n                },\n                \"Bcc\": {\n                  \"description\": \"Bcc recipients email addresses only\",\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"type\": \"string\"\n                  },\n                  \"example\": [\n                    \"jack@example.com\"\n                  ]\n                },\n                \"Cc\": {\n                  \"description\": \"Cc recipients\",\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"type\": \"object\",\n                    \"required\": [\n                      \"Email\"\n                    ],\n                    \"properties\": {\n                      \"Email\": {\n                        \"description\": \"Email address\",\n                        \"type\": \"string\",\n                        \"example\": \"manager@example.com\"\n                      },\n                      \"Name\": {\n                        \"description\": \"Optional name\",\n                        \"type\": \"string\",\n                        \"example\": \"Manager\"\n                      }\n                    }\n                  }\n                },\n                \"From\": {\n                  \"description\": \"\\\"From\\\" recipient\",\n                  \"type\": \"object\",\n                  \"required\": [\n                    \"Email\"\n                  ],\n                  \"properties\": {\n                    \"Email\": {\n                      \"description\": \"Email address\",\n                      \"type\": \"string\",\n                      \"example\": \"john@example.com\"\n                    },\n                    \"Name\": {\n                      \"description\": \"Optional name\",\n                      \"type\": \"string\",\n                      \"example\": \"John Doe\"\n                    }\n                  }\n                },\n                \"HTML\": {\n                  \"description\": \"Message body (HTML)\",\n                  \"type\": \"string\",\n                  \"example\": \"\\u003cdiv style=\\\"text-align:center\\\"\\u003e\\u003cp style=\\\"font-family: arial; font-size: 24px;\\\"\\u003eMailpit is \\u003cb\\u003eawesome\\u003c/b\\u003e!\\u003c/p\\u003e\\u003cp\\u003e\\u003cimg src=\\\"cid:mailpit-logo\\\" /\\u003e\\u003c/p\\u003e\\u003c/div\\u003e\"\n                },\n                \"Headers\": {\n                  \"description\": \"Optional headers in {\\\"key\\\":\\\"value\\\"} format\",\n                  \"type\": \"object\",\n                  \"additionalProperties\": {\n                    \"type\": \"string\"\n                  },\n                  \"example\": {\n                    \"X-IP\": \"1.2.3.4\"\n                  }\n                },\n                \"ReplyTo\": {\n                  \"description\": \"Optional Reply-To recipients\",\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"type\": \"object\",\n                    \"required\": [\n                      \"Email\"\n                    ],\n                    \"properties\": {\n                      \"Email\": {\n                        \"description\": \"Email address\",\n                        \"type\": \"string\",\n                        \"example\": \"secretary@example.com\"\n                      },\n                      \"Name\": {\n                        \"description\": \"Optional name\",\n                        \"type\": \"string\",\n                        \"example\": \"Secretary\"\n                      }\n                    }\n                  }\n                },\n                \"Subject\": {\n                  \"description\": \"Subject\",\n                  \"type\": \"string\",\n                  \"example\": \"Mailpit message via the HTTP API\"\n                },\n                \"Tags\": {\n                  \"description\": \"Mailpit tags\",\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"type\": \"string\"\n                  },\n                  \"example\": [\n                    \"Tag 1\",\n                    \"Tag 2\"\n                  ]\n                },\n                \"Text\": {\n                  \"description\": \"Message body (text)\",\n                  \"type\": \"string\",\n                  \"example\": \"Mailpit is awesome!\"\n                },\n                \"To\": {\n                  \"description\": \"\\\"To\\\" recipients\",\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"type\": \"object\",\n                    \"required\": [\n                      \"Email\"\n                    ],\n                    \"properties\": {\n                      \"Email\": {\n                        \"description\": \"Email address\",\n                        \"type\": \"string\",\n                        \"example\": \"jane@example.com\"\n                      },\n                      \"Name\": {\n                        \"description\": \"Optional name\",\n                        \"type\": \"string\",\n                        \"example\": \"Jane Doe\"\n                      }\n                    }\n                  }\n                }\n              }\n            }\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"$ref\": \"#/responses/SendMessageResponse\"\n          },\n          \"400\": {\n            \"$ref\": \"#/responses/JSONErrorResponse\"\n          }\n        }\n      }\n    },\n    \"/api/v1/tags\": {\n      \"get\": {\n        \"description\": \"Returns a JSON array of all unique message tags.\",\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"schemes\": [\n          \"http\",\n          \"https\"\n        ],\n        \"tags\": [\n          \"tags\"\n        ],\n        \"summary\": \"Get all current tags\",\n        \"operationId\": \"GetAllTags\",\n        \"responses\": {\n          \"200\": {\n            \"$ref\": \"#/responses/ArrayResponse\"\n          },\n          \"400\": {\n            \"$ref\": \"#/responses/ErrorResponse\"\n          }\n        }\n      },\n      \"put\": {\n        \"description\": \"This will overwrite any existing tags for selected message database IDs. To remove all tags from a message, pass an empty tags array.\",\n        \"consumes\": [\n          \"application/json\"\n        ],\n        \"produces\": [\n          \"text/plain\"\n        ],\n        \"schemes\": [\n          \"http\",\n          \"https\"\n        ],\n        \"tags\": [\n          \"tags\"\n        ],\n        \"summary\": \"Set message tags\",\n        \"operationId\": \"SetTagsParams\",\n        \"parameters\": [\n          {\n            \"name\": \"Body\",\n            \"in\": \"body\",\n            \"schema\": {\n              \"type\": \"object\",\n              \"required\": [\n                \"Tags\",\n                \"IDs\"\n              ],\n              \"properties\": {\n                \"IDs\": {\n                  \"description\": \"Array of message database IDs\",\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"type\": \"string\"\n                  },\n                  \"example\": [\n                    \"4oRBnPtCXgAqZniRhzLNmS\",\n                    \"hXayS6wnCgNnt6aFTvmOF6\"\n                  ]\n                },\n                \"Tags\": {\n                  \"description\": \"Array of tag names to set\",\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"type\": \"string\"\n                  },\n                  \"example\": [\n                    \"Tag 1\",\n                    \"Tag 2\"\n                  ]\n                }\n              }\n            }\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"$ref\": \"#/responses/OKResponse\"\n          },\n          \"400\": {\n            \"$ref\": \"#/responses/ErrorResponse\"\n          }\n        }\n      }\n    },\n    \"/api/v1/tags/{Tag}\": {\n      \"put\": {\n        \"description\": \"Renames an existing tag.\",\n        \"produces\": [\n          \"text/plain\"\n        ],\n        \"schemes\": [\n          \"http\",\n          \"https\"\n        ],\n        \"tags\": [\n          \"tags\"\n        ],\n        \"summary\": \"Rename a tag\",\n        \"operationId\": \"RenameTagParams\",\n        \"parameters\": [\n          {\n            \"type\": \"string\",\n            \"description\": \"The url-encoded tag name to rename\",\n            \"name\": \"Tag\",\n            \"in\": \"path\",\n            \"required\": true\n          },\n          {\n            \"name\": \"Body\",\n            \"in\": \"body\",\n            \"schema\": {\n              \"type\": \"object\",\n              \"required\": [\n                \"Name\"\n              ],\n              \"properties\": {\n                \"Name\": {\n                  \"description\": \"New name\",\n                  \"type\": \"string\",\n                  \"example\": \"New name\"\n                }\n              }\n            }\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"$ref\": \"#/responses/OKResponse\"\n          },\n          \"400\": {\n            \"$ref\": \"#/responses/ErrorResponse\"\n          }\n        }\n      },\n      \"delete\": {\n        \"description\": \"Deletes a tag. This will not delete any messages with the tag, but will remove the tag from any messages containing the tag.\",\n        \"produces\": [\n          \"text/plain\"\n        ],\n        \"schemes\": [\n          \"http\",\n          \"https\"\n        ],\n        \"tags\": [\n          \"tags\"\n        ],\n        \"summary\": \"Delete a tag\",\n        \"operationId\": \"DeleteTagParams\",\n        \"parameters\": [\n          {\n            \"type\": \"string\",\n            \"description\": \"The url-encoded tag name to delete\",\n            \"name\": \"Tag\",\n            \"in\": \"path\",\n            \"required\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"$ref\": \"#/responses/OKResponse\"\n          },\n          \"400\": {\n            \"$ref\": \"#/responses/ErrorResponse\"\n          }\n        }\n      }\n    },\n    \"/api/v1/webui\": {\n      \"get\": {\n        \"description\": \"Returns configuration settings for the web UI.\\nIntended for web UI only!\",\n        \"produces\": [\n          \"application/json\"\n        ],\n        \"schemes\": [\n          \"http\",\n          \"https\"\n        ],\n        \"tags\": [\n          \"application\"\n        ],\n        \"summary\": \"Get web UI configuration\",\n        \"operationId\": \"WebUIConfigurationResponse\",\n        \"responses\": {\n          \"200\": {\n            \"$ref\": \"#/responses/WebUIConfigurationResponse\"\n          },\n          \"400\": {\n            \"$ref\": \"#/responses/ErrorResponse\"\n          }\n        }\n      }\n    },\n    \"/view/{ID}.html\": {\n      \"get\": {\n        \"description\": \"Renders just the message's HTML part which can be used for UI integration testing.\\nAttached inline images are modified to link to the API provided they exist.\\nNote that is the message does not contain a HTML part then an 404 error is returned.\\n\\nThe ID can be set to `latest` to return the latest message.\",\n        \"produces\": [\n          \"text/html\"\n        ],\n        \"schemes\": [\n          \"http\",\n          \"https\"\n        ],\n        \"tags\": [\n          \"testing\"\n        ],\n        \"summary\": \"Render message HTML part\",\n        \"operationId\": \"GetMessageHTMLParams\",\n        \"parameters\": [\n          {\n            \"type\": \"string\",\n            \"description\": \"Message database ID or \\\"latest\\\"\",\n            \"name\": \"ID\",\n            \"in\": \"path\",\n            \"required\": true\n          },\n          {\n            \"type\": \"string\",\n            \"x-go-name\": \"Embed\",\n            \"description\": \"If this is route is to be embedded in an iframe, set embed to `1` in the URL to add `target=\\\"_blank\\\"` and `rel=\\\"noreferrer noopener\\\"` to all links.\\n\\nIn addition, a small script will be added to the end of the document to post (postMessage()) the height of the document back to the parent window for optional iframe height resizing.\\n\\nNote that this will also *transform* the message into a full HTML document (if it isn't already), so this option is useful for viewing but not programmatic testing.\",\n            \"name\": \"embed\",\n            \"in\": \"query\"\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"$ref\": \"#/responses/HTMLResponse\"\n          },\n          \"400\": {\n            \"$ref\": \"#/responses/ErrorResponse\"\n          },\n          \"404\": {\n            \"$ref\": \"#/responses/NotFoundResponse\"\n          }\n        }\n      }\n    },\n    \"/view/{ID}.txt\": {\n      \"get\": {\n        \"description\": \"Renders just the message's text part which can be used for UI integration testing.\\n\\nThe ID can be set to `latest` to return the latest message.\",\n        \"produces\": [\n          \"text/plain\"\n        ],\n        \"schemes\": [\n          \"http\",\n          \"https\"\n        ],\n        \"tags\": [\n          \"testing\"\n        ],\n        \"summary\": \"Render message text part\",\n        \"operationId\": \"GetMessageTextParams\",\n        \"parameters\": [\n          {\n            \"type\": \"string\",\n            \"description\": \"Message database ID or \\\"latest\\\"\",\n            \"name\": \"ID\",\n            \"in\": \"path\",\n            \"required\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"$ref\": \"#/responses/TextResponse\"\n          },\n          \"400\": {\n            \"$ref\": \"#/responses/ErrorResponse\"\n          },\n          \"404\": {\n            \"$ref\": \"#/responses/NotFoundResponse\"\n          }\n        }\n      }\n    }\n  },\n  \"definitions\": {\n    \"Address\": {\n      \"description\": \"An address such as \\\"Barry Gibbs \\u003cbg@example.com\\u003e\\\" is represented\\nas Address{Name: \\\"Barry Gibbs\\\", Address: \\\"bg@example.com\\\"}.\",\n      \"type\": \"object\",\n      \"title\": \"Address represents a single mail address.\",\n      \"properties\": {\n        \"Address\": {\n          \"type\": \"string\"\n        },\n        \"Name\": {\n          \"type\": \"string\"\n        }\n      },\n      \"x-go-package\": \"net/mail\"\n    },\n    \"AppInformation\": {\n      \"description\": \"AppInformation struct\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"Database\": {\n          \"description\": \"Database path\",\n          \"type\": \"string\"\n        },\n        \"DatabaseSize\": {\n          \"description\": \"Database size in bytes\",\n          \"type\": \"integer\",\n          \"format\": \"uint64\"\n        },\n        \"LatestVersion\": {\n          \"description\": \"Latest Mailpit version\",\n          \"type\": \"string\"\n        },\n        \"Messages\": {\n          \"description\": \"Total number of messages in the database\",\n          \"type\": \"integer\",\n          \"format\": \"uint64\"\n        },\n        \"RuntimeStats\": {\n          \"description\": \"Runtime statistics\",\n          \"type\": \"object\",\n          \"properties\": {\n            \"Memory\": {\n              \"description\": \"Current memory usage in bytes\",\n              \"type\": \"integer\",\n              \"format\": \"uint64\"\n            },\n            \"MessagesDeleted\": {\n              \"description\": \"Database runtime messages deleted\",\n              \"type\": \"integer\",\n              \"format\": \"uint64\"\n            },\n            \"SMTPAccepted\": {\n              \"description\": \"Accepted runtime SMTP messages\",\n              \"type\": \"integer\",\n              \"format\": \"uint64\"\n            },\n            \"SMTPAcceptedSize\": {\n              \"description\": \"Total runtime accepted messages size in bytes\",\n              \"type\": \"integer\",\n              \"format\": \"uint64\"\n            },\n            \"SMTPIgnored\": {\n              \"description\": \"Ignored runtime SMTP messages (when using --ignore-duplicate-ids)\",\n              \"type\": \"integer\",\n              \"format\": \"uint64\"\n            },\n            \"SMTPRejected\": {\n              \"description\": \"Rejected runtime SMTP messages\",\n              \"type\": \"integer\",\n              \"format\": \"uint64\"\n            },\n            \"Uptime\": {\n              \"description\": \"Mailpit server uptime in seconds\",\n              \"type\": \"integer\",\n              \"format\": \"uint64\"\n            }\n          }\n        },\n        \"Tags\": {\n          \"description\": \"Tags and message totals per tag\",\n          \"type\": \"object\",\n          \"additionalProperties\": {\n            \"type\": \"integer\",\n            \"format\": \"int64\"\n          }\n        },\n        \"Unread\": {\n          \"description\": \"Total number of messages in the database\",\n          \"type\": \"integer\",\n          \"format\": \"uint64\"\n        },\n        \"Version\": {\n          \"description\": \"Current Mailpit version\",\n          \"type\": \"string\"\n        }\n      },\n      \"x-go-package\": \"github.com/axllent/mailpit/internal/stats\"\n    },\n    \"Attachment\": {\n      \"description\": \"Attachment struct for inline images and attachments\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"Checksums\": {\n          \"description\": \"File checksums\",\n          \"type\": \"object\",\n          \"properties\": {\n            \"MD5\": {\n              \"description\": \"MD5 checksum hash of file\",\n              \"type\": \"string\"\n            },\n            \"SHA1\": {\n              \"description\": \"SHA1 checksum hash of file\",\n              \"type\": \"string\"\n            },\n            \"SHA256\": {\n              \"description\": \"SHA256 checksum hash of file\",\n              \"type\": \"string\"\n            }\n          }\n        },\n        \"ContentID\": {\n          \"description\": \"Content ID\",\n          \"type\": \"string\"\n        },\n        \"ContentType\": {\n          \"description\": \"Content type\",\n          \"type\": \"string\"\n        },\n        \"FileName\": {\n          \"description\": \"File name\",\n          \"type\": \"string\"\n        },\n        \"PartID\": {\n          \"description\": \"Attachment part ID\",\n          \"type\": \"string\"\n        },\n        \"Size\": {\n          \"description\": \"Size in bytes\",\n          \"type\": \"integer\",\n          \"format\": \"uint64\"\n        }\n      },\n      \"x-go-package\": \"github.com/axllent/mailpit/internal/storage\"\n    },\n    \"ChaosTrigger\": {\n      \"description\": \"Trigger for Chaos\",\n      \"type\": \"object\",\n      \"required\": [\n        \"ErrorCode\",\n        \"Probability\"\n      ],\n      \"properties\": {\n        \"ErrorCode\": {\n          \"description\": \"SMTP error code to return. The value must range from 400 to 599.\",\n          \"type\": \"integer\",\n          \"format\": \"int64\",\n          \"example\": 451\n        },\n        \"Probability\": {\n          \"description\": \"Probability (chance) of triggering the error. The value must range from 0 to 100.\",\n          \"type\": \"integer\",\n          \"format\": \"int64\",\n          \"example\": 5\n        }\n      },\n      \"x-go-name\": \"Trigger\",\n      \"x-go-package\": \"github.com/axllent/mailpit/internal/smtpd/chaos\"\n    },\n    \"ChaosTriggers\": {\n      \"description\": \"Triggers for the Chaos configuration\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"Authentication\": {\n          \"$ref\": \"#/definitions/ChaosTrigger\"\n        },\n        \"Recipient\": {\n          \"$ref\": \"#/definitions/ChaosTrigger\"\n        },\n        \"Sender\": {\n          \"$ref\": \"#/definitions/ChaosTrigger\"\n        }\n      },\n      \"x-go-name\": \"Triggers\",\n      \"x-go-package\": \"github.com/axllent/mailpit/internal/smtpd/chaos\"\n    },\n    \"HTMLCheckResponse\": {\n      \"description\": \"Response represents the HTML check response struct\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"Platforms\": {\n          \"description\": \"All platforms tested, mainly for the web UI\",\n          \"type\": \"object\",\n          \"additionalProperties\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"type\": \"string\"\n            }\n          }\n        },\n        \"Total\": {\n          \"$ref\": \"#/definitions/HTMLCheckTotal\"\n        },\n        \"Warnings\": {\n          \"description\": \"List of warnings from tests\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/HTMLCheckWarning\"\n          }\n        }\n      },\n      \"x-go-name\": \"Response\",\n      \"x-go-package\": \"github.com/axllent/mailpit/internal/htmlcheck\"\n    },\n    \"HTMLCheckResult\": {\n      \"description\": \"Result struct\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"Family\": {\n          \"description\": \"Family eg: Outlook, Mozilla Thunderbird\",\n          \"type\": \"string\"\n        },\n        \"Name\": {\n          \"description\": \"Friendly name of result, combining family, platform \\u0026 version\",\n          \"type\": \"string\"\n        },\n        \"NoteNumber\": {\n          \"description\": \"Note number for partially supported if applicable\",\n          \"type\": \"string\"\n        },\n        \"Platform\": {\n          \"description\": \"Platform eg: ios, android, windows\",\n          \"type\": \"string\"\n        },\n        \"Support\": {\n          \"description\": \"Support [yes, no, partial]\",\n          \"type\": \"string\"\n        },\n        \"Version\": {\n          \"description\": \"Family version eg: 4.7.1, 2019-10, 10.3\",\n          \"type\": \"string\"\n        }\n      },\n      \"x-go-name\": \"Result\",\n      \"x-go-package\": \"github.com/axllent/mailpit/internal/htmlcheck\"\n    },\n    \"HTMLCheckScore\": {\n      \"description\": \"Score struct\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"Found\": {\n          \"description\": \"Number of matches in the document\",\n          \"type\": \"integer\",\n          \"format\": \"int64\"\n        },\n        \"Partial\": {\n          \"description\": \"Total percentage partially supported\",\n          \"type\": \"number\",\n          \"format\": \"float\"\n        },\n        \"Supported\": {\n          \"description\": \"Total percentage supported\",\n          \"type\": \"number\",\n          \"format\": \"float\"\n        },\n        \"Unsupported\": {\n          \"description\": \"Total percentage unsupported\",\n          \"type\": \"number\",\n          \"format\": \"float\"\n        }\n      },\n      \"x-go-name\": \"Score\",\n      \"x-go-package\": \"github.com/axllent/mailpit/internal/htmlcheck\"\n    },\n    \"HTMLCheckTotal\": {\n      \"description\": \"Total weighted result for all scores\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"Nodes\": {\n          \"description\": \"Total number of HTML nodes detected in message\",\n          \"type\": \"integer\",\n          \"format\": \"int64\"\n        },\n        \"Partial\": {\n          \"description\": \"Overall percentage partially supported\",\n          \"type\": \"number\",\n          \"format\": \"float\"\n        },\n        \"Supported\": {\n          \"description\": \"Overall percentage supported\",\n          \"type\": \"number\",\n          \"format\": \"float\"\n        },\n        \"Tests\": {\n          \"description\": \"Total number of tests done\",\n          \"type\": \"integer\",\n          \"format\": \"int64\"\n        },\n        \"Unsupported\": {\n          \"description\": \"Overall percentage unsupported\",\n          \"type\": \"number\",\n          \"format\": \"float\"\n        }\n      },\n      \"x-go-name\": \"Total\",\n      \"x-go-package\": \"github.com/axllent/mailpit/internal/htmlcheck\"\n    },\n    \"HTMLCheckWarning\": {\n      \"description\": \"Warning represents a failed test\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"Category\": {\n          \"description\": \"Category [css, html]\",\n          \"type\": \"string\"\n        },\n        \"Description\": {\n          \"description\": \"Description\",\n          \"type\": \"string\"\n        },\n        \"Keywords\": {\n          \"description\": \"Keywords\",\n          \"type\": \"string\"\n        },\n        \"NotesByNumber\": {\n          \"description\": \"Notes based on results\",\n          \"type\": \"object\",\n          \"additionalProperties\": {\n            \"type\": \"string\"\n          }\n        },\n        \"Results\": {\n          \"description\": \"Test results\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/HTMLCheckResult\"\n          }\n        },\n        \"Score\": {\n          \"$ref\": \"#/definitions/HTMLCheckScore\"\n        },\n        \"Slug\": {\n          \"description\": \"Slug identifier\",\n          \"type\": \"string\"\n        },\n        \"Tags\": {\n          \"description\": \"Tags\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"Title\": {\n          \"description\": \"Friendly title\",\n          \"type\": \"string\"\n        },\n        \"URL\": {\n          \"description\": \"URL to caniemail.com\",\n          \"type\": \"string\"\n        }\n      },\n      \"x-go-name\": \"Warning\",\n      \"x-go-package\": \"github.com/axllent/mailpit/internal/htmlcheck\"\n    },\n    \"Link\": {\n      \"description\": \"Link struct\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"Status\": {\n          \"description\": \"HTTP status definition\",\n          \"type\": \"string\"\n        },\n        \"StatusCode\": {\n          \"description\": \"HTTP status code\",\n          \"type\": \"integer\",\n          \"format\": \"int64\"\n        },\n        \"URL\": {\n          \"description\": \"Link URL\",\n          \"type\": \"string\"\n        }\n      },\n      \"x-go-package\": \"github.com/axllent/mailpit/internal/linkcheck\"\n    },\n    \"LinkCheckResponse\": {\n      \"description\": \"Response represents the Link check response\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"Errors\": {\n          \"description\": \"Total number of errors\",\n          \"type\": \"integer\",\n          \"format\": \"int64\"\n        },\n        \"Links\": {\n          \"description\": \"Tested links\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/Link\"\n          }\n        }\n      },\n      \"x-go-name\": \"Response\",\n      \"x-go-package\": \"github.com/axllent/mailpit/internal/linkcheck\"\n    },\n    \"ListUnsubscribe\": {\n      \"description\": \"ListUnsubscribe contains a summary of List-Unsubscribe \\u0026 List-Unsubscribe-Post headers\\nincluding validation of the link structure\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"Errors\": {\n          \"description\": \"Validation errors (if any)\",\n          \"type\": \"string\"\n        },\n        \"Header\": {\n          \"description\": \"List-Unsubscribe header value\",\n          \"type\": \"string\"\n        },\n        \"HeaderPost\": {\n          \"description\": \"List-Unsubscribe-Post value (if set)\",\n          \"type\": \"string\"\n        },\n        \"Links\": {\n          \"description\": \"Detected links, maximum one email and one HTTP(S) link\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"x-go-package\": \"github.com/axllent/mailpit/internal/storage\"\n    },\n    \"Message\": {\n      \"description\": \"Message data excluding physical attachments\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"Attachments\": {\n          \"description\": \"Message attachments\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/Attachment\"\n          }\n        },\n        \"Bcc\": {\n          \"description\": \"Bcc addresses\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/Address\"\n          }\n        },\n        \"Cc\": {\n          \"description\": \"Cc addresses\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/Address\"\n          }\n        },\n        \"Date\": {\n          \"description\": \"Message RFC3339Nano date \\u0026 time (if set), else date \\u0026 time received\\n([extended RFC3339](https://tools.ietf.org/html/rfc3339#section-5.6) format with optional nano seconds)\",\n          \"type\": \"string\",\n          \"format\": \"date-time\"\n        },\n        \"From\": {\n          \"$ref\": \"#/definitions/Address\"\n        },\n        \"HTML\": {\n          \"description\": \"Message body HTML\",\n          \"type\": \"string\"\n        },\n        \"ID\": {\n          \"description\": \"Database ID\",\n          \"type\": \"string\"\n        },\n        \"Inline\": {\n          \"description\": \"Inline message attachments\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/Attachment\"\n          }\n        },\n        \"ListUnsubscribe\": {\n          \"$ref\": \"#/definitions/ListUnsubscribe\"\n        },\n        \"MessageID\": {\n          \"description\": \"Message ID\",\n          \"type\": \"string\"\n        },\n        \"ReplyTo\": {\n          \"description\": \"ReplyTo addresses\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/Address\"\n          }\n        },\n        \"ReturnPath\": {\n          \"description\": \"Return-Path\",\n          \"type\": \"string\"\n        },\n        \"Size\": {\n          \"description\": \"Message size in bytes\",\n          \"type\": \"integer\",\n          \"format\": \"uint64\"\n        },\n        \"Subject\": {\n          \"description\": \"Message subject\",\n          \"type\": \"string\"\n        },\n        \"Tags\": {\n          \"description\": \"Message tags\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"Text\": {\n          \"description\": \"Message body text\",\n          \"type\": \"string\"\n        },\n        \"To\": {\n          \"description\": \"To addresses\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/Address\"\n          }\n        },\n        \"Username\": {\n          \"description\": \"Username used for authentication (if provided) with the SMTP or Send API\",\n          \"type\": \"string\"\n        }\n      },\n      \"x-go-package\": \"github.com/axllent/mailpit/internal/storage\"\n    },\n    \"MessageHeadersResponse\": {\n      \"description\": \"Message headers\",\n      \"type\": \"object\",\n      \"additionalProperties\": {\n        \"type\": \"array\",\n        \"items\": {\n          \"type\": \"string\"\n        }\n      },\n      \"x-go-name\": \"messageHeadersResponse\",\n      \"x-go-package\": \"github.com/axllent/mailpit/server/apiv1\"\n    },\n    \"MessageSummary\": {\n      \"description\": \"MessageSummary struct for frontend messages\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"Attachments\": {\n          \"description\": \"Whether the message has any attachments\",\n          \"type\": \"integer\",\n          \"format\": \"int64\"\n        },\n        \"Bcc\": {\n          \"description\": \"Bcc addresses\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/Address\"\n          }\n        },\n        \"Cc\": {\n          \"description\": \"Cc addresses\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/Address\"\n          }\n        },\n        \"Created\": {\n          \"description\": \"Received RFC3339Nano date \\u0026 time ([extended RFC3339](https://tools.ietf.org/html/rfc3339#section-5.6) format with optional nano seconds)\",\n          \"type\": \"string\",\n          \"format\": \"date-time\"\n        },\n        \"From\": {\n          \"$ref\": \"#/definitions/Address\"\n        },\n        \"ID\": {\n          \"description\": \"Database ID\",\n          \"type\": \"string\"\n        },\n        \"MessageID\": {\n          \"description\": \"Message ID\",\n          \"type\": \"string\"\n        },\n        \"Read\": {\n          \"description\": \"Read status\",\n          \"type\": \"boolean\"\n        },\n        \"ReplyTo\": {\n          \"description\": \"Reply-To address\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/Address\"\n          }\n        },\n        \"Size\": {\n          \"description\": \"Message size in bytes (total)\",\n          \"type\": \"integer\",\n          \"format\": \"uint64\"\n        },\n        \"Snippet\": {\n          \"description\": \"Message snippet includes up to 250 characters\",\n          \"type\": \"string\"\n        },\n        \"Subject\": {\n          \"description\": \"Email subject\",\n          \"type\": \"string\"\n        },\n        \"Tags\": {\n          \"description\": \"Message tags\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"To\": {\n          \"description\": \"To address\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/Address\"\n          }\n        },\n        \"Username\": {\n          \"description\": \"Username used for authentication (if provided) with the SMTP or Send API\",\n          \"type\": \"string\"\n        }\n      },\n      \"x-go-package\": \"github.com/axllent/mailpit/internal/storage\"\n    },\n    \"MessagesSummary\": {\n      \"description\": \"MessagesSummary is a summary of a list of messages\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"messages\": {\n          \"description\": \"Messages summary\\nin: body\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/MessageSummary\"\n          },\n          \"x-go-name\": \"Messages\"\n        },\n        \"messages_count\": {\n          \"description\": \"Total number of messages matching current query\",\n          \"type\": \"integer\",\n          \"format\": \"uint64\",\n          \"x-go-name\": \"MessagesCount\"\n        },\n        \"messages_unread\": {\n          \"description\": \"Total number of unread messages matching current query\",\n          \"type\": \"integer\",\n          \"format\": \"uint64\",\n          \"x-go-name\": \"MessagesUnreadCount\"\n        },\n        \"start\": {\n          \"description\": \"Pagination offset\",\n          \"type\": \"integer\",\n          \"format\": \"int64\",\n          \"x-go-name\": \"Start\"\n        },\n        \"tags\": {\n          \"description\": \"All current tags\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"x-go-name\": \"Tags\"\n        },\n        \"total\": {\n          \"description\": \"Total number of messages in mailbox\",\n          \"type\": \"integer\",\n          \"format\": \"uint64\",\n          \"x-go-name\": \"Total\"\n        },\n        \"unread\": {\n          \"description\": \"Total number of unread messages in mailbox\",\n          \"type\": \"integer\",\n          \"format\": \"uint64\",\n          \"x-go-name\": \"Unread\"\n        }\n      },\n      \"x-go-package\": \"github.com/axllent/mailpit/server/apiv1\"\n    },\n    \"Rule\": {\n      \"description\": \"Rule struct\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"Description\": {\n          \"description\": \"SpamAssassin rule description\",\n          \"type\": \"string\"\n        },\n        \"Name\": {\n          \"description\": \"SpamAssassin rule name\",\n          \"type\": \"string\"\n        },\n        \"Score\": {\n          \"description\": \"Spam rule score\",\n          \"type\": \"number\",\n          \"format\": \"double\"\n        }\n      },\n      \"x-go-package\": \"github.com/axllent/mailpit/internal/spamassassin\"\n    },\n    \"SpamAssassinResponse\": {\n      \"description\": \"Result is a SpamAssassin result\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"Error\": {\n          \"description\": \"If populated will return an error string\",\n          \"type\": \"string\"\n        },\n        \"IsSpam\": {\n          \"description\": \"Whether the message is spam or not\",\n          \"type\": \"boolean\"\n        },\n        \"Rules\": {\n          \"description\": \"Spam rules triggered\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/Rule\"\n          }\n        },\n        \"Score\": {\n          \"description\": \"Total spam score based on triggered rules\",\n          \"type\": \"number\",\n          \"format\": \"double\"\n        }\n      },\n      \"x-go-name\": \"Result\",\n      \"x-go-package\": \"github.com/axllent/mailpit/internal/spamassassin\"\n    }\n  },\n  \"responses\": {\n    \"AppInfoResponse\": {\n      \"description\": \"Application information\",\n      \"schema\": {\n        \"$ref\": \"#/definitions/AppInformation\"\n      }\n    },\n    \"ArrayResponse\": {\n      \"description\": \"Plain JSON array response\",\n      \"schema\": {\n        \"type\": \"array\",\n        \"items\": {\n          \"type\": \"string\"\n        }\n      }\n    },\n    \"BinaryResponse\": {\n      \"description\": \"Binary data response which inherits the attachment's content type.\",\n      \"schema\": {\n        \"type\": \"string\"\n      }\n    },\n    \"ChaosResponse\": {\n      \"description\": \"Response for the Chaos triggers configuration\",\n      \"schema\": {\n        \"$ref\": \"#/definitions/ChaosTriggers\"\n      }\n    },\n    \"ErrorResponse\": {\n      \"description\": \"Server error will return with a 400 status code\\nwith the error message in the body\",\n      \"schema\": {\n        \"type\": \"string\"\n      }\n    },\n    \"HTMLResponse\": {\n      \"description\": \"HTML response\",\n      \"schema\": {\n        \"type\": \"string\"\n      }\n    },\n    \"JSONErrorResponse\": {\n      \"description\": \"JSON error response\",\n      \"schema\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"Error\": {\n            \"description\": \"Error message\",\n            \"type\": \"string\",\n            \"example\": \"invalid format\"\n          }\n        }\n      }\n    },\n    \"MessagesSummaryResponse\": {\n      \"description\": \"Summary of messages\",\n      \"schema\": {\n        \"$ref\": \"#/definitions/MessagesSummary\"\n      }\n    },\n    \"NotFoundResponse\": {\n      \"description\": \"Not found error will return a 404 status code\",\n      \"schema\": {\n        \"type\": \"string\"\n      }\n    },\n    \"OKResponse\": {\n      \"description\": \"Plain text \\\"ok\\\" response\",\n      \"schema\": {\n        \"type\": \"string\"\n      }\n    },\n    \"SendMessageResponse\": {\n      \"description\": \"Confirmation message for HTTP send API\",\n      \"schema\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"ID\": {\n            \"description\": \"Database ID\",\n            \"type\": \"string\",\n            \"example\": \"iAfZVVe2UQfNSG5BAjgYwa\"\n          }\n        }\n      }\n    },\n    \"TextResponse\": {\n      \"description\": \"Plain text response\",\n      \"schema\": {\n        \"type\": \"string\"\n      }\n    },\n    \"WebUIConfigurationResponse\": {\n      \"description\": \"Web UI configuration response\",\n      \"schema\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"ChaosEnabled\": {\n            \"description\": \"Whether Chaos support is enabled at runtime\",\n            \"type\": \"boolean\"\n          },\n          \"DuplicatesIgnored\": {\n            \"description\": \"Whether messages with duplicate IDs are ignored\",\n            \"type\": \"boolean\"\n          },\n          \"HideDeleteAllButton\": {\n            \"description\": \"Whether the delete button should be hidden\",\n            \"type\": \"boolean\"\n          },\n          \"Label\": {\n            \"description\": \"Optional label to identify this Mailpit instance\",\n            \"type\": \"string\"\n          },\n          \"MessageRelay\": {\n            \"description\": \"Message Relay information\",\n            \"type\": \"object\",\n            \"properties\": {\n              \"AllowedRecipients\": {\n                \"description\": \"Only allow relaying to these recipients (regex)\",\n                \"type\": \"string\"\n              },\n              \"BlockedRecipients\": {\n                \"description\": \"Block relaying to these recipients (regex)\",\n                \"type\": \"string\"\n              },\n              \"Enabled\": {\n                \"description\": \"Whether message relaying (release) is enabled\",\n                \"type\": \"boolean\"\n              },\n              \"OverrideFrom\": {\n                \"description\": \"Overrides the \\\"From\\\" address for all relayed messages\",\n                \"type\": \"string\"\n              },\n              \"PreserveMessageIDs\": {\n                \"description\": \"Preserve the original Message-IDs when relaying messages\",\n                \"type\": \"boolean\"\n              },\n              \"ReturnPath\": {\n                \"description\": \"Enforced Return-Path (if set) for relay bounces\",\n                \"type\": \"string\"\n              },\n              \"SMTPServer\": {\n                \"description\": \"The configured SMTP server address\",\n                \"type\": \"string\"\n              }\n            }\n          },\n          \"SpamAssassin\": {\n            \"description\": \"Whether SpamAssassin is enabled\",\n            \"type\": \"boolean\"\n          }\n        }\n      }\n    }\n  }\n}"
  },
  {
    "path": "server/ui-src/App.vue",
    "content": "<script>\nimport CommonMixins from \"./mixins/CommonMixins\";\nimport Favicon from \"./components/AppFavicon.vue\";\nimport AppBadge from \"./components/AppBadge.vue\";\nimport Notifications from \"./components/AppNotifications.vue\";\nimport EditTags from \"./components/EditTags.vue\";\nimport { mailbox } from \"./stores/mailbox\";\n\nexport default {\n\tcomponents: {\n\t\tFavicon,\n\t\tAppBadge,\n\t\tNotifications,\n\t\tEditTags,\n\t},\n\n\tmixins: [CommonMixins],\n\n\twatch: {\n\t\t$route() {\n\t\t\t// hide mobile menu on URL change\n\t\t\tthis.hideNav();\n\t\t},\n\t},\n\n\tbeforeMount() {\n\t\t// load global config\n\t\tthis.get(this.resolve(\"/api/v1/webui\"), false, (response) => {\n\t\t\tmailbox.uiConfig = response.data;\n\n\t\t\tif (mailbox.uiConfig.Label) {\n\t\t\t\tdocument.title = document.title + \" - \" + mailbox.uiConfig.Label;\n\t\t\t} else {\n\t\t\t\tdocument.title = document.title + \" - \" + location.hostname;\n\t\t\t}\n\t\t});\n\t},\n};\n</script>\n\n<template>\n\t<RouterView />\n\t<Favicon />\n\t<AppBadge />\n\t<Notifications />\n\t<EditTags />\n</template>\n"
  },
  {
    "path": "server/ui-src/app.js",
    "content": "import App from \"./App.vue\";\nimport router from \"./router\";\nimport { createApp } from \"vue\";\nimport mitt from \"mitt\";\n\nimport \"./assets/styles.scss\";\nimport \"bootstrap-icons/font/bootstrap-icons.scss\";\nimport \"bootstrap\";\nimport \"vue-css-donut-chart/src/styles/main.css\";\n\nconst app = createApp(App);\n\n// Global event bus used to subscribe to websocket events\n// such as message deletes, updates & truncation.\nconst eventBus = mitt();\napp.provide(\"eventBus\", eventBus);\n\napp.use(router);\napp.mount(\"#app\");\n"
  },
  {
    "path": "server/ui-src/assets/_bootstrap.scss",
    "content": "@import \"_bootstrap_variables\";\n\n// scss-docs-start import-stack\n// Configuration\n@import \"bootstrap/scss/functions\";\n@import \"bootstrap/scss/variables\";\n@import \"bootstrap/scss/variables-dark\";\n@import \"bootstrap/scss/maps\";\n@import \"bootstrap/scss/mixins\";\n@import \"bootstrap/scss/utilities\";\n\n// Layout & components\n@import \"bootstrap/scss/root\";\n@import \"bootstrap/scss/reboot\";\n@import \"bootstrap/scss/type\";\n@import \"bootstrap/scss/images\";\n@import \"bootstrap/scss/containers\";\n@import \"bootstrap/scss/grid\";\n@import \"bootstrap/scss/tables\";\n@import \"bootstrap/scss/forms\";\n@import \"bootstrap/scss/buttons\";\n@import \"bootstrap/scss/transitions\";\n@import \"bootstrap/scss/dropdown\";\n@import \"bootstrap/scss/button-group\";\n@import \"bootstrap/scss/nav\";\n@import \"bootstrap/scss/navbar\";\n@import \"bootstrap/scss/card\";\n@import \"bootstrap/scss/accordion\";\n// @import \"bootstrap/scss/breadcrumb\";\n// @import \"bootstrap/scss/pagination\";\n@import \"bootstrap/scss/badge\";\n@import \"bootstrap/scss/alert\";\n// @import \"bootstrap/scss/progress\";\n@import \"bootstrap/scss/list-group\";\n@import \"bootstrap/scss/close\";\n@import \"bootstrap/scss/toasts\";\n@import \"bootstrap/scss/modal\";\n@import \"bootstrap/scss/tooltip\";\n// @import \"bootstrap/scss/popover\";\n// @import \"bootstrap/scss/carousel\";\n@import \"bootstrap/scss/spinners\";\n@import \"bootstrap/scss/offcanvas\";\n// @import \"bootstrap/scss/popover\";\n@import \"bootstrap/scss/progress\";\n\n// Helpers\n@import \"bootstrap/scss/helpers\";\n\n// Utilities\n@import \"bootstrap/scss/utilities/api\";\n// scss-docs-end import-stack\n"
  },
  {
    "path": "server/ui-src/assets/_bootstrap_variables.scss",
    "content": "// Removed \"Noto Color Emoji\" from list re: https://github.com/axllent/mailpit/issues/92\n$font-family-sans-serif:\n    system-ui,\n    -apple-system,\n    \"Segoe UI\",\n    Roboto,\n    \"Helvetica Neue\",\n    \"Noto Sans\",\n    \"Liberation Sans\",\n    Arial,\n    sans-serif,\n    \"Apple Color Emoji\",\n    \"Segoe UI Emoji\",\n    \"Segoe UI Symbol\";\n\n$link-decoration: none;\n$primary: #2c3e50;\n$secondary: #495057;\n$list-group-disabled-color: #adb5bd;\n$enable-negative-margins: true;\n$body-color-dark: #e7eaed;\n$offcanvas-border-width: 0;\n$body-color: #080808;\n$btn-disabled-opacity: 0.4;\n"
  },
  {
    "path": "server/ui-src/assets/styles.scss",
    "content": "@import \"./bootstrap\";\n\n[v-cloak] {\n\tdisplay: none !important;\n}\n\n.navbar {\n\tz-index: 99;\n\n\t.navbar-brand {\n\t\tcolor: #2d4a5d;\n\t\ttransition: all 0.2s;\n\n\t\timg {\n\t\t\twidth: 40px;\n\t\t}\n\n\t\t@include media-breakpoint-down(md) {\n\t\t\tpadding: 0;\n\n\t\t\timg {\n\t\t\t\twidth: 35px;\n\t\t\t}\n\t\t}\n\t}\n}\n\n.navbar-brand {\n\tspan {\n\t\topacity: 0.8;\n\t\ttransition: all 0.5s;\n\t}\n\n\t&:hover {\n\t\tspan {\n\t\t\topacity: 1;\n\t\t}\n\t}\n}\n\n.nav-tabs .nav-link {\n\t@include media-breakpoint-down(xl) {\n\t\tpadding-left: 10px;\n\t\tpadding-right: 10px;\n\t}\n}\n\n:not(.text-view) > a:not(.no-icon) {\n\t&[href^=\"http://\"],\n\t&[href^=\"https://\"]\n\t{\n\t\t&:after {\n\t\t\tcontent: \"\\f1c5\";\n\t\t\tdisplay: inline-block;\n\t\t\tfont-family: \"bootstrap-icons\" !important;\n\t\t\tfont-style: normal;\n\t\t\tfont-weight: normal !important;\n\t\t\tfont-variant: normal;\n\t\t\ttext-transform: none;\n\t\t\tline-height: 1;\n\t\t\tvertical-align: -0.125em;\n\t\t\tmargin-left: 4px;\n\t\t}\n\t}\n}\n\n.link {\n\t@extend a;\n\tcursor: pointer;\n}\n\n.loader {\n\tposition: fixed;\n\ttop: 0;\n\tleft: 0;\n\twidth: 100%;\n\theight: 100%;\n\tbackground: rgba(255, 255, 255, 0.4);\n\tz-index: 1500;\n}\n\n// dark mode adjustments\n@include color-mode(dark) {\n\t.loader {\n\t\tbackground: rgba(0, 0, 0, 0.4);\n\t}\n\n\t.token.tag,\n\t.token.property {\n\t\tcolor: #ee6969;\n\t}\n\n\t.btn-outline-secondary {\n\t\tcolor: #9c9c9c;\n\n\t\t&:hover {\n\t\t\tcolor: $body-color-dark;\n\t\t}\n\t}\n}\n\n.text-spaces-nowrap {\n\twhite-space: pre;\n}\n\n.text-spaces {\n\twhite-space: pre-wrap;\n}\n\n#nav-plain-text .text-view,\n#nav-source {\n\twhite-space: pre;\n\tfont-family: \"Courier New\", Courier, System, fixed-width;\n\tfont-size: 0.85em;\n}\n\n#nav-html-source pre[class*=\"language-\"] code {\n\twhite-space: pre-wrap;\n}\n\n#nav-plain-text .text-view {\n\twhite-space: pre-wrap;\n}\n\n.messageHeaders {\n\tmargin: 15px 0 0;\n\n\tth {\n\t\tpadding-right: 1.5rem;\n\t\tfont-weight: normal;\n\t\tvertical-align: top;\n\t\tmin-width: 120px;\n\t}\n\n\ttd {\n\t\tvertical-align: top;\n\t}\n}\n\n#nav-html {\n\t@include media-breakpoint-up(md) {\n\t\tpadding-right: 1.5rem;\n\t}\n}\n\n#preview-html {\n\tmin-height: 300px;\n\n\t&.tablet,\n\t&.phone {\n\t\tborder: solid $gray-300 1px;\n\t}\n}\n\n#responsive-view {\n\tmargin: auto;\n\ttransition: width 0.5s;\n\tposition: relative;\n\n\t&.tablet,\n\t&.phone {\n\t\tborder-radius: 35px;\n\t\tbox-sizing: content-box;\n\t\tpadding-bottom: 76px;\n\t\tpadding-top: 54px;\n\t\tpadding-left: 10px;\n\t\tpadding-right: 10px;\n\t\tbackground: $gray-800;\n\n\t\tiframe {\n\t\t\theight: 100% !important;\n\t\t\tbackground: #fff;\n\t\t}\n\t}\n\n\t&.phone {\n\t\t&::before {\n\t\t\tborder-radius: 5px;\n\t\t\tbackground: $gray-600;\n\t\t\ttop: 22px;\n\t\t\tcontent: \"\";\n\t\t\tdisplay: block;\n\t\t\theight: 10px;\n\t\t\tleft: 50%;\n\t\t\tposition: absolute;\n\t\t\ttransform: translateX(-50%);\n\t\t\twidth: 80px;\n\t\t}\n\n\t\t&::after {\n\t\t\tborder-radius: 20px;\n\t\t\tbackground: $gray-900;\n\t\t\tbottom: 20px;\n\t\t\tcontent: \"\";\n\t\t\tdisplay: block;\n\t\t\twidth: 65px;\n\t\t\theight: 40px;\n\t\t\tleft: 50%;\n\t\t\tposition: absolute;\n\t\t\ttransform: translateX(-50%);\n\t\t}\n\t}\n\n\t&.tablet {\n\t\t&::before {\n\t\t\tborder-radius: 50%;\n\t\t\tborder: solid #b5b0b0 2px;\n\t\t\ttop: 22px;\n\t\t\tcontent: \"\";\n\t\t\tdisplay: block;\n\t\t\twidth: 10px;\n\t\t\theight: 10px;\n\t\t\tleft: 50%;\n\t\t\tposition: absolute;\n\t\t\ttransform: translateX(-50%);\n\t\t}\n\n\t\t&::after {\n\t\t\tborder-radius: 50%;\n\t\t\tborder: solid #b5b0b0 2px;\n\t\t\tbottom: 23px;\n\t\t\tcontent: \"\";\n\t\t\tdisplay: block;\n\t\t\twidth: 30px;\n\t\t\theight: 30px;\n\t\t\tleft: 50%;\n\t\t\tposition: absolute;\n\t\t\ttransform: translateX(-50%);\n\t\t}\n\t}\n}\n\n.messageHeaders {\n\tth {\n\t\tvertical-align: top;\n\t}\n}\n\n#message-page,\n#MessageList {\n\t.list-group-item.message:first-child {\n\t\tborder-top: 0;\n\t}\n\n\t.message:not(.active) {\n\t\tb {\n\t\t\tcolor: $list-group-color;\n\t\t}\n\n\t\t&.read {\n\t\t\tcolor: $text-muted;\n\n\t\t\t> div {\n\t\t\t\topacity: 0.5;\n\t\t\t}\n\n\t\t\tb {\n\t\t\t\tcolor: $list-group-color;\n\t\t\t}\n\t\t}\n\t\t&.selected {\n\t\t\tbackground: var(--bs-primary-bg-subtle);\n\t\t}\n\t}\n}\n\nbody.blur {\n\t.privacy {\n\t\tfilter: blur(3px);\n\t}\n}\n\n.card.attachment {\n\tcolor: $gray-800;\n\n\t.icon {\n\t\tposition: absolute;\n\t\ttop: 18px;\n\t\tleft: 0;\n\t\tright: 0;\n\t\tfont-size: 3.5rem;\n\t\ttext-align: center;\n\t\tcolor: $gray-300;\n\t}\n\n\t.card-body {\n\t\tposition: absolute;\n\t\ttop: 0;\n\t\tright: 0;\n\t\tbottom: 0;\n\t\tleft: 0;\n\t\toverflow: hidden;\n\t\topacity: 0;\n\t}\n\n\t.card-footer {\n\t\tbackground: $gray-300;\n\n\t\t.bi {\n\t\t\tfont-size: 1.3em;\n\t\t\tmargin-left: -10px;\n\t\t}\n\t}\n\n\t&:hover {\n\t\t.card-body {\n\t\t\topacity: 1;\n\t\t\tbackground: $gray-300;\n\t\t}\n\t}\n}\n\n.form-select.tag-selector {\n\tdisplay: none;\n}\n\n// dropdown doesn't always appear in correct position inside modals\n.dropdown.form-select {\n\tposition: relative !important;\n}\n\n.message {\n\t&.read {\n\t\t> div {\n\t\t\topacity: 0.7;\n\t\t}\n\t}\n}\n\n#message-view {\n\t.form-control.dropdown {\n\t\tpadding: 0;\n\t\tborder: 0;\n\n\t\tinput {\n\t\t\tfont-size: 0.875em;\n\t\t}\n\n\t\tdiv {\n\t\t\tcursor: text; // html5-tags\n\t\t}\n\t}\n}\n\n.dropdown-menu.checks {\n\t.dropdown-item {\n\t\tmin-width: 190px;\n\t}\n}\n\n// bootstrap5-tags\n.tags-badge {\n\tdisplay: flex;\n}\n\n#DownloadBtn {\n\t@include media-breakpoint-down(sm) {\n\t\tposition: static;\n\n\t\t.dropdown-menu {\n\t\t\tleft: 0;\n\t\t\tright: 0;\n\t\t}\n\t}\n}\n\n// HighlightJS for HTML rendering\n@import \"highlight.js/styles/github.css\";\n\n@include color-mode(dark) {\n\t@import \"highlight.js/scss/github-dark\";\n\n\t.hljs {\n\t\tbackground: transparent;\n\t}\n}\n\ncode[class*=\"language-\"],\npre[class*=\"language-\"] {\n\tfont-size: 0.85em;\n\ttext-align: left;\n\twhite-space: pre;\n\tword-spacing: normal;\n\tword-break: normal;\n\tword-wrap: normal;\n\tline-height: 1.5;\n\t-moz-tab-size: 4;\n\t-o-tab-size: 4;\n\ttab-size: 4;\n\t-webkit-hyphens: none;\n\t-moz-hyphens: none;\n\t-ms-hyphens: none;\n\thyphens: none;\n}\npre[class*=\"language-\"] {\n\tposition: relative;\n\toverflow: visible;\n}\npre[class*=\"language-\"] > code {\n\tposition: relative;\n\tz-index: 1;\n}\ncode[class*=\"language-\"] {\n\tmax-height: inherit;\n\theight: inherit;\n\tpadding: 0 1em;\n\tdisplay: block;\n\toverflow: auto;\n}\n:not(pre) > code[class*=\"language-\"],\npre[class*=\"language-\"] {\n\t// background-color: #fdfdfd;\n\t-webkit-box-sizing: border-box;\n\t-moz-box-sizing: border-box;\n\tbox-sizing: border-box;\n\tmargin-bottom: 1em;\n}\n:not(pre) > code[class*=\"language-\"] {\n\tposition: relative;\n\tpadding: 0.2em;\n\tborder-radius: 0.3em;\n\tcolor: #c92c2c;\n\tborder: 1px solid rgba(0, 0, 0, 0.1);\n\tdisplay: inline;\n\twhite-space: normal;\n}\n\n@media screen and (max-width: 767px) {\n\tpre[class*=\"language-\"]::after,\n\tpre[class*=\"language-\"]::before {\n\t\tbottom: 14px;\n\t\tbox-shadow: none;\n\t}\n}\n"
  },
  {
    "path": "server/ui-src/components/AjaxLoader.vue",
    "content": "<script>\nexport default {\n\tprops: {\n\t\tloading: {\n\t\t\ttype: Number,\n\t\t\tdefault: 0,\n\t\t},\n\t},\n};\n</script>\n\n<template>\n\t<div v-if=\"loading > 0\" class=\"loader\">\n\t\t<div class=\"d-flex justify-content-center align-items-center h-100\">\n\t\t\t<div class=\"spinner-border text-muted\" role=\"status\">\n\t\t\t\t<span class=\"visually-hidden\">Loading...</span>\n\t\t\t</div>\n\t\t</div>\n\t</div>\n</template>\n"
  },
  {
    "path": "server/ui-src/components/AppAbout.vue",
    "content": "<script>\nimport AjaxLoader from \"./AjaxLoader.vue\";\nimport Settings from \"./AppSettings.vue\";\nimport CommonMixins from \"../mixins/CommonMixins\";\nimport { mailbox } from \"../stores/mailbox\";\n\nexport default {\n\tcomponents: {\n\t\tAjaxLoader,\n\t\tSettings,\n\t},\n\n\tmixins: [CommonMixins],\n\n\tprops: {\n\t\tmodals: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\t},\n\n\tdata() {\n\t\treturn {\n\t\t\tmailbox,\n\t\t};\n\t},\n\n\tcomputed: {\n\t\tisEdgeBuild() {\n\t\t\tconst re = /^(v\\d+.\\d+.\\d+-)/i;\n\t\t\treturn re.test(mailbox.appInfo.Version);\n\t\t},\n\t},\n\n\tmethods: {\n\t\tloadInfo() {\n\t\t\tthis.get(this.resolve(\"/api/v1/info\"), false, (response) => {\n\t\t\t\tmailbox.appInfo = response.data;\n\t\t\t\tthis.modal(\"AppInfoModal\").show();\n\t\t\t});\n\t\t},\n\n\t\trequestNotifications() {\n\t\t\t// check if the browser supports notifications\n\t\t\tif (!(\"Notification\" in window)) {\n\t\t\t\talert(\"This browser does not support desktop notifications\");\n\t\t\t}\n\n\t\t\t// we need to ask the user for permission\n\t\t\telse if (Notification.permission !== \"denied\") {\n\t\t\t\tNotification.requestPermission().then((permission) => {\n\t\t\t\t\tif (permission === \"granted\") {\n\t\t\t\t\t\tmailbox.notificationsEnabled = true;\n\t\t\t\t\t}\n\n\t\t\t\t\tthis.modal(\"EnableNotificationsModal\").hide();\n\t\t\t\t});\n\t\t\t}\n\t\t},\n\t},\n};\n</script>\n\n<template>\n\t<template v-if=\"!modals\">\n\t\t<div class=\"bg-body ms-sm-n1 me-sm-n1 py-2 text-muted small about-mailpit\">\n\t\t\t<button class=\"text-muted btn btn-sm\" @click=\"loadInfo()\">\n\t\t\t\t<i class=\"bi bi-info-circle-fill me-1\"></i>\n\t\t\t\tAbout\n\t\t\t</button>\n\n\t\t\t<button\n\t\t\t\tclass=\"btn btn-sm btn-outline-secondary float-end\"\n\t\t\t\tdata-bs-toggle=\"modal\"\n\t\t\t\tdata-bs-target=\"#SettingsModal\"\n\t\t\t\ttitle=\"Mailpit UI settings\"\n\t\t\t>\n\t\t\t\t<i class=\"bi bi-gear-fill\"></i>\n\t\t\t</button>\n\n\t\t\t<button\n\t\t\t\tv-if=\"mailbox.connected && mailbox.notificationsSupported && !mailbox.notificationsEnabled\"\n\t\t\t\tclass=\"btn btn-sm btn-outline-secondary float-end me-2\"\n\t\t\t\tdata-bs-toggle=\"modal\"\n\t\t\t\tdata-bs-target=\"#EnableNotificationsModal\"\n\t\t\t\ttitle=\"Enable browser notifications\"\n\t\t\t>\n\t\t\t\t<i class=\"bi bi-bell\"></i>\n\t\t\t</button>\n\t\t</div>\n\t</template>\n\n\t<template v-else>\n\t\t<!-- Modals -->\n\t\t<div\n\t\t\tid=\"AppInfoModal\"\n\t\t\tclass=\"modal modal-xl fade\"\n\t\t\ttabindex=\"-1\"\n\t\t\taria-labelledby=\"AppInfoModalLabel\"\n\t\t\taria-hidden=\"true\"\n\t\t>\n\t\t\t<div class=\"modal-dialog\">\n\t\t\t\t<div v-if=\"mailbox.appInfo.RuntimeStats\" class=\"modal-content\">\n\t\t\t\t\t<div class=\"modal-header\">\n\t\t\t\t\t\t<h5 id=\"AppInfoModalLabel\" class=\"modal-title\">\n\t\t\t\t\t\t\tMailpit\n\t\t\t\t\t\t\t<code>({{ mailbox.appInfo.Version }})</code>\n\t\t\t\t\t\t\t<span v-if=\"isEdgeBuild\" class=\"badge bg-info text-dark ms-2\">edge build</span>\n\t\t\t\t\t\t</h5>\n\t\t\t\t\t\t<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"modal-body\">\n\t\t\t\t\t\t<div class=\"row g-3\">\n\t\t\t\t\t\t\t<div class=\"col-xl-6\">\n\t\t\t\t\t\t\t\t<div v-if=\"mailbox.appInfo.LatestVersion != 'disabled'\">\n\t\t\t\t\t\t\t\t\t<div v-if=\"mailbox.appInfo.LatestVersion == ''\" class=\"row g-3\">\n\t\t\t\t\t\t\t\t\t\t<div class=\"col\">\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"alert alert-warning mb-3\">\n\t\t\t\t\t\t\t\t\t\t\t\tThere might be a newer version available. The check failed.\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\tv-else-if=\"mailbox.appInfo.Version != mailbox.appInfo.LatestVersion\"\n\t\t\t\t\t\t\t\t\t\tclass=\"row g-3\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<div class=\"col\">\n\t\t\t\t\t\t\t\t\t\t\t<a\n\t\t\t\t\t\t\t\t\t\t\t\tclass=\"btn btn-warning d-block mb-3\"\n\t\t\t\t\t\t\t\t\t\t\t\t:href=\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t'https://github.com/axllent/mailpit/releases/tag/' +\n\t\t\t\t\t\t\t\t\t\t\t\t\tmailbox.appInfo.LatestVersion\n\t\t\t\t\t\t\t\t\t\t\t\t\"\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\tA new version of Mailpit ({{ mailbox.appInfo.LatestVersion }}) is\n\t\t\t\t\t\t\t\t\t\t\t\tavailable.\n\t\t\t\t\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div class=\"row g-3\">\n\t\t\t\t\t\t\t\t\t<div class=\"col-12\">\n\t\t\t\t\t\t\t\t\t\t<RouterLink to=\"/api/v1/\" class=\"btn btn-primary w-100\" target=\"_blank\">\n\t\t\t\t\t\t\t\t\t\t\t<i class=\"bi bi-braces\"></i>\n\t\t\t\t\t\t\t\t\t\t\tOpenAPI / Swagger API documentation\n\t\t\t\t\t\t\t\t\t\t</RouterLink>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div class=\"col-sm-6\">\n\t\t\t\t\t\t\t\t\t\t<a\n\t\t\t\t\t\t\t\t\t\t\tclass=\"btn btn-primary w-100\"\n\t\t\t\t\t\t\t\t\t\t\thref=\"https://github.com/axllent/mailpit\"\n\t\t\t\t\t\t\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<i class=\"bi bi-github\"></i>\n\t\t\t\t\t\t\t\t\t\t\tGithub\n\t\t\t\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div class=\"col-sm-6\">\n\t\t\t\t\t\t\t\t\t\t<a\n\t\t\t\t\t\t\t\t\t\t\tclass=\"btn btn-primary w-100\"\n\t\t\t\t\t\t\t\t\t\t\thref=\"https://mailpit.axllent.org/docs/\"\n\t\t\t\t\t\t\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\tDocumentation\n\t\t\t\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div class=\"col-6\">\n\t\t\t\t\t\t\t\t\t\t<div class=\"card border-secondary text-center\">\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"card-header\">Database size</div>\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"card-body text-muted\">\n\t\t\t\t\t\t\t\t\t\t\t\t<h5 class=\"card-title\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t{{ getFileSize(mailbox.appInfo.DatabaseSize) }}\n\t\t\t\t\t\t\t\t\t\t\t\t</h5>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div class=\"col-6\">\n\t\t\t\t\t\t\t\t\t\t<div class=\"card border-secondary text-center\">\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"card-header\">RAM usage</div>\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"card-body text-muted\">\n\t\t\t\t\t\t\t\t\t\t\t\t<h5 class=\"card-title\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t{{ getFileSize(mailbox.appInfo.RuntimeStats.Memory) }}\n\t\t\t\t\t\t\t\t\t\t\t\t</h5>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div class=\"col-xl-6\">\n\t\t\t\t\t\t\t\t<div class=\"card border-secondary h-100\">\n\t\t\t\t\t\t\t\t\t<div class=\"card-header h4\">\n\t\t\t\t\t\t\t\t\t\tRuntime statistics\n\t\t\t\t\t\t\t\t\t\t<button class=\"btn btn-sm btn-outline-secondary float-end\" @click=\"loadInfo()\">\n\t\t\t\t\t\t\t\t\t\t\tRefresh\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div class=\"card-body text-muted\">\n\t\t\t\t\t\t\t\t\t\t<table class=\"table table-sm table-borderless mb-0\">\n\t\t\t\t\t\t\t\t\t\t\t<tbody>\n\t\t\t\t\t\t\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<td>Mailpit up since</td>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<td>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{{ secondsToRelative(mailbox.appInfo.RuntimeStats.Uptime) }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<td>Messages deleted</td>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<td>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{{ formatNumber(mailbox.appInfo.RuntimeStats.MessagesDeleted) }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<td>SMTP messages accepted</td>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<td>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPAccepted) }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<small class=\"text-muted\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t({{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tgetFileSize(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tmailbox.appInfo.RuntimeStats.SMTPAcceptedSize,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t}})\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</small>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<td>SMTP messages rejected</td>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<td>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPRejected) }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t\t\t\t<tr v-if=\"mailbox.uiConfig.DuplicatesIgnored\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<td>SMTP messages ignored</td>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<td>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPIgnored) }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t\t\t</tbody>\n\t\t\t\t\t\t\t\t\t\t</table>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"modal-footer\">\n\t\t\t\t\t\t<button type=\"button\" class=\"btn btn-outline-secondary\" data-bs-dismiss=\"modal\">Close</button>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\n\t\t<div\n\t\t\tid=\"EnableNotificationsModal\"\n\t\t\tclass=\"modal fade\"\n\t\t\ttabindex=\"-1\"\n\t\t\taria-labelledby=\"EnableNotificationsModalLabel\"\n\t\t\taria-hidden=\"true\"\n\t\t>\n\t\t\t<div class=\"modal-dialog modal-lg\">\n\t\t\t\t<div class=\"modal-content\">\n\t\t\t\t\t<div class=\"modal-header\">\n\t\t\t\t\t\t<h5 id=\"EnableNotificationsModalLabel\" class=\"modal-title\">Enable browser notifications?</h5>\n\t\t\t\t\t\t<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"modal-body\">\n\t\t\t\t\t\t<p class=\"h4\">Get browser notifications when Mailpit receives new messages?</p>\n\t\t\t\t\t\t<p>\n\t\t\t\t\t\t\tNote that your browser will ask you for confirmation when you click\n\t\t\t\t\t\t\t<code>enable notifications</code>, and that you must have Mailpit open in a browser tab to\n\t\t\t\t\t\t\tbe able to receive the notifications.\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"modal-footer\">\n\t\t\t\t\t\t<button type=\"button\" class=\"btn btn-outline-secondary\" data-bs-dismiss=\"modal\">Cancel</button>\n\t\t\t\t\t\t<button type=\"button\" class=\"btn btn-success\" @click=\"requestNotifications\">\n\t\t\t\t\t\t\tEnable notifications\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\n\t\t<Settings />\n\t</template>\n\n\t<AjaxLoader :loading=\"loading\" />\n</template>\n"
  },
  {
    "path": "server/ui-src/components/AppBadge.vue",
    "content": "<script>\nimport { mailbox } from \"../stores/mailbox.js\";\n\nexport default {\n\tdata() {\n\t\treturn {\n\t\t\tupdating: false,\n\t\t\tneedsUpdate: false,\n\t\t\ttimeout: 500,\n\t\t};\n\t},\n\n\tcomputed: {\n\t\tmailboxUnread() {\n\t\t\treturn mailbox.unread;\n\t\t},\n\t},\n\n\twatch: {\n\t\tmailboxUnread: {\n\t\t\thandler() {\n\t\t\t\tif (this.updating) {\n\t\t\t\t\tthis.needsUpdate = true;\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tthis.scheduleUpdate();\n\t\t\t},\n\t\t\timmediate: true,\n\t\t},\n\t},\n\n\tmethods: {\n\t\tscheduleUpdate() {\n\t\t\tthis.updating = true;\n\t\t\tthis.needsUpdate = false;\n\n\t\t\twindow.setTimeout(() => {\n\t\t\t\tthis.updateAppBadge();\n\t\t\t\tthis.updating = false;\n\n\t\t\t\tif (this.needsUpdate) {\n\t\t\t\t\tthis.scheduleUpdate();\n\t\t\t\t}\n\t\t\t}, this.timeout);\n\t\t},\n\n\t\tupdateAppBadge() {\n\t\t\tif (!(\"setAppBadge\" in navigator)) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tnavigator.setAppBadge(this.mailboxUnread);\n\t\t},\n\t},\n\n\trender() {\n\t\t// to remove webkit warnings about missing template or render function\n\t\treturn false;\n\t},\n};\n</script>\n"
  },
  {
    "path": "server/ui-src/components/AppFavicon.vue",
    "content": "<script>\nimport { mailbox } from \"../stores/mailbox.js\";\n\nexport default {\n\tdata() {\n\t\treturn {\n\t\t\tfavicon: false,\n\t\t\ticonPath: false,\n\t\t\ticonTextColor: \"#ffffff\",\n\t\t\ticonBgColor: \"#dd0000\",\n\t\t\ticonFontSize: 40,\n\t\t\ticonProcessing: false,\n\t\t\ticonTimeout: 500,\n\t\t};\n\t},\n\n\tcomputed: {\n\t\tcount() {\n\t\t\tlet i = mailbox.unread;\n\t\t\tif (i > 1000) {\n\t\t\t\ti = Math.floor(i / 1000) + \"k\";\n\t\t\t}\n\n\t\t\treturn i;\n\t\t},\n\t},\n\n\twatch: {\n\t\tcount() {\n\t\t\tif (!this.favicon || this.iconProcessing) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tthis.iconProcessing = true;\n\n\t\t\twindow.setTimeout(() => {\n\t\t\t\tthis.icoUpdate();\n\t\t\t}, this.iconTimeout);\n\t\t},\n\t},\n\n\tmounted() {\n\t\tthis.favicon = document.head.querySelector('link[rel=\"icon\"]');\n\t\tif (this.favicon) {\n\t\t\tthis.iconPath = this.favicon.href;\n\t\t}\n\t},\n\n\tmethods: {\n\t\tasync icoUpdate() {\n\t\t\tif (!this.favicon) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (!this.count) {\n\t\t\t\tthis.iconProcessing = false;\n\t\t\t\tthis.favicon.href = this.iconPath;\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tlet fontSize = this.iconFontSize;\n\t\t\t// Draw badge text\n\t\t\tlet textPaddingX = 7;\n\t\t\tconst textPaddingY = 3;\n\n\t\t\tconst strlen = this.count.toString().length;\n\n\t\t\tif (strlen > 2) {\n\t\t\t\t// if text >= 3 characters then reduce size and padding\n\t\t\t\ttextPaddingX = 4;\n\t\t\t\tfontSize = strlen > 3 ? 30 : 36;\n\t\t\t}\n\n\t\t\tconst canvas = document.createElement(\"canvas\");\n\t\t\tcanvas.width = 64;\n\t\t\tcanvas.height = 64;\n\n\t\t\tconst ctx = canvas.getContext(\"2d\");\n\n\t\t\t// Draw base icon\n\t\t\tconst icon = new Image();\n\t\t\ticon.src = this.iconPath;\n\t\t\tawait icon.decode();\n\n\t\t\tctx.drawImage(icon, 0, 0, 64, 64);\n\n\t\t\t// Measure text\n\t\t\tctx.font = `${fontSize}px Arial, sans-serif`;\n\t\t\tctx.textAlign = \"right\";\n\t\t\tctx.textBaseline = \"top\";\n\t\t\tconst textMetrics = ctx.measureText(this.count);\n\n\t\t\t// Draw badge\n\t\t\tconst paddingX = 7;\n\t\t\tconst paddingY = 4;\n\t\t\tconst cornerRadius = 8;\n\n\t\t\tconst width = textMetrics.width + paddingX * 2;\n\t\t\tconst height = fontSize + paddingY * 2;\n\t\t\tconst x = canvas.width - width;\n\t\t\tconst y = canvas.height - height - 1;\n\n\t\t\tctx.fillStyle = this.iconBgColor;\n\t\t\tctx.roundRect(x, y, width, height, cornerRadius);\n\t\t\tctx.fill();\n\n\t\t\tctx.fillStyle = this.iconTextColor;\n\t\t\tctx.fillText(this.count, canvas.width - textPaddingX, canvas.height - fontSize - textPaddingY);\n\n\t\t\tthis.iconProcessing = false;\n\n\t\t\tthis.favicon.href = canvas.toDataURL(\"image/png\");\n\t\t},\n\t},\n\n\trender() {\n\t\t// to remove webkit warnings about missing template or render function\n\t\treturn false;\n\t},\n};\n</script>\n"
  },
  {
    "path": "server/ui-src/components/AppNotifications.vue",
    "content": "<script>\nimport CommonMixins from \"../mixins/CommonMixins\";\nimport { Toast } from \"bootstrap\";\nimport { mailbox } from \"../stores/mailbox\";\nimport { pagination } from \"../stores/pagination\";\n\nexport default {\n\tmixins: [CommonMixins],\n\n\t// global event bus to handle message status changes\n\tinject: [\"eventBus\"],\n\n\tdata() {\n\t\treturn {\n\t\t\tpagination,\n\t\t\tmailbox,\n\t\t\ttoastMessage: false,\n\t\t\treconnectRefresh: false,\n\t\t\tsocketURI: false,\n\t\t\tsocketLastConnection: 0, // timestamp to track reconnection times & avoid reloading mailbox on short disconnections\n\t\t\tsocketBreaks: 0, // to track sockets that continually connect & disconnect, reset every 15s\n\t\t\tpauseNotifications: false, // prevent spamming\n\t\t\tversion: false,\n\t\t\tclientErrors: [], // errors received via websocket\n\t\t};\n\t},\n\n\tmounted() {\n\t\tconst d = document.getElementById(\"app\");\n\t\tif (d) {\n\t\t\tthis.version = d.dataset.version;\n\t\t}\n\n\t\tconst proto = location.protocol === \"https:\" ? \"wss\" : \"ws\";\n\t\tthis.socketURI = proto + \"://\" + document.location.host + this.resolve(`/api/events`);\n\n\t\tthis.socketBreakReset();\n\t\tthis.connect();\n\n\t\tmailbox.notificationsSupported =\n\t\t\twindow.isSecureContext && \"Notification\" in window && Notification.permission !== \"denied\";\n\t\tmailbox.notificationsEnabled = mailbox.notificationsSupported && Notification.permission === \"granted\";\n\n\t\tthis.errorNotificationCron();\n\t},\n\n\tmethods: {\n\t\t// websocket connect\n\t\tconnect() {\n\t\t\tconst ws = new WebSocket(this.socketURI);\n\t\t\tws.onmessage = (e) => {\n\t\t\t\tlet response;\n\t\t\t\ttry {\n\t\t\t\t\tresponse = JSON.parse(e.data);\n\t\t\t\t} catch {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\t// new messages\n\t\t\t\tif (response.Type === \"new\" && response.Data) {\n\t\t\t\t\tthis.eventBus.emit(\"new\", response.Data);\n\n\t\t\t\t\tfor (const i in response.Data.Tags) {\n\t\t\t\t\t\tif (\n\t\t\t\t\t\t\tmailbox.tags.findIndex((e) => {\n\t\t\t\t\t\t\t\treturn e.toLowerCase() === response.Data.Tags[i].toLowerCase();\n\t\t\t\t\t\t\t}) < 0\n\t\t\t\t\t\t) {\n\t\t\t\t\t\t\tmailbox.tags.push(response.Data.Tags[i]);\n\t\t\t\t\t\t\tmailbox.tags.sort((a, b) => {\n\t\t\t\t\t\t\t\treturn a.toLowerCase().localeCompare(b.toLowerCase());\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\t// send notifications\n\t\t\t\t\tif (!this.pauseNotifications) {\n\t\t\t\t\t\tthis.pauseNotifications = true;\n\t\t\t\t\t\tconst from = response.Data.From !== null ? response.Data.From.Address : \"[unknown]\";\n\t\t\t\t\t\tconst subject = String(response.Data.Subject ?? \"\").substring(0, 100);\n\t\t\t\t\t\tthis.browserNotify(\"New mail from: \" + from, subject);\n\t\t\t\t\t\tthis.setMessageToast(response.Data);\n\t\t\t\t\t\t// delay notifications by 2s\n\t\t\t\t\t\twindow.setTimeout(() => {\n\t\t\t\t\t\t\tthis.pauseNotifications = false;\n\t\t\t\t\t\t}, 2000);\n\t\t\t\t\t}\n\t\t\t\t} else if (response.Type === \"prune\") {\n\t\t\t\t\t// messages have been deleted, reload messages to adjust\n\t\t\t\t\twindow.scrollInPlace = true;\n\t\t\t\t\tmailbox.refresh = true; // trigger refresh\n\t\t\t\t\twindow.setTimeout(() => {\n\t\t\t\t\t\tmailbox.refresh = false;\n\t\t\t\t\t}, 500);\n\t\t\t\t\tthis.eventBus.emit(\"prune\");\n\t\t\t\t} else if (response.Type === \"stats\" && response.Data) {\n\t\t\t\t\t// refresh mailbox stats\n\t\t\t\t\tmailbox.total = response.Data.Total;\n\t\t\t\t\tmailbox.unread = response.Data.Unread;\n\n\t\t\t\t\t// detect version updated, refresh is needed\n\t\t\t\t\tif (this.version !== response.Data.Version) {\n\t\t\t\t\t\tlocation.reload();\n\t\t\t\t\t}\n\t\t\t\t} else if (response.Type === \"delete\" && response.Data) {\n\t\t\t\t\t// broadcast for components\n\t\t\t\t\tthis.eventBus.emit(\"delete\", response.Data);\n\t\t\t\t} else if (response.Type === \"update\" && response.Data) {\n\t\t\t\t\t// broadcast for components\n\t\t\t\t\tthis.eventBus.emit(\"update\", response.Data);\n\t\t\t\t} else if (response.Type === \"truncate\") {\n\t\t\t\t\t// broadcast for components\n\t\t\t\t\tthis.eventBus.emit(\"truncate\");\n\t\t\t\t} else if (response.Type === \"error\") {\n\t\t\t\t\t// broadcast for components\n\t\t\t\t\tthis.addClientError(response.Data);\n\t\t\t\t}\n\t\t\t};\n\n\t\t\tws.onopen = () => {\n\t\t\t\tmailbox.connected = true;\n\t\t\t\tthis.socketLastConnection = Date.now();\n\t\t\t\tif (this.reconnectRefresh) {\n\t\t\t\t\tthis.reconnectRefresh = false;\n\t\t\t\t\tmailbox.refresh = true; // trigger refresh\n\t\t\t\t\twindow.setTimeout(() => {\n\t\t\t\t\t\tmailbox.refresh = false;\n\t\t\t\t\t}, 500);\n\t\t\t\t}\n\t\t\t};\n\n\t\t\tws.onclose = () => {\n\t\t\t\tif (this.socketLastConnection === 0) {\n\t\t\t\t\t// connection failed immediately after connecting to Mailpit implies proxy websockets aren't configured\n\t\t\t\t\tconsole.log(\"Unable to connect to websocket, disabling websocket support\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tif (mailbox.connected) {\n\t\t\t\t\t// count disconnections\n\t\t\t\t\tthis.socketBreaks++;\n\t\t\t\t}\n\n\t\t\t\t// set disconnected state\n\t\t\t\tmailbox.connected = false;\n\n\t\t\t\tif (this.socketBreaks > 3) {\n\t\t\t\t\t// give up after > 3 successful socket connections & disconnections within a 15 second window,\n\t\t\t\t\t// something is not working right on their end, see issue #319\n\t\t\t\t\tconsole.log(\"Unstable websocket connection, disabling websocket support\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (Date.now() - this.socketLastConnection > 5000) {\n\t\t\t\t\t// only refresh mailbox if the last successful connection was broken for > 5 seconds\n\t\t\t\t\tthis.reconnectRefresh = true;\n\t\t\t\t} else {\n\t\t\t\t\tthis.reconnectRefresh = false;\n\t\t\t\t}\n\n\t\t\t\tsetTimeout(() => {\n\t\t\t\t\tthis.connect(); // reconnect\n\t\t\t\t}, 1000);\n\t\t\t};\n\n\t\t\tws.onerror = function () {\n\t\t\t\tws.close();\n\t\t\t};\n\t\t},\n\n\t\tsocketBreakReset() {\n\t\t\twindow.setTimeout(() => {\n\t\t\t\tthis.socketBreaks = 0;\n\t\t\t\tthis.socketBreakReset();\n\t\t\t}, 15000);\n\t\t},\n\n\t\tbrowserNotify(title, message) {\n\t\t\tif (!(\"Notification\" in window)) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (Notification.permission === \"granted\") {\n\t\t\t\tconst options = {\n\t\t\t\t\tbody: message,\n\t\t\t\t\ticon: this.resolve(\"/notification.png\"),\n\t\t\t\t};\n\n\t\t\t\t(() => new Notification(title, options))();\n\t\t\t}\n\t\t},\n\n\t\tsetMessageToast(m) {\n\t\t\t// don't display if browser notifications are enabled, or a toast is already displayed\n\t\t\tif (mailbox.notificationsEnabled || this.toastMessage) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tthis.toastMessage = m;\n\n\t\t\tconst el = document.getElementById(\"messageToast\");\n\t\t\tif (el) {\n\t\t\t\tel.addEventListener(\"hidden.bs.toast\", () => {\n\t\t\t\t\tthis.toastMessage = false;\n\t\t\t\t});\n\n\t\t\t\tToast.getOrCreateInstance(el).show();\n\t\t\t}\n\t\t},\n\n\t\tcloseToast() {\n\t\t\tconst el = document.getElementById(\"messageToast\");\n\t\t\tif (el) {\n\t\t\t\tToast.getOrCreateInstance(el).hide();\n\t\t\t}\n\t\t},\n\n\t\taddClientError(d) {\n\t\t\td.expire = Date.now() + 5000; // expire after 5s\n\t\t\tthis.clientErrors.push(d);\n\t\t},\n\n\t\terrorNotificationCron() {\n\t\t\twindow.setTimeout(() => {\n\t\t\t\tthis.clientErrors.forEach((err, idx) => {\n\t\t\t\t\tif (err.expire < Date.now()) {\n\t\t\t\t\t\tthis.clientErrors.splice(idx, 1);\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t\tthis.errorNotificationCron();\n\t\t\t}, 1000);\n\t\t},\n\t},\n};\n</script>\n\n<template>\n\t<div class=\"toast-container position-fixed bottom-0 end-0 p-3\">\n\t\t<div\n\t\t\tv-for=\"(error, i) in clientErrors\"\n\t\t\t:key=\"'error_' + i\"\n\t\t\tclass=\"toast show\"\n\t\t\trole=\"alert\"\n\t\t\taria-live=\"assertive\"\n\t\t\taria-atomic=\"true\"\n\t\t>\n\t\t\t<div class=\"toast-header\">\n\t\t\t\t<svg\n\t\t\t\t\tclass=\"bd-placeholder-img rounded me-2\"\n\t\t\t\t\twidth=\"20\"\n\t\t\t\t\theight=\"20\"\n\t\t\t\t\txmlns=\"http://www.w3.org/2000/svg\"\n\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\tpreserveAspectRatio=\"xMidYMid slice\"\n\t\t\t\t\tfocusable=\"false\"\n\t\t\t\t>\n\t\t\t\t\t<rect width=\"100%\" height=\"100%\" :fill=\"error.Level === 'warning' ? '#ffc107' : '#dc3545'\"></rect>\n\t\t\t\t</svg>\n\t\t\t\t<strong class=\"me-auto\">{{ error.Type }}</strong>\n\t\t\t\t<small class=\"text-body-secondary\">{{ error.IP }}</small>\n\t\t\t\t<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"toast\" aria-label=\"Close\"></button>\n\t\t\t</div>\n\t\t\t<div class=\"toast-body\">\n\t\t\t\t{{ error.Message }}\n\t\t\t</div>\n\t\t</div>\n\n\t\t<div id=\"messageToast\" class=\"toast\" role=\"alert\" aria-live=\"assertive\" aria-atomic=\"true\">\n\t\t\t<div v-if=\"toastMessage\" class=\"toast-header\">\n\t\t\t\t<i class=\"bi bi-envelope-exclamation-fill me-2\"></i>\n\t\t\t\t<strong class=\"me-auto\">\n\t\t\t\t\t<RouterLink :to=\"'/view/' + toastMessage.ID\" @click=\"closeToast\">New message</RouterLink>\n\t\t\t\t</strong>\n\t\t\t\t<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"toast\" aria-label=\"Close\"></button>\n\t\t\t</div>\n\n\t\t\t<div class=\"toast-body\">\n\t\t\t\t<div>\n\t\t\t\t\t<RouterLink\n\t\t\t\t\t\t:to=\"'/view/' + toastMessage.ID\"\n\t\t\t\t\t\tclass=\"d-block text-truncate text-body-secondary\"\n\t\t\t\t\t\t@click=\"closeToast\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<template v-if=\"toastMessage.Subject !== ''\">{{ toastMessage.Subject }}</template>\n\t\t\t\t\t\t<template v-else> [ no subject ] </template>\n\t\t\t\t\t</RouterLink>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t</div>\n</template>\n"
  },
  {
    "path": "server/ui-src/components/AppSettings.vue",
    "content": "<script>\nimport CommonMixins from \"../mixins/CommonMixins\";\nimport Tags from \"bootstrap5-tags\";\nimport timezones from \"timezones-list\";\nimport { mailbox } from \"../stores/mailbox\";\n\nexport default {\n\tmixins: [CommonMixins],\n\n\tdata() {\n\t\treturn {\n\t\t\tmailbox,\n\t\t\ttheme: localStorage.getItem(\"theme\") ? localStorage.getItem(\"theme\") : \"auto\",\n\t\t\ttimezones,\n\t\t\tchaosConfig: false,\n\t\t\tchaosUpdated: false,\n\t\t\tdefaultReleaseAddressesOptions: mailbox.defaultReleaseAddresses.slice(), // set with default release addresses\n\t\t};\n\t},\n\n\twatch: {\n\t\ttheme(v) {\n\t\t\tif (v === \"auto\") {\n\t\t\t\tlocalStorage.removeItem(\"theme\");\n\t\t\t} else {\n\t\t\t\tlocalStorage.setItem(\"theme\", v);\n\t\t\t}\n\t\t\tthis.setTheme();\n\t\t},\n\n\t\tchaosConfig: {\n\t\t\thandler() {\n\t\t\t\tthis.chaosUpdated = true;\n\t\t\t},\n\t\t\tdeep: true,\n\t\t},\n\n\t\t\"mailbox.skipConfirmations\"(v) {\n\t\t\tif (v) {\n\t\t\t\tlocalStorage.setItem(\"skip-confirmations\", \"true\");\n\t\t\t} else {\n\t\t\t\tlocalStorage.removeItem(\"skip-confirmations\");\n\t\t\t}\n\t\t},\n\t},\n\n\tmounted() {\n\t\tthis.setTheme();\n\n\t\tmailbox.skipConfirmations = localStorage.getItem(\"skip-confirmations\");\n\n\t\twindow.setTimeout(() => {\n\t\t\tTags.init(\"select.tz\");\n\t\t\tTags.init(\"select.default-release-addresses\");\n\t\t}, 500);\n\t},\n\n\tmethods: {\n\t\tsetTheme() {\n\t\t\tif (this.theme === \"auto\" && window.matchMedia(\"(prefers-color-scheme: dark)\").matches) {\n\t\t\t\tdocument.documentElement.setAttribute(\"data-bs-theme\", \"dark\");\n\t\t\t} else {\n\t\t\t\tdocument.documentElement.setAttribute(\"data-bs-theme\", this.theme);\n\t\t\t}\n\t\t},\n\n\t\tloadChaos() {\n\t\t\tthis.get(this.resolve(\"/api/v1/chaos\"), null, (response) => {\n\t\t\t\tthis.chaosConfig = response.data;\n\t\t\t\tthis.$nextTick(() => {\n\t\t\t\t\tthis.chaosUpdated = false;\n\t\t\t\t});\n\t\t\t});\n\t\t},\n\n\t\tsaveChaos() {\n\t\t\tthis.put(this.resolve(\"/api/v1/chaos\"), this.chaosConfig, (response) => {\n\t\t\t\tthis.chaosConfig = response.data;\n\t\t\t\tthis.$nextTick(() => {\n\t\t\t\t\tthis.chaosUpdated = false;\n\t\t\t\t});\n\t\t\t});\n\t\t},\n\t},\n};\n</script>\n\n<template>\n\t<div\n\t\tid=\"SettingsModal\"\n\t\tclass=\"modal fade\"\n\t\ttabindex=\"-1\"\n\t\taria-labelledby=\"SettingsModalLabel\"\n\t\taria-hidden=\"true\"\n\t\tdata-bs-keyboard=\"false\"\n\t>\n\t\t<div class=\"modal-dialog modal-lg\">\n\t\t\t<div class=\"modal-content\">\n\t\t\t\t<div class=\"modal-header\">\n\t\t\t\t\t<h5 id=\"SettingsModalLabel\" class=\"modal-title\">Mailpit settings</h5>\n\t\t\t\t\t<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"modal-body\">\n\t\t\t\t\t<ul id=\"myTab\" class=\"nav nav-tabs\" role=\"tablist\">\n\t\t\t\t\t\t<li class=\"nav-item\" role=\"presentation\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tid=\"ui-tab\"\n\t\t\t\t\t\t\t\tclass=\"nav-link active\"\n\t\t\t\t\t\t\t\tdata-bs-toggle=\"tab\"\n\t\t\t\t\t\t\t\tdata-bs-target=\"#ui-tab-pane\"\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\trole=\"tab\"\n\t\t\t\t\t\t\t\taria-controls=\"ui-tab-pane\"\n\t\t\t\t\t\t\t\taria-selected=\"true\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tWeb UI\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</li>\n\t\t\t\t\t\t<li\n\t\t\t\t\t\t\tv-if=\"mailbox.uiConfig.MessageRelay && mailbox.uiConfig.MessageRelay.Enabled\"\n\t\t\t\t\t\t\tclass=\"nav-item\"\n\t\t\t\t\t\t\trole=\"presentation\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tid=\"relay-tab\"\n\t\t\t\t\t\t\t\tclass=\"nav-link\"\n\t\t\t\t\t\t\t\tdata-bs-toggle=\"tab\"\n\t\t\t\t\t\t\t\tdata-bs-target=\"#relay-tab-pane\"\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\trole=\"tab\"\n\t\t\t\t\t\t\t\taria-controls=\"relay-tab-pane\"\n\t\t\t\t\t\t\t\taria-selected=\"false\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tMessage release\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</li>\n\t\t\t\t\t\t<li v-if=\"mailbox.uiConfig.ChaosEnabled\" class=\"nav-item\" role=\"presentation\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tid=\"chaos-tab\"\n\t\t\t\t\t\t\t\tclass=\"nav-link\"\n\t\t\t\t\t\t\t\tdata-bs-toggle=\"tab\"\n\t\t\t\t\t\t\t\tdata-bs-target=\"#chaos-tab-pane\"\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\trole=\"tab\"\n\t\t\t\t\t\t\t\taria-controls=\"chaos-tab-pane\"\n\t\t\t\t\t\t\t\taria-selected=\"false\"\n\t\t\t\t\t\t\t\t@click=\"loadChaos\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tChaos\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</li>\n\t\t\t\t\t</ul>\n\n\t\t\t\t\t<div class=\"tab-content\">\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tid=\"ui-tab-pane\"\n\t\t\t\t\t\t\tclass=\"tab-pane fade show active\"\n\t\t\t\t\t\t\trole=\"tabpanel\"\n\t\t\t\t\t\t\taria-labelledby=\"ui-tab\"\n\t\t\t\t\t\t\ttabindex=\"0\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div class=\"my-3\">\n\t\t\t\t\t\t\t\t<label for=\"theme\" class=\"form-label\">Mailpit theme</label>\n\t\t\t\t\t\t\t\t<select id=\"theme\" v-model=\"theme\" class=\"form-select\">\n\t\t\t\t\t\t\t\t\t<option value=\"auto\">Auto (detect from browser)</option>\n\t\t\t\t\t\t\t\t\t<option value=\"light\">Light theme</option>\n\t\t\t\t\t\t\t\t\t<option value=\"dark\">Dark theme</option>\n\t\t\t\t\t\t\t\t</select>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div class=\"mb-3\">\n\t\t\t\t\t\t\t\t<label for=\"timezone\" class=\"form-label\">Timezone (for date searches)</label>\n\t\t\t\t\t\t\t\t<select\n\t\t\t\t\t\t\t\t\tid=\"timezone\"\n\t\t\t\t\t\t\t\t\tv-model=\"mailbox.timeZone\"\n\t\t\t\t\t\t\t\t\tclass=\"form-select tz\"\n\t\t\t\t\t\t\t\t\tdata-allow-same=\"true\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<option disabled hidden value=\"\">Select a timezone...</option>\n\t\t\t\t\t\t\t\t\t<option v-for=\"t in timezones\" :key=\"t\" :value=\"t.tzCode\">{{ t.label }}</option>\n\t\t\t\t\t\t\t\t</select>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div class=\"mb-3\">\n\t\t\t\t\t\t\t\t<div class=\"form-check form-switch\">\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\tid=\"tagColors\"\n\t\t\t\t\t\t\t\t\t\tv-model=\"mailbox.showTagColors\"\n\t\t\t\t\t\t\t\t\t\tclass=\"form-check-input\"\n\t\t\t\t\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\t\t\t\t\trole=\"switch\"\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t<label class=\"form-check-label\" for=\"tagColors\">\n\t\t\t\t\t\t\t\t\t\tUse auto-generated tag colors\n\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div class=\"mb-3\">\n\t\t\t\t\t\t\t\t<div class=\"form-check form-switch\">\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\tid=\"htmlCheck\"\n\t\t\t\t\t\t\t\t\t\tv-model=\"mailbox.showHTMLCheck\"\n\t\t\t\t\t\t\t\t\t\tclass=\"form-check-input\"\n\t\t\t\t\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\t\t\t\t\trole=\"switch\"\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t<label class=\"form-check-label\" for=\"htmlCheck\">\n\t\t\t\t\t\t\t\t\t\tShow HTML check message tab\n\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div class=\"mb-3\">\n\t\t\t\t\t\t\t\t<div class=\"form-check form-switch\">\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\tid=\"linkCheck\"\n\t\t\t\t\t\t\t\t\t\tv-model=\"mailbox.showLinkCheck\"\n\t\t\t\t\t\t\t\t\t\tclass=\"form-check-input\"\n\t\t\t\t\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\t\t\t\t\trole=\"switch\"\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t<label class=\"form-check-label\" for=\"linkCheck\">\n\t\t\t\t\t\t\t\t\t\tShow link check message tab\n\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div v-if=\"mailbox.uiConfig.SpamAssassin\" class=\"mb-3\">\n\t\t\t\t\t\t\t\t<div class=\"form-check form-switch\">\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\tid=\"spamCheck\"\n\t\t\t\t\t\t\t\t\t\tv-model=\"mailbox.showSpamCheck\"\n\t\t\t\t\t\t\t\t\t\tclass=\"form-check-input\"\n\t\t\t\t\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\t\t\t\t\trole=\"switch\"\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t<label class=\"form-check-label\" for=\"spamCheck\">\n\t\t\t\t\t\t\t\t\t\tShow spam check message tab\n\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div class=\"mb-3\">\n\t\t\t\t\t\t\t\t<div class=\"form-check form-switch\">\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\tid=\"skip-confirmations\"\n\t\t\t\t\t\t\t\t\t\tv-model=\"mailbox.skipConfirmations\"\n\t\t\t\t\t\t\t\t\t\tclass=\"form-check-input\"\n\t\t\t\t\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\t\t\t\t\trole=\"switch\"\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t<label class=\"form-check-label\" for=\"skip-confirmations\">\n\t\t\t\t\t\t\t\t\t\tSkip\n\t\t\t\t\t\t\t\t\t\t<template v-if=\"!mailbox.uiConfig.HideDeleteAllButton\">\n\t\t\t\t\t\t\t\t\t\t\t<code>Delete all</code> &amp;\n\t\t\t\t\t\t\t\t\t\t</template>\n\t\t\t\t\t\t\t\t\t\t<code>Mark all read</code> confirmation dialogs\n\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<!-- Default relay addresses -->\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tv-if=\"mailbox.uiConfig.MessageRelay && mailbox.uiConfig.MessageRelay.Enabled\"\n\t\t\t\t\t\t\tid=\"relay-tab-pane\"\n\t\t\t\t\t\t\tclass=\"tab-pane fade\"\n\t\t\t\t\t\t\trole=\"tabpanel\"\n\t\t\t\t\t\t\taria-labelledby=\"relay-tab\"\n\t\t\t\t\t\t\ttabindex=\"0\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div class=\"my-3 mb-5\">\n\t\t\t\t\t\t\t\t<label class=\"form-label\">Default release address(es)</label>\n\t\t\t\t\t\t\t\t<div class=\"form-text mb-2\">\n\t\t\t\t\t\t\t\t\tYou can designate the default \"send to\" addresses here, which will automatically\n\t\t\t\t\t\t\t\t\tpopulate the field in the message release dialog. This setting applies only to your\n\t\t\t\t\t\t\t\t\tbrowser. If this field is left empty, it will revert to the original recipients of\n\t\t\t\t\t\t\t\t\tthe message.\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<select\n\t\t\t\t\t\t\t\t\tv-model=\"mailbox.defaultReleaseAddresses\"\n\t\t\t\t\t\t\t\t\tclass=\"form-select tag-selector default-release-addresses\"\n\t\t\t\t\t\t\t\t\tmultiple\n\t\t\t\t\t\t\t\t\tdata-allow-new=\"true\"\n\t\t\t\t\t\t\t\t\tdata-clear-end=\"true\"\n\t\t\t\t\t\t\t\t\tdata-allow-clear=\"true\"\n\t\t\t\t\t\t\t\t\tdata-placeholder=\"Enter email addresses...\"\n\t\t\t\t\t\t\t\t\tdata-add-on-blur=\"true\"\n\t\t\t\t\t\t\t\t\tdata-badge-style=\"primary\"\n\t\t\t\t\t\t\t\t\tdata-regex='^(([^<>()[\\]\\\\.,;:\\s@\"]+(\\.[^<>()[\\]\\\\.,;:\\s@\"]+)*)|.(\".+\"))@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\])|(([a-zA-Z\\-0-9]+\\.)+[a-zA-Z]{2,}))$'\n\t\t\t\t\t\t\t\t\tdata-separator=\"|,|\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<option value=\"\">Enter email addresses...</option>\n\t\t\t\t\t\t\t\t\t<!-- you need at least one option with the placeholder -->\n\t\t\t\t\t\t\t\t\t<option\n\t\t\t\t\t\t\t\t\t\tv-for=\"t in defaultReleaseAddressesOptions\"\n\t\t\t\t\t\t\t\t\t\t:key=\"'address+' + t\"\n\t\t\t\t\t\t\t\t\t\t:value=\"t\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{{ t }}\n\t\t\t\t\t\t\t\t\t</option>\n\t\t\t\t\t\t\t\t</select>\n\t\t\t\t\t\t\t\t<div class=\"invalid-feedback\">Invalid email address</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tv-if=\"mailbox.uiConfig.ChaosEnabled\"\n\t\t\t\t\t\t\tid=\"chaos-tab-pane\"\n\t\t\t\t\t\t\tclass=\"tab-pane fade\"\n\t\t\t\t\t\t\trole=\"tabpanel\"\n\t\t\t\t\t\t\taria-labelledby=\"chaos-tab\"\n\t\t\t\t\t\t\ttabindex=\"0\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<p class=\"my-3\">\n\t\t\t\t\t\t\t\t<b>Chaos</b> allows you to set random SMTP failures and response codes at various stages\n\t\t\t\t\t\t\t\tin a SMTP transaction to test application resilience (<a\n\t\t\t\t\t\t\t\t\thref=\"https://mailpit.axllent.org/docs/integration/chaos/\"\n\t\t\t\t\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tsee documentation </a\n\t\t\t\t\t\t\t\t>).\n\t\t\t\t\t\t\t</p>\n\n\t\t\t\t\t\t\t<ul>\n\t\t\t\t\t\t\t\t<li>\n\t\t\t\t\t\t\t\t\t<code>Response code</code> is the SMTP error code returned by the server if this\n\t\t\t\t\t\t\t\t\terror is triggered. Error codes must range between 400 and 599.\n\t\t\t\t\t\t\t\t</li>\n\t\t\t\t\t\t\t\t<li>\n\t\t\t\t\t\t\t\t\t<code>Error probability</code> is the % chance that the error will occur per message\n\t\t\t\t\t\t\t\t\tdelivery, where <code>0</code>(%) is disabled and <code>100</code>(%) wil always\n\t\t\t\t\t\t\t\t\ttrigger. A probability of <code>50</code> will trigger on approximately 50% of\n\t\t\t\t\t\t\t\t\tmessages received.\n\t\t\t\t\t\t\t\t</li>\n\t\t\t\t\t\t\t</ul>\n\n\t\t\t\t\t\t\t<template v-if=\"chaosConfig\">\n\t\t\t\t\t\t\t\t<div class=\"mt-4 mb-4\" :class=\"chaosUpdated ? 'was-validated' : ''\">\n\t\t\t\t\t\t\t\t\t<div class=\"mb-4\">\n\t\t\t\t\t\t\t\t\t\t<label>Trigger: <code>Sender</code></label>\n\t\t\t\t\t\t\t\t\t\t<div class=\"form-text\">\n\t\t\t\t\t\t\t\t\t\t\tTrigger an error response based on the sender (From / Sender).\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div class=\"row mt-1\">\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"col\">\n\t\t\t\t\t\t\t\t\t\t\t\t<label class=\"form-label\"> Response code </label>\n\t\t\t\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\t\t\t\tv-model.number=\"chaosConfig.Sender.ErrorCode\"\n\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tclass=\"form-control\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tmin=\"400\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tmax=\"599\"\n\t\t\t\t\t\t\t\t\t\t\t\t\trequired\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"col\">\n\t\t\t\t\t\t\t\t\t\t\t\t<label class=\"form-label\">\n\t\t\t\t\t\t\t\t\t\t\t\t\tError probability ({{ chaosConfig.Sender.Probability }}%)\n\t\t\t\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\t\t\t\tv-model.number=\"chaosConfig.Sender.Probability\"\n\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"range\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tclass=\"form-range mt-1\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tmin=\"0\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tmax=\"100\"\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t\t\t<div class=\"mb-4\">\n\t\t\t\t\t\t\t\t\t\t<label>Trigger: <code>Recipient</code></label>\n\t\t\t\t\t\t\t\t\t\t<div class=\"form-text\">\n\t\t\t\t\t\t\t\t\t\t\tTrigger an error response based on the recipients (To, Cc, Bcc).\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div class=\"row mt-1\">\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"col\">\n\t\t\t\t\t\t\t\t\t\t\t\t<label class=\"form-label\"> Response code </label>\n\t\t\t\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\t\t\t\tv-model.number=\"chaosConfig.Recipient.ErrorCode\"\n\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tclass=\"form-control\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tmin=\"400\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tmax=\"599\"\n\t\t\t\t\t\t\t\t\t\t\t\t\trequired\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"col\">\n\t\t\t\t\t\t\t\t\t\t\t\t<label class=\"form-label\">\n\t\t\t\t\t\t\t\t\t\t\t\t\tError probability ({{ chaosConfig.Recipient.Probability }}%)\n\t\t\t\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\t\t\t\tv-model.number=\"chaosConfig.Recipient.Probability\"\n\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"range\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tclass=\"form-range mt-1\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tmin=\"0\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tmax=\"100\"\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t\t\t<div class=\"mb-4\">\n\t\t\t\t\t\t\t\t\t\t<label>Trigger: <code>Authentication</code></label>\n\t\t\t\t\t\t\t\t\t\t<div class=\"form-text\">\n\t\t\t\t\t\t\t\t\t\t\tTrigger an authentication error response. Note that SMTP authentication must\n\t\t\t\t\t\t\t\t\t\t\tbe configured too.\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div class=\"row mt-1\">\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"col\">\n\t\t\t\t\t\t\t\t\t\t\t\t<label class=\"form-label\"> Response code </label>\n\t\t\t\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\t\t\t\tv-model.number=\"chaosConfig.Authentication.ErrorCode\"\n\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tclass=\"form-control\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tmin=\"400\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tmax=\"599\"\n\t\t\t\t\t\t\t\t\t\t\t\t\trequired\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"col\">\n\t\t\t\t\t\t\t\t\t\t\t\t<label class=\"form-label\">\n\t\t\t\t\t\t\t\t\t\t\t\t\tError probability ({{ chaosConfig.Authentication.Probability }}%)\n\t\t\t\t\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\t\t\t\tv-model.number=\"chaosConfig.Authentication.Probability\"\n\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"range\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tclass=\"form-range mt-1\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tmin=\"0\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tmax=\"100\"\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t\t<div v-if=\"chaosUpdated\" class=\"mb-3 text-center\">\n\t\t\t\t\t\t\t\t\t<button class=\"btn btn-success\" @click=\"saveChaos\">Update Chaos</button>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</template>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"modal-footer\">\n\t\t\t\t\t\t<button type=\"button\" class=\"btn btn-outline-secondary\" data-bs-dismiss=\"modal\">Close</button>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t</div>\n</template>\n"
  },
  {
    "path": "server/ui-src/components/EditTags.vue",
    "content": "<script>\nimport CommonMixins from \"../mixins/CommonMixins\";\nimport { mailbox } from \"../stores/mailbox\";\n\nexport default {\n\tmixins: [CommonMixins],\n\n\tdata() {\n\t\treturn {\n\t\t\tmailbox,\n\t\t\teditableTags: [],\n\t\t\tvalidTagRe: /^([a-zA-Z0-9\\- ._@]){1,100}$/,\n\t\t\ttagToDelete: false,\n\t\t};\n\t},\n\n\twatch: {\n\t\t\"mailbox.tags\": {\n\t\t\thandler(tags) {\n\t\t\t\tthis.editableTags = [];\n\t\t\t\ttags.forEach((t) => {\n\t\t\t\t\tthis.editableTags.push({ before: t, after: t });\n\t\t\t\t});\n\t\t\t},\n\t\t\tdeep: true,\n\t\t},\n\t},\n\n\tmethods: {\n\t\tvalidTag(t) {\n\t\t\tif (!t.after.match(/^([a-zA-Z0-9\\- ._@]){1,100}$/)) {\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\tconst lower = t.after.toLowerCase();\n\t\t\tfor (let x = 0; x < this.editableTags.length; x++) {\n\t\t\t\tif (this.editableTags[x].before !== t.before && lower === this.editableTags[x].before.toLowerCase()) {\n\t\t\t\t\treturn false;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn true;\n\t\t},\n\n\t\trenameTag(t) {\n\t\t\tif (!this.validTag(t) || t.before === t.after) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tthis.put(this.resolve(`/api/v1/tags/` + encodeURI(t.before)), { Name: t.after }, () => {\n\t\t\t\t// the API triggers a reload via websockets\n\t\t\t});\n\t\t},\n\n\t\tdeleteTag() {\n\t\t\tthis.delete(this.resolve(`/api/v1/tags/` + encodeURI(this.tagToDelete.before)), null, () => {\n\t\t\t\t// the API triggers a reload via websockets\n\t\t\t\tthis.tagToDelete = false;\n\t\t\t});\n\t\t},\n\n\t\tresetTagEdit(t) {\n\t\t\tfor (let x = 0; x < this.editableTags.length; x++) {\n\t\t\t\tif (\n\t\t\t\t\tthis.editableTags[x].before !== t.before &&\n\t\t\t\t\tthis.editableTags[x].before !== this.editableTags[x].after\n\t\t\t\t) {\n\t\t\t\t\tthis.editableTags[x].after = this.editableTags[x].before;\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t},\n};\n</script>\n\n<template>\n\t<div\n\t\tid=\"EditTagsModal\"\n\t\tclass=\"modal fade\"\n\t\ttabindex=\"-1\"\n\t\taria-labelledby=\"EditTagsModalLabel\"\n\t\taria-hidden=\"true\"\n\t\tdata-bs-keyboard=\"false\"\n\t>\n\t\t<div class=\"modal-dialog modal-lg\">\n\t\t\t<div class=\"modal-content\">\n\t\t\t\t<div class=\"modal-header\">\n\t\t\t\t\t<h5 id=\"EditTagsModalLabel\" class=\"modal-title\">Edit tags</h5>\n\t\t\t\t\t<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"modal-body\">\n\t\t\t\t\t<p>\n\t\t\t\t\t\tRenaming a tag will update the tag for all messages. Deleting a tag will only delete the tag\n\t\t\t\t\t\titself, and not any messages which had the tag.\n\t\t\t\t\t</p>\n\t\t\t\t\t<div v-for=\"(t, i) in editableTags\" :key=\"'tag_' + i\" class=\"mb-3\">\n\t\t\t\t\t\t<div class=\"input-group has-validation\">\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\tv-model.trim=\"t.after\"\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tclass=\"form-control\"\n\t\t\t\t\t\t\t\t:class=\"!validTag(t) ? 'is-invalid' : ''\"\n\t\t\t\t\t\t\t\taria-describedby=\"inputGroupPrepend\"\n\t\t\t\t\t\t\t\trequired\n\t\t\t\t\t\t\t\t@keydown.enter=\"renameTag(t)\"\n\t\t\t\t\t\t\t\t@keydown.esc=\"t.after = t.before\"\n\t\t\t\t\t\t\t\t@focus=\"resetTagEdit(t)\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<button v-if=\"t.before != t.after\" class=\"btn btn-success\" @click=\"renameTag(t)\">\n\t\t\t\t\t\t\t\tSave\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<template v-else>\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tclass=\"btn btn-outline-danger\"\n\t\t\t\t\t\t\t\t\t:class=\"tagToDelete.before == t.before ? 'text-white btn-danger' : ''\"\n\t\t\t\t\t\t\t\t\t@click=\"!tagToDelete ? (tagToDelete = t) : deleteTag()\"\n\t\t\t\t\t\t\t\t\t@blur=\"tagToDelete = false\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<template v-if=\"tagToDelete == t\"> Confirm? </template>\n\t\t\t\t\t\t\t\t\t<template v-else> Delete </template>\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t</template>\n\t\t\t\t\t\t\t<div class=\"invalid-feedback\">Invalid tag name</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"modal-footer\">\n\t\t\t\t\t<button type=\"button\" class=\"btn btn-outline-secondary\" data-bs-dismiss=\"modal\">Close</button>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t</div>\n</template>\n"
  },
  {
    "path": "server/ui-src/components/ListMessages.vue",
    "content": "<script>\nimport { mailbox } from \"../stores/mailbox\";\nimport CommonMixins from \"../mixins/CommonMixins\";\nimport dayjs from \"dayjs\";\nimport { pagination } from \"../stores/pagination\";\n\nexport default {\n\tmixins: [CommonMixins],\n\n\tprops: {\n\t\t// use different name to `loading` as that is already in use in CommonMixins\n\t\tloadingMessages: {\n\t\t\ttype: Number,\n\t\t\tdefault: 0,\n\t\t},\n\t},\n\n\tdata() {\n\t\treturn {\n\t\t\tmailbox,\n\t\t\tpagination,\n\t\t};\n\t},\n\n\tcreated() {\n\t\tconst relativeTime = require(\"dayjs/plugin/relativeTime\");\n\t\tdayjs.extend(relativeTime);\n\t},\n\n\tmounted() {\n\t\tthis.refreshUI();\n\t},\n\n\tmethods: {\n\t\trefreshUI() {\n\t\t\twindow.setTimeout(() => {\n\t\t\t\tthis.$forceUpdate();\n\t\t\t\tthis.refreshUI();\n\t\t\t}, 30000);\n\t\t},\n\n\t\tgetRelativeCreated(message) {\n\t\t\tconst d = new Date(message.Created);\n\t\t\treturn dayjs(d).fromNow();\n\t\t},\n\n\t\tgetPrimaryEmailTo(message) {\n\t\t\tif (message.To && message.To.length > 0) {\n\t\t\t\treturn message.To[0].Address;\n\t\t\t}\n\n\t\t\treturn \"[ Undisclosed recipients ]\";\n\t\t},\n\n\t\tisSelected(id) {\n\t\t\treturn mailbox.selected.indexOf(id) !== -1;\n\t\t},\n\n\t\ttoggleSelected(e, id) {\n\t\t\te.preventDefault();\n\n\t\t\tif (this.isSelected(id)) {\n\t\t\t\tmailbox.selected = mailbox.selected.filter((ele) => {\n\t\t\t\t\treturn ele !== id;\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\tmailbox.selected.push(id);\n\t\t\t}\n\t\t},\n\n\t\tselectRange(e, id) {\n\t\t\te.preventDefault();\n\n\t\t\tlet selecting = false;\n\t\t\tconst lastSelected = mailbox.selected.length > 0 && mailbox.selected[mailbox.selected.length - 1];\n\t\t\tif (lastSelected === id) {\n\t\t\t\tmailbox.selected = mailbox.selected.filter((ele) => {\n\t\t\t\t\treturn ele !== id;\n\t\t\t\t});\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (lastSelected === false) {\n\t\t\t\tmailbox.selected.push(id);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tfor (const d of mailbox.messages) {\n\t\t\t\tif (selecting) {\n\t\t\t\t\tif (!this.isSelected(d.ID)) {\n\t\t\t\t\t\tmailbox.selected.push(d.ID);\n\t\t\t\t\t}\n\t\t\t\t\tif (d.ID === lastSelected || d.ID === id) {\n\t\t\t\t\t\t// reached backwards select\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\t\t\t\t} else if (d.ID === id || d.ID === lastSelected) {\n\t\t\t\t\tif (!this.isSelected(d.ID)) {\n\t\t\t\t\t\tmailbox.selected.push(d.ID);\n\t\t\t\t\t}\n\t\t\t\t\tselecting = true;\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\n\t\ttoTagUrl(t) {\n\t\t\tif (t.match(/ /)) {\n\t\t\t\tt = `\"${t}\"`;\n\t\t\t}\n\t\t\tconst p = {\n\t\t\t\tq: \"tag:\" + t,\n\t\t\t};\n\t\t\tif (pagination.limit !== pagination.defaultLimit) {\n\t\t\t\tp.limit = pagination.limit.toString();\n\t\t\t}\n\t\t\tconst params = new URLSearchParams(p);\n\t\t\treturn \"/search?\" + params.toString();\n\t\t},\n\t},\n};\n</script>\n\n<template>\n\t<template v-if=\"mailbox.messages && mailbox.messages.length\">\n\t\t<div class=\"list-group my-2\">\n\t\t\t<RouterLink\n\t\t\t\tv-for=\"message in mailbox.messages\"\n\t\t\t\t:id=\"message.ID\"\n\t\t\t\t:key=\"'message_' + message.ID\"\n\t\t\t\t:to=\"'/view/' + message.ID\"\n\t\t\t\tclass=\"row gx-1 message d-flex small list-group-item list-group-item-action border-start-0 border-end-0\"\n\t\t\t\t:class=\"[message.Read ? 'read' : '', isSelected(message.ID) ? ' selected' : '']\"\n\t\t\t\t@click.meta=\"toggleSelected($event, message.ID)\"\n\t\t\t\t@click.ctrl=\"toggleSelected($event, message.ID)\"\n\t\t\t\t@click.shift=\"selectRange($event, message.ID)\"\n\t\t\t>\n\t\t\t\t<div class=\"col-lg-3\">\n\t\t\t\t\t<div class=\"d-lg-none float-end text-muted text-nowrap small\">\n\t\t\t\t\t\t<i v-if=\"message.Attachments\" class=\"bi bi-paperclip h6 me-1\"></i>\n\t\t\t\t\t\t{{ getRelativeCreated(message) }}\n\t\t\t\t\t</div>\n\t\t\t\t\t<div v-if=\"message.From\" class=\"overflow-x-hidden\">\n\t\t\t\t\t\t<div class=\"text-truncate privacy\">\n\t\t\t\t\t\t\t<b :title=\"'From: ' + message.From.Address\">\n\t\t\t\t\t\t\t\t{{ message.From.Name ? message.From.Name : message.From.Address }}\n\t\t\t\t\t\t\t</b>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"overflow-x-hidden\">\n\t\t\t\t\t\t<div class=\"text-truncate text-muted small privacy\">\n\t\t\t\t\t\t\tTo: {{ getPrimaryEmailTo(message) }}\n\t\t\t\t\t\t\t<span v-if=\"message.To && message.To.length > 1\"> [+{{ message.To.length - 1 }}] </span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"col-lg-6 col-xxl-7 mt-2 mt-lg-0\">\n\t\t\t\t\t<div class=\"subject text-truncate text-spaces-nowrap\">\n\t\t\t\t\t\t<b>{{ message.Subject !== \"\" ? message.Subject : \"[ no subject ]\" }}</b>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div v-if=\"message.Snippet !== ''\" class=\"small text-muted text-truncate\">\n\t\t\t\t\t\t{{ message.Snippet }}\n\t\t\t\t\t</div>\n\t\t\t\t\t<div v-if=\"message.Tags.length\">\n\t\t\t\t\t\t<RouterLink\n\t\t\t\t\t\t\tv-for=\"t in message.Tags\"\n\t\t\t\t\t\t\t:key=\"t\"\n\t\t\t\t\t\t\tclass=\"badge me-1\"\n\t\t\t\t\t\t\t:to=\"toTagUrl(t)\"\n\t\t\t\t\t\t\t:style=\"\n\t\t\t\t\t\t\t\tmailbox.showTagColors\n\t\t\t\t\t\t\t\t\t? { backgroundColor: colorHash(t) }\n\t\t\t\t\t\t\t\t\t: { backgroundColor: '#6c757d' }\n\t\t\t\t\t\t\t\"\n\t\t\t\t\t\t\t:title=\"'Filter messages tagged with ' + t\"\n\t\t\t\t\t\t\t@click=\"pagination.start = 0\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{{ t }}\n\t\t\t\t\t\t</RouterLink>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"d-none d-lg-block col-1 small text-end text-muted\">\n\t\t\t\t\t<i v-if=\"message.Attachments\" class=\"bi bi-paperclip float-start h6\"></i>\n\t\t\t\t\t{{ getFileSize(message.Size) }}\n\t\t\t\t</div>\n\t\t\t\t<div class=\"d-none d-lg-block col-2 col-xxl-1 small text-end text-muted\">\n\t\t\t\t\t{{ getRelativeCreated(message) }}\n\t\t\t\t</div>\n\t\t\t</RouterLink>\n\t\t</div>\n\t</template>\n\t<template v-else>\n\t\t<p class=\"text-center mt-5\">\n\t\t\t<span v-if=\"loadingMessages > 0\" class=\"text-muted\"> Loading messages... </span>\n\t\t\t<template v-else-if=\"getSearch()\"\n\t\t\t\t>No results for <code>{{ getSearch() }}</code></template\n\t\t\t>\n\t\t\t<template v-else>No messages in your mailbox</template>\n\t\t</p>\n\t</template>\n</template>\n"
  },
  {
    "path": "server/ui-src/components/NavMailbox.vue",
    "content": "<script>\nimport NavSelected from \"../components/NavSelected.vue\";\nimport AjaxLoader from \"./AjaxLoader.vue\";\nimport CommonMixins from \"../mixins/CommonMixins\";\nimport { mailbox } from \"../stores/mailbox\";\nimport { pagination } from \"../stores/pagination\";\n\nexport default {\n\tcomponents: {\n\t\tNavSelected,\n\t\tAjaxLoader,\n\t},\n\n\tmixins: [CommonMixins],\n\n\tprops: {\n\t\tmodals: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\t},\n\n\temits: [\"loadMessages\"],\n\n\tdata() {\n\t\treturn {\n\t\t\tmailbox,\n\t\t\tpagination,\n\t\t};\n\t},\n\n\tmethods: {\n\t\treloadInbox() {\n\t\t\tconst paginationParams = this.getPaginationParams();\n\t\t\tconst reload = !paginationParams?.start;\n\n\t\t\tthis.$router.push(\"/\");\n\t\t\tif (reload) {\n\t\t\t\t// already on first page, reload messages\n\t\t\t\tthis.loadMessages();\n\t\t\t}\n\t\t},\n\n\t\tloadMessages() {\n\t\t\tthis.hideNav(); // hide mobile menu\n\t\t\tthis.$emit(\"loadMessages\");\n\t\t},\n\n\t\tmarkAllRead() {\n\t\t\tthis.put(this.resolve(`/api/v1/messages`), { read: true }, () => {\n\t\t\t\twindow.scrollInPlace = true;\n\t\t\t\tthis.loadMessages();\n\t\t\t});\n\t\t},\n\n\t\tdeleteAllMessages() {\n\t\t\tthis.delete(this.resolve(`/api/v1/messages`), false, () => {\n\t\t\t\tpagination.start = 0;\n\t\t\t\tthis.loadMessages();\n\t\t\t});\n\t\t},\n\t},\n};\n</script>\n\n<template>\n\t<template v-if=\"!modals\">\n\t\t<div v-if=\"mailbox.uiConfig.Label\" class=\"text-center badge text-bg-primary py-2 my-2 w-100\">\n\t\t\t<div class=\"text-truncate fw-normal\" style=\"line-height: 1rem\">\n\t\t\t\t{{ mailbox.uiConfig.Label }}\n\t\t\t</div>\n\t\t</div>\n\n\t\t<div class=\"list-group my-2\" :class=\"mailbox.uiConfig.Label ? 'mt-0' : ''\">\n\t\t\t<button class=\"list-group-item list-group-item-action active\" @click=\"reloadInbox\">\n\t\t\t\t<i v-if=\"mailbox.connected\" class=\"bi bi-envelope-fill me-1\"></i>\n\t\t\t\t<i v-else class=\"bi bi-arrow-clockwise me-1\"></i>\n\t\t\t\t<span class=\"ms-1\">Inbox</span>\n\t\t\t\t<span\n\t\t\t\t\tv-if=\"mailbox.unread\"\n\t\t\t\t\tclass=\"badge rounded-pill ms-1 float-end text-bg-secondary\"\n\t\t\t\t\ttitle=\"Unread messages\"\n\t\t\t\t>\n\t\t\t\t\t{{ formatNumber(mailbox.unread) }}\n\t\t\t\t</span>\n\t\t\t</button>\n\n\t\t\t<template v-if=\"!mailbox.selected.length\">\n\t\t\t\t<button\n\t\t\t\t\tv-if=\"mailbox.skipConfirmations\"\n\t\t\t\t\tclass=\"list-group-item list-group-item-action\"\n\t\t\t\t\t:disabled=\"!mailbox.unread\"\n\t\t\t\t\t@click=\"markAllRead\"\n\t\t\t\t>\n\t\t\t\t\t<i class=\"bi bi-eye-fill me-1\"></i>\n\t\t\t\t\tMark all read\n\t\t\t\t</button>\n\t\t\t\t<button\n\t\t\t\t\tv-else\n\t\t\t\t\tclass=\"list-group-item list-group-item-action\"\n\t\t\t\t\tdata-bs-toggle=\"modal\"\n\t\t\t\t\tdata-bs-target=\"#MarkAllReadModal\"\n\t\t\t\t\t:disabled=\"!mailbox.unread\"\n\t\t\t\t>\n\t\t\t\t\t<i class=\"bi bi-eye-fill me-1\"></i>\n\t\t\t\t\tMark all read\n\t\t\t\t</button>\n\t\t\t\t<!-- checking if MessageRelay is defined prevents UI flicker while loading -->\n\t\t\t\t<template v-if=\"mailbox.uiConfig.MessageRelay && !mailbox.uiConfig.HideDeleteAllButton\">\n\t\t\t\t\t<button\n\t\t\t\t\t\tv-if=\"mailbox.skipConfirmations\"\n\t\t\t\t\t\tclass=\"list-group-item list-group-item-action\"\n\t\t\t\t\t\t:disabled=\"!mailbox.total\"\n\t\t\t\t\t\t@click=\"deleteAllMessages\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<i class=\"bi bi-trash-fill me-1 text-danger\"></i>\n\t\t\t\t\t\tDelete all\n\t\t\t\t\t</button>\n\t\t\t\t\t<button\n\t\t\t\t\t\tv-else\n\t\t\t\t\t\tclass=\"list-group-item list-group-item-action\"\n\t\t\t\t\t\tdata-bs-toggle=\"modal\"\n\t\t\t\t\t\tdata-bs-target=\"#DeleteAllModal\"\n\t\t\t\t\t\t:disabled=\"!mailbox.total\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<i class=\"bi bi-trash-fill me-1 text-danger\"></i>\n\t\t\t\t\t\tDelete all\n\t\t\t\t\t</button>\n\t\t\t\t</template>\n\t\t\t</template>\n\n\t\t\t<NavSelected @load-messages=\"loadMessages\" />\n\t\t</div>\n\t</template>\n\n\t<template v-else>\n\t\t<!-- Modals -->\n\t\t<div\n\t\t\tid=\"MarkAllReadModal\"\n\t\t\tclass=\"modal fade\"\n\t\t\ttabindex=\"-1\"\n\t\t\taria-labelledby=\"MarkAllReadModalLabel\"\n\t\t\taria-hidden=\"true\"\n\t\t>\n\t\t\t<div class=\"modal-dialog\">\n\t\t\t\t<div class=\"modal-content\">\n\t\t\t\t\t<div class=\"modal-header\">\n\t\t\t\t\t\t<h5 id=\"MarkAllReadModalLabel\" class=\"modal-title\">Mark all messages as read?</h5>\n\t\t\t\t\t\t<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"modal-body\">\n\t\t\t\t\t\tThis will mark {{ formatNumber(mailbox.unread) }} message<span v-if=\"mailbox.unread > 1\"\n\t\t\t\t\t\t\t>s</span\n\t\t\t\t\t\t>\n\t\t\t\t\t\tas read.\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"modal-footer\">\n\t\t\t\t\t\t<button type=\"button\" class=\"btn btn-outline-secondary\" data-bs-dismiss=\"modal\">Cancel</button>\n\t\t\t\t\t\t<button type=\"button\" class=\"btn btn-success\" data-bs-dismiss=\"modal\" @click=\"markAllRead\">\n\t\t\t\t\t\t\tConfirm\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\n\t\t<div\n\t\t\tid=\"DeleteAllModal\"\n\t\t\tclass=\"modal fade\"\n\t\t\ttabindex=\"-1\"\n\t\t\taria-labelledby=\"DeleteAllModalLabel\"\n\t\t\taria-hidden=\"true\"\n\t\t>\n\t\t\t<div class=\"modal-dialog\">\n\t\t\t\t<div class=\"modal-content\">\n\t\t\t\t\t<div class=\"modal-header\">\n\t\t\t\t\t\t<h5 id=\"DeleteAllModalLabel\" class=\"modal-title\">Delete all messages?</h5>\n\t\t\t\t\t\t<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"modal-body\">\n\t\t\t\t\t\tThis will permanently delete {{ formatNumber(mailbox.total) }} message<span\n\t\t\t\t\t\t\tv-if=\"mailbox.total > 1\"\n\t\t\t\t\t\t\t>s</span\n\t\t\t\t\t\t>.\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"modal-footer\">\n\t\t\t\t\t\t<button type=\"button\" class=\"btn btn-outline-secondary\" data-bs-dismiss=\"modal\">Cancel</button>\n\t\t\t\t\t\t<button type=\"button\" class=\"btn btn-danger\" data-bs-dismiss=\"modal\" @click=\"deleteAllMessages\">\n\t\t\t\t\t\t\tDelete\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t</template>\n\n\t<AjaxLoader :loading=\"loading\" />\n</template>\n"
  },
  {
    "path": "server/ui-src/components/NavPagination.vue",
    "content": "<script>\nimport CommonMixins from \"../mixins/CommonMixins\";\nimport { mailbox } from \"../stores/mailbox\";\nimport { limitOptions, pagination } from \"../stores/pagination\";\n\nexport default {\n\tmixins: [CommonMixins],\n\n\tprops: {\n\t\ttotal: {\n\t\t\ttype: Number,\n\t\t\tdefault: 0,\n\t\t},\n\t},\n\n\tdata() {\n\t\treturn {\n\t\t\tpagination,\n\t\t\tmailbox,\n\t\t\tlimitOptions,\n\t\t};\n\t},\n\n\tcomputed: {\n\t\tcanPrev() {\n\t\t\treturn pagination.start > 0;\n\t\t},\n\n\t\tcanNext() {\n\t\t\treturn this.total > pagination.start + mailbox.messages.length;\n\t\t},\n\n\t\t// returns the number of next X messages\n\t\tnextMessages() {\n\t\t\tlet t = pagination.start + parseInt(pagination.limit, 10);\n\t\t\tif (t > this.total) {\n\t\t\t\tt = this.total;\n\t\t\t}\n\n\t\t\treturn t;\n\t\t},\n\t},\n\n\tmethods: {\n\t\tchangeLimit() {\n\t\t\tpagination.start = 0;\n\t\t\tthis.updateQueryParams();\n\t\t},\n\n\t\tviewNext() {\n\t\t\tpagination.start = parseInt(pagination.start, 10) + parseInt(pagination.limit, 10);\n\t\t\tthis.updateQueryParams();\n\t\t},\n\n\t\tviewPrev() {\n\t\t\tlet s = pagination.start - pagination.limit;\n\t\t\tif (s < 0) {\n\t\t\t\ts = 0;\n\t\t\t}\n\t\t\tpagination.start = s;\n\t\t\tthis.updateQueryParams();\n\t\t},\n\n\t\tupdateQueryParams() {\n\t\t\tconst path = this.$route.path;\n\t\t\tconst p = {\n\t\t\t\t...this.$route.query,\n\t\t\t};\n\t\t\tif (pagination.start > 0) {\n\t\t\t\tp.start = pagination.start.toString();\n\t\t\t} else {\n\t\t\t\tdelete p.start;\n\t\t\t}\n\t\t\tif (pagination.limit !== pagination.defaultLimit) {\n\t\t\t\tp.limit = pagination.limit.toString();\n\t\t\t} else {\n\t\t\t\tdelete p.limit;\n\t\t\t}\n\t\t\tconst params = new URLSearchParams(p);\n\t\t\tthis.$router.push(path + \"?\" + params.toString());\n\t\t},\n\t},\n};\n</script>\n<template>\n\t<select\n\t\tv-model=\"pagination.limit\"\n\t\tclass=\"form-select form-select-sm d-inline w-auto me-2 me-xl-3\"\n\t\t:disabled=\"total == 0\"\n\t\ttitle=\"The number of messages displayed per page\"\n\t\t@change=\"changeLimit\"\n\t>\n\t\t<option v-for=\"option in limitOptions\" :key=\"option\" :value=\"option\">{{ option }}</option>\n\t</select>\n\n\t<small>\n\t\t<template v-if=\"total > 0\">\n\t\t\t{{ formatNumber(pagination.start + 1) }}-{{ formatNumber(nextMessages) }}\n\t\t\t<small>of</small>\n\t\t\t{{ formatNumber(total) }}\n\t\t</template>\n\t\t<span v-else class=\"text-light\">0 of 0</span>\n\t</small>\n\n\t<button\n\t\tclass=\"btn btn-outline-light ms-2 ms-xl-3 me-1\"\n\t\t:disabled=\"!canPrev\"\n\t\t:title=\"'View previous ' + pagination.limit + ' messages'\"\n\t\t@click=\"viewPrev\"\n\t>\n\t\t<i class=\"bi bi-caret-left-fill\"></i>\n\t</button>\n\t<button\n\t\tclass=\"btn btn-outline-light\"\n\t\t:disabled=\"!canNext\"\n\t\t:title=\"'View next ' + pagination.limit + ' messages'\"\n\t\t@click=\"viewNext\"\n\t>\n\t\t<i class=\"bi bi-caret-right-fill\"></i>\n\t</button>\n</template>\n"
  },
  {
    "path": "server/ui-src/components/NavSearch.vue",
    "content": "<script>\nimport NavSelected from \"../components/NavSelected.vue\";\nimport AjaxLoader from \"./AjaxLoader.vue\";\nimport CommonMixins from \"../mixins/CommonMixins\";\nimport { mailbox } from \"../stores/mailbox\";\nimport { pagination } from \"../stores/pagination\";\n\nexport default {\n\tcomponents: {\n\t\tNavSelected,\n\t\tAjaxLoader,\n\t},\n\n\tmixins: [CommonMixins],\n\n\tprops: {\n\t\tmodals: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\t},\n\n\temits: [\"loadMessages\"],\n\n\tdata() {\n\t\treturn {\n\t\t\tmailbox,\n\t\t\tpagination,\n\t\t};\n\t},\n\n\tmethods: {\n\t\tloadMessages() {\n\t\t\tthis.hideNav(); // hide mobile menu\n\t\t\tthis.$emit(\"loadMessages\");\n\t\t},\n\n\t\tdeleteAllMessages() {\n\t\t\tconst s = this.getSearch();\n\t\t\tif (!s) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tlet uri = this.resolve(`/api/v1/search`) + \"?query=\" + encodeURIComponent(s);\n\t\t\tif (mailbox.timeZone !== \"\" && (s.indexOf(\"after:\") !== -1 || s.indexOf(\"before:\") !== -1)) {\n\t\t\t\turi += \"&tz=\" + encodeURIComponent(mailbox.timeZone);\n\t\t\t}\n\n\t\t\tthis.delete(uri, false, () => {\n\t\t\t\tthis.$router.push(\"/\");\n\t\t\t});\n\t\t},\n\n\t\tmarkAllRead() {\n\t\t\tconst s = this.getSearch();\n\t\t\tif (!s) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tlet uri = this.resolve(`/api/v1/messages`);\n\t\t\tif (mailbox.timeZone !== \"\" && (s.indexOf(\"after:\") !== -1 || s.indexOf(\"before:\") !== -1)) {\n\t\t\t\turi += \"?tz=\" + encodeURIComponent(mailbox.timeZone);\n\t\t\t}\n\n\t\t\tthis.put(uri, { read: true, search: s }, () => {\n\t\t\t\twindow.scrollInPlace = true;\n\t\t\t\tthis.loadMessages();\n\t\t\t});\n\t\t},\n\t},\n};\n</script>\n\n<template>\n\t<template v-if=\"!modals\">\n\t\t<div v-if=\"mailbox.uiConfig.Label\" class=\"text-center badge text-bg-primary py-2 my-2 w-100\">\n\t\t\t<div class=\"text-truncate fw-normal\" style=\"line-height: 1rem\">\n\t\t\t\t{{ mailbox.uiConfig.Label }}\n\t\t\t</div>\n\t\t</div>\n\n\t\t<div class=\"list-group my-2\" :class=\"mailbox.uiConfig.Label ? 'mt-0' : ''\">\n\t\t\t<RouterLink to=\"/\" class=\"list-group-item list-group-item-action\" @click=\"pagination.start = 0\">\n\t\t\t\t<i class=\"bi bi-arrow-return-left me-1\"></i>\n\t\t\t\t<span class=\"ms-1\">Inbox</span>\n\t\t\t\t<span\n\t\t\t\t\tv-if=\"mailbox.unread\"\n\t\t\t\t\tclass=\"badge rounded-pill ms-1 float-end text-bg-secondary\"\n\t\t\t\t\ttitle=\"Unread messages\"\n\t\t\t\t>\n\t\t\t\t\t{{ formatNumber(mailbox.unread) }}\n\t\t\t\t</span>\n\t\t\t</RouterLink>\n\t\t\t<template v-if=\"!mailbox.selected.length\">\n\t\t\t\t<button\n\t\t\t\t\tv-if=\"mailbox.skipConfirmations\"\n\t\t\t\t\tclass=\"list-group-item list-group-item-action\"\n\t\t\t\t\t:disabled=\"!mailbox.messages_unread\"\n\t\t\t\t\t@click=\"markAllRead\"\n\t\t\t\t>\n\t\t\t\t\t<i class=\"bi bi-eye-fill me-1\"></i>\n\t\t\t\t\tMark all read\n\t\t\t\t</button>\n\t\t\t\t<button\n\t\t\t\t\tv-else\n\t\t\t\t\tclass=\"list-group-item list-group-item-action\"\n\t\t\t\t\tdata-bs-toggle=\"modal\"\n\t\t\t\t\tdata-bs-target=\"#MarkAllReadModal\"\n\t\t\t\t\t:disabled=\"!mailbox.messages_unread\"\n\t\t\t\t>\n\t\t\t\t\t<i class=\"bi bi-eye-fill me-1\"></i>\n\t\t\t\t\tMark all read\n\t\t\t\t</button>\n\t\t\t\t<!-- checking if MessageRelay is defined prevents UI flicker while loading -->\n\t\t\t\t<template v-if=\"mailbox.uiConfig.MessageRelay && !mailbox.uiConfig.HideDeleteAllButton\">\n\t\t\t\t\t<button\n\t\t\t\t\t\tv-if=\"mailbox.skipConfirmations\"\n\t\t\t\t\t\tclass=\"list-group-item list-group-item-action\"\n\t\t\t\t\t\t:disabled=\"!mailbox.count\"\n\t\t\t\t\t\t@click=\"deleteAllMessages\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<i class=\"bi bi-trash-fill me-1 text-danger\"></i>\n\t\t\t\t\t\tDelete all\n\t\t\t\t\t</button>\n\t\t\t\t\t<button\n\t\t\t\t\t\tv-else\n\t\t\t\t\t\tclass=\"list-group-item list-group-item-action\"\n\t\t\t\t\t\tdata-bs-toggle=\"modal\"\n\t\t\t\t\t\tdata-bs-target=\"#DeleteAllModal\"\n\t\t\t\t\t\t:disabled=\"!mailbox.count\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<i class=\"bi bi-trash-fill me-1 text-danger\"></i>\n\t\t\t\t\t\tDelete all\n\t\t\t\t\t</button>\n\t\t\t\t</template>\n\t\t\t</template>\n\n\t\t\t<NavSelected @load-messages=\"loadMessages\" />\n\t\t</div>\n\t</template>\n\n\t<template v-else>\n\t\t<!-- Modals -->\n\t\t<div\n\t\t\tid=\"MarkAllReadModal\"\n\t\t\tclass=\"modal fade\"\n\t\t\ttabindex=\"-1\"\n\t\t\taria-labelledby=\"MarkAllReadModalLabel\"\n\t\t\taria-hidden=\"true\"\n\t\t>\n\t\t\t<div class=\"modal-dialog\">\n\t\t\t\t<div class=\"modal-content\">\n\t\t\t\t\t<div class=\"modal-header\">\n\t\t\t\t\t\t<h5 id=\"MarkAllReadModalLabel\" class=\"modal-title\">Mark all search results as read?</h5>\n\t\t\t\t\t\t<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"modal-body\">\n\t\t\t\t\t\tThis will mark {{ formatNumber(mailbox.messages_unread) }} message<span\n\t\t\t\t\t\t\tv-if=\"mailbox.messages_unread > 1\"\n\t\t\t\t\t\t\t>s</span\n\t\t\t\t\t\t>\n\t\t\t\t\t\tmatching <code>{{ getSearch() }}</code>\n\t\t\t\t\t\tas read.\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"modal-footer\">\n\t\t\t\t\t\t<button type=\"button\" class=\"btn btn-outline-secondary\" data-bs-dismiss=\"modal\">Cancel</button>\n\t\t\t\t\t\t<button type=\"button\" class=\"btn btn-success\" data-bs-dismiss=\"modal\" @click=\"markAllRead\">\n\t\t\t\t\t\t\tConfirm\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\n\t\t<div\n\t\t\tid=\"DeleteAllModal\"\n\t\t\tclass=\"modal fade\"\n\t\t\ttabindex=\"-1\"\n\t\t\taria-labelledby=\"DeleteAllModalLabel\"\n\t\t\taria-hidden=\"true\"\n\t\t>\n\t\t\t<div class=\"modal-dialog\">\n\t\t\t\t<div class=\"modal-content\">\n\t\t\t\t\t<div class=\"modal-header\">\n\t\t\t\t\t\t<h5 id=\"DeleteAllModalLabel\" class=\"modal-title\">Delete all messages matching search?</h5>\n\t\t\t\t\t\t<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"modal-body\">\n\t\t\t\t\t\tThis will permanently delete {{ formatNumber(mailbox.count) }} message<span\n\t\t\t\t\t\t\tv-if=\"mailbox.count > 1\"\n\t\t\t\t\t\t\t>s</span\n\t\t\t\t\t\t>\n\t\t\t\t\t\tmatching\n\t\t\t\t\t\t<code>{{ getSearch() }}</code>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"modal-footer\">\n\t\t\t\t\t\t<button type=\"button\" class=\"btn btn-outline-secondary\" data-bs-dismiss=\"modal\">Cancel</button>\n\t\t\t\t\t\t<button type=\"button\" class=\"btn btn-danger\" data-bs-dismiss=\"modal\" @click=\"deleteAllMessages\">\n\t\t\t\t\t\t\tDelete\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t</template>\n\n\t<AjaxLoader :loading=\"loading\" />\n</template>\n"
  },
  {
    "path": "server/ui-src/components/NavSelected.vue",
    "content": "<script>\nimport AjaxLoader from \"./AjaxLoader.vue\";\nimport CommonMixins from \"../mixins/CommonMixins\";\nimport { mailbox } from \"../stores/mailbox\";\n\nexport default {\n\tcomponents: {\n\t\tAjaxLoader,\n\t},\n\n\tmixins: [CommonMixins],\n\n\temits: [\"loadMessages\"],\n\n\tdata() {\n\t\treturn {\n\t\t\tmailbox,\n\t\t};\n\t},\n\n\tmethods: {\n\t\tloadMessages() {\n\t\t\tthis.$emit(\"loadMessages\");\n\t\t},\n\n\t\t// mark selected messages as read\n\t\tmarkSelectedRead() {\n\t\t\tif (!mailbox.selected.length) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tthis.put(this.resolve(`/api/v1/messages`), { Read: true, IDs: mailbox.selected }, () => {\n\t\t\t\twindow.scrollInPlace = true;\n\t\t\t\tthis.loadMessages();\n\t\t\t});\n\t\t},\n\n\t\tisSelected(id) {\n\t\t\treturn mailbox.selected.indexOf(id) !== -1;\n\t\t},\n\n\t\t// mark selected messages as unread\n\t\tmarkSelectedUnread() {\n\t\t\tif (!mailbox.selected.length) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tthis.put(this.resolve(`/api/v1/messages`), { Read: false, IDs: mailbox.selected }, () => {\n\t\t\t\twindow.scrollInPlace = true;\n\t\t\t\tthis.loadMessages();\n\t\t\t});\n\t\t},\n\n\t\t// universal handler to delete current or selected messages\n\t\tdeleteMessages() {\n\t\t\tconst ids = JSON.parse(JSON.stringify(mailbox.selected));\n\t\t\tif (!ids.length) {\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\tthis.delete(this.resolve(`/api/v1/messages`), { IDs: ids }, () => {\n\t\t\t\twindow.scrollInPlace = true;\n\t\t\t\tthis.loadMessages();\n\t\t\t});\n\t\t},\n\n\t\t// test if any selected emails are unread\n\t\tselectedHasUnread() {\n\t\t\tif (!mailbox.selected.length) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tfor (const i in mailbox.messages) {\n\t\t\t\tif (this.isSelected(mailbox.messages[i].ID) && !mailbox.messages[i].Read) {\n\t\t\t\t\treturn true;\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn false;\n\t\t},\n\n\t\t// test of any selected emails are read\n\t\tselectedHasRead() {\n\t\t\tif (!mailbox.selected.length) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tfor (const i in mailbox.messages) {\n\t\t\t\tif (this.isSelected(mailbox.messages[i].ID) && mailbox.messages[i].Read) {\n\t\t\t\t\treturn true;\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn false;\n\t\t},\n\t},\n};\n</script>\n\n<template>\n\t<template v-if=\"mailbox.selected.length\">\n\t\t<button\n\t\t\tclass=\"list-group-item list-group-item-action\"\n\t\t\t:disabled=\"!selectedHasUnread()\"\n\t\t\t@click=\"markSelectedRead\"\n\t\t>\n\t\t\t<i class=\"bi bi-eye-fill me-1\"></i>\n\t\t\tMark read\n\t\t</button>\n\t\t<button\n\t\t\tclass=\"list-group-item list-group-item-action\"\n\t\t\t:disabled=\"!selectedHasRead()\"\n\t\t\t@click=\"markSelectedUnread\"\n\t\t>\n\t\t\t<i class=\"bi bi-eye-slash me-1\"></i>\n\t\t\tMark unread\n\t\t</button>\n\t\t<button class=\"list-group-item list-group-item-action\" @click=\"deleteMessages()\">\n\t\t\t<i class=\"bi bi-trash-fill me-1 text-danger\"></i>\n\t\t\tDelete selected\n\t\t</button>\n\t\t<button class=\"list-group-item list-group-item-action\" @click=\"mailbox.selected = []\">\n\t\t\t<i class=\"bi bi-x-circle me-1\"></i>\n\t\t\tCancel selection\n\t\t</button>\n\t</template>\n\n\t<AjaxLoader :loading=\"loading\" />\n</template>\n"
  },
  {
    "path": "server/ui-src/components/NavTags.vue",
    "content": "<script>\nimport CommonMixins from \"../mixins/CommonMixins\";\nimport { mailbox } from \"../stores/mailbox\";\nimport { pagination } from \"../stores/pagination\";\n\nexport default {\n\tmixins: [CommonMixins],\n\n\tdata() {\n\t\treturn {\n\t\t\tmailbox,\n\t\t\tpagination,\n\t\t};\n\t},\n\n\tmethods: {\n\t\t// test whether a tag is currently being searched for (in the URL)\n\t\tinSearch(tag) {\n\t\t\tconst urlParams = new URLSearchParams(window.location.search);\n\t\t\tconst query = urlParams.get(\"q\");\n\t\t\tif (!query) {\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\tconst re = new RegExp(`(^|\\\\s)tag:(\"${tag}\"|${tag}\\\\b)`, \"i\");\n\t\t\treturn query.match(re);\n\t\t},\n\n\t\t// toggle a tag search in the search URL, add or remove it accordingly\n\t\ttoggleTag(e, tag) {\n\t\t\te.preventDefault();\n\n\t\t\tconst urlParams = new URLSearchParams(window.location.search);\n\t\t\tlet query = urlParams.get(\"q\") ? urlParams.get(\"q\") : \"\";\n\n\t\t\tconst re = new RegExp(`(^|\\\\s)((-|\\\\!)?tag:\"?${tag}\"?)($|\\\\s)`, \"i\");\n\n\t\t\tif (query.match(re)) {\n\t\t\t\t// remove is exists\n\t\t\t\tquery = query.replace(re, \"$1$4\");\n\t\t\t} else {\n\t\t\t\t// add to query\n\t\t\t\tif (tag.match(/ /)) {\n\t\t\t\t\ttag = `\"${tag}\"`;\n\t\t\t\t}\n\t\t\t\tquery = query + \" tag:\" + tag;\n\t\t\t}\n\n\t\t\tquery = query.trim();\n\n\t\t\tif (query === \"\") {\n\t\t\t\tthis.$router.push(\"/\");\n\t\t\t} else {\n\t\t\t\tconst params = new URLSearchParams({\n\t\t\t\t\tq: query,\n\t\t\t\t\tstart: pagination.start.toString(),\n\t\t\t\t\tlimit: pagination.limit.toString(),\n\t\t\t\t});\n\t\t\t\tthis.$router.push(\"/search?\" + params.toString());\n\t\t\t}\n\t\t},\n\n\t\ttoTagUrl(t) {\n\t\t\tif (t.match(/ /)) {\n\t\t\t\tt = `\"${t}\"`;\n\t\t\t}\n\t\t\tconst p = {\n\t\t\t\tq: \"tag:\" + t,\n\t\t\t};\n\t\t\tif (pagination.limit !== pagination.defaultLimit) {\n\t\t\t\tp.limit = pagination.limit.toString();\n\t\t\t}\n\t\t\tconst params = new URLSearchParams(p);\n\t\t\treturn \"/search?\" + params.toString();\n\t\t},\n\t},\n};\n</script>\n\n<template>\n\t<template v-if=\"mailbox.tags && mailbox.tags.length\">\n\t\t<div class=\"mt-4 text-muted\">\n\t\t\t<button class=\"btn btn-sm dropdown-toggle\" data-bs-toggle=\"dropdown\" aria-expanded=\"false\">Tags</button>\n\t\t\t<ul class=\"dropdown-menu dropdown-menu-end\">\n\t\t\t\t<li>\n\t\t\t\t\t<button class=\"dropdown-item\" data-bs-toggle=\"modal\" data-bs-target=\"#EditTagsModal\">\n\t\t\t\t\t\tEdit tags\n\t\t\t\t\t</button>\n\t\t\t\t</li>\n\t\t\t\t<li>\n\t\t\t\t\t<button class=\"dropdown-item\" @click=\"mailbox.showTagColors = !mailbox.showTagColors\">\n\t\t\t\t\t\t<template v-if=\"mailbox.showTagColors\">Hide</template>\n\t\t\t\t\t\t<template v-else>Show</template>\n\t\t\t\t\t\ttag colors\n\t\t\t\t\t</button>\n\t\t\t\t</li>\n\t\t\t</ul>\n\t\t</div>\n\t\t<div class=\"list-group mt-1 mb-2\">\n\t\t\t<RouterLink\n\t\t\t\tv-for=\"tag in mailbox.tags\"\n\t\t\t\t:key=\"tag\"\n\t\t\t\t:to=\"toTagUrl(tag)\"\n\t\t\t\t:style=\"mailbox.showTagColors ? { borderLeftColor: colorHash(tag), borderLeftWidth: '4px' } : ''\"\n\t\t\t\tclass=\"list-group-item list-group-item-action small px-2\"\n\t\t\t\t:class=\"inSearch(tag) ? 'active' : ''\"\n\t\t\t\t@click.exact=\"hideNav\"\n\t\t\t\t@click=\"pagination.start = 0\"\n\t\t\t\t@click.meta=\"toggleTag($event, tag)\"\n\t\t\t\t@click.ctrl=\"toggleTag($event, tag)\"\n\t\t\t>\n\t\t\t\t<i v-if=\"inSearch(tag)\" class=\"bi bi-tag-fill\"></i>\n\t\t\t\t<i v-else class=\"bi bi-tag\"></i>\n\t\t\t\t{{ tag }}\n\t\t\t</RouterLink>\n\t\t</div>\n\t</template>\n</template>\n"
  },
  {
    "path": "server/ui-src/components/SearchForm.vue",
    "content": "<script>\nimport CommonMixins from \"../mixins/CommonMixins\";\nimport { pagination } from \"../stores/pagination\";\n\nexport default {\n\tmixins: [CommonMixins],\n\n\temits: [\"loadMessages\"],\n\n\tdata() {\n\t\treturn {\n\t\t\tsearch: \"\",\n\t\t};\n\t},\n\n\twatch: {\n\t\t$route() {\n\t\t\tthis.searchFromURL();\n\t\t},\n\t},\n\n\tmounted() {\n\t\tthis.searchFromURL();\n\t},\n\n\tmethods: {\n\t\tsearchFromURL() {\n\t\t\tconst urlParams = new URLSearchParams(window.location.search);\n\t\t\tthis.search = urlParams.get(\"q\") ? urlParams.get(\"q\") : \"\";\n\t\t},\n\n\t\tdoSearch(e) {\n\t\t\tpagination.start = 0;\n\t\t\tif (this.search === \"\") {\n\t\t\t\tthis.$router.push(\"/\");\n\t\t\t} else {\n\t\t\t\tconst urlParams = new URLSearchParams(window.location.search);\n\t\t\t\tconst curr = urlParams.get(\"q\");\n\t\t\t\tif (curr && curr === this.search) {\n\t\t\t\t\tpagination.start = 0;\n\t\t\t\t\tthis.$emit(\"loadMessages\");\n\t\t\t\t}\n\t\t\t\tconst p = {\n\t\t\t\t\tq: this.search,\n\t\t\t\t};\n\t\t\t\tif (pagination.start > 0) {\n\t\t\t\t\tp.start = pagination.start.toString();\n\t\t\t\t}\n\t\t\t\tif (pagination.limit !== pagination.defaultLimit) {\n\t\t\t\t\tp.limit = pagination.limit.toString();\n\t\t\t\t}\n\n\t\t\t\tconst params = new URLSearchParams(p);\n\t\t\t\tthis.$router.push(\"/search?\" + params.toString());\n\t\t\t}\n\n\t\t\te.preventDefault();\n\t\t},\n\n\t\tresetSearch() {\n\t\t\tthis.search = \"\";\n\t\t\tthis.$router.push(\"/\");\n\t\t},\n\t},\n};\n</script>\n\n<template>\n\t<form @submit=\"doSearch\">\n\t\t<div class=\"input-group flex-nowrap\">\n\t\t\t<div class=\"ms-md-2 d-flex border bg-body rounded-start flex-fill position-relative\">\n\t\t\t\t<input\n\t\t\t\t\tv-model.trim=\"search\"\n\t\t\t\t\ttype=\"text\"\n\t\t\t\t\tclass=\"form-control border-0\"\n\t\t\t\t\taria-label=\"Search\"\n\t\t\t\t\tplaceholder=\"Search mailbox\"\n\t\t\t\t/>\n\t\t\t\t<span v-if=\"search != ''\" class=\"btn btn-link position-absolute end-0 text-muted\" @click=\"resetSearch\"\n\t\t\t\t\t><i class=\"bi bi-x-circle\"></i\n\t\t\t\t></span>\n\t\t\t</div>\n\t\t\t<button class=\"btn btn-outline-secondary\" type=\"submit\">\n\t\t\t\t<i class=\"bi bi-search\"></i>\n\t\t\t</button>\n\t\t</div>\n\t</form>\n</template>\n"
  },
  {
    "path": "server/ui-src/components/message/HTMLCheck.vue",
    "content": "<script>\nimport { VcDonut } from \"vue-css-donut-chart\";\nimport axios from \"axios\";\nimport commonMixins from \"../../mixins/CommonMixins\";\nimport { Tooltip } from \"bootstrap\";\nimport DOMPurify from \"dompurify\";\n\nexport default {\n\tcomponents: {\n\t\tVcDonut,\n\t},\n\n\tmixins: [commonMixins],\n\n\tprops: {\n\t\tmessage: {\n\t\t\ttype: Object,\n\t\t\trequired: true,\n\t\t},\n\t},\n\n\temits: [\"setHtmlScore\", \"setBadgeStyle\"],\n\n\tdata() {\n\t\treturn {\n\t\t\terror: false,\n\t\t\tcheck: false,\n\t\t\tplatforms: [],\n\t\t\tallPlatforms: {\n\t\t\t\twindows: \"Windows\",\n\t\t\t\t\"windows-mail\": \"Windows Mail\",\n\t\t\t\t\"outlook-com\": \"Outlook.com\",\n\t\t\t\tmacos: \"macOS\",\n\t\t\t\tios: \"iOS\",\n\t\t\t\tandroid: \"Android\",\n\t\t\t\t\"desktop-webmail\": \"Desktop Webmail\",\n\t\t\t\t\"mobile-webmail\": \"Mobile Webmail\",\n\t\t\t},\n\t\t};\n\t},\n\n\tcomputed: {\n\t\tsummary() {\n\t\t\tif (!this.check) {\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\tconst result = {\n\t\t\t\tWarnings: [],\n\t\t\t\tTotal: {\n\t\t\t\t\tNodes: this.check.Total.Nodes,\n\t\t\t\t},\n\t\t\t};\n\n\t\t\tfor (let i = 0; i < this.check.Warnings.length; i++) {\n\t\t\t\tconst o = JSON.parse(JSON.stringify(this.check.Warnings[i]));\n\n\t\t\t\t// for <script> test\n\t\t\t\tif (o.Results.length === 0) {\n\t\t\t\t\tresult.Warnings.push(o);\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\t// filter by enabled platforms\n\t\t\t\tconst results = o.Results.filter((w) => {\n\t\t\t\t\treturn this.platforms.indexOf(w.Platform) !== -1;\n\t\t\t\t});\n\n\t\t\t\tif (results.length === 0) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\t// recalculate the percentages\n\t\t\t\tlet y = 0;\n\t\t\t\tlet p = 0;\n\t\t\t\tlet n = 0;\n\n\t\t\t\tresults.forEach((r) => {\n\t\t\t\t\tif (r.Support === \"yes\") {\n\t\t\t\t\t\ty++;\n\t\t\t\t\t} else if (r.Support === \"partial\") {\n\t\t\t\t\t\tp++;\n\t\t\t\t\t} else {\n\t\t\t\t\t\tn++;\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t\tconst total = y + p + n;\n\t\t\t\to.Results = results;\n\t\t\t\to.Score = {\n\t\t\t\t\tFound: o.Score.Found,\n\t\t\t\t\tSupported: (y / total) * 100,\n\t\t\t\t\tPartial: (p / total) * 100,\n\t\t\t\t\tUnsupported: (n / total) * 100,\n\t\t\t\t};\n\n\t\t\t\tresult.Warnings.push(o);\n\t\t\t}\n\n\t\t\tlet maxPartial = 0;\n\t\t\tlet maxUnsupported = 0;\n\t\t\tresult.Warnings.forEach((w) => {\n\t\t\t\tlet scoreWeight = 1;\n\t\t\t\tif (w.Score.Found < result.Total.Nodes) {\n\t\t\t\t\t// each error is weighted based on the number of occurrences vs: the total message nodes\n\t\t\t\t\tscoreWeight = w.Score.Found / result.Total.Nodes;\n\t\t\t\t}\n\n\t\t\t\t// pseudo-classes & at-rules need to be weighted lower as we do not know how many times they\n\t\t\t\t// are actually used in the HTML, and including things like bootstrap styles completely throws\n\t\t\t\t// off the calculation as these dominate.\n\t\t\t\tif (this.isPseudoClassOrAtRule(w.Title)) {\n\t\t\t\t\tscoreWeight = 0.05;\n\t\t\t\t\tw.PseudoClassOrAtRule = true;\n\t\t\t\t}\n\n\t\t\t\tconst scorePartial = w.Score.Partial * scoreWeight;\n\t\t\t\tconst scoreUnsupported = w.Score.Unsupported * scoreWeight;\n\t\t\t\tif (scorePartial > maxPartial) {\n\t\t\t\t\tmaxPartial = scorePartial;\n\t\t\t\t}\n\t\t\t\tif (scoreUnsupported > maxUnsupported) {\n\t\t\t\t\tmaxUnsupported = scoreUnsupported;\n\t\t\t\t}\n\t\t\t});\n\n\t\t\t// sort warnings by final score\n\t\t\tresult.Warnings.sort((a, b) => {\n\t\t\t\tlet aWeight =\n\t\t\t\t\ta.Score.Found > result.Total.Nodes ? result.Total.Nodes : a.Score.Found / result.Total.Nodes;\n\t\t\t\tlet bWeight =\n\t\t\t\t\tb.Score.Found > result.Total.Nodes ? result.Total.Nodes : b.Score.Found / result.Total.Nodes;\n\n\t\t\t\tif (this.isPseudoClassOrAtRule(a.Title)) {\n\t\t\t\t\taWeight = 0.05;\n\t\t\t\t}\n\n\t\t\t\tif (this.isPseudoClassOrAtRule(b.Title)) {\n\t\t\t\t\tbWeight = 0.05;\n\t\t\t\t}\n\n\t\t\t\treturn (\n\t\t\t\t\t(a.Score.Unsupported + a.Score.Partial) * aWeight <\n\t\t\t\t\t(b.Score.Unsupported + b.Score.Partial) * bWeight\n\t\t\t\t);\n\t\t\t});\n\n\t\t\tresult.Total.Supported = 100 - maxPartial - maxUnsupported;\n\t\t\tresult.Total.Partial = maxPartial;\n\t\t\tresult.Total.Unsupported = maxUnsupported;\n\n\t\t\tthis.$emit(\"setHtmlScore\", result.Total.Supported);\n\n\t\t\treturn result;\n\t\t},\n\n\t\tgraphSections() {\n\t\t\tconst s = Math.round(this.summary.Total.Supported);\n\t\t\tconst p = Math.round(this.summary.Total.Partial);\n\t\t\tconst u = 100 - s - p;\n\t\t\treturn [\n\t\t\t\t{\n\t\t\t\t\tlabel: this.round2dm(this.summary.Total.Supported) + \"% supported\",\n\t\t\t\t\tvalue: s,\n\t\t\t\t\tcolor: \"#198754\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tlabel: this.round2dm(this.summary.Total.Partial) + \"% partially supported\",\n\t\t\t\t\tvalue: p,\n\t\t\t\t\tcolor: \"#ffc107\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tlabel: this.round2dm(this.summary.Total.Unsupported) + \"% not supported\",\n\t\t\t\t\tvalue: u,\n\t\t\t\t\tcolor: \"#dc3545\",\n\t\t\t\t},\n\t\t\t];\n\t\t},\n\n\t\t// colors depend on both varying unsupported & partially unsupported percentages\n\t\tscoreColor() {\n\t\t\tif (this.summary.Total.Unsupported < 5 && this.summary.Total.Partial < 10) {\n\t\t\t\tthis.$emit(\"setBadgeStyle\", \"bg-success\");\n\t\t\t\treturn \"text-success\";\n\t\t\t} else if (this.summary.Total.Unsupported < 10 && this.summary.Total.Partial < 15) {\n\t\t\t\tthis.$emit(\"setBadgeStyle\", \"bg-warning text-primary\");\n\t\t\t\treturn \"text-warning\";\n\t\t\t}\n\n\t\t\tthis.$emit(\"setBadgeStyle\", \"bg-danger\");\n\t\t\treturn \"text-danger\";\n\t\t},\n\t},\n\n\twatch: {\n\t\tmessage: {\n\t\t\thandler() {\n\t\t\t\tthis.$emit(\"setHtmlScore\", false);\n\t\t\t\tthis.doCheck();\n\t\t\t},\n\t\t\tdeep: true,\n\t\t},\n\t\tplatforms(v) {\n\t\t\tlocalStorage.setItem(\"html-check-platforms\", JSON.stringify(v));\n\t\t},\n\t},\n\n\tmounted() {\n\t\tthis.loadConfig();\n\t\tthis.doCheck();\n\t},\n\n\tmethods: {\n\t\tdoCheck() {\n\t\t\tthis.check = false;\n\n\t\t\tif (this.message.HTML === \"\") {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// ignore any error, do not show loader\n\t\t\taxios\n\t\t\t\t.get(this.resolve(\"/api/v1/message/\" + this.message.ID + \"/html-check\"), null)\n\t\t\t\t.then((result) => {\n\t\t\t\t\tthis.check = result.data;\n\t\t\t\t\tthis.error = false;\n\n\t\t\t\t\t// set tooltips\n\t\t\t\t\twindow.setTimeout(() => {\n\t\t\t\t\t\tconst tooltipTriggerList = document.querySelectorAll('[data-bs-toggle=\"tooltip\"]');\n\t\t\t\t\t\t[...tooltipTriggerList].map((tooltipTriggerEl) => new Tooltip(tooltipTriggerEl));\n\t\t\t\t\t}, 500);\n\t\t\t\t})\n\t\t\t\t.catch((error) => {\n\t\t\t\t\t// handle error\n\t\t\t\t\tif (error.response && error.response.data) {\n\t\t\t\t\t\t// The request was made and the server responded with a status code\n\t\t\t\t\t\t// that falls out of the range of 2xx\n\t\t\t\t\t\tif (error.response.data.Error) {\n\t\t\t\t\t\t\tthis.error = error.response.data.Error;\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tthis.error = error.response.data;\n\t\t\t\t\t\t}\n\t\t\t\t\t} else if (error.request) {\n\t\t\t\t\t\t// The request was made but no response was received\n\t\t\t\t\t\t// `error.request` is an instance of XMLHttpRequest in the browser and an instance of\n\t\t\t\t\t\t// http.ClientRequest in node.js\n\t\t\t\t\t\tthis.error = \"Error sending data to the server. Please try again.\";\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Something happened in setting up the request that triggered an Error\n\t\t\t\t\t\tthis.error = error.message;\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t},\n\n\t\tloadConfig() {\n\t\t\tconst platforms = localStorage.getItem(\"html-check-platforms\");\n\t\t\tif (platforms) {\n\t\t\t\ttry {\n\t\t\t\t\tthis.platforms = JSON.parse(platforms);\n\t\t\t\t} catch {\n\t\t\t\t\t// if parsing fails, reset to default\n\t\t\t\t\tthis.platforms = [];\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// set all options\n\t\t\tif (this.platforms.length === 0) {\n\t\t\t\tthis.platforms = Object.keys(this.allPlatforms);\n\t\t\t}\n\t\t},\n\n\t\t// return a platform's families (email clients)\n\t\tfamilies(k) {\n\t\t\tif (this.check.Platforms[k]) {\n\t\t\t\treturn this.check.Platforms[k];\n\t\t\t}\n\n\t\t\treturn [];\n\t\t},\n\n\t\t// return whether the test string is a pseudo class (:<test>) or at rule (@<test>)\n\t\tisPseudoClassOrAtRule(t) {\n\t\t\treturn t.match(/^(:|@)/);\n\t\t},\n\n\t\tround(v) {\n\t\t\treturn Math.round(v);\n\t\t},\n\n\t\tround2dm(v) {\n\t\t\treturn Math.round(v * 100) / 100;\n\t\t},\n\n\t\tscrollToWarnings() {\n\t\t\tif (!this.$refs.warnings) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tthis.$refs.warnings.scrollIntoView({ behavior: \"smooth\" });\n\t\t},\n\n\t\t// Sanitize HTML to prevent XSS\n\t\tsanitizeHTML(html) {\n\t\t\treturn DOMPurify.sanitize(html);\n\t\t},\n\t},\n};\n</script>\n\n<template>\n\t<template v-if=\"error\">\n\t\t<p>HTML check failed to load:</p>\n\t\t<div class=\"alert alert-warning\">\n\t\t\t{{ error }}\n\t\t</div>\n\t</template>\n\n\t<template v-if=\"summary\">\n\t\t<div class=\"mt-5 mb-3\">\n\t\t\t<div class=\"row w-100\">\n\t\t\t\t<div class=\"col-md-8\">\n\t\t\t\t\t<vc-donut\n\t\t\t\t\t\t:sections=\"graphSections\"\n\t\t\t\t\t\tbackground=\"var(--bs-body-bg)\"\n\t\t\t\t\t\t:size=\"180\"\n\t\t\t\t\t\tunit=\"px\"\n\t\t\t\t\t\t:thickness=\"20\"\n\t\t\t\t\t\thas-legend\n\t\t\t\t\t\tlegend-placement=\"bottom\"\n\t\t\t\t\t\t:total=\"100\"\n\t\t\t\t\t\t:start-angle=\"0\"\n\t\t\t\t\t\t:auto-adjust-text-size=\"true\"\n\t\t\t\t\t\t@section-click=\"scrollToWarnings\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<h2 class=\"m-0\" :class=\"scoreColor\" @click=\"scrollToWarnings\">\n\t\t\t\t\t\t\t{{ round2dm(summary.Total.Supported) }}%\n\t\t\t\t\t\t</h2>\n\t\t\t\t\t\t<div class=\"text-body\">support</div>\n\t\t\t\t\t\t<template #legend>\n\t\t\t\t\t\t\t<p class=\"my-3 small mb-1 text-center\" @click=\"scrollToWarnings\">\n\t\t\t\t\t\t\t\t<span class=\"text-nowrap\">\n\t\t\t\t\t\t\t\t\t<i class=\"bi bi-circle-fill text-success\"></i>\n\t\t\t\t\t\t\t\t\t{{ round2dm(summary.Total.Supported) }}% supported\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t&nbsp;\n\t\t\t\t\t\t\t\t<span class=\"text-nowrap\">\n\t\t\t\t\t\t\t\t\t<i class=\"bi bi-circle-fill text-warning\"></i>\n\t\t\t\t\t\t\t\t\t{{ round2dm(summary.Total.Partial) }}% partially supported\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t&nbsp;\n\t\t\t\t\t\t\t\t<span class=\"text-nowrap\">\n\t\t\t\t\t\t\t\t\t<i class=\"bi bi-circle-fill text-danger\"></i>\n\t\t\t\t\t\t\t\t\t{{ round2dm(summary.Total.Unsupported) }}% not supported\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t<p class=\"small text-muted\">calculated from {{ formatNumber(check.Total.Tests) }} tests</p>\n\t\t\t\t\t\t</template>\n\t\t\t\t\t</vc-donut>\n\n\t\t\t\t\t<div class=\"input-group justify-content-center mb-3\">\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tclass=\"btn btn-outline-secondary\"\n\t\t\t\t\t\t\tdata-bs-toggle=\"modal\"\n\t\t\t\t\t\t\tdata-bs-target=\"#AboutHTMLCheckResults\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<i class=\"bi bi-info-circle-fill\"></i>\n\t\t\t\t\t\t\tHelp\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"col-md\">\n\t\t\t\t\t<h2 class=\"h5 mb-3\">Tested platforms:</h2>\n\t\t\t\t\t<div v-for=\"(p, k) in allPlatforms\" :key=\"'check_' + k\" class=\"form-check form-switch\">\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t:id=\"'Check_' + k\"\n\t\t\t\t\t\t\tv-model=\"platforms\"\n\t\t\t\t\t\t\tclass=\"form-check-input\"\n\t\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\t\trole=\"switch\"\n\t\t\t\t\t\t\t:value=\"k\"\n\t\t\t\t\t\t\t:aria-label=\"p\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\tclass=\"form-check-label\"\n\t\t\t\t\t\t\t:for=\"'Check_' + k\"\n\t\t\t\t\t\t\t:class=\"platforms.indexOf(k) !== -1 ? '' : 'text-muted'\"\n\t\t\t\t\t\t\t:title=\"families(k).join(', ')\"\n\t\t\t\t\t\t\tdata-bs-toggle=\"tooltip\"\n\t\t\t\t\t\t\t:data-bs-title=\"families(k).join(', ')\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{{ p }}\n\t\t\t\t\t\t</label>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\n\t\t<template v-if=\"summary.Warnings.length\">\n\t\t\t<h4 ref=\"warnings\" class=\"h5 mt-4\">\n\t\t\t\t{{ summary.Warnings.length }} Warnings from {{ formatNumber(summary.Total.Nodes) }} HTML nodes:\n\t\t\t</h4>\n\t\t\t<div id=\"warnings\" class=\"accordion\">\n\t\t\t\t<div v-for=\"(warning, i) in summary.Warnings\" :key=\"'warning_' + i\" class=\"accordion-item\">\n\t\t\t\t\t<h2 class=\"accordion-header\">\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tclass=\"accordion-button collapsed\"\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tdata-bs-toggle=\"collapse\"\n\t\t\t\t\t\t\t:data-bs-target=\"'#' + warning.Slug\"\n\t\t\t\t\t\t\taria-expanded=\"false\"\n\t\t\t\t\t\t\t:aria-controls=\"warning.Slug\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div class=\"row w-100 w-lg-75\">\n\t\t\t\t\t\t\t\t<div class=\"col-sm\">\n\t\t\t\t\t\t\t\t\t{{ warning.Title }}\n\t\t\t\t\t\t\t\t\t<span class=\"ms-2 small badge text-bg-secondary\" title=\"Test category\">\n\t\t\t\t\t\t\t\t\t\t{{ warning.Category }}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\tclass=\"ms-2 small badge text-bg-light\"\n\t\t\t\t\t\t\t\t\t\ttitle=\"The number of times this was detected\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\tx {{ warning.Score.Found }}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div class=\"col-sm mt-2 mt-sm-0\">\n\t\t\t\t\t\t\t\t\t<div class=\"progress-stacked\">\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tclass=\"progress\"\n\t\t\t\t\t\t\t\t\t\t\trole=\"progressbar\"\n\t\t\t\t\t\t\t\t\t\t\taria-label=\"Supported\"\n\t\t\t\t\t\t\t\t\t\t\t:aria-valuenow=\"warning.Score.Supported\"\n\t\t\t\t\t\t\t\t\t\t\taria-valuemin=\"0\"\n\t\t\t\t\t\t\t\t\t\t\taria-valuemax=\"100\"\n\t\t\t\t\t\t\t\t\t\t\t:style=\"{ width: warning.Score.Supported + '%' }\"\n\t\t\t\t\t\t\t\t\t\t\ttitle=\"Supported\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"progress-bar bg-success\">\n\t\t\t\t\t\t\t\t\t\t\t\t{{ round(warning.Score.Supported) + \"%\" }}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tclass=\"progress\"\n\t\t\t\t\t\t\t\t\t\t\trole=\"progressbar\"\n\t\t\t\t\t\t\t\t\t\t\taria-label=\"Partial\"\n\t\t\t\t\t\t\t\t\t\t\t:aria-valuenow=\"warning.Score.Partial\"\n\t\t\t\t\t\t\t\t\t\t\taria-valuemin=\"0\"\n\t\t\t\t\t\t\t\t\t\t\taria-valuemax=\"100\"\n\t\t\t\t\t\t\t\t\t\t\t:style=\"{ width: warning.Score.Partial + '%' }\"\n\t\t\t\t\t\t\t\t\t\t\ttitle=\"Partial support\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"progress-bar progress-bar-striped bg-warning text-dark\">\n\t\t\t\t\t\t\t\t\t\t\t\t{{ round(warning.Score.Partial) + \"%\" }}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tclass=\"progress\"\n\t\t\t\t\t\t\t\t\t\t\trole=\"progressbar\"\n\t\t\t\t\t\t\t\t\t\t\taria-label=\"No\"\n\t\t\t\t\t\t\t\t\t\t\t:aria-valuenow=\"warning.Score.Unsupported\"\n\t\t\t\t\t\t\t\t\t\t\taria-valuemin=\"0\"\n\t\t\t\t\t\t\t\t\t\t\taria-valuemax=\"100\"\n\t\t\t\t\t\t\t\t\t\t\t:style=\"{ width: warning.Score.Unsupported + '%' }\"\n\t\t\t\t\t\t\t\t\t\t\ttitle=\"Not supported\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"progress-bar bg-danger\">\n\t\t\t\t\t\t\t\t\t\t\t\t{{ round(warning.Score.Unsupported) + \"%\" }}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</h2>\n\t\t\t\t\t<div :id=\"warning.Slug\" class=\"accordion-collapse collapse\" data-bs-parent=\"#warnings\">\n\t\t\t\t\t\t<div class=\"accordion-body\">\n\t\t\t\t\t\t\t<p v-if=\"warning.Description !== '' || warning.PseudoClassOrAtRule\">\n\t\t\t\t\t\t\t\t<span v-if=\"warning.PseudoClassOrAtRule\" class=\"d-block alert alert-warning mb-2\">\n\t\t\t\t\t\t\t\t\t<i class=\"bi bi-info-circle me-2\"></i>\n\t\t\t\t\t\t\t\t\tDetected {{ warning.Score.Found }} <code>{{ warning.Title }}</code>\n\t\t\t\t\t\t\t\t\t<template v-if=\"warning.Score.Found === 1\">property</template>\n\t\t\t\t\t\t\t\t\t<template v-else>properties</template>\n\t\t\t\t\t\t\t\t\tin the CSS styles, but unable to test if used or not.\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t<!-- eslint-disable vue/no-v-html -->\n\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\tv-if=\"warning.Description !== ''\"\n\t\t\t\t\t\t\t\t\tclass=\"me-2\"\n\t\t\t\t\t\t\t\t\tv-html=\"sanitizeHTML(warning.Description)\"\n\t\t\t\t\t\t\t\t></span>\n\t\t\t\t\t\t\t\t<!-- -eslint-disable vue/no-v-html -->\n\t\t\t\t\t\t\t</p>\n\n\t\t\t\t\t\t\t<template v-if=\"warning.Results.length\">\n\t\t\t\t\t\t\t\t<h3 class=\"h6\">Clients with partial or no support:</h3>\n\t\t\t\t\t\t\t\t<p>\n\t\t\t\t\t\t\t\t\t<small\n\t\t\t\t\t\t\t\t\t\tv-for=\"(warningRes, wi) in warning.Results\"\n\t\t\t\t\t\t\t\t\t\t:key=\"'warning_results_' + wi\"\n\t\t\t\t\t\t\t\t\t\tclass=\"text-nowrap d-inline-block me-4\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<i\n\t\t\t\t\t\t\t\t\t\t\tclass=\"bi bi-circle-fill\"\n\t\t\t\t\t\t\t\t\t\t\t:class=\"warningRes.Support === 'no' ? 'text-danger' : 'text-warning'\"\n\t\t\t\t\t\t\t\t\t\t\t:title=\"\n\t\t\t\t\t\t\t\t\t\t\t\twarningRes.Support === 'no' ? 'Not supported' : 'Partially supported'\n\t\t\t\t\t\t\t\t\t\t\t\"\n\t\t\t\t\t\t\t\t\t\t></i>\n\t\t\t\t\t\t\t\t\t\t{{ warningRes.Name }}\n\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\tv-if=\"warningRes.NoteNumber !== ''\"\n\t\t\t\t\t\t\t\t\t\t\tclass=\"badge text-bg-secondary\"\n\t\t\t\t\t\t\t\t\t\t\ttitle=\"See notes\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{{ warningRes.NoteNumber }}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t</small>\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t</template>\n\n\t\t\t\t\t\t\t<div v-if=\"Object.keys(warning.NotesByNumber).length\" class=\"mt-3\">\n\t\t\t\t\t\t\t\t<h3 class=\"h6\">Notes:</h3>\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tv-for=\"(n, ni) in warning.NotesByNumber\"\n\t\t\t\t\t\t\t\t\t:key=\"'warning_notes' + ni\"\n\t\t\t\t\t\t\t\t\tclass=\"small row my-2\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<div class=\"col-auto pe-0\">\n\t\t\t\t\t\t\t\t\t\t<span class=\"badge text-bg-secondary\">\n\t\t\t\t\t\t\t\t\t\t\t{{ ni }}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div class=\"col\" v-html=\"sanitizeHTML(n)\"></div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t<p v-if=\"warning.URL\" class=\"small mt-3 mb-0\">\n\t\t\t\t\t\t\t\t<a :href=\"warning.URL\" target=\"_blank\">Online reference</a>\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<p class=\"text-center text-muted small mt-4\">\n\t\t\t\tScores based on <b>{{ check.Total.Tests }}</b> tests of HTML and CSS properties using compatibility data\n\t\t\t\tfrom <a href=\"https://www.caniemail.com/\" target=\"_blank\">caniemail.com</a>.\n\t\t\t</p>\n\t\t</template>\n\n\t\t<div\n\t\t\tid=\"AboutHTMLCheckResults\"\n\t\t\tclass=\"modal fade\"\n\t\t\ttabindex=\"-1\"\n\t\t\taria-labelledby=\"AboutHTMLCheckResultsLabel\"\n\t\t\taria-hidden=\"true\"\n\t\t>\n\t\t\t<div class=\"modal-dialog modal-lg modal-dialog-scrollable\">\n\t\t\t\t<div class=\"modal-content\">\n\t\t\t\t\t<div class=\"modal-header\">\n\t\t\t\t\t\t<h1 id=\"AboutHTMLCheckResultsLabel\" class=\"modal-title fs-5\">About HTML check</h1>\n\t\t\t\t\t\t<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"modal-body\">\n\t\t\t\t\t\t<div id=\"HTMLCheckAboutAccordion\" class=\"accordion\">\n\t\t\t\t\t\t\t<div class=\"accordion-item\">\n\t\t\t\t\t\t\t\t<h2 class=\"accordion-header\">\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\tclass=\"accordion-button collapsed\"\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tdata-bs-toggle=\"collapse\"\n\t\t\t\t\t\t\t\t\t\tdata-bs-target=\"#col1\"\n\t\t\t\t\t\t\t\t\t\taria-expanded=\"false\"\n\t\t\t\t\t\t\t\t\t\taria-controls=\"col1\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\tWhat is HTML check?\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t</h2>\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tid=\"col1\"\n\t\t\t\t\t\t\t\t\tclass=\"accordion-collapse collapse\"\n\t\t\t\t\t\t\t\t\tdata-bs-parent=\"#HTMLCheckAboutAccordion\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<div class=\"accordion-body\">\n\t\t\t\t\t\t\t\t\t\tThe support for HTML/CSS messages varies greatly across email clients. HTML\n\t\t\t\t\t\t\t\t\t\tcheck attempts to calculate the overall support for your email for all selected\n\t\t\t\t\t\t\t\t\t\tplatforms to give you some idea of the general compatibility of your HTML email.\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div class=\"accordion-item\">\n\t\t\t\t\t\t\t\t<h2 class=\"accordion-header\">\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\tclass=\"accordion-button collapsed\"\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tdata-bs-toggle=\"collapse\"\n\t\t\t\t\t\t\t\t\t\tdata-bs-target=\"#col2\"\n\t\t\t\t\t\t\t\t\t\taria-expanded=\"false\"\n\t\t\t\t\t\t\t\t\t\taria-controls=\"col2\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\tHow does it work?\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t</h2>\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tid=\"col2\"\n\t\t\t\t\t\t\t\t\tclass=\"accordion-collapse collapse\"\n\t\t\t\t\t\t\t\t\tdata-bs-parent=\"#HTMLCheckAboutAccordion\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<div class=\"accordion-body\">\n\t\t\t\t\t\t\t\t\t\t<p>\n\t\t\t\t\t\t\t\t\t\t\tInternally the original HTML message is run against\n\t\t\t\t\t\t\t\t\t\t\t<b>{{ check.Total.Tests }}</b> different HTML and CSS tests. All tests\n\t\t\t\t\t\t\t\t\t\t\t(except for <code>&lt;script&gt;</code>) correspond to a test on\n\t\t\t\t\t\t\t\t\t\t\t<a href=\"https://www.caniemail.com/\" target=\"_blank\">caniemail.com</a>, and\n\t\t\t\t\t\t\t\t\t\t\tthe final score is calculated using the available compatibility data.\n\t\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t\t<p>\n\t\t\t\t\t\t\t\t\t\t\tCSS support is very difficult to programmatically test, especially if a\n\t\t\t\t\t\t\t\t\t\t\tmessage contains CSS style blocks or is linked to remote stylesheets. Remote\n\t\t\t\t\t\t\t\t\t\t\tstylesheets are, unless blocked via\n\t\t\t\t\t\t\t\t\t\t\t<code>--block-remote-css-and-fonts</code>, downloaded and injected into the\n\t\t\t\t\t\t\t\t\t\t\tmessage as style blocks. The email is then\n\t\t\t\t\t\t\t\t\t\t\t<a href=\"https://github.com/vanng822/go-premailer\" target=\"_blank\"\n\t\t\t\t\t\t\t\t\t\t\t\t>inlined</a\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\tto matching HTML elements. This gives Mailpit fairly accurate results.\n\t\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t\t<p>\n\t\t\t\t\t\t\t\t\t\t\tCSS properties such as <code>@font-face</code>, <code>:visited</code>,\n\t\t\t\t\t\t\t\t\t\t\t<code>:hover</code> etc cannot be inlined however, so these are searched for\n\t\t\t\t\t\t\t\t\t\t\twithin CSS blocks. This method is not accurate as Mailpit does not know how\n\t\t\t\t\t\t\t\t\t\t\tmany nodes it actually applies to, if any, so they are weighted lightly (5%)\n\t\t\t\t\t\t\t\t\t\t\tas not to affect the score. An example of this would be any email linking to\n\t\t\t\t\t\t\t\t\t\t\tthe full bootstrap CSS which contains dozens of unused attributes.\n\t\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t\t<p>\n\t\t\t\t\t\t\t\t\t\t\tAll warnings are displayed with their respective support, including any\n\t\t\t\t\t\t\t\t\t\t\tspecific notes, and it is up to you to decide what you do with that\n\t\t\t\t\t\t\t\t\t\t\tinformation and how badly it may impact your message.\n\t\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div class=\"accordion-item\">\n\t\t\t\t\t\t\t\t<h2 class=\"accordion-header\">\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\tclass=\"accordion-button collapsed\"\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tdata-bs-toggle=\"collapse\"\n\t\t\t\t\t\t\t\t\t\tdata-bs-target=\"#col3\"\n\t\t\t\t\t\t\t\t\t\taria-expanded=\"false\"\n\t\t\t\t\t\t\t\t\t\taria-controls=\"col3\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\tIs the final score accurate?\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t</h2>\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tid=\"col3\"\n\t\t\t\t\t\t\t\t\tclass=\"accordion-collapse collapse\"\n\t\t\t\t\t\t\t\t\tdata-bs-parent=\"#HTMLCheckAboutAccordion\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<div class=\"accordion-body\">\n\t\t\t\t\t\t\t\t\t\t<p>\n\t\t\t\t\t\t\t\t\t\t\tThere are many ways to define \"accurate\", and how one should calculate the\n\t\t\t\t\t\t\t\t\t\t\tcompatibility score of an email. There is also no way to programmatically\n\t\t\t\t\t\t\t\t\t\t\tdetermine the relevance of a single test to the entire email.\n\t\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t\t<p>\n\t\t\t\t\t\t\t\t\t\t\tFor each test, Mailpit calculates both the unsupported & partially-supported\n\t\t\t\t\t\t\t\t\t\t\tpercentages in relation to the number of matches against the total number of\n\t\t\t\t\t\t\t\t\t\t\tnodes (elements) in the HTML. The maximum unsupported and\n\t\t\t\t\t\t\t\t\t\t\tpartially-supported weighted scores are then used for the final score (ie:\n\t\t\t\t\t\t\t\t\t\t\tworst case scenario).\n\t\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t\t<p>\n\t\t\t\t\t\t\t\t\t\t\tTo try explain this logic in very simple terms: Assuming a\n\t\t\t\t\t\t\t\t\t\t\t<code>&lt;script&gt;</code> node (element) has 100% failure (not supported\n\t\t\t\t\t\t\t\t\t\t\tin any email client), and a <code>&lt;p&gt;</code> node has 100% pass\n\t\t\t\t\t\t\t\t\t\t\t(supported).\n\t\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t\t<ul>\n\t\t\t\t\t\t\t\t\t\t\t<li>\n\t\t\t\t\t\t\t\t\t\t\t\tAn email containing just a single <code>&lt;script&gt;</code>: the final\n\t\t\t\t\t\t\t\t\t\t\t\tscore is 0% supported.\n\t\t\t\t\t\t\t\t\t\t\t</li>\n\t\t\t\t\t\t\t\t\t\t\t<li>\n\t\t\t\t\t\t\t\t\t\t\t\tAn email containing just a <code>&lt;script&gt;</code> and a\n\t\t\t\t\t\t\t\t\t\t\t\t<code>&lt;p&gt;</code>: the final score is 50% supported.\n\t\t\t\t\t\t\t\t\t\t\t</li>\n\t\t\t\t\t\t\t\t\t\t\t<li>\n\t\t\t\t\t\t\t\t\t\t\t\tAn email containing just a <code>&lt;script&gt;</code> and two\n\t\t\t\t\t\t\t\t\t\t\t\t<code>&lt;p&gt;</code>: the final score is 66.67% supported.\n\t\t\t\t\t\t\t\t\t\t\t</li>\n\t\t\t\t\t\t\t\t\t\t</ul>\n\t\t\t\t\t\t\t\t\t\t<p>\n\t\t\t\t\t\t\t\t\t\t\tMailpit will sort the warnings according to their weighted unsupported\n\t\t\t\t\t\t\t\t\t\t\tscores.\n\t\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t<div class=\"accordion-item\">\n\t\t\t\t\t\t\t\t<h2 class=\"accordion-header\">\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\tclass=\"accordion-button collapsed\"\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tdata-bs-toggle=\"collapse\"\n\t\t\t\t\t\t\t\t\t\tdata-bs-target=\"#col4\"\n\t\t\t\t\t\t\t\t\t\taria-expanded=\"false\"\n\t\t\t\t\t\t\t\t\t\taria-controls=\"col4\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\tWhat about invalid HTML?\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t</h2>\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tid=\"col4\"\n\t\t\t\t\t\t\t\t\tclass=\"accordion-collapse collapse\"\n\t\t\t\t\t\t\t\t\tdata-bs-parent=\"#HTMLCheckAboutAccordion\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<div class=\"accordion-body\">\n\t\t\t\t\t\t\t\t\t\tHTML check does not detect if the original HTML is valid. In order to detect\n\t\t\t\t\t\t\t\t\t\tapplied styles to every node, the HTML email is run through a parser which is\n\t\t\t\t\t\t\t\t\t\tvery good at turning invalid input into valid output. It is what it is...\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"modal-footer\">\n\t\t\t\t\t\t<button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t</template>\n</template>\n"
  },
  {
    "path": "server/ui-src/components/message/LinkCheck.vue",
    "content": "<script>\nimport axios from \"axios\";\nimport commonMixins from \"../../mixins/CommonMixins\";\n\nexport default {\n\tmixins: [commonMixins],\n\n\tprops: {\n\t\tmessage: {\n\t\t\ttype: Object,\n\t\t\trequired: true,\n\t\t},\n\t},\n\n\temits: [\"setLinkErrors\"],\n\n\tdata() {\n\t\treturn {\n\t\t\terror: false,\n\t\t\tautoScan: false,\n\t\t\tfollowRedirects: false,\n\t\t\tcheck: false,\n\t\t\tloaded: false,\n\t\t\tloading: false,\n\t\t};\n\t},\n\n\tcomputed: {\n\t\tgroupedStatuses() {\n\t\t\tconst results = {};\n\n\t\t\tif (!this.check) {\n\t\t\t\treturn results;\n\t\t\t}\n\n\t\t\t// group by status\n\t\t\tthis.check.Links.forEach((r) => {\n\t\t\t\tif (!results[r.StatusCode]) {\n\t\t\t\t\tlet css = \"\";\n\t\t\t\t\tif (r.StatusCode >= 400 || r.StatusCode === 0) {\n\t\t\t\t\t\tcss = \"text-danger\";\n\t\t\t\t\t} else if (r.StatusCode >= 300) {\n\t\t\t\t\t\tcss = \"text-info\";\n\t\t\t\t\t}\n\n\t\t\t\t\tif (r.StatusCode === 0) {\n\t\t\t\t\t\tr.Status = \"Cannot connect to server\";\n\t\t\t\t\t}\n\t\t\t\t\tresults[r.StatusCode] = {\n\t\t\t\t\t\tStatusCode: r.StatusCode,\n\t\t\t\t\t\tStatus: r.Status,\n\t\t\t\t\t\tClass: css,\n\t\t\t\t\t\tURLS: [],\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t\tresults[r.StatusCode].URLS.push(r.URL);\n\t\t\t});\n\n\t\t\tconst newArr = [];\n\n\t\t\tfor (const i in results) {\n\t\t\t\tnewArr.push(results[i]);\n\t\t\t}\n\n\t\t\t// sort statuses\n\t\t\tconst sorted = newArr.sort((a, b) => {\n\t\t\t\tif (a.StatusCode === 0) {\n\t\t\t\t\treturn false;\n\t\t\t\t}\n\t\t\t\treturn a.StatusCode < b.StatusCode;\n\t\t\t});\n\n\t\t\treturn sorted;\n\t\t},\n\t},\n\n\twatch: {\n\t\tautoScan(v) {\n\t\t\tif (!this.loaded) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (v) {\n\t\t\t\tlocalStorage.setItem(\"LinkCheckAutoScan\", true);\n\t\t\t\tif (!this.check) {\n\t\t\t\t\tthis.doCheck();\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tlocalStorage.removeItem(\"LinkCheckAutoScan\");\n\t\t\t}\n\t\t},\n\t\tfollowRedirects(v) {\n\t\t\tif (!this.loaded) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (v) {\n\t\t\t\tlocalStorage.setItem(\"LinkCheckFollowRedirects\", true);\n\t\t\t} else {\n\t\t\t\tlocalStorage.removeItem(\"LinkCheckFollowRedirects\");\n\t\t\t}\n\t\t\tif (this.check) {\n\t\t\t\tthis.doCheck();\n\t\t\t}\n\t\t},\n\t},\n\n\tcreated() {\n\t\tthis.autoScan = localStorage.getItem(\"LinkCheckAutoScan\");\n\t\tthis.followRedirects = localStorage.getItem(\"LinkCheckFollowRedirects\");\n\t},\n\n\tmounted() {\n\t\tthis.loaded = true;\n\t\tif (this.autoScan) {\n\t\t\tthis.doCheck();\n\t\t}\n\t},\n\n\tmethods: {\n\t\tdoCheck() {\n\t\t\tthis.check = false;\n\t\t\tthis.loading = true;\n\t\t\tlet uri = this.resolve(\"/api/v1/message/\" + this.message.ID + \"/link-check\");\n\t\t\tif (this.followRedirects) {\n\t\t\t\turi += \"?follow=true\";\n\t\t\t}\n\n\t\t\t// ignore any error, do not show loader\n\t\t\taxios\n\t\t\t\t.get(uri, null)\n\t\t\t\t.then((result) => {\n\t\t\t\t\tthis.check = result.data;\n\t\t\t\t\tthis.error = false;\n\n\t\t\t\t\tthis.$emit(\"setLinkErrors\", result.data.Errors);\n\t\t\t\t})\n\t\t\t\t.catch((error) => {\n\t\t\t\t\t// handle error\n\t\t\t\t\tif (error.response && error.response.data) {\n\t\t\t\t\t\t// The request was made and the server responded with a status code\n\t\t\t\t\t\t// that falls out of the range of 2xx\n\t\t\t\t\t\tif (error.response.data.Error) {\n\t\t\t\t\t\t\tthis.error = error.response.data.Error;\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tthis.error = error.response.data;\n\t\t\t\t\t\t}\n\t\t\t\t\t} else if (error.request) {\n\t\t\t\t\t\t// The request was made but no response was received\n\t\t\t\t\t\t// `error.request` is an instance of XMLHttpRequest in the browser and an instance of\n\t\t\t\t\t\t// http.ClientRequest in node.js\n\t\t\t\t\t\tthis.error = \"Error sending data to the server. Please try again.\";\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Something happened in setting up the request that triggered an Error\n\t\t\t\t\t\tthis.error = error.message;\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\t.then(() => {\n\t\t\t\t\t// always run\n\t\t\t\t\tthis.loading = false;\n\t\t\t\t});\n\t\t},\n\t},\n};\n</script>\n\n<template>\n\t<div class=\"pe-3\">\n\t\t<div class=\"row mb-3 align-items-center\">\n\t\t\t<div class=\"col\">\n\t\t\t\t<h4 class=\"mb-0\">\n\t\t\t\t\t<template v-if=\"!check\"> Link check </template>\n\t\t\t\t\t<template v-else>\n\t\t\t\t\t\t<template v-if=\"check.Links.length\">\n\t\t\t\t\t\t\tScanned {{ formatNumber(check.Links.length) }} link<template v-if=\"check.Links.length != 1\"\n\t\t\t\t\t\t\t\t>s</template\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t</template>\n\t\t\t\t\t\t<template v-else> No links detected </template>\n\t\t\t\t\t</template>\n\t\t\t\t</h4>\n\t\t\t</div>\n\t\t\t<div class=\"col-auto\">\n\t\t\t\t<div class=\"input-group\">\n\t\t\t\t\t<button\n\t\t\t\t\t\tclass=\"btn btn-outline-secondary\"\n\t\t\t\t\t\tdata-bs-toggle=\"modal\"\n\t\t\t\t\t\tdata-bs-target=\"#AboutLinkCheckResults\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<i class=\"bi bi-info-circle-fill\"></i>\n\t\t\t\t\t\tHelp\n\t\t\t\t\t</button>\n\t\t\t\t\t<button class=\"btn btn-outline-secondary\" data-bs-toggle=\"modal\" data-bs-target=\"#LinkCheckOptions\">\n\t\t\t\t\t\t<i class=\"bi bi-gear-fill\"></i>\n\t\t\t\t\t\tSettings\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\n\t\t<div v-if=\"!check\">\n\t\t\t<p class=\"text-muted\">\n\t\t\t\tLink check scans your email text &amp; HTML for unique links, testing the response status codes. This\n\t\t\t\tincludes links to images and remote CSS stylesheets.\n\t\t\t</p>\n\n\t\t\t<p class=\"text-center my-5\">\n\t\t\t\t<button v-if=\"!check\" class=\"btn btn-primary btn-lg\" :disabled=\"loading\" @click=\"doCheck()\">\n\t\t\t\t\t<template v-if=\"loading\">\n\t\t\t\t\t\tChecking links\n\t\t\t\t\t\t<div class=\"ms-1 spinner-border spinner-border-sm text-light\" role=\"status\">\n\t\t\t\t\t\t\t<span class=\"visually-hidden\">Loading...</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</template>\n\t\t\t\t\t<template v-else>\n\t\t\t\t\t\t<i class=\"bi bi-check-square me-2\"></i>\n\t\t\t\t\t\tCheck message links\n\t\t\t\t\t</template>\n\t\t\t\t</button>\n\t\t\t</p>\n\t\t</div>\n\n\t\t<div v-for=\"(s, k) in groupedStatuses\" v-else :key=\"k\">\n\t\t\t<div class=\"card mb-3\">\n\t\t\t\t<div class=\"card-header h4\" :class=\"s.Class\">\n\t\t\t\t\tStatus {{ s.StatusCode }}\n\t\t\t\t\t<small v-if=\"s.Status != ''\" class=\"ms-2 small text-muted\">({{ s.Status }})</small>\n\t\t\t\t</div>\n\t\t\t\t<ul class=\"list-group list-group-flush\">\n\t\t\t\t\t<li v-for=\"(u, i) in s.URLS\" :key=\"'status' + i\" class=\"list-group-item\">\n\t\t\t\t\t\t<a :href=\"u\" target=\"_blank\" class=\"no-icon\">{{ u }}</a>\n\t\t\t\t\t</li>\n\t\t\t\t</ul>\n\t\t\t</div>\n\t\t</div>\n\n\t\t<template v-if=\"error\">\n\t\t\t<p>Link check failed to load:</p>\n\t\t\t<div class=\"alert alert-warning\">\n\t\t\t\t{{ error }}\n\t\t\t</div>\n\t\t</template>\n\t</div>\n\n\t<div\n\t\tid=\"LinkCheckOptions\"\n\t\tclass=\"modal fade\"\n\t\ttabindex=\"-1\"\n\t\taria-labelledby=\"LinkCheckOptionsLabel\"\n\t\taria-hidden=\"true\"\n\t>\n\t\t<div class=\"modal-dialog modal-lg modal-dialog-scrollable\">\n\t\t\t<div class=\"modal-content\">\n\t\t\t\t<div class=\"modal-header\">\n\t\t\t\t\t<h1 id=\"LinkCheckOptionsLabel\" class=\"modal-title fs-5\">Link check options</h1>\n\t\t\t\t\t<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"modal-body\">\n\t\t\t\t\t<h6 class=\"mt-4\">Follow HTTP redirects (status 301 & 302)</h6>\n\t\t\t\t\t<div class=\"form-check form-switch mb-4\">\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\tid=\"LinkCheckFollowRedirectsSwitch\"\n\t\t\t\t\t\t\tv-model=\"followRedirects\"\n\t\t\t\t\t\t\tclass=\"form-check-input\"\n\t\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\t\trole=\"switch\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<label class=\"form-check-label\" for=\"LinkCheckFollowRedirectsSwitch\">\n\t\t\t\t\t\t\t<template v-if=\"followRedirects\">Following HTTP redirects</template>\n\t\t\t\t\t\t\t<template v-else>Not following HTTP redirects</template>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<h6 class=\"mt-4\">Automatic link checking</h6>\n\t\t\t\t\t<div class=\"form-check form-switch mb-3\">\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\tid=\"LinkCheckAutoCheckSwitch\"\n\t\t\t\t\t\t\tv-model=\"autoScan\"\n\t\t\t\t\t\t\tclass=\"form-check-input\"\n\t\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\t\trole=\"switch\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<label class=\"form-check-label\" for=\"LinkCheckAutoCheckSwitch\">\n\t\t\t\t\t\t\t<template v-if=\"autoScan\">Automatic link checking is enabled</template>\n\t\t\t\t\t\t\t<template v-else>Automatic link checking is disabled</template>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<div class=\"form-text\">\n\t\t\t\t\t\t\tNote: Enabling auto checking will scan every link & image every time a message is opened.\n\t\t\t\t\t\t\tOnly enable this if you understand the potential risks &amp; consequences.\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"modal-footer\">\n\t\t\t\t\t<button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t</div>\n\n\t<div\n\t\tid=\"AboutLinkCheckResults\"\n\t\tclass=\"modal fade\"\n\t\ttabindex=\"-1\"\n\t\taria-labelledby=\"AboutLinkCheckResultsLabel\"\n\t\taria-hidden=\"true\"\n\t>\n\t\t<div class=\"modal-dialog modal-lg modal-dialog-scrollable\">\n\t\t\t<div class=\"modal-content\">\n\t\t\t\t<div class=\"modal-header\">\n\t\t\t\t\t<h1 id=\"AboutLinkCheckResultsLabel\" class=\"modal-title fs-5\">About Link check</h1>\n\t\t\t\t\t<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"modal-body\">\n\t\t\t\t\t<div id=\"LinkCheckAboutAccordion\" class=\"accordion\">\n\t\t\t\t\t\t<div class=\"accordion-item\">\n\t\t\t\t\t\t\t<h2 class=\"accordion-header\">\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tclass=\"accordion-button collapsed\"\n\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\tdata-bs-toggle=\"collapse\"\n\t\t\t\t\t\t\t\t\tdata-bs-target=\"#col1\"\n\t\t\t\t\t\t\t\t\taria-expanded=\"false\"\n\t\t\t\t\t\t\t\t\taria-controls=\"col1\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tWhat is Link check?\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t</h2>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tid=\"col1\"\n\t\t\t\t\t\t\t\tclass=\"accordion-collapse collapse\"\n\t\t\t\t\t\t\t\tdata-bs-parent=\"#LinkCheckAboutAccordion\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<div class=\"accordion-body\">\n\t\t\t\t\t\t\t\t\tLink check scans your message HTML and text for all unique links, images and linked\n\t\t\t\t\t\t\t\t\tstylesheets. It then does a HTTP <code>HEAD</code> request to each link, 5 at a\n\t\t\t\t\t\t\t\t\ttime, to test whether the link/image/stylesheet exists.\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div class=\"accordion-item\">\n\t\t\t\t\t\t\t<h2 class=\"accordion-header\">\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tclass=\"accordion-button collapsed\"\n\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\tdata-bs-toggle=\"collapse\"\n\t\t\t\t\t\t\t\t\tdata-bs-target=\"#col2\"\n\t\t\t\t\t\t\t\t\taria-expanded=\"false\"\n\t\t\t\t\t\t\t\t\taria-controls=\"col2\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tWhat are \"301\" and \"302\" links?\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t</h2>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tid=\"col2\"\n\t\t\t\t\t\t\t\tclass=\"accordion-collapse collapse\"\n\t\t\t\t\t\t\t\tdata-bs-parent=\"#LinkCheckAboutAccordion\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<div class=\"accordion-body\">\n\t\t\t\t\t\t\t\t\t<p>\n\t\t\t\t\t\t\t\t\t\tThese are links that redirect you to another URL, for example newsletters often\n\t\t\t\t\t\t\t\t\t\tuse redirect links to track user clicks.\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t<p>\n\t\t\t\t\t\t\t\t\t\tBy default Link check will not follow these links, however you can turn this on\n\t\t\t\t\t\t\t\t\t\tvia the settings and Link check will \"follow\" those redirects.\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div class=\"accordion-item\">\n\t\t\t\t\t\t\t<h2 class=\"accordion-header\">\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tclass=\"accordion-button collapsed\"\n\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\tdata-bs-toggle=\"collapse\"\n\t\t\t\t\t\t\t\t\tdata-bs-target=\"#col3\"\n\t\t\t\t\t\t\t\t\taria-expanded=\"false\"\n\t\t\t\t\t\t\t\t\taria-controls=\"col3\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tWhy are some links returning an error but work in my browser?\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t</h2>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tid=\"col3\"\n\t\t\t\t\t\t\t\tclass=\"accordion-collapse collapse\"\n\t\t\t\t\t\t\t\tdata-bs-parent=\"#LinkCheckAboutAccordion\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<div class=\"accordion-body\">\n\t\t\t\t\t\t\t\t\t<p>This may be due to various reasons, for instance:</p>\n\t\t\t\t\t\t\t\t\t<ul>\n\t\t\t\t\t\t\t\t\t\t<li>The Mailpit server cannot resolve (DNS) the hostname of the URL.</li>\n\t\t\t\t\t\t\t\t\t\t<li>Mailpit is not allowed to access the URL.</li>\n\t\t\t\t\t\t\t\t\t\t<li>\n\t\t\t\t\t\t\t\t\t\t\tThe webserver is blocking requests that don't come from authenticated web\n\t\t\t\t\t\t\t\t\t\t\tbrowsers.\n\t\t\t\t\t\t\t\t\t\t</li>\n\t\t\t\t\t\t\t\t\t\t<li>The webserver or doesn't allow HTTP <code>HEAD</code> requests.</li>\n\t\t\t\t\t\t\t\t\t</ul>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div class=\"accordion-item\">\n\t\t\t\t\t\t\t<h2 class=\"accordion-header\">\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tclass=\"accordion-button collapsed\"\n\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\tdata-bs-toggle=\"collapse\"\n\t\t\t\t\t\t\t\t\tdata-bs-target=\"#col4\"\n\t\t\t\t\t\t\t\t\taria-expanded=\"false\"\n\t\t\t\t\t\t\t\t\taria-controls=\"col4\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tWhat are the risks of running Link check automatically?\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t</h2>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tid=\"col4\"\n\t\t\t\t\t\t\t\tclass=\"accordion-collapse collapse\"\n\t\t\t\t\t\t\t\tdata-bs-parent=\"#LinkCheckAboutAccordion\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<div class=\"accordion-body\">\n\t\t\t\t\t\t\t\t\t<p>\n\t\t\t\t\t\t\t\t\t\tDepending on the type of messages you are testing, opening all links on all\n\t\t\t\t\t\t\t\t\t\tmessages may have undesired consequences:\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t<ul>\n\t\t\t\t\t\t\t\t\t\t<li>If the message contains tracking links this may reveal your identity.</li>\n\t\t\t\t\t\t\t\t\t\t<li>\n\t\t\t\t\t\t\t\t\t\t\tIf the message contains unsubscribe links, Link check could unintentionally\n\t\t\t\t\t\t\t\t\t\t\tunsubscribe you.\n\t\t\t\t\t\t\t\t\t\t</li>\n\t\t\t\t\t\t\t\t\t\t<li>\n\t\t\t\t\t\t\t\t\t\t\tTo speed up the checking process, Link check will attempt 5 URLs at a time.\n\t\t\t\t\t\t\t\t\t\t\tThis could lead to temporary heady load on the remote server.\n\t\t\t\t\t\t\t\t\t\t</li>\n\t\t\t\t\t\t\t\t\t</ul>\n\t\t\t\t\t\t\t\t\t<p>\n\t\t\t\t\t\t\t\t\t\tUnless you know what messages you receive, it is advised to only run the Link\n\t\t\t\t\t\t\t\t\t\tcheck manually.\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"modal-footer\">\n\t\t\t\t\t<button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t</div>\n</template>\n"
  },
  {
    "path": "server/ui-src/components/message/MessageAttachments.vue",
    "content": "<script>\nimport commonMixins from \"../../mixins/CommonMixins\";\nimport { mailbox } from \"../../stores/mailbox\";\nimport ICAL from \"ical.js\";\nimport dayjs from \"dayjs\";\n\nexport default {\n\tmixins: [commonMixins],\n\n\tprops: {\n\t\tmessage: {\n\t\t\ttype: Object,\n\t\t\trequired: true,\n\t\t},\n\t\tattachments: {\n\t\t\ttype: Object,\n\t\t\trequired: true,\n\t\t},\n\t},\n\n\tdata() {\n\t\treturn {\n\t\t\tmailbox,\n\t\t\tical: false,\n\t\t};\n\t},\n\n\tmethods: {\n\t\topenAttachment(part, e) {\n\t\t\tconst filename = part.FileName;\n\t\t\tconst contentType = part.ContentType;\n\t\t\tconst href = this.resolve(\"/api/v1/message/\" + this.message.ID + \"/part/\" + part.PartID);\n\t\t\tif (filename.match(/\\.ics$/i) || contentType === \"text/calendar\") {\n\t\t\t\te.preventDefault();\n\n\t\t\t\tthis.get(href, null, (response) => {\n\t\t\t\t\tconst comp = new ICAL.Component(ICAL.parse(response.data));\n\t\t\t\t\tconst vevent = comp.getFirstSubcomponent(\"vevent\");\n\t\t\t\t\tif (!vevent) {\n\t\t\t\t\t\talert(\"Error parsing ICS file\");\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tconst event = new ICAL.Event(vevent);\n\n\t\t\t\t\tconst summary = {};\n\t\t\t\t\tsummary.link = href;\n\t\t\t\t\tsummary.status = vevent.getFirstPropertyValue(\"status\");\n\t\t\t\t\tsummary.url = vevent.getFirstPropertyValue(\"url\");\n\t\t\t\t\tsummary.summary = event.summary;\n\t\t\t\t\tsummary.description = event.description;\n\t\t\t\t\tsummary.location = event.location;\n\t\t\t\t\tsummary.start = dayjs(event.startDate).format(\"ddd, D MMM YYYY, h:mm a\");\n\t\t\t\t\tsummary.end = dayjs(event.endDate).format(\"ddd, D MMM YYYY, h:mm a\");\n\t\t\t\t\tsummary.isRecurring = event.isRecurring();\n\t\t\t\t\tsummary.organizer = event.organizer ? event.organizer.replace(/^mailto:/, \"\") : false;\n\t\t\t\t\tsummary.attendees = [];\n\t\t\t\t\tevent.attendees.forEach((a) => {\n\t\t\t\t\t\tif (a.jCal[1].cn) {\n\t\t\t\t\t\t\tsummary.attendees.push(a.jCal[1].cn);\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\n\t\t\t\t\tcomp.getAllSubcomponents(\"vtimezone\").forEach((vtimezone) => {\n\t\t\t\t\t\tsummary.timezone = vtimezone.getFirstPropertyValue(\"tzid\");\n\t\t\t\t\t});\n\n\t\t\t\t\tthis.ical = summary;\n\n\t\t\t\t\t// display modal\n\t\t\t\t\tthis.modal(\"ICSView\").show();\n\t\t\t\t});\n\t\t\t}\n\t\t},\n\t},\n};\n</script>\n\n<template>\n\t<hr />\n\n\t<button\n\t\tclass=\"btn btn-sm btn-outline-secondary mb-3\"\n\t\t@click=\"mailbox.showAttachmentDetails = !mailbox.showAttachmentDetails\"\n\t>\n\t\t<i class=\"bi me-1\" :class=\"mailbox.showAttachmentDetails ? 'bi-eye-slash' : 'bi-eye'\"></i>\n\t\t{{ mailbox.showAttachmentDetails ? \"Hide\" : \"Show\" }} attachment details\n\t</button>\n\n\t<div class=\"row gx-1 w-100\">\n\t\t<div\n\t\t\tv-for=\"part in attachments\"\n\t\t\t:key=\"part.PartID\"\n\t\t\t:class=\"mailbox.showAttachmentDetails ? 'col-12' : 'col-auto'\"\n\t\t>\n\t\t\t<div class=\"row gx-1 mb-3\">\n\t\t\t\t<div class=\"col-auto\">\n\t\t\t\t\t<a\n\t\t\t\t\t\t:href=\"resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID)\"\n\t\t\t\t\t\tclass=\"card attachment float-start me-3 mb-3\"\n\t\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\t\tstyle=\"width: 180px\"\n\t\t\t\t\t\t@click=\"openAttachment(part, $event)\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<img\n\t\t\t\t\t\t\tv-if=\"isImage(part)\"\n\t\t\t\t\t\t\t:src=\"resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID + '/thumb')\"\n\t\t\t\t\t\t\tclass=\"card-img-top\"\n\t\t\t\t\t\t\talt=\"\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<img\n\t\t\t\t\t\t\tv-else\n\t\t\t\t\t\t\tsrc=\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAB4AQMAAABhKUq+AAAAA1BMVEX///+nxBvIAAAAGUlEQVQYGe3BgQAAAADDoPtTT+EA1QAAgFsLQAAB12s2WgAAAABJRU5ErkJggg==\"\n\t\t\t\t\t\t\tclass=\"card-img-top\"\n\t\t\t\t\t\t\talt=\"\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<div v-if=\"!isImage(part)\" class=\"icon\">\n\t\t\t\t\t\t\t<i class=\"bi\" :class=\"attachmentIcon(part)\"></i>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div class=\"card-body border-0\">\n\t\t\t\t\t\t\t<p class=\"mb-1\">\n\t\t\t\t\t\t\t\t<i class=\"bi me-1\" :class=\"attachmentIcon(part)\"></i>\n\t\t\t\t\t\t\t\t<small>{{ getFileSize(part.Size) }}</small>\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t<p class=\"card-text mb-0 small\">\n\t\t\t\t\t\t\t\t{{ part.FileName != \"\" ? part.FileName : \"[ unknown ]\" + part.ContentType }}\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div class=\"card-footer small border-0 text-center text-truncate\">\n\t\t\t\t\t\t\t{{ part.FileName != \"\" ? part.FileName : \"[ unknown ]\" + part.ContentType }}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</a>\n\t\t\t\t</div>\n\t\t\t\t<div v-if=\"mailbox.showAttachmentDetails\" class=\"col\">\n\t\t\t\t\t<h5 class=\"mb-1\">\n\t\t\t\t\t\t<a\n\t\t\t\t\t\t\t:href=\"resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID)\"\n\t\t\t\t\t\t\tclass=\"me-2\"\n\t\t\t\t\t\t\t@click=\"openAttachment(part, $event)\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{{ part.FileName != \"\" ? part.FileName : \"[ unknown ]\" + part.ContentType }}\n\t\t\t\t\t\t</a>\n\t\t\t\t\t\t<small class=\"text-muted fw-light\">\n\t\t\t\t\t\t\t<small>({{ getFileSize(part.Size) }})</small>\n\t\t\t\t\t\t</small>\n\t\t\t\t\t</h5>\n\t\t\t\t\t<p class=\"mb-1 small\"><strong>Disposition</strong>: {{ part.ContentDisposition }}</p>\n\t\t\t\t\t<p class=\"mb-2 small\">\n\t\t\t\t\t\t<strong>Content type</strong>: <code>{{ part.ContentType }}</code>\n\t\t\t\t\t</p>\n\t\t\t\t\t<p class=\"m-0 small\">\n\t\t\t\t\t\t<strong>MD5</strong>:\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tv-if=\"copyToClipboardSupported\"\n\t\t\t\t\t\t\tclass=\"btn btn-sm btn-link p-0\"\n\t\t\t\t\t\t\ttitle=\"Click to copy to clipboard\"\n\t\t\t\t\t\t\t@click=\"copyToClipboard(part.Checksums.MD5, $event)\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{{ part.Checksums.MD5 }}\n\t\t\t\t\t\t\t<i v-if=\"!copiedText[part.Checksums.MD5]\" class=\"bi bi-clipboard ms-1\"></i>\n\t\t\t\t\t\t\t<i v-else class=\"bi bi-check2-square ms-1 text-success\"></i>\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t<code v-else>{{ part.Checksums.MD5 }}</code>\n\t\t\t\t\t</p>\n\t\t\t\t\t<p class=\"m-0 small\">\n\t\t\t\t\t\t<strong>SHA1</strong>:\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tv-if=\"copyToClipboardSupported\"\n\t\t\t\t\t\t\tclass=\"btn btn-link p-0\"\n\t\t\t\t\t\t\ttitle=\"Click to copy to clipboard\"\n\t\t\t\t\t\t\t@click=\"copyToClipboard(part.Checksums.SHA1, $event)\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{{ part.Checksums.SHA1 }}\n\t\t\t\t\t\t\t<i v-if=\"!copiedText[part.Checksums.SHA1]\" class=\"bi bi-clipboard ms-1\"></i>\n\t\t\t\t\t\t\t<i v-else class=\"bi bi-check2-square ms-1 text-success\"></i>\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t<code v-else>{{ part.Checksums.SHA1 }}</code>\n\t\t\t\t\t</p>\n\t\t\t\t\t<p class=\"m-0 small\">\n\t\t\t\t\t\t<strong>SHA256</strong>:\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tv-if=\"copyToClipboardSupported\"\n\t\t\t\t\t\t\tclass=\"btn btn-sm btn-link p-0\"\n\t\t\t\t\t\t\ttitle=\"Click to copy to clipboard\"\n\t\t\t\t\t\t\t@click=\"copyToClipboard(part.Checksums.SHA256, $event)\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{{ part.Checksums.SHA256 }}\n\t\t\t\t\t\t\t<i v-if=\"!copiedText[part.Checksums.SHA256]\" class=\"bi bi-clipboard ms-1\"></i>\n\t\t\t\t\t\t\t<i v-else class=\"bi bi-check2-square ms-1 text-success\"></i>\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t<code v-else>{{ part.Checksums.SHA256 }}</code>\n\t\t\t\t\t</p>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t</div>\n\n\t<!-- ICS Modal -->\n\t<div id=\"ICSView\" class=\"modal fade\" tabindex=\"-1\" aria-hidden=\"true\">\n\t\t<div class=\"modal-dialog modal-dialog-centered modal-dialog-scrollable modal-lg\">\n\t\t\t<div class=\"modal-content\">\n\t\t\t\t<div class=\"modal-header\">\n\t\t\t\t\t<h5 class=\"modal-title fs-5\">\n\t\t\t\t\t\t<i class=\"bi bi-calendar-event me-2\"></i>\n\t\t\t\t\t\tiCalendar summary\n\t\t\t\t\t</h5>\n\t\t\t\t\t<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button>\n\t\t\t\t</div>\n\t\t\t\t<div v-if=\"ical\" class=\"modal-body\">\n\t\t\t\t\t<table class=\"table\">\n\t\t\t\t\t\t<tbody>\n\t\t\t\t\t\t\t<tr v-if=\"ical.summary\">\n\t\t\t\t\t\t\t\t<th>Summary</th>\n\t\t\t\t\t\t\t\t<td>{{ ical.summary }}</td>\n\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t<tr v-if=\"ical.description\">\n\t\t\t\t\t\t\t\t<th>Description</th>\n\t\t\t\t\t\t\t\t<td>{{ ical.description }}</td>\n\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t\t<th>When</th>\n\t\t\t\t\t\t\t\t<td>\n\t\t\t\t\t\t\t\t\t{{ ical.start }} &mdash; {{ ical.end }}\n\t\t\t\t\t\t\t\t\t<span v-if=\"ical.isRecurring\">(recurring)</span>\n\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t<tr v-if=\"ical.status\">\n\t\t\t\t\t\t\t\t<th>Status</th>\n\t\t\t\t\t\t\t\t<td>{{ ical.status }}</td>\n\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t<tr v-if=\"ical.location\">\n\t\t\t\t\t\t\t\t<th>Location</th>\n\t\t\t\t\t\t\t\t<td>{{ ical.location }}</td>\n\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t<tr v-if=\"ical.url\">\n\t\t\t\t\t\t\t\t<th>URL</th>\n\t\t\t\t\t\t\t\t<td>\n\t\t\t\t\t\t\t\t\t<a :href=\"ical.url\" target=\"_blank\">{{ ical.url }}</a>\n\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t<tr v-if=\"ical.organizer\">\n\t\t\t\t\t\t\t\t<th>Organizer</th>\n\t\t\t\t\t\t\t\t<td>{{ ical.organizer }}</td>\n\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t<tr v-if=\"ical.attendees.length\">\n\t\t\t\t\t\t\t\t<th>Attendees</th>\n\t\t\t\t\t\t\t\t<td>\n\t\t\t\t\t\t\t\t\t<span v-for=\"(a, i) in ical.attendees\" :key=\"'attendee_' + i\">\n\t\t\t\t\t\t\t\t\t\t<template v-if=\"i > 0\">,</template>\n\t\t\t\t\t\t\t\t\t\t{{ a }}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t</tbody>\n\t\t\t\t\t</table>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"modal-footer\">\n\t\t\t\t\t<button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button>\n\t\t\t\t\t<a class=\"btn btn-primary\" target=\"_blank\" :href=\"ical.link\"> Download attachment </a>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t</div>\n</template>\n"
  },
  {
    "path": "server/ui-src/components/message/MessageHeaders.vue",
    "content": "<script>\nimport commonMixins from \"../../mixins/CommonMixins\";\n\nexport default {\n\tmixins: [commonMixins],\n\n\tprops: {\n\t\tmessage: {\n\t\t\ttype: Object,\n\t\t\trequired: true,\n\t\t},\n\t},\n\n\tdata() {\n\t\treturn {\n\t\t\theaders: false,\n\t\t};\n\t},\n\n\tmounted() {\n\t\tconst uri = this.resolve(\"/api/v1/message/\" + this.message.ID + \"/headers\");\n\t\tthis.get(uri, false, (response) => {\n\t\t\tthis.headers = response.data;\n\t\t});\n\t},\n};\n</script>\n\n<template>\n\t<div v-if=\"headers\" class=\"small\">\n\t\t<div v-for=\"(values, k) in headers\" :key=\"'headers_' + k\" class=\"row mb-2 pb-2 border-bottom w-100\">\n\t\t\t<div class=\"col-md-4 col-lg-3 col-xl-2 mb-2\">\n\t\t\t\t<b>{{ k }}</b>\n\t\t\t</div>\n\t\t\t<div class=\"col-md-8 col-lg-9 col-xl-10 text-body-secondary\">\n\t\t\t\t<div v-for=\"(x, i) in values\" :key=\"'line_' + i\" class=\"mb-2 text-break\">{{ x }}</div>\n\t\t\t</div>\n\t\t</div>\n\t</div>\n</template>\n"
  },
  {
    "path": "server/ui-src/components/message/MessageItem.vue",
    "content": "<script>\nimport Attachments from \"./MessageAttachments.vue\";\nimport Headers from \"./MessageHeaders.vue\";\nimport HTMLCheck from \"./HTMLCheck.vue\";\nimport LinkCheck from \"./LinkCheck.vue\";\nimport SpamAssassin from \"./SpamAssassin.vue\";\nimport Tags from \"bootstrap5-tags\";\nimport { Tooltip } from \"bootstrap\";\nimport commonMixins from \"../../mixins/CommonMixins\";\nimport { mailbox } from \"../../stores/mailbox\";\nimport DOMPurify from \"dompurify\";\nimport hljs from \"highlight.js/lib/core\";\nimport xml from \"highlight.js/lib/languages/xml\";\n\nhljs.registerLanguage(\"html\", xml);\n\nexport default {\n\tcomponents: {\n\t\tAttachments,\n\t\tHeaders,\n\t\tHTMLCheck,\n\t\tLinkCheck,\n\t\tSpamAssassin,\n\t},\n\n\tmixins: [commonMixins],\n\n\tprops: {\n\t\tmessage: {\n\t\t\ttype: Object,\n\t\t\trequired: true,\n\t\t},\n\t},\n\n\temits: [\"loadMessages\"],\n\n\tdata() {\n\t\treturn {\n\t\t\tmailbox,\n\t\t\tsrcURI: false,\n\t\t\tiframes: [], // for resizing\n\t\t\tcanSaveTags: false, // prevent auto-saving tags on render\n\t\t\tavailableTags: [],\n\t\t\tmessageTags: [],\n\t\t\tloadHeaders: false,\n\t\t\thtmlScore: false,\n\t\t\thtmlScoreColor: false,\n\t\t\tlinkCheckErrors: false,\n\t\t\tspamScore: false,\n\t\t\tspamScoreColor: false,\n\t\t\tshowMobileButtons: false,\n\t\t\tshowUnsubscribe: false,\n\t\t\tscaleHTMLPreview: \"display\",\n\t\t\t// keys names match bootstrap icon names\n\t\t\tresponsiveSizes: {\n\t\t\t\tphone: \"width: 322px; height: 570px\",\n\t\t\t\ttablet: \"width: 768px; height: 1024px\",\n\t\t\t\tdisplay: \"width: 100%; height: 100%\",\n\t\t\t},\n\t\t};\n\t},\n\n\tcomputed: {\n\t\thasAnyChecksEnabled() {\n\t\t\treturn (\n\t\t\t\t(mailbox.showHTMLCheck && this.message.HTML) ||\n\t\t\t\tmailbox.showLinkCheck ||\n\t\t\t\t(mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin)\n\t\t\t);\n\t\t},\n\n\t\t// remove bad HTML, JavaScript, iframes etc\n\t\tsanitizedHTML() {\n\t\t\t// set target & rel on all links\n\t\t\tDOMPurify.addHook(\"afterSanitizeAttributes\", (node) => {\n\t\t\t\tif (\n\t\t\t\t\tnode.tagName !== \"A\" ||\n\t\t\t\t\t(node.hasAttribute(\"href\") && node.getAttribute(\"href\").substring(0, 1) === \"#\")\n\t\t\t\t) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (\"target\" in node) {\n\t\t\t\t\tnode.setAttribute(\"target\", \"_blank\");\n\t\t\t\t\tnode.setAttribute(\"rel\", \"noopener noreferrer\");\n\t\t\t\t}\n\t\t\t\tif (!node.hasAttribute(\"target\") && (node.hasAttribute(\"xlink:href\") || node.hasAttribute(\"href\"))) {\n\t\t\t\t\tnode.setAttribute(\"xlink:show\", \"_blank\");\n\t\t\t\t}\n\t\t\t});\n\n\t\t\tconst clean = DOMPurify.sanitize(this.message.HTML, {\n\t\t\t\tWHOLE_DOCUMENT: true,\n\t\t\t\tSANITIZE_DOM: false,\n\t\t\t\tADD_TAGS: [\"link\", \"meta\", \"o:p\", \"style\"],\n\t\t\t\tADD_ATTR: [\n\t\t\t\t\t\"bordercolor\",\n\t\t\t\t\t\"charset\",\n\t\t\t\t\t\"content\",\n\t\t\t\t\t\"hspace\",\n\t\t\t\t\t\"http-equiv\",\n\t\t\t\t\t\"itemprop\",\n\t\t\t\t\t\"itemscope\",\n\t\t\t\t\t\"itemtype\",\n\t\t\t\t\t\"link\",\n\t\t\t\t\t\"vertical-align\",\n\t\t\t\t\t\"vlink\",\n\t\t\t\t\t\"vspace\",\n\t\t\t\t\t\"xml:lang\",\n\t\t\t\t],\n\t\t\t\tFORBID_TAGS: [\"script\", \"form\"], // all JavaScript should be removed\n\t\t\t\tALLOW_UNKNOWN_PROTOCOLS: true, // allow link href protocols like myapp:// etc\n\t\t\t});\n\n\t\t\t// for debugging\n\t\t\t// this.debugDOMPurify(DOMPurify.removed);\n\n\t\t\treturn clean;\n\t\t},\n\t},\n\n\twatch: {\n\t\tmessageTags() {\n\t\t\tif (this.canSaveTags) {\n\t\t\t\t// save changes to tags\n\t\t\t\tthis.saveTags();\n\t\t\t}\n\t\t},\n\n\t\tscaleHTMLPreview(v) {\n\t\t\tif (v === \"display\") {\n\t\t\t\twindow.setTimeout(() => {\n\t\t\t\t\tthis.resizeIFrames();\n\t\t\t\t}, 500);\n\t\t\t}\n\t\t},\n\t},\n\n\tmounted() {\n\t\tthis.canSaveTags = false;\n\t\tthis.messageTags = this.message.Tags;\n\t\tthis.renderUI();\n\n\t\twindow.addEventListener(\"resize\", this.resizeIFrames);\n\n\t\tconst headersTab = document.getElementById(\"nav-headers-tab\");\n\t\theadersTab.addEventListener(\"shown.bs.tab\", () => {\n\t\t\tthis.loadHeaders = true;\n\t\t});\n\n\t\tconst rawTab = document.getElementById(\"nav-raw-tab\");\n\t\trawTab.addEventListener(\"shown.bs.tab\", () => {\n\t\t\tthis.srcURI = this.resolve(\"/api/v1/message/\" + this.message.ID + \"/raw\");\n\t\t\tthis.resizeIFrames();\n\t\t});\n\n\t\t// manually refresh tags\n\t\tthis.get(this.resolve(`/api/v1/tags`), false, (response) => {\n\t\t\tthis.availableTags = response.data;\n\t\t\tthis.$nextTick(() => {\n\t\t\t\tTags.init(\"select[multiple]\");\n\t\t\t\t// delay tag change detection to allow Tags to load\n\t\t\t\twindow.setTimeout(() => {\n\t\t\t\t\tthis.canSaveTags = true;\n\t\t\t\t}, 200);\n\t\t\t});\n\t\t});\n\t},\n\n\tmethods: {\n\t\tisHTMLTabSelected() {\n\t\t\tthis.showMobileButtons = this.$refs.navhtml && this.$refs.navhtml.classList.contains(\"active\");\n\t\t},\n\n\t\trenderUI() {\n\t\t\t// activate the first non-disabled tab\n\t\t\tdocument.querySelector(\"#nav-tab button:not([disabled])\").click();\n\t\t\tdocument.activeElement.blur(); // blur focus\n\t\t\tdocument.getElementById(\"message-view\").scrollTop = 0;\n\n\t\t\tthis.isHTMLTabSelected();\n\n\t\t\tdocument.querySelectorAll('button[data-bs-toggle=\"tab\"]').forEach((listObj) => {\n\t\t\t\tlistObj.addEventListener(\"shown.bs.tab\", () => {\n\t\t\t\t\tthis.isHTMLTabSelected();\n\t\t\t\t});\n\t\t\t});\n\n\t\t\tconst tooltipTriggerList = document.querySelectorAll('[data-bs-toggle=\"tooltip\"]');\n\t\t\t[...tooltipTriggerList].map((tooltipTriggerEl) => new Tooltip(tooltipTriggerEl));\n\n\t\t\t// delay 0.5s until vue has rendered the iframe content\n\t\t\twindow.setTimeout(() => {\n\t\t\t\tconst p = document.getElementById(\"preview-html\");\n\t\t\t\tif (p && typeof p.contentWindow.document.body === \"object\") {\n\t\t\t\t\ttry {\n\t\t\t\t\t\t// make links open in new window\n\t\t\t\t\t\tconst anchorEls = p.contentWindow.document.body.querySelectorAll(\"a\");\n\t\t\t\t\t\tfor (let i = 0; i < anchorEls.length; i++) {\n\t\t\t\t\t\t\tconst anchorEl = anchorEls[i];\n\t\t\t\t\t\t\tconst href = anchorEl.getAttribute(\"href\");\n\n\t\t\t\t\t\t\tif (href && href.match(/^https?:\\/\\//i)) {\n\t\t\t\t\t\t\t\tanchorEl.setAttribute(\"target\", \"_blank\");\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t} catch {\n\t\t\t\t\t\t// ignore errors when accessing the iframe content\n\t\t\t\t\t}\n\t\t\t\t\tthis.resizeIFrames();\n\t\t\t\t}\n\t\t\t}, 500);\n\n\t\t\t// HTML highlighting\n\t\t\thljs.highlightAll();\n\t\t},\n\n\t\tresizeIframe(el) {\n\t\t\tconst i = el.target;\n\t\t\tif (typeof i.contentWindow.document.body.scrollHeight === \"number\") {\n\t\t\t\ti.style.height = i.contentWindow.document.body.scrollHeight + 50 + \"px\";\n\t\t\t}\n\t\t},\n\n\t\tresizeIFrames() {\n\t\t\tif (this.scaleHTMLPreview !== \"display\") {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst h = document.getElementById(\"preview-html\");\n\t\t\tif (h) {\n\t\t\t\tif (typeof h.contentWindow.document.body.scrollHeight === \"number\") {\n\t\t\t\t\th.style.height = h.contentWindow.document.body.scrollHeight + 50 + \"px\";\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\n\t\t// set the iframe body & text colors based on current theme\n\t\tinitRawIframe(el) {\n\t\t\tconst bodyStyles = window.getComputedStyle(document.body, null);\n\t\t\tconst bg = bodyStyles.getPropertyValue(\"background-color\");\n\t\t\tconst txt = bodyStyles.getPropertyValue(\"color\");\n\n\t\t\tconst body = el.target.contentWindow.document.querySelector(\"body\");\n\t\t\tif (body) {\n\t\t\t\tbody.style.color = txt;\n\t\t\t\tbody.style.backgroundColor = bg;\n\t\t\t}\n\n\t\t\tthis.resizeIframe(el);\n\t\t},\n\n\t\t// this function is unused but kept here to use for debugging\n\t\tdebugDOMPurify(removed) {\n\t\t\tif (!removed.length) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst ignoreNodes = [\"target\", \"base\", \"script\", \"v:shapes\"];\n\n\t\t\tconst d = removed.filter((r) => {\n\t\t\t\tif (\n\t\t\t\t\ttypeof r.attribute !== \"undefined\" &&\n\t\t\t\t\t(ignoreNodes.includes(r.attribute.nodeName) || r.attribute.nodeName.startsWith(\"xmlns:\"))\n\t\t\t\t) {\n\t\t\t\t\treturn false;\n\t\t\t\t}\n\t\t\t\t// inline comments\n\t\t\t\tif (typeof r.element !== \"undefined\" && (r.element.nodeType === 8 || r.element.tagName === \"SCRIPT\")) {\n\t\t\t\t\treturn false;\n\t\t\t\t}\n\n\t\t\t\treturn true;\n\t\t\t});\n\n\t\t\tif (d.length) {\n\t\t\t\tconsole.log(d);\n\t\t\t}\n\t\t},\n\n\t\tsaveTags() {\n\t\t\tconst data = {\n\t\t\t\tIDs: [this.message.ID],\n\t\t\t\tTags: this.messageTags,\n\t\t\t};\n\n\t\t\tthis.put(this.resolve(\"/api/v1/tags\"), data, () => {\n\t\t\t\twindow.scrollInPlace = true;\n\t\t\t\tthis.$emit(\"loadMessages\");\n\t\t\t});\n\t\t},\n\n\t\t// Convert plain text to HTML including anchor links.\n\t\t// Only <a> tags are permitted in the output (enforced by DOMPurify).\n\t\ttextToHTML(s) {\n\t\t\t// Strip the Unicode placeholder characters used below so that attacker-\n\t\t\t// controlled input cannot pre-inject fake HTML tags via those chars.\n\t\t\tlet html = s.replace(/(˱˱˱|ˠˠˠ|˲˲˲)/gu, \"\");\n\n\t\t\t// RFC2396 appendix E states angle brackets are recommended for text/plain emails to\n\t\t\t// recognize potential spaces in between the URL\n\t\t\t// @see https://www.rfc-editor.org/rfc/rfc2396#appendix-E\n\t\t\tconst angleLinks = /<((https?|ftp):\\/\\/[-\\w@:%_+'!.~#?,&//=; ][^>]+)>/gim;\n\t\t\thtml = html.replace(angleLinks, \"<˱˱˱a href=ˠˠˠ$1ˠˠˠ target=_blank rel=noopener˲˲˲$1˱˱˱/a˲˲˲>\");\n\n\t\t\t// find links without angle brackets, starting with http(s) or ftp\n\t\t\tconst regularLinks = /([^ˠ˲]\\b)(((https?|ftp):\\/\\/[-\\w@:%_+'!.~#?,&//=;]+))/gim;\n\t\t\thtml = html.replace(regularLinks, \"$1˱˱˱a href=ˠˠˠ$2ˠˠˠ target=_blank rel=noopener˲˲˲$2˱˱˱/a˲˲˲\");\n\n\t\t\t// plain www links without https?:// prefix\n\t\t\tconst shortLinks = /(^|[^/])(www\\.[\\S]+(\\b|$))/gim;\n\t\t\thtml = html.replace(\n\t\t\t\tshortLinks,\n\t\t\t\t\"$1˱˱˱a href=ˠˠˠhttp://$2ˠˠˠ target=ˠˠˠ_blankˠˠˠ rel=ˠˠˠnoopenerˠˠˠ˲˲˲$2˱˱˱/a˲˲˲\",\n\t\t\t);\n\n\t\t\t// escape to HTML & convert <>\" characters back\n\t\t\thtml = html\n\t\t\t\t.replace(/&/g, \"&amp;\")\n\t\t\t\t.replace(/</g, \"&lt;\")\n\t\t\t\t.replace(/>/g, \"&gt;\")\n\t\t\t\t.replace(/\"/g, \"&quot;\")\n\t\t\t\t.replace(/'/g, \"&#039;\")\n\t\t\t\t.replace(/˱˱˱/g, \"<\")\n\t\t\t\t.replace(/˲˲˲/g, \">\")\n\t\t\t\t.replace(/ˠˠˠ/g, '\"');\n\n\t\t\treturn DOMPurify.sanitize(html, {\n\t\t\t\tALLOWED_TAGS: [\"a\"],\n\t\t\t\tALLOWED_ATTR: [\"href\", \"target\", \"rel\"],\n\t\t\t});\n\t\t},\n\t},\n};\n</script>\n\n<template>\n\t<div v-if=\"message\" id=\"message-view\" class=\"px-2 px-md-0 mh-100\">\n\t\t<div class=\"row w-100\">\n\t\t\t<div class=\"col-md\">\n\t\t\t\t<table class=\"messageHeaders\">\n\t\t\t\t\t<tbody>\n\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t<th class=\"small\">From</th>\n\t\t\t\t\t\t\t<td class=\"privacy\">\n\t\t\t\t\t\t\t\t<span v-if=\"message.From\">\n\t\t\t\t\t\t\t\t\t<span v-if=\"message.From.Name\" class=\"text-spaces\">\n\t\t\t\t\t\t\t\t\t\t{{ message.From.Name + \" \" }}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t<span v-if=\"message.From.Address\" class=\"small\">\n\t\t\t\t\t\t\t\t\t\t&lt;<a :href=\"searchURI(message.From.Address)\" class=\"text-body\">\n\t\t\t\t\t\t\t\t\t\t\t{{ message.From.Address }} </a\n\t\t\t\t\t\t\t\t\t\t>&gt;\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t<span v-else> [ Unknown ] </span>\n\n\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\tv-if=\"message.ListUnsubscribe.Header != ''\"\n\t\t\t\t\t\t\t\t\tclass=\"small ms-3 link\"\n\t\t\t\t\t\t\t\t\t:title=\"\n\t\t\t\t\t\t\t\t\t\tshowUnsubscribe\n\t\t\t\t\t\t\t\t\t\t\t? 'Hide unsubscribe information'\n\t\t\t\t\t\t\t\t\t\t\t: 'Show unsubscribe information'\n\t\t\t\t\t\t\t\t\t\"\n\t\t\t\t\t\t\t\t\t@click=\"showUnsubscribe = !showUnsubscribe\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tUnsubscribe\n\t\t\t\t\t\t\t\t\t<i\n\t\t\t\t\t\t\t\t\t\tclass=\"bi bi bi-info-circle\"\n\t\t\t\t\t\t\t\t\t\t:class=\"{ 'text-danger': message.ListUnsubscribe.Errors != '' }\"\n\t\t\t\t\t\t\t\t\t></i>\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t<tr class=\"small\">\n\t\t\t\t\t\t\t<th>To</th>\n\t\t\t\t\t\t\t<td class=\"privacy\">\n\t\t\t\t\t\t\t\t<template v-if=\"message.To && message.To.length\">\n\t\t\t\t\t\t\t\t\t<span v-for=\"(t, i) in message.To\" :key=\"'to_' + i\">\n\t\t\t\t\t\t\t\t\t\t<template v-if=\"i > 0\">, </template>\n\t\t\t\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t\t\t\t\t<span class=\"text-spaces\">{{ t.Name }}</span>\n\t\t\t\t\t\t\t\t\t\t\t&lt;<a :href=\"searchURI(t.Address)\" class=\"text-body\"> {{ t.Address }} </a\n\t\t\t\t\t\t\t\t\t\t\t>&gt;\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</template>\n\t\t\t\t\t\t\t\t<span v-else class=\"text-body-secondary\">[Undisclosed recipients]</span>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t<tr v-if=\"message.Cc && message.Cc.length\" class=\"small\">\n\t\t\t\t\t\t\t<th>Cc</th>\n\t\t\t\t\t\t\t<td class=\"privacy\">\n\t\t\t\t\t\t\t\t<span v-for=\"(t, i) in message.Cc\" :key=\"'cc_' + i\">\n\t\t\t\t\t\t\t\t\t<template v-if=\"i > 0\">,</template>\n\t\t\t\t\t\t\t\t\t<span class=\"text-spaces\">{{ t.Name }}</span>\n\t\t\t\t\t\t\t\t\t&lt;<a :href=\"searchURI(t.Address)\" class=\"text-body\"> {{ t.Address }} </a>&gt;\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t<tr v-if=\"message.Bcc && message.Bcc.length\" class=\"small\">\n\t\t\t\t\t\t\t<th>Bcc</th>\n\t\t\t\t\t\t\t<td class=\"privacy\">\n\t\t\t\t\t\t\t\t<span v-for=\"(t, i) in message.Bcc\" :key=\"'bcc_' + i\">\n\t\t\t\t\t\t\t\t\t<template v-if=\"i > 0\">,</template>\n\t\t\t\t\t\t\t\t\t<span class=\"text-spaces\">{{ t.Name }}</span>\n\t\t\t\t\t\t\t\t\t&lt;<a :href=\"searchURI(t.Address)\" class=\"text-body\"> {{ t.Address }} </a>&gt;\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t<tr v-if=\"message.ReplyTo && message.ReplyTo.length\" class=\"small\">\n\t\t\t\t\t\t\t<th class=\"text-nowrap\">Reply-To</th>\n\t\t\t\t\t\t\t<td class=\"privacy text-body-secondary text-break\">\n\t\t\t\t\t\t\t\t<span v-for=\"(t, i) in message.ReplyTo\" :key=\"'bcc_' + i\">\n\t\t\t\t\t\t\t\t\t<template v-if=\"i > 0\">,</template>\n\t\t\t\t\t\t\t\t\t<span class=\"text-spaces\">{{ t.Name }}</span>\n\t\t\t\t\t\t\t\t\t&lt;<a :href=\"searchURI(t.Address)\" class=\"text-body-secondary\"> {{ t.Address }} </a\n\t\t\t\t\t\t\t\t\t>&gt;\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t<tr\n\t\t\t\t\t\t\tv-if=\"message.ReturnPath && message.From && message.ReturnPath != message.From.Address\"\n\t\t\t\t\t\t\tclass=\"small\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<th class=\"text-nowrap\">Return-Path</th>\n\t\t\t\t\t\t\t<td class=\"privacy text-body-secondary text-break\">\n\t\t\t\t\t\t\t\t&lt;<a :href=\"searchURI(message.ReturnPath)\" class=\"text-body-secondary\">\n\t\t\t\t\t\t\t\t\t{{ message.ReturnPath }} </a\n\t\t\t\t\t\t\t\t>&gt;\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t<th class=\"small\">Subject</th>\n\t\t\t\t\t\t\t<td>\n\t\t\t\t\t\t\t\t<strong v-if=\"message.Subject != ''\" class=\"text-spaces\">{{ message.Subject }}</strong>\n\t\t\t\t\t\t\t\t<small v-else class=\"text-body-secondary\">[ no subject ]</small>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t<tr class=\"small\">\n\t\t\t\t\t\t\t<th class=\"small\">Date</th>\n\t\t\t\t\t\t\t<td>\n\t\t\t\t\t\t\t\t{{ messageDate(message.Date) }}\n\t\t\t\t\t\t\t\t<small class=\"ms-2\">({{ getFileSize(message.Size) }})</small>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t<tr v-if=\"message.Username\" class=\"small\">\n\t\t\t\t\t\t\t<th class=\"small\">\n\t\t\t\t\t\t\t\tUsername\n\t\t\t\t\t\t\t\t<i\n\t\t\t\t\t\t\t\t\tclass=\"bi bi-exclamation-circle ms-1\"\n\t\t\t\t\t\t\t\t\tdata-bs-toggle=\"tooltip\"\n\t\t\t\t\t\t\t\t\tdata-bs-placement=\"top\"\n\t\t\t\t\t\t\t\t\tdata-bs-custom-class=\"custom-tooltip\"\n\t\t\t\t\t\t\t\t\tdata-bs-title=\"The SMTP or send API username the client authenticated with\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t</i>\n\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t<td class=\"small\">\n\t\t\t\t\t\t\t\t{{ message.Username }}\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t<tr class=\"small\">\n\t\t\t\t\t\t\t<th>Tags</th>\n\t\t\t\t\t\t\t<td>\n\t\t\t\t\t\t\t\t<select\n\t\t\t\t\t\t\t\t\tv-model=\"messageTags\"\n\t\t\t\t\t\t\t\t\tclass=\"form-select small tag-selector\"\n\t\t\t\t\t\t\t\t\tmultiple\n\t\t\t\t\t\t\t\t\tdata-full-width=\"false\"\n\t\t\t\t\t\t\t\t\tdata-suggestions-threshold=\"1\"\n\t\t\t\t\t\t\t\t\tdata-allow-new=\"true\"\n\t\t\t\t\t\t\t\t\tdata-clear-end=\"true\"\n\t\t\t\t\t\t\t\t\tdata-allow-clear=\"true\"\n\t\t\t\t\t\t\t\t\tdata-placeholder=\"Add tags...\"\n\t\t\t\t\t\t\t\t\tdata-badge-style=\"secondary\"\n\t\t\t\t\t\t\t\t\tdata-regex=\"^([a-zA-Z0-9\\-\\ \\_\\.@]){1,100}$\"\n\t\t\t\t\t\t\t\t\tdata-separator=\"|,|\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<option value=\"\">Type a tag...</option>\n\t\t\t\t\t\t\t\t\t<!-- you need at least one option with the placeholder -->\n\t\t\t\t\t\t\t\t\t<option v-for=\"t in availableTags\" :key=\"t\" :value=\"t\">{{ t }}</option>\n\t\t\t\t\t\t\t\t</select>\n\t\t\t\t\t\t\t\t<div class=\"invalid-feedback\">Invalid tag name</div>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t</tr>\n\n\t\t\t\t\t\t<tr\n\t\t\t\t\t\t\tv-if=\"message.ListUnsubscribe.Header != ''\"\n\t\t\t\t\t\t\tclass=\"small\"\n\t\t\t\t\t\t\t:class=\"showUnsubscribe ? '' : 'd-none'\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<th>Unsubscribe</th>\n\t\t\t\t\t\t\t<td>\n\t\t\t\t\t\t\t\t<span v-if=\"message.ListUnsubscribe.Links.length\" class=\"text-muted small me-2\">\n\t\t\t\t\t\t\t\t\t<template v-for=\"(u, i) in message.ListUnsubscribe.Links\">\n\t\t\t\t\t\t\t\t\t\t<template v-if=\"i > 0\">, </template>\n\t\t\t\t\t\t\t\t\t\t&lt;{{ u }}&gt;\n\t\t\t\t\t\t\t\t\t</template>\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t<i\n\t\t\t\t\t\t\t\t\tv-if=\"message.ListUnsubscribe.HeaderPost != ''\"\n\t\t\t\t\t\t\t\t\tclass=\"bi bi-info-circle text-success me-2 link\"\n\t\t\t\t\t\t\t\t\tdata-bs-toggle=\"tooltip\"\n\t\t\t\t\t\t\t\t\tdata-bs-placement=\"top\"\n\t\t\t\t\t\t\t\t\tdata-bs-custom-class=\"custom-tooltip\"\n\t\t\t\t\t\t\t\t\t:data-bs-title=\"'List-Unsubscribe-Post: ' + message.ListUnsubscribe.HeaderPost\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t</i>\n\t\t\t\t\t\t\t\t<i\n\t\t\t\t\t\t\t\t\tv-if=\"message.ListUnsubscribe.Errors != ''\"\n\t\t\t\t\t\t\t\t\tclass=\"bi bi-exclamation-circle text-danger link\"\n\t\t\t\t\t\t\t\t\tdata-bs-toggle=\"tooltip\"\n\t\t\t\t\t\t\t\t\tdata-bs-placement=\"top\"\n\t\t\t\t\t\t\t\t\tdata-bs-custom-class=\"custom-tooltip\"\n\t\t\t\t\t\t\t\t\t:data-bs-title=\"message.ListUnsubscribe.Errors\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t</i>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t</tbody>\n\t\t\t\t</table>\n\t\t\t</div>\n\t\t\t<div\n\t\t\t\tv-if=\"(message.Attachments && message.Attachments.length) || (message.Inline && message.Inline.length)\"\n\t\t\t\tclass=\"col-md-auto d-none d-md-block text-end mt-md-3\"\n\t\t\t>\n\t\t\t\t<div class=\"mt-2 mt-md-0\">\n\t\t\t\t\t<template v-if=\"message.Attachments.length\">\n\t\t\t\t\t\t<span class=\"badge rounded-pill text-bg-secondary p-2 mb-2\" title=\"Attachments in this message\">\n\t\t\t\t\t\t\tAttachment<span v-if=\"message.Attachments.length > 1\">s</span> ({{\n\t\t\t\t\t\t\t\tmessage.Attachments.length\n\t\t\t\t\t\t\t}})\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<br />\n\t\t\t\t\t</template>\n\t\t\t\t\t<span\n\t\t\t\t\t\tv-if=\"message.Inline.length\"\n\t\t\t\t\t\tclass=\"badge rounded-pill text-bg-secondary p-2\"\n\t\t\t\t\t\ttitle=\"Inline images in this message\"\n\t\t\t\t\t>\n\t\t\t\t\t\tInline image<span v-if=\"message.Inline.length > 1\">s</span> ({{ message.Inline.length }})\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\n\t\t<nav id=\"nav-tab\" class=\"nav nav-tabs my-3 d-print-none\" role=\"tablist\">\n\t\t\t<template v-if=\"message.HTML\">\n\t\t\t\t<div class=\"btn-group\">\n\t\t\t\t\t<button\n\t\t\t\t\t\tid=\"nav-html-tab\"\n\t\t\t\t\t\tref=\"navhtml\"\n\t\t\t\t\t\tclass=\"nav-link\"\n\t\t\t\t\t\tdata-bs-toggle=\"tab\"\n\t\t\t\t\t\tdata-bs-target=\"#nav-html\"\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\trole=\"tab\"\n\t\t\t\t\t\taria-controls=\"nav-html\"\n\t\t\t\t\t\taria-selected=\"true\"\n\t\t\t\t\t\t@click=\"resizeIFrames()\"\n\t\t\t\t\t>\n\t\t\t\t\t\tHTML\n\t\t\t\t\t</button>\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tclass=\"nav-link dropdown-toggle dropdown-toggle-split d-sm-none\"\n\t\t\t\t\t\tdata-bs-toggle=\"dropdown\"\n\t\t\t\t\t\taria-expanded=\"false\"\n\t\t\t\t\t\tdata-bs-reference=\"parent\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<span class=\"visually-hidden\">Toggle Dropdown</span>\n\t\t\t\t\t</button>\n\t\t\t\t\t<div class=\"dropdown-menu\">\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tclass=\"dropdown-item\"\n\t\t\t\t\t\t\tdata-bs-toggle=\"tab\"\n\t\t\t\t\t\t\tdata-bs-target=\"#nav-html-source\"\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\trole=\"tab\"\n\t\t\t\t\t\t\taria-controls=\"nav-html-source\"\n\t\t\t\t\t\t\taria-selected=\"false\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tHTML Source\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<button\n\t\t\t\t\tid=\"nav-html-source-tab\"\n\t\t\t\t\tclass=\"nav-link d-none d-sm-inline\"\n\t\t\t\t\tdata-bs-toggle=\"tab\"\n\t\t\t\t\tdata-bs-target=\"#nav-html-source\"\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\trole=\"tab\"\n\t\t\t\t\taria-controls=\"nav-html-source\"\n\t\t\t\t\taria-selected=\"false\"\n\t\t\t\t>\n\t\t\t\t\tHTML <span class=\"d-sm-none\">Src</span><span class=\"d-none d-sm-inline\">Source</span>\n\t\t\t\t</button>\n\t\t\t</template>\n\n\t\t\t<button\n\t\t\t\tid=\"nav-plain-text-tab\"\n\t\t\t\tclass=\"nav-link\"\n\t\t\t\tdata-bs-toggle=\"tab\"\n\t\t\t\tdata-bs-target=\"#nav-plain-text\"\n\t\t\t\ttype=\"button\"\n\t\t\t\trole=\"tab\"\n\t\t\t\taria-controls=\"nav-plain-text\"\n\t\t\t\taria-selected=\"false\"\n\t\t\t\t:class=\"message.HTML == '' ? 'show' : ''\"\n\t\t\t>\n\t\t\t\tText\n\t\t\t</button>\n\t\t\t<button\n\t\t\t\tid=\"nav-headers-tab\"\n\t\t\t\tclass=\"nav-link\"\n\t\t\t\tdata-bs-toggle=\"tab\"\n\t\t\t\tdata-bs-target=\"#nav-headers\"\n\t\t\t\ttype=\"button\"\n\t\t\t\trole=\"tab\"\n\t\t\t\taria-controls=\"nav-headers\"\n\t\t\t\taria-selected=\"false\"\n\t\t\t>\n\t\t\t\t<span class=\"d-sm-none\">Hdrs</span><span class=\"d-none d-sm-inline\">Headers</span>\n\t\t\t</button>\n\t\t\t<button\n\t\t\t\tid=\"nav-raw-tab\"\n\t\t\t\tclass=\"nav-link\"\n\t\t\t\tdata-bs-toggle=\"tab\"\n\t\t\t\tdata-bs-target=\"#nav-raw\"\n\t\t\t\ttype=\"button\"\n\t\t\t\trole=\"tab\"\n\t\t\t\taria-controls=\"nav-raw\"\n\t\t\t\taria-selected=\"false\"\n\t\t\t>\n\t\t\t\tRaw\n\t\t\t</button>\n\t\t\t<div v-show=\"hasAnyChecksEnabled\" class=\"dropdown d-xl-none\">\n\t\t\t\t<button class=\"nav-link dropdown-toggle\" type=\"button\" data-bs-toggle=\"dropdown\" aria-expanded=\"false\">\n\t\t\t\t\tChecks\n\t\t\t\t</button>\n\t\t\t\t<ul class=\"dropdown-menu checks\">\n\t\t\t\t\t<li v-if=\"mailbox.showHTMLCheck && message.HTML != ''\">\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tid=\"nav-html-check-tab\"\n\t\t\t\t\t\t\tclass=\"dropdown-item\"\n\t\t\t\t\t\t\tdata-bs-toggle=\"tab\"\n\t\t\t\t\t\t\tdata-bs-target=\"#nav-html-check\"\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\trole=\"tab\"\n\t\t\t\t\t\t\taria-controls=\"nav-html\"\n\t\t\t\t\t\t\taria-selected=\"false\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tHTML Check\n\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\tv-if=\"htmlScore !== false\"\n\t\t\t\t\t\t\t\tclass=\"badge rounded-pill p-1 float-end\"\n\t\t\t\t\t\t\t\t:class=\"htmlScoreColor\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<small>{{ Math.floor(htmlScore) }}%</small>\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</li>\n\t\t\t\t\t<li v-if=\"mailbox.showLinkCheck\">\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tid=\"nav-link-check-tab\"\n\t\t\t\t\t\t\tclass=\"dropdown-item\"\n\t\t\t\t\t\t\tdata-bs-toggle=\"tab\"\n\t\t\t\t\t\t\tdata-bs-target=\"#nav-link-check\"\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\trole=\"tab\"\n\t\t\t\t\t\t\taria-controls=\"nav-link-check\"\n\t\t\t\t\t\t\taria-selected=\"false\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tLink Check\n\t\t\t\t\t\t\t<span v-if=\"linkCheckErrors === 0\" class=\"badge rounded-pill bg-success float-end\">\n\t\t\t\t\t\t\t\t<small>0</small>\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<span v-else-if=\"linkCheckErrors > 0\" class=\"badge rounded-pill bg-danger float-end\">\n\t\t\t\t\t\t\t\t<small>{{ formatNumber(linkCheckErrors) }}</small>\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</li>\n\t\t\t\t\t<li v-if=\"mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin\">\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tid=\"nav-spam-check-tab\"\n\t\t\t\t\t\t\tclass=\"dropdown-item\"\n\t\t\t\t\t\t\tdata-bs-toggle=\"tab\"\n\t\t\t\t\t\t\tdata-bs-target=\"#nav-spam-check\"\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\trole=\"tab\"\n\t\t\t\t\t\t\taria-controls=\"nav-html\"\n\t\t\t\t\t\t\taria-selected=\"false\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tSpam Analysis\n\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\tv-if=\"spamScore !== false\"\n\t\t\t\t\t\t\t\tclass=\"badge rounded-pill float-end\"\n\t\t\t\t\t\t\t\t:class=\"spamScoreColor\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<small>{{ spamScore }}</small>\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</li>\n\t\t\t\t</ul>\n\t\t\t</div>\n\t\t\t<button\n\t\t\t\tv-if=\"mailbox.showHTMLCheck && message.HTML != ''\"\n\t\t\t\tid=\"nav-html-check-tab\"\n\t\t\t\tclass=\"d-none d-xl-inline-block nav-link position-relative\"\n\t\t\t\tdata-bs-toggle=\"tab\"\n\t\t\t\tdata-bs-target=\"#nav-html-check\"\n\t\t\t\ttype=\"button\"\n\t\t\t\trole=\"tab\"\n\t\t\t\taria-controls=\"nav-html\"\n\t\t\t\taria-selected=\"false\"\n\t\t\t>\n\t\t\t\tHTML Check\n\t\t\t\t<span v-if=\"htmlScore !== false\" class=\"badge rounded-pill p-1\" :class=\"htmlScoreColor\">\n\t\t\t\t\t<small>{{ Math.floor(htmlScore) }}%</small>\n\t\t\t\t</span>\n\t\t\t</button>\n\t\t\t<button\n\t\t\t\tv-if=\"mailbox.showLinkCheck\"\n\t\t\t\tid=\"nav-link-check-tab\"\n\t\t\t\tclass=\"d-none d-xl-inline-block nav-link\"\n\t\t\t\tdata-bs-toggle=\"tab\"\n\t\t\t\tdata-bs-target=\"#nav-link-check\"\n\t\t\t\ttype=\"button\"\n\t\t\t\trole=\"tab\"\n\t\t\t\taria-controls=\"nav-link-check\"\n\t\t\t\taria-selected=\"false\"\n\t\t\t>\n\t\t\t\tLink Check\n\t\t\t\t<i v-if=\"linkCheckErrors === 0\" class=\"bi bi-check-all text-success\"></i>\n\t\t\t\t<span v-else-if=\"linkCheckErrors > 0\" class=\"badge rounded-pill bg-danger\">\n\t\t\t\t\t<small>{{ formatNumber(linkCheckErrors) }}</small>\n\t\t\t\t</span>\n\t\t\t</button>\n\t\t\t<button\n\t\t\t\tv-if=\"mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin\"\n\t\t\t\tid=\"nav-spam-check-tab\"\n\t\t\t\tclass=\"d-none d-xl-inline-block nav-link position-relative\"\n\t\t\t\tdata-bs-toggle=\"tab\"\n\t\t\t\tdata-bs-target=\"#nav-spam-check\"\n\t\t\t\ttype=\"button\"\n\t\t\t\trole=\"tab\"\n\t\t\t\taria-controls=\"nav-html\"\n\t\t\t\taria-selected=\"false\"\n\t\t\t>\n\t\t\t\tSpam Analysis\n\t\t\t\t<span v-if=\"spamScore !== false\" class=\"badge rounded-pill\" :class=\"spamScoreColor\">\n\t\t\t\t\t<small>{{ spamScore }}</small>\n\t\t\t\t</span>\n\t\t\t</button>\n\n\t\t\t<div v-if=\"showMobileButtons\" class=\"d-none d-lg-block ms-auto me-3\">\n\t\t\t\t<template v-for=\"(_, key) in responsiveSizes\" :key=\"'responsive_' + key\">\n\t\t\t\t\t<button\n\t\t\t\t\t\tclass=\"btn\"\n\t\t\t\t\t\t:disabled=\"scaleHTMLPreview == key\"\n\t\t\t\t\t\t:title=\"'Switch to ' + key + ' view'\"\n\t\t\t\t\t\t@click=\"scaleHTMLPreview = key\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<i class=\"bi\" :class=\"'bi-' + key\"></i>\n\t\t\t\t\t</button>\n\t\t\t\t</template>\n\t\t\t</div>\n\t\t</nav>\n\n\t\t<div id=\"nav-tabContent\" class=\"tab-content mb-5\">\n\t\t\t<div\n\t\t\t\tv-if=\"message.HTML != ''\"\n\t\t\t\tid=\"nav-html\"\n\t\t\t\tclass=\"tab-pane fade show\"\n\t\t\t\trole=\"tabpanel\"\n\t\t\t\taria-labelledby=\"nav-html-tab\"\n\t\t\t\ttabindex=\"0\"\n\t\t\t>\n\t\t\t\t<div id=\"responsive-view\" :class=\"scaleHTMLPreview\" :style=\"responsiveSizes[scaleHTMLPreview]\">\n\t\t\t\t\t<iframe\n\t\t\t\t\t\tid=\"preview-html\"\n\t\t\t\t\t\ttarget-blank=\"\"\n\t\t\t\t\t\tclass=\"tab-pane d-block\"\n\t\t\t\t\t\t:srcdoc=\"sanitizedHTML\"\n\t\t\t\t\t\tframeborder=\"0\"\n\t\t\t\t\t\tstyle=\"width: 100%; height: 100%; background: #fff\"\n\t\t\t\t\t\t@load=\"resizeIframe\"\n\t\t\t\t\t>\n\t\t\t\t\t</iframe>\n\t\t\t\t</div>\n\t\t\t\t<Attachments\n\t\t\t\t\tv-if=\"allAttachments(message).length\"\n\t\t\t\t\t:message=\"message\"\n\t\t\t\t\t:attachments=\"allAttachments(message)\"\n\t\t\t\t>\n\t\t\t\t</Attachments>\n\t\t\t</div>\n\t\t\t<div\n\t\t\t\tv-if=\"message.HTML\"\n\t\t\t\tid=\"nav-html-source\"\n\t\t\t\tclass=\"tab-pane fade\"\n\t\t\t\trole=\"tabpanel\"\n\t\t\t\taria-labelledby=\"nav-html-source-tab\"\n\t\t\t\ttabindex=\"0\"\n\t\t\t>\n\t\t\t\t<pre class=\"language-html\"><code class=\"language-html\">{{ message.HTML }}</code></pre>\n\t\t\t</div>\n\t\t\t<div\n\t\t\t\tid=\"nav-plain-text\"\n\t\t\t\tclass=\"tab-pane fade\"\n\t\t\t\trole=\"tabpanel\"\n\t\t\t\taria-labelledby=\"nav-plain-text-tab\"\n\t\t\t\ttabindex=\"0\"\n\t\t\t\t:class=\"message.HTML == '' ? 'show' : ''\"\n\t\t\t>\n\t\t\t\t<!-- eslint-disable vue/no-v-html -->\n\t\t\t\t<div class=\"text-view\" v-html=\"textToHTML(message.Text)\"></div>\n\t\t\t\t<!-- -eslint-disable vue/no-v-html -->\n\t\t\t\t<Attachments\n\t\t\t\t\tv-if=\"allAttachments(message).length\"\n\t\t\t\t\t:message=\"message\"\n\t\t\t\t\t:attachments=\"allAttachments(message)\"\n\t\t\t\t>\n\t\t\t\t</Attachments>\n\t\t\t</div>\n\t\t\t<div id=\"nav-headers\" class=\"tab-pane fade\" role=\"tabpanel\" aria-labelledby=\"nav-headers-tab\" tabindex=\"0\">\n\t\t\t\t<Headers v-if=\"loadHeaders\" :message=\"message\"></Headers>\n\t\t\t</div>\n\t\t\t<div id=\"nav-raw\" class=\"tab-pane fade\" role=\"tabpanel\" aria-labelledby=\"nav-raw-tab\" tabindex=\"0\">\n\t\t\t\t<iframe\n\t\t\t\t\tv-if=\"srcURI\"\n\t\t\t\t\t:src=\"srcURI\"\n\t\t\t\t\tframeborder=\"0\"\n\t\t\t\t\tstyle=\"width: 100%; height: 300px\"\n\t\t\t\t\t@load=\"initRawIframe\"\n\t\t\t\t></iframe>\n\t\t\t</div>\n\t\t\t<div\n\t\t\t\tid=\"nav-html-check\"\n\t\t\t\tclass=\"tab-pane fade\"\n\t\t\t\trole=\"tabpanel\"\n\t\t\t\taria-labelledby=\"nav-html-check-tab\"\n\t\t\t\ttabindex=\"0\"\n\t\t\t>\n\t\t\t\t<HTMLCheck\n\t\t\t\t\tv-if=\"mailbox.showHTMLCheck && message.HTML != ''\"\n\t\t\t\t\t:message=\"message\"\n\t\t\t\t\t@set-html-score=\"(n) => (htmlScore = n)\"\n\t\t\t\t\t@set-badge-style=\"(v) => (htmlScoreColor = v)\"\n\t\t\t\t/>\n\t\t\t</div>\n\t\t\t<div\n\t\t\t\tv-if=\"mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin\"\n\t\t\t\tid=\"nav-spam-check\"\n\t\t\t\tclass=\"tab-pane fade\"\n\t\t\t\trole=\"tabpanel\"\n\t\t\t\taria-labelledby=\"nav-spam-check-tab\"\n\t\t\t\ttabindex=\"0\"\n\t\t\t>\n\t\t\t\t<SpamAssassin\n\t\t\t\t\t:message=\"message\"\n\t\t\t\t\t@set-spam-score=\"(n) => (spamScore = n)\"\n\t\t\t\t\t@set-badge-style=\"(v) => (spamScoreColor = v)\"\n\t\t\t\t/>\n\t\t\t</div>\n\t\t\t<div\n\t\t\t\tv-if=\"mailbox.showLinkCheck\"\n\t\t\t\tid=\"nav-link-check\"\n\t\t\t\tclass=\"tab-pane fade\"\n\t\t\t\trole=\"tabpanel\"\n\t\t\t\taria-labelledby=\"nav-html-check-tab\"\n\t\t\t\ttabindex=\"0\"\n\t\t\t>\n\t\t\t\t<LinkCheck :message=\"message\" @set-link-errors=\"(n) => (linkCheckErrors = n)\" />\n\t\t\t</div>\n\t\t</div>\n\t</div>\n</template>\n"
  },
  {
    "path": "server/ui-src/components/message/MessageRelease.vue",
    "content": "<script>\nimport AjaxLoader from \"../AjaxLoader.vue\";\nimport Tags from \"bootstrap5-tags\";\nimport commonMixins from \"../../mixins/CommonMixins\";\nimport { mailbox } from \"../../stores/mailbox\";\n\nexport default {\n\tcomponents: {\n\t\tAjaxLoader,\n\t},\n\n\tmixins: [commonMixins],\n\n\tprops: {\n\t\tmessage: {\n\t\t\ttype: Object,\n\t\t\tdefault: () => ({}),\n\t\t},\n\t},\n\n\temits: [\"delete\"],\n\n\tdata() {\n\t\treturn {\n\t\t\taddresses: [],\n\t\t\tdeleteAfterRelease: false,\n\t\t\tmailbox,\n\t\t\tallAddresses: [],\n\t\t};\n\t},\n\n\tmounted() {\n\t\tconst a = [];\n\t\tfor (const i in this.message.To) {\n\t\t\ta.push(this.message.To[i].Address);\n\t\t}\n\t\tfor (const i in this.message.Cc) {\n\t\t\ta.push(this.message.Cc[i].Address);\n\t\t}\n\t\tfor (const i in this.message.Bcc) {\n\t\t\ta.push(this.message.Bcc[i].Address);\n\t\t}\n\n\t\t// include only unique email addresses, regardless of casing\n\t\tthis.allAddresses = JSON.parse(JSON.stringify([...new Map(a.map((ad) => [ad.toLowerCase(), ad])).values()]));\n\n\t\t// include default release addresses from mailbox settings\n\t\tconst defaultAddr = mailbox.defaultReleaseAddresses;\n\t\tfor (const i in defaultAddr) {\n\t\t\tif (!this.allAddresses.includes(defaultAddr[i])) {\n\t\t\t\tthis.allAddresses.push(defaultAddr[i]);\n\t\t\t}\n\t\t}\n\n\t\tif (defaultAddr.length === 0) {\n\t\t\t// prefill with all addresses if no default is set\n\t\t\tthis.addresses = this.allAddresses;\n\t\t} else {\n\t\t\tthis.addresses = defaultAddr;\n\t\t}\n\t},\n\n\tmethods: {\n\t\t// triggered manually after modal is shown\n\t\tinitTags() {\n\t\t\tTags.init(\"select[multiple]\");\n\t\t},\n\n\t\treleaseMessage() {\n\t\t\t// set timeout to allow for user clicking send before the tag filter has applied the tag\n\t\t\twindow.setTimeout(() => {\n\t\t\t\tif (!this.addresses.length) {\n\t\t\t\t\treturn false;\n\t\t\t\t}\n\n\t\t\t\tconst data = {\n\t\t\t\t\tTo: this.addresses,\n\t\t\t\t};\n\n\t\t\t\tthis.post(this.resolve(\"/api/v1/message/\" + this.message.ID + \"/release\"), data, () => {\n\t\t\t\t\tthis.modal(\"ReleaseModal\").hide();\n\t\t\t\t\tif (this.deleteAfterRelease) {\n\t\t\t\t\t\tthis.$emit(\"delete\");\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t}, 100);\n\t\t},\n\t},\n};\n</script>\n\n<template>\n\t<div id=\"ReleaseModal\" class=\"modal fade\" tabindex=\"-1\" aria-labelledby=\"AppInfoModalLabel\" aria-hidden=\"true\">\n\t\t<div v-if=\"message\" class=\"modal-dialog modal-xl\">\n\t\t\t<div class=\"modal-content\">\n\t\t\t\t<div class=\"modal-header\">\n\t\t\t\t\t<h1 id=\"AppInfoModalLabel\" class=\"modal-title fs-5\">Release email</h1>\n\t\t\t\t\t<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"modal-body\">\n\t\t\t\t\t<h6>Send this message to one or more addresses specified below.</h6>\n\t\t\t\t\t<div class=\"row\">\n\t\t\t\t\t\t<label class=\"col-sm-2 col-form-label text-body-secondary\">From</label>\n\t\t\t\t\t\t<div class=\"col-sm-10\">\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\tv-if=\"mailbox.uiConfig.MessageRelay.OverrideFrom != ''\"\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\taria-label=\"From address\"\n\t\t\t\t\t\t\t\treadonly\n\t\t\t\t\t\t\t\tclass=\"form-control-plaintext\"\n\t\t\t\t\t\t\t\t:value=\"mailbox.uiConfig.MessageRelay.OverrideFrom\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\tv-else\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\taria-label=\"From address\"\n\t\t\t\t\t\t\t\treadonly\n\t\t\t\t\t\t\t\tclass=\"form-control-plaintext\"\n\t\t\t\t\t\t\t\t:value=\"message.From ? message.From.Address : ''\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"row\">\n\t\t\t\t\t\t<label class=\"col-sm-2 col-form-label text-body-secondary\">Subject</label>\n\t\t\t\t\t\t<div class=\"col-sm-10\">\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\taria-label=\"Subject\"\n\t\t\t\t\t\t\t\treadonly\n\t\t\t\t\t\t\t\tclass=\"form-control-plaintext\"\n\t\t\t\t\t\t\t\t:value=\"message.Subject\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"row mb-3\">\n\t\t\t\t\t\t<label class=\"col-sm-2 col-form-label text-body-secondary\">Send to</label>\n\t\t\t\t\t\t<div class=\"col-sm-10\">\n\t\t\t\t\t\t\t<select\n\t\t\t\t\t\t\t\tv-model=\"addresses\"\n\t\t\t\t\t\t\t\tclass=\"form-select tag-selector\"\n\t\t\t\t\t\t\t\tmultiple\n\t\t\t\t\t\t\t\tdata-allow-new=\"true\"\n\t\t\t\t\t\t\t\tdata-clear-end=\"true\"\n\t\t\t\t\t\t\t\tdata-allow-clear=\"true\"\n\t\t\t\t\t\t\t\tdata-placeholder=\"Enter email addresses...\"\n\t\t\t\t\t\t\t\tdata-add-on-blur=\"true\"\n\t\t\t\t\t\t\t\tdata-badge-style=\"primary\"\n\t\t\t\t\t\t\t\tdata-regex='^(([^<>()[\\]\\\\.,;:\\s@\"]+(\\.[^<>()[\\]\\\\.,;:\\s@\"]+)*)|.(\".+\"))@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\])|(([a-zA-Z\\-0-9]+\\.)+[a-zA-Z]{2,}))$'\n\t\t\t\t\t\t\t\tdata-separator=\"|,|\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<option value=\"\">Enter email addresses...</option>\n\t\t\t\t\t\t\t\t<!-- you need at least one option with the placeholder -->\n\t\t\t\t\t\t\t\t<option v-for=\"t in allAddresses\" :key=\"'address+' + t\" :value=\"t\">{{ t }}</option>\n\t\t\t\t\t\t\t</select>\n\t\t\t\t\t\t\t<div class=\"invalid-feedback\">Invalid email address</div>\n\t\t\t\t\t\t\t<div class=\"form-text mt-1\">\n\t\t\t\t\t\t\t\tDefault release addresses can be configured in\n\t\t\t\t\t\t\t\t<a href=\"#\" data-bs-toggle=\"modal\" data-bs-target=\"#SettingsModal\">\n\t\t\t\t\t\t\t\t\t<i class=\"bi bi-gear-fill ms-1\"></i>\n\t\t\t\t\t\t\t\t\tSettings </a\n\t\t\t\t\t\t\t\t>.\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"row mb-3\">\n\t\t\t\t\t\t<div class=\"col-sm-10 offset-sm-2\">\n\t\t\t\t\t\t\t<div class=\"form-check\">\n\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\tid=\"DeleteAfterRelease\"\n\t\t\t\t\t\t\t\t\tv-model=\"deleteAfterRelease\"\n\t\t\t\t\t\t\t\t\tclass=\"form-check-input\"\n\t\t\t\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<label class=\"form-check-label\" for=\"DeleteAfterRelease\">\n\t\t\t\t\t\t\t\t\tDelete the message after release\n\t\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<h6>Notes</h6>\n\t\t\t\t\t<ul>\n\t\t\t\t\t\t<li v-if=\"mailbox.uiConfig.MessageRelay.AllowedRecipients != ''\" class=\"form-text\">\n\t\t\t\t\t\t\tA recipient <b>allowlist</b> has been configured. Any mail address not matching the\n\t\t\t\t\t\t\tfollowing will be rejected:\n\t\t\t\t\t\t\t<code>{{ mailbox.uiConfig.MessageRelay.AllowedRecipients }}</code>\n\t\t\t\t\t\t</li>\n\t\t\t\t\t\t<li v-if=\"mailbox.uiConfig.MessageRelay.BlockedRecipients != ''\" class=\"form-text\">\n\t\t\t\t\t\t\tA recipient <b>blocklist</b> has been configured. Any mail address matching the following\n\t\t\t\t\t\t\twill be rejected:\n\t\t\t\t\t\t\t<code>{{ mailbox.uiConfig.MessageRelay.BlockedRecipients }}</code>\n\t\t\t\t\t\t</li>\n\t\t\t\t\t\t<li v-if=\"!mailbox.uiConfig.MessageRelay.PreserveMessageIDs\" class=\"form-text\">\n\t\t\t\t\t\t\tFor testing purposes, a new unique <code>Message-ID</code> will be generated on send.\n\t\t\t\t\t\t</li>\n\t\t\t\t\t\t<li v-if=\"mailbox.uiConfig.MessageRelay.OverrideFrom != ''\" class=\"form-text\">\n\t\t\t\t\t\t\tThe <code>From</code> email address has been overridden by the relay configuration to\n\t\t\t\t\t\t\t<code>{{ mailbox.uiConfig.MessageRelay.OverrideFrom }}</code\n\t\t\t\t\t\t\t>.\n\t\t\t\t\t\t</li>\n\t\t\t\t\t\t<li class=\"form-text\">\n\t\t\t\t\t\t\tSMTP delivery failures will bounce back to\n\t\t\t\t\t\t\t<code v-if=\"mailbox.uiConfig.MessageRelay.ReturnPath != ''\">\n\t\t\t\t\t\t\t\t{{ mailbox.uiConfig.MessageRelay.ReturnPath }}\n\t\t\t\t\t\t\t</code>\n\t\t\t\t\t\t\t<code v-else-if=\"mailbox.uiConfig.MessageRelay.OverrideFrom != ''\">\n\t\t\t\t\t\t\t\t{{ mailbox.uiConfig.MessageRelay.OverrideFrom }}\n\t\t\t\t\t\t\t</code>\n\t\t\t\t\t\t\t<code v-else>{{ message.ReturnPath }}</code\n\t\t\t\t\t\t\t>.\n\t\t\t\t\t\t</li>\n\t\t\t\t\t</ul>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"modal-footer\">\n\t\t\t\t\t<button type=\"button\" class=\"btn btn-outline-secondary\" data-bs-dismiss=\"modal\">Cancel</button>\n\t\t\t\t\t<button type=\"button\" class=\"btn btn-primary\" :disabled=\"!addresses.length\" @click=\"releaseMessage\">\n\t\t\t\t\t\tRelease\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t</div>\n\n\t<AjaxLoader :loading=\"loading\" />\n</template>\n"
  },
  {
    "path": "server/ui-src/components/message/MessageScreenshot.vue",
    "content": "<script>\nimport AjaxLoader from \"../AjaxLoader.vue\";\nimport CommonMixins from \"../../mixins/CommonMixins\";\nimport { domToPng } from \"modern-screenshot\";\nimport DOMPurify from \"dompurify\";\n\nexport default {\n\tcomponents: {\n\t\tAjaxLoader,\n\t},\n\n\tmixins: [CommonMixins],\n\n\tprops: {\n\t\tmessage: {\n\t\t\ttype: Object,\n\t\t\tdefault: () => ({}),\n\t\t},\n\t},\n\n\tdata() {\n\t\treturn {\n\t\t\thtml: false,\n\t\t\tloading: 0,\n\t\t};\n\t},\n\n\tmethods: {\n\t\tinitScreenshot() {\n\t\t\tthis.loading = 1;\n\t\t\tconst baseUrl = `${location.protocol}//${location.host}/`;\n\t\t\t// absolute proxy URL\n\t\t\tconst proxy = new URL(this.resolve(\"/proxy\"), baseUrl).href;\n\t\t\tconst urlRegex = /(url\\(('|\")?(https?:\\/\\/[^)'\"]+)('|\")?\\))/gim;\n\n\t\t\t// remove base tag, if set\n\t\t\tlet h = this.message.HTML.replace(/<base .*>/im, \"\");\n\n\t\t\t// Outlook hacks - else screenshot returns blank image\n\t\t\th = h.replace(/<html [^>]+>/gim, \"<html>\"); // remove html attributes\n\t\t\th = h.replace(/<o:p><\\/o:p>/gm, \"\"); // remove empty `<o:p></o:p>` tags\n\t\t\th = h.replace(/<o:/gm, \"<\"); // replace `<o:p>` tags with `<p>`\n\t\t\th = h.replace(/<\\/o:/gm, \"</\"); // replace `</o:p>` tags with `</p>`\n\n\t\t\t// Sanitize HTML before writing to the temporary document.\n\t\t\t// This removes <script>, <noscript>, inline event handlers (on*),\n\t\t\t// SVG <animate>/<set> with xlink:href and other active content\n\t\t\t// that manual tag removal would miss.\n\t\t\th = DOMPurify.sanitize(h, {\n\t\t\t\tWHOLE_DOCUMENT: true,\n\t\t\t\tFORCE_BODY: false,\n\t\t\t\tADD_TAGS: [\"link\", \"meta\", \"o:p\", \"style\"],\n\t\t\t\tADD_ATTR: [\n\t\t\t\t\t\"bordercolor\",\n\t\t\t\t\t\"charset\",\n\t\t\t\t\t\"content\",\n\t\t\t\t\t\"hspace\",\n\t\t\t\t\t\"http-equiv\",\n\t\t\t\t\t\"itemprop\",\n\t\t\t\t\t\"itemscope\",\n\t\t\t\t\t\"itemtype\",\n\t\t\t\t\t\"vertical-align\",\n\t\t\t\t\t\"vlink\",\n\t\t\t\t\t\"vspace\",\n\t\t\t\t\t\"xml:lang\",\n\t\t\t\t\t\"background\", // needed for background= URL replacement below\n\t\t\t\t],\n\t\t\t\tFORBID_TAGS: [\"script\", \"noscript\"],\n\t\t\t});\n\n\t\t\t// create temporary document to manipulate\n\t\t\tconst doc = document.implementation.createHTMLDocument();\n\t\t\tdoc.open();\n\t\t\tdoc.writeln(h);\n\t\t\tdoc.close();\n\n\t\t\t// replace any url(...) links in <style> blocks\n\t\t\tconst styles = doc.getElementsByTagName(\"style\");\n\t\t\tfor (const i of styles) {\n\t\t\t\ti.innerHTML = i.innerHTML.replaceAll(urlRegex, (match, p1, p2, p3) => {\n\t\t\t\t\tif (typeof p2 === \"string\") {\n\t\t\t\t\t\t// quoted URL\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t`url(${p2}${proxy}?data=` + btoa(this.message.ID + \":\" + this.decodeEntities(p3)) + `${p2})`\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t\treturn `url(${proxy}?data=` + btoa(this.message.ID + \":\" + this.decodeEntities(p3)) + `)`;\n\t\t\t\t});\n\t\t\t}\n\n\t\t\t// replace stylesheet links with proxy links\n\t\t\tconst stylesheets = doc.getElementsByTagName(\"link\");\n\t\t\tfor (const i of stylesheets) {\n\t\t\t\tconst src = i.getAttribute(\"href\");\n\t\t\t\tif (\n\t\t\t\t\tsrc &&\n\t\t\t\t\tsrc.match(/^https?:\\/\\//i) &&\n\t\t\t\t\tsrc.indexOf(window.location.origin + window.location.pathname) !== 0\n\t\t\t\t) {\n\t\t\t\t\ti.setAttribute(\"href\", `${proxy}?data=` + btoa(this.message.ID + \":\" + this.decodeEntities(src)));\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// replace images with proxy links\n\t\t\tconst images = doc.getElementsByTagName(\"img\");\n\t\t\tfor (const i of images) {\n\t\t\t\tconst src = i.getAttribute(\"src\");\n\t\t\t\tif (\n\t\t\t\t\tsrc &&\n\t\t\t\t\tsrc.match(/^https?:\\/\\//i) &&\n\t\t\t\t\tsrc.indexOf(window.location.origin + window.location.pathname) !== 0\n\t\t\t\t) {\n\t\t\t\t\ti.setAttribute(\"src\", `${proxy}?data=` + btoa(this.message.ID + \":\" + this.decodeEntities(src)));\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// replace background=\"\" attributes with proxy links\n\t\t\tconst backgrounds = doc.querySelectorAll(\"[background]\");\n\t\t\tfor (const i of backgrounds) {\n\t\t\t\tconst src = i.getAttribute(\"background\");\n\n\t\t\t\tif (\n\t\t\t\t\tsrc &&\n\t\t\t\t\tsrc.match(/^https?:\\/\\//i) &&\n\t\t\t\t\tsrc.indexOf(window.location.origin + window.location.pathname) !== 0\n\t\t\t\t) {\n\t\t\t\t\t// replace with proxy link\n\t\t\t\t\ti.setAttribute(\n\t\t\t\t\t\t\"background\",\n\t\t\t\t\t\t`${proxy}?data=` + btoa(this.message.ID + \":\" + this.decodeEntities(src)),\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// set html with manipulated document content\n\t\t\tthis.html = new XMLSerializer().serializeToString(doc);\n\t\t},\n\n\t\t// HTML decode function\n\t\tdecodeEntities(s) {\n\t\t\treturn new DOMParser().parseFromString(s, \"text/html\").body.textContent;\n\t\t},\n\n\t\tdoScreenshot() {\n\t\t\tlet width = document.getElementById(\"message-view\").getBoundingClientRect().width;\n\n\t\t\tconst prev = document.getElementById(\"preview-html\");\n\t\t\tif (prev && prev.getBoundingClientRect().width) {\n\t\t\t\twidth = prev.getBoundingClientRect().width;\n\t\t\t}\n\n\t\t\tif (width < 300) {\n\t\t\t\twidth = 300;\n\t\t\t}\n\n\t\t\tconst i = document.getElementById(\"screenshot-html\");\n\n\t\t\t// set the iframe width\n\t\t\ti.style.width = width + \"px\";\n\n\t\t\tconst body = i.contentWindow.document.querySelector(\"body\");\n\n\t\t\t// Add body padding to prevent content touching edge of screenshot.\n\t\t\tbody.style.padding = \"20px\";\n\n\t\t\t// take screenshot of iframe\n\t\t\tdomToPng(body, {\n\t\t\t\tbackgroundColor: \"#ffffff\",\n\t\t\t\theight: i.contentWindow.document.body.scrollHeight,\n\t\t\t\twidth,\n\t\t\t\t// remove the transparent 8px top and left gap from html object (default browser margins).\n\t\t\t\tstyle: {\n\t\t\t\t\tmargin: \"0\",\n\t\t\t\t},\n\t\t\t}).then((dataUrl) => {\n\t\t\t\tconst link = document.createElement(\"a\");\n\t\t\t\tlink.download = this.message.ID + \".png\";\n\t\t\t\tlink.href = dataUrl;\n\t\t\t\tlink.click();\n\t\t\t\tthis.loading = 0;\n\t\t\t\tthis.html = false;\n\t\t\t});\n\t\t},\n\t},\n};\n</script>\n\n<template>\n\t<iframe\n\t\tv-if=\"html\"\n\t\tid=\"screenshot-html\"\n\t\t:srcdoc=\"html\"\n\t\tframeborder=\"0\"\n\t\tstyle=\"position: absolute; margin-left: -100000px\"\n\t\t@load=\"doScreenshot\"\n\t>\n\t</iframe>\n\n\t<AjaxLoader :loading=\"loading\" />\n</template>\n"
  },
  {
    "path": "server/ui-src/components/message/SpamAssassin.vue",
    "content": "<script>\nimport { VcDonut } from \"vue-css-donut-chart\";\nimport axios from \"axios\";\nimport commonMixins from \"../../mixins/CommonMixins\";\n\nexport default {\n\tcomponents: {\n\t\tVcDonut,\n\t},\n\n\tmixins: [commonMixins],\n\n\tprops: {\n\t\tmessage: {\n\t\t\ttype: Object,\n\t\t\tdefault: () => ({}),\n\t\t},\n\t},\n\n\temits: [\"setSpamScore\", \"setBadgeStyle\"],\n\n\tdata() {\n\t\treturn {\n\t\t\terror: false,\n\t\t\tcheck: false,\n\t\t};\n\t},\n\n\tcomputed: {\n\t\tgraphSections() {\n\t\t\tconst score = this.check.Score;\n\t\t\tlet p = Math.round((score / 5) * 100);\n\t\t\tif (p > 100) {\n\t\t\t\tp = 100;\n\t\t\t} else if (p < 0) {\n\t\t\t\tp = 0;\n\t\t\t}\n\n\t\t\tlet c = \"#ffc107\";\n\t\t\tif (this.check.IsSpam) {\n\t\t\t\tc = \"#dc3545\";\n\t\t\t}\n\n\t\t\treturn [\n\t\t\t\t{\n\t\t\t\t\tlabel: score + \" / 5\",\n\t\t\t\t\tvalue: p,\n\t\t\t\t\tcolor: c,\n\t\t\t\t},\n\t\t\t];\n\t\t},\n\n\t\tscoreColor() {\n\t\t\treturn this.graphSections[0].color;\n\t\t},\n\t},\n\n\twatch: {\n\t\tmessage: {\n\t\t\thandler() {\n\t\t\t\tthis.$emit(\"setSpamScore\", false);\n\t\t\t\tthis.doCheck();\n\t\t\t},\n\t\t\tdeep: true,\n\t\t},\n\t},\n\n\tmounted() {\n\t\tthis.doCheck();\n\t},\n\n\tmethods: {\n\t\tdoCheck() {\n\t\t\tthis.check = false;\n\n\t\t\t// ignore any error, do not show loader\n\t\t\taxios\n\t\t\t\t.get(this.resolve(\"/api/v1/message/\" + this.message.ID + \"/sa-check\"), null)\n\t\t\t\t.then((result) => {\n\t\t\t\t\tthis.check = result.data;\n\t\t\t\t\tthis.error = false;\n\t\t\t\t\tthis.setIcons();\n\t\t\t\t})\n\t\t\t\t.catch((error) => {\n\t\t\t\t\t// handle error\n\t\t\t\t\tif (error.response && error.response.data) {\n\t\t\t\t\t\t// The request was made and the server responded with a status code\n\t\t\t\t\t\t// that falls out of the range of 2xx\n\t\t\t\t\t\tif (error.response.data.Error) {\n\t\t\t\t\t\t\tthis.error = error.response.data.Error;\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tthis.error = error.response.data;\n\t\t\t\t\t\t}\n\t\t\t\t\t} else if (error.request) {\n\t\t\t\t\t\t// The request was made but no response was received\n\t\t\t\t\t\t// `error.request` is an instance of XMLHttpRequest in the browser and an instance of\n\t\t\t\t\t\t// http.ClientRequest in node.js\n\t\t\t\t\t\tthis.error = \"Error sending data to the server. Please try again.\";\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Something happened in setting up the request that triggered an Error\n\t\t\t\t\t\tthis.error = error.message;\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t},\n\n\t\tbadgeStyle(ignorePadding = false) {\n\t\t\tlet badgeStyle = \"bg-success\";\n\t\t\tif (this.check.Error) {\n\t\t\t\tbadgeStyle = \"bg-warning text-primary\";\n\t\t\t} else if (this.check.IsSpam) {\n\t\t\t\tbadgeStyle = \"bg-danger\";\n\t\t\t} else if (this.check.Score >= 4) {\n\t\t\t\tbadgeStyle = \"bg-warning text-primary\";\n\t\t\t}\n\n\t\t\tif (!ignorePadding && String(this.check.Score).includes(\".\")) {\n\t\t\t\tbadgeStyle += \" p-1\";\n\t\t\t}\n\n\t\t\treturn badgeStyle;\n\t\t},\n\n\t\tsetIcons() {\n\t\t\tlet score = this.check.Score;\n\t\t\tif (this.check.Error && this.check.Error !== \"\") {\n\t\t\t\tscore = \"!\";\n\t\t\t}\n\t\t\tconst badgeStyle = this.badgeStyle();\n\t\t\tthis.$emit(\"setBadgeStyle\", badgeStyle);\n\t\t\tthis.$emit(\"setSpamScore\", score);\n\t\t},\n\t},\n};\n</script>\n\n<template>\n\t<div class=\"row mb-3 w-100 align-items-center\">\n\t\t<div class=\"col\">\n\t\t\t<h4 class=\"mb-0\">Spam Analysis</h4>\n\t\t</div>\n\t\t<div class=\"col-auto\">\n\t\t\t<button class=\"btn btn-outline-secondary\" data-bs-toggle=\"modal\" data-bs-target=\"#AboutSpamAnalysis\">\n\t\t\t\t<i class=\"bi bi-info-circle-fill\"></i>\n\t\t\t\tHelp\n\t\t\t</button>\n\t\t</div>\n\t</div>\n\n\t<template v-if=\"error || check.Error != ''\">\n\t\t<p>Your message could not be checked</p>\n\t\t<div v-if=\"error\" class=\"alert alert-warning\">\n\t\t\t{{ error }}\n\t\t</div>\n\t\t<div v-else class=\"alert alert-warning\">\n\t\t\tThere was an error contacting the configured SpamAssassin server: {{ check.Error }}\n\t\t</div>\n\t</template>\n\n\t<template v-else-if=\"check\">\n\t\t<div class=\"row w-100 mt-5\">\n\t\t\t<div class=\"col-xl-5 mb-2\">\n\t\t\t\t<vc-donut\n\t\t\t\t\t:sections=\"graphSections\"\n\t\t\t\t\tbackground=\"var(--bs-body-bg)\"\n\t\t\t\t\t:size=\"230\"\n\t\t\t\t\tunit=\"px\"\n\t\t\t\t\t:thickness=\"20\"\n\t\t\t\t\t:total=\"100\"\n\t\t\t\t\t:start-angle=\"270\"\n\t\t\t\t\t:auto-adjust-text-size=\"true\"\n\t\t\t\t\tforeground=\"#198754\"\n\t\t\t\t>\n\t\t\t\t\t<h2 class=\"m-0\" :class=\"scoreColor\" @click=\"scrollToWarnings\">{{ check.Score }} / 5</h2>\n\t\t\t\t\t<div class=\"text-body mt-2\">\n\t\t\t\t\t\t<span v-if=\"check.IsSpam\" class=\"text-white badge rounded-pill bg-danger p-2\">Spam</span>\n\t\t\t\t\t\t<span v-else class=\"badge rounded-pill p-2\" :class=\"badgeStyle()\">Not spam</span>\n\t\t\t\t\t</div>\n\t\t\t\t</vc-donut>\n\t\t\t</div>\n\t\t\t<div class=\"col-xl-7\">\n\t\t\t\t<div class=\"row w-100 py-2 border-bottom\">\n\t\t\t\t\t<div class=\"col-2 col-lg-1\">\n\t\t\t\t\t\t<strong>Score</strong>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"col-10 col-lg-5\">\n\t\t\t\t\t\t<strong>Rule <span class=\"d-none d-lg-inline\">name</span></strong>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"col-auto d-none d-lg-block\">\n\t\t\t\t\t\t<strong>Description</strong>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div v-for=\"r in check.Rules\" :key=\"'rule_' + r.Name\" class=\"row w-100 py-2 border-bottom small\">\n\t\t\t\t\t<div class=\"col-2 col-lg-1\">\n\t\t\t\t\t\t{{ r.Score }}\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"col-10 col-lg-5\">\n\t\t\t\t\t\t{{ r.Name }}\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"col-auto col-lg-6 mt-2 mt-lg-0 offset-2 offset-lg-0\">\n\t\t\t\t\t\t{{ r.Description }}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t</template>\n\n\t<div\n\t\tid=\"AboutSpamAnalysis\"\n\t\tclass=\"modal fade\"\n\t\ttabindex=\"-1\"\n\t\taria-labelledby=\"AboutSpamAnalysisLabel\"\n\t\taria-hidden=\"true\"\n\t>\n\t\t<div class=\"modal-dialog modal-lg modal-dialog-scrollable\">\n\t\t\t<div class=\"modal-content\">\n\t\t\t\t<div class=\"modal-header\">\n\t\t\t\t\t<h1 id=\"AboutSpamAnalysisLabel\" class=\"modal-title fs-5\">About Spam Analysis</h1>\n\t\t\t\t\t<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"modal-body\">\n\t\t\t\t\t<div id=\"SpamAnalysisAboutAccordion\" class=\"accordion\">\n\t\t\t\t\t\t<div class=\"accordion-item\">\n\t\t\t\t\t\t\t<h2 class=\"accordion-header\">\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tclass=\"accordion-button collapsed\"\n\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\tdata-bs-toggle=\"collapse\"\n\t\t\t\t\t\t\t\t\tdata-bs-target=\"#col1\"\n\t\t\t\t\t\t\t\t\taria-expanded=\"false\"\n\t\t\t\t\t\t\t\t\taria-controls=\"col1\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tWhat is Spam Analysis?\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t</h2>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tid=\"col1\"\n\t\t\t\t\t\t\t\tclass=\"accordion-collapse collapse\"\n\t\t\t\t\t\t\t\tdata-bs-parent=\"#SpamAnalysisAboutAccordion\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<div class=\"accordion-body\">\n\t\t\t\t\t\t\t\t\t<p>\n\t\t\t\t\t\t\t\t\t\tMailpit integrates with SpamAssassin to provide you with some insight into the\n\t\t\t\t\t\t\t\t\t\t\"spamminess\" of your messages. It sends your complete message (including any\n\t\t\t\t\t\t\t\t\t\tattachments) to a running SpamAssassin server and then displays the results\n\t\t\t\t\t\t\t\t\t\treturned by SpamAssassin.\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div class=\"accordion-item\">\n\t\t\t\t\t\t\t<h2 class=\"accordion-header\">\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tclass=\"accordion-button collapsed\"\n\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\tdata-bs-toggle=\"collapse\"\n\t\t\t\t\t\t\t\t\tdata-bs-target=\"#col2\"\n\t\t\t\t\t\t\t\t\taria-expanded=\"false\"\n\t\t\t\t\t\t\t\t\taria-controls=\"col2\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tHow does the point system work?\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t</h2>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tid=\"col2\"\n\t\t\t\t\t\t\t\tclass=\"accordion-collapse collapse\"\n\t\t\t\t\t\t\t\tdata-bs-parent=\"#SpamAnalysisAboutAccordion\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<div class=\"accordion-body\">\n\t\t\t\t\t\t\t\t\t<p>\n\t\t\t\t\t\t\t\t\t\tThe default spam threshold is <code>5</code>, meaning any score lower than 5 is\n\t\t\t\t\t\t\t\t\t\tconsidered ham (not spam), and any score of 5 or above is spam.\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t<p>\n\t\t\t\t\t\t\t\t\t\tSpamAssassin will also return the tests which are triggered by the message.\n\t\t\t\t\t\t\t\t\t\tThese tests can differ depending on the configuration of your SpamAssassin\n\t\t\t\t\t\t\t\t\t\tserver. The total of this score makes up the the \"spamminess\" of the message.\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div class=\"accordion-item\">\n\t\t\t\t\t\t\t<h2 class=\"accordion-header\">\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tclass=\"accordion-button collapsed\"\n\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\tdata-bs-toggle=\"collapse\"\n\t\t\t\t\t\t\t\t\tdata-bs-target=\"#col3\"\n\t\t\t\t\t\t\t\t\taria-expanded=\"false\"\n\t\t\t\t\t\t\t\t\taria-controls=\"col3\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tBut I don't agree with the results...\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t</h2>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tid=\"col3\"\n\t\t\t\t\t\t\t\tclass=\"accordion-collapse collapse\"\n\t\t\t\t\t\t\t\tdata-bs-parent=\"#SpamAnalysisAboutAccordion\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<div class=\"accordion-body\">\n\t\t\t\t\t\t\t\t\t<p>\n\t\t\t\t\t\t\t\t\t\tMailpit does not manipulate the results nor determine the \"spamminess\" of your\n\t\t\t\t\t\t\t\t\t\tmessage. The result is what SpamAssassin returns, and it entirely dependent on\n\t\t\t\t\t\t\t\t\t\thow SpamAssassin is set up and optionally trained.\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t<p>\n\t\t\t\t\t\t\t\t\t\tThis tool is simply provided as an aid to assist you. If you are running your\n\t\t\t\t\t\t\t\t\t\town instance of SpamAssassin, then you look into your SpamAssassin\n\t\t\t\t\t\t\t\t\t\tconfiguration.\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div class=\"accordion-item\">\n\t\t\t\t\t\t\t<h2 class=\"accordion-header\">\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tclass=\"accordion-button collapsed\"\n\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\tdata-bs-toggle=\"collapse\"\n\t\t\t\t\t\t\t\t\tdata-bs-target=\"#col4\"\n\t\t\t\t\t\t\t\t\taria-expanded=\"false\"\n\t\t\t\t\t\t\t\t\taria-controls=\"col4\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tWhere can I find more information about the triggered rules?\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t</h2>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tid=\"col4\"\n\t\t\t\t\t\t\t\tclass=\"accordion-collapse collapse\"\n\t\t\t\t\t\t\t\tdata-bs-parent=\"#SpamAnalysisAboutAccordion\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<div class=\"accordion-body\">\n\t\t\t\t\t\t\t\t\t<p>\n\t\t\t\t\t\t\t\t\t\tUnfortunately the current\n\t\t\t\t\t\t\t\t\t\t<a href=\"https://spamassassin.apache.org/\" target=\"_blank\"\n\t\t\t\t\t\t\t\t\t\t\t>SpamAssassin website</a\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\tno longer contains any relative documentation about these, most likely because\n\t\t\t\t\t\t\t\t\t\tthe rules come from different locations and change often. You will need to\n\t\t\t\t\t\t\t\t\t\tsearch the internet for these yourself.\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"modal-footer\">\n\t\t\t\t\t<button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t</div>\n</template>\n"
  },
  {
    "path": "server/ui-src/docs.js",
    "content": "import \"rapidoc\";\n"
  },
  {
    "path": "server/ui-src/mixins/CommonMixins.js",
    "content": "import axios from \"axios\";\nimport dayjs from \"dayjs\";\nimport ColorHash from \"color-hash\";\nimport { Modal, Offcanvas } from \"bootstrap\";\nimport { limitOptions } from \"../stores/pagination\";\n\n// BootstrapElement is used to return a fake Bootstrap element\n// if the ID returns nothing to prevent errors.\nclass BootstrapElement {\n\thide() {}\n\tshow() {}\n}\n\n// Set up the color hash generator lightness and hue to ensure darker colors\nconst colorHash = new ColorHash({ lightness: 0.3, saturation: [0.35, 0.5, 0.65] });\n\n/* Common mixin functions used in apps */\nexport default {\n\tdata() {\n\t\treturn {\n\t\t\tloading: 0,\n\t\t\ttagColorCache: {},\n\t\t\tcopiedText: {}, // used for clipboard copy feedback\n\t\t};\n\t},\n\n\tcomputed: {\n\t\tcopyToClipboardSupported() {\n\t\t\treturn !!navigator.clipboard;\n\t\t},\n\t},\n\n\tmethods: {\n\t\tresolve(u) {\n\t\t\treturn this.$router.resolve(u).href;\n\t\t},\n\n\t\tsearchURI(s) {\n\t\t\treturn this.resolve(\"/search\") + \"?q=\" + encodeURIComponent(s);\n\t\t},\n\n\t\tgetFileSize(bytes) {\n\t\t\tif (bytes === 0) {\n\t\t\t\treturn \"0B\";\n\t\t\t}\n\t\t\tconst i = Math.floor(Math.log(bytes) / Math.log(1024));\n\t\t\treturn (bytes / Math.pow(1024, i)).toFixed(1) * 1 + \" \" + [\"B\", \"kB\", \"MB\", \"GB\", \"TB\"][i];\n\t\t},\n\n\t\tformatNumber(nr) {\n\t\t\treturn new Intl.NumberFormat().format(nr);\n\t\t},\n\n\t\tmessageDate(d) {\n\t\t\treturn dayjs(d).format(\"ddd, D MMM YYYY, h:mm a\");\n\t\t},\n\n\t\tsecondsToRelative(d) {\n\t\t\treturn dayjs().subtract(d, \"seconds\").fromNow();\n\t\t},\n\n\t\ttagEncodeURI(tag) {\n\t\t\tif (tag.match(/ /)) {\n\t\t\t\ttag = `\"${tag}\"`;\n\t\t\t}\n\n\t\t\treturn encodeURIComponent(`tag:${tag}`);\n\t\t},\n\n\t\tgetSearch() {\n\t\t\tif (!window.location.search) {\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\tconst urlParams = new URLSearchParams(window.location.search);\n\t\t\tconst q = urlParams.get(\"q\")?.trim();\n\t\t\tif (!q) {\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\treturn q;\n\t\t},\n\n\t\tgetPaginationParams() {\n\t\t\tif (!window.location.search) {\n\t\t\t\treturn null;\n\t\t\t}\n\n\t\t\tconst urlParams = new URLSearchParams(window.location.search);\n\t\t\tconst start = parseInt(urlParams.get(\"start\")?.trim(), 10);\n\t\t\tconst limit = parseInt(urlParams.get(\"limit\")?.trim(), 10);\n\t\t\treturn {\n\t\t\t\tstart: Number.isInteger(start) && start >= 0 ? start : null,\n\t\t\t\tlimit: limitOptions.includes(limit) ? limit : null,\n\t\t\t};\n\t\t},\n\n\t\t// generic modal get/set function\n\t\tmodal(id) {\n\t\t\tconst e = document.getElementById(id);\n\t\t\tif (e) {\n\t\t\t\treturn Modal.getOrCreateInstance(e);\n\t\t\t}\n\t\t\t// in case there are open/close actions\n\t\t\treturn new BootstrapElement();\n\t\t},\n\n\t\t// close mobile navigation\n\t\thideNav() {\n\t\t\tconst e = document.getElementById(\"offcanvas\");\n\t\t\tif (e) {\n\t\t\t\tOffcanvas.getOrCreateInstance(e).hide();\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Axios GET request\n\t\t *\n\t\t * @params string   url\n\t\t * @params array    array parameters Object/array\n\t\t * @params function callback function\n\t\t * @params function error callback function\n\t\t */\n\t\tget(url, values, callback, errorCallback, hideLoader) {\n\t\t\tif (!hideLoader) {\n\t\t\t\tthis.loading++;\n\t\t\t}\n\t\t\taxios\n\t\t\t\t.get(url, { params: values })\n\t\t\t\t.then(callback)\n\t\t\t\t.catch((err) => {\n\t\t\t\t\tif (typeof errorCallback === \"function\") {\n\t\t\t\t\t\treturn errorCallback(err);\n\t\t\t\t\t}\n\n\t\t\t\t\tthis.handleError(err);\n\t\t\t\t})\n\t\t\t\t.then(() => {\n\t\t\t\t\t// always executed\n\t\t\t\t\tif (!hideLoader && this.loading > 0) {\n\t\t\t\t\t\tthis.loading--;\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t},\n\n\t\t/**\n\t\t * Axios POST request\n\t\t *\n\t\t * @params string   url\n\t\t * @params array    object/array values\n\t\t * @params function callback function\n\t\t */\n\t\tpost(url, data, callback) {\n\t\t\tthis.loading++;\n\t\t\taxios\n\t\t\t\t.post(url, data)\n\t\t\t\t.then(callback)\n\t\t\t\t.catch(this.handleError)\n\t\t\t\t.then(() => {\n\t\t\t\t\t// always executed\n\t\t\t\t\tif (this.loading > 0) {\n\t\t\t\t\t\tthis.loading--;\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t},\n\n\t\t/**\n\t\t * Axios DELETE request (REST only)\n\t\t *\n\t\t * @params string   url\n\t\t * @params array    object/array values\n\t\t * @params function callback function\n\t\t */\n\t\tdelete(url, data, callback) {\n\t\t\tthis.loading++;\n\t\t\taxios\n\t\t\t\t.delete(url, { data })\n\t\t\t\t.then(callback)\n\t\t\t\t.catch(this.handleError)\n\t\t\t\t.then(() => {\n\t\t\t\t\t// always executed\n\t\t\t\t\tif (this.loading > 0) {\n\t\t\t\t\t\tthis.loading--;\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t},\n\n\t\t/**\n\t\t * Axios PUT request (REST only)\n\t\t *\n\t\t * @params string   url\n\t\t * @params array    object/array values\n\t\t * @params function callback function\n\t\t */\n\t\tput(url, data, callback) {\n\t\t\tthis.loading++;\n\t\t\taxios\n\t\t\t\t.put(url, data)\n\t\t\t\t.then(callback)\n\t\t\t\t.catch(this.handleError)\n\t\t\t\t.then(() => {\n\t\t\t\t\t// always executed\n\t\t\t\t\tif (this.loading > 0) {\n\t\t\t\t\t\tthis.loading--;\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t},\n\n\t\t// Ajax error message\n\t\thandleError(error) {\n\t\t\t// handle error\n\t\t\tif (error.response && error.response.data) {\n\t\t\t\t// The request was made and the server responded with a status code\n\t\t\t\t// that falls out of the range of 2xx\n\t\t\t\tif (error.response.data.Error) {\n\t\t\t\t\talert(error.response.data.Error);\n\t\t\t\t} else {\n\t\t\t\t\talert(error.response.data);\n\t\t\t\t}\n\t\t\t} else if (error.request) {\n\t\t\t\t// The request was made but no response was received\n\t\t\t\talert(\"Error sending data to the server. Please try again.\");\n\t\t\t} else {\n\t\t\t\t// Something happened in setting up the request that triggered an Error\n\t\t\t\talert(error.message);\n\t\t\t}\n\t\t},\n\n\t\tallAttachments(message) {\n\t\t\tconst a = [];\n\t\t\tfor (const i in message.Attachments) {\n\t\t\t\tmessage.Attachments[i].ContentDisposition = \"Attachment\";\n\t\t\t\ta.push(message.Attachments[i]);\n\t\t\t}\n\t\t\tfor (const i in message.OtherParts) {\n\t\t\t\tmessage.OtherParts[i].ContentDisposition = \"Other\";\n\t\t\t\ta.push(message.OtherParts[i]);\n\t\t\t}\n\t\t\tfor (const i in message.Inline) {\n\t\t\t\tmessage.Inline[i].ContentDisposition = \"Inline\";\n\t\t\t\ta.push(message.Inline[i]);\n\t\t\t}\n\n\t\t\treturn a.length ? a : false;\n\t\t},\n\n\t\tisImage(a) {\n\t\t\treturn a.ContentType.match(/^image\\//);\n\t\t},\n\n\t\tattachmentIcon(a) {\n\t\t\tconst ext = a.FileName.split(\".\").pop().toLowerCase();\n\n\t\t\tif (a.ContentType.match(/^image\\//)) {\n\t\t\t\treturn \"bi-file-image-fill\";\n\t\t\t}\n\t\t\tif (a.ContentType.match(/\\/pdf$/) || ext === \"pdf\") {\n\t\t\t\treturn \"bi-file-pdf-fill\";\n\t\t\t}\n\t\t\tif ([\"doc\", \"docx\", \"odt\", \"rtf\"].includes(ext)) {\n\t\t\t\treturn \"bi-file-word-fill\";\n\t\t\t}\n\t\t\tif ([\"xls\", \"xlsx\", \"ods\"].includes(ext)) {\n\t\t\t\treturn \"bi-file-spreadsheet-fill\";\n\t\t\t}\n\t\t\tif ([\"ppt\", \"pptx\", \"key\", \"ppt\", \"odp\"].includes(ext)) {\n\t\t\t\treturn \"bi-file-slides-fill\";\n\t\t\t}\n\t\t\tif ([\"zip\", \"tar\", \"rar\", \"bz2\", \"gz\", \"xz\"].includes(ext)) {\n\t\t\t\treturn \"bi-file-zip-fill\";\n\t\t\t}\n\t\t\tif ([\"ics\"].includes(ext)) {\n\t\t\t\treturn \"bi-calendar-event\";\n\t\t\t}\n\t\t\tif (a.ContentType.match(/^audio\\//)) {\n\t\t\t\treturn \"bi-file-music-fill\";\n\t\t\t}\n\t\t\tif (a.ContentType.match(/^video\\//)) {\n\t\t\t\treturn \"bi-file-play-fill\";\n\t\t\t}\n\t\t\tif (a.ContentType.match(/\\/calendar$/)) {\n\t\t\t\treturn \"bi-file-check-fill\";\n\t\t\t}\n\t\t\tif (a.ContentType.match(/^text\\//) || [\"txt\", \"sh\", \"log\"].includes(ext)) {\n\t\t\t\treturn \"bi-file-text-fill\";\n\t\t\t}\n\n\t\t\treturn \"bi-file-arrow-down-fill\";\n\t\t},\n\n\t\t// Returns a hex color based on a string.\n\t\t// Values are stored in an array for faster lookup / processing.\n\t\tcolorHash(s) {\n\t\t\tif (this.tagColorCache[s] !== undefined) {\n\t\t\t\treturn this.tagColorCache[s];\n\t\t\t}\n\t\t\tthis.tagColorCache[s] = colorHash.hex(s);\n\n\t\t\treturn this.tagColorCache[s];\n\t\t},\n\n\t\t// Copy to clipboard functionality\n\t\tcopyToClipboard(text) {\n\t\t\tnavigator.clipboard.writeText(text).then(\n\t\t\t\t() => {\n\t\t\t\t\tthis.copiedText[text] = true;\n\t\t\t\t\tsetTimeout(() => {\n\t\t\t\t\t\tdelete this.copiedText[text];\n\t\t\t\t\t}, 2000);\n\t\t\t\t},\n\t\t\t\t() => {\n\t\t\t\t\t// failure\n\t\t\t\t\talert(\"Failed to copy to clipboard\");\n\t\t\t\t},\n\t\t\t);\n\t\t},\n\t},\n};\n"
  },
  {
    "path": "server/ui-src/mixins/MessagesMixins.js",
    "content": "import CommonMixins from \"./CommonMixins.js\";\nimport { mailbox } from \"../stores/mailbox.js\";\nimport { pagination } from \"../stores/pagination.js\";\n\nexport default {\n\tmixins: [CommonMixins],\n\n\tdata() {\n\t\treturn {\n\t\t\tapiURI: false,\n\t\t\tpagination,\n\t\t\tmailbox,\n\t\t};\n\t},\n\n\twatch: {\n\t\t\"mailbox.refresh\"(v) {\n\t\t\tif (v) {\n\t\t\t\t// trigger a refresh\n\t\t\t\tthis.loadMessages();\n\t\t\t}\n\t\t\tmailbox.refresh = false;\n\t\t},\n\t},\n\n\tmethods: {\n\t\treloadMailbox() {\n\t\t\tpagination.start = 0;\n\t\t\tthis.loadMessages();\n\t\t},\n\n\t\tloadMessages() {\n\t\t\tif (!this.apiURI) {\n\t\t\t\talert(\"apiURL not set!\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// auto-pagination changes the URL but should not fetch new messages\n\t\t\t// when viewing page > 0 and new messages are received (inbox only)\n\t\t\tif (!mailbox.autoPaginating) {\n\t\t\t\tmailbox.autoPaginating = true; // reset\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst params = {};\n\t\t\tmailbox.selected = [];\n\n\t\t\tparams.limit = pagination.limit;\n\t\t\tif (pagination.start > 0) {\n\t\t\t\tparams.start = pagination.start;\n\t\t\t}\n\n\t\t\tthis.get(this.apiURI, params, (response) => {\n\t\t\t\tmailbox.total = response.data.total; // all messages\n\t\t\t\tmailbox.unread = response.data.unread; // all unread messages\n\t\t\t\tmailbox.tags = response.data.tags; // all tags\n\t\t\t\tmailbox.messages = response.data.messages; // current messages\n\t\t\t\tmailbox.count = response.data.messages_count; // total results for this mailbox/search\n\t\t\t\tmailbox.messages_unread = response.data.messages_unread; // total unread results for this mailbox/search\n\t\t\t\t// ensure the pagination remains consistent\n\t\t\t\tpagination.start = response.data.start;\n\n\t\t\t\tif (response.data.count === 0 && response.data.start > 0) {\n\t\t\t\t\tpagination.start = 0;\n\t\t\t\t\treturn this.loadMessages();\n\t\t\t\t}\n\n\t\t\t\tif (mailbox.lastMessage) {\n\t\t\t\t\twindow.setTimeout(() => {\n\t\t\t\t\t\tconst m = document.getElementById(mailbox.lastMessage);\n\t\t\t\t\t\tif (m) {\n\t\t\t\t\t\t\tm.focus();\n\t\t\t\t\t\t\t// m.scrollIntoView({ behavior: 'smooth', block: 'center' })\n\t\t\t\t\t\t\tm.scrollIntoView({ block: \"center\" });\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tconst mp = document.getElementById(\"message-page\");\n\t\t\t\t\t\t\tif (mp) {\n\t\t\t\t\t\t\t\tmp.scrollTop = 0;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tmailbox.lastMessage = false;\n\t\t\t\t\t}, 50);\n\t\t\t\t} else if (!window.scrollInPlace) {\n\t\t\t\t\tconst mp = document.getElementById(\"message-page\");\n\t\t\t\t\tif (mp) {\n\t\t\t\t\t\tmp.scrollTop = 0;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\twindow.scrollInPlace = false;\n\t\t\t});\n\t\t},\n\t},\n};\n"
  },
  {
    "path": "server/ui-src/router/index.js",
    "content": "import { createRouter, createWebHistory } from \"vue-router\";\nimport MailboxView from \"../views/MailboxView.vue\";\nimport MessageView from \"../views/MessageView.vue\";\nimport NotFoundView from \"../views/NotFoundView.vue\";\nimport SearchView from \"../views/SearchView.vue\";\n\nconst d = document.getElementById(\"app\");\nlet webroot = \"/\";\nif (d) {\n\twebroot = d.dataset.webroot;\n}\n\n// paths are relative to webroot\nconst router = createRouter({\n\thistory: createWebHistory(webroot),\n\troutes: [\n\t\t{\n\t\t\tpath: \"/\",\n\t\t\tcomponent: MailboxView,\n\t\t},\n\t\t{\n\t\t\tpath: \"/search\",\n\t\t\tcomponent: SearchView,\n\t\t},\n\t\t{\n\t\t\tpath: \"/view/:id\",\n\t\t\tcomponent: MessageView,\n\t\t},\n\t\t{\n\t\t\tpath: \"/:pathMatch(.*)*\",\n\t\t\tname: \"NotFound\",\n\t\t\tcomponent: NotFoundView,\n\t\t},\n\t],\n});\n\nexport default router;\n"
  },
  {
    "path": "server/ui-src/stores/mailbox.js",
    "content": "// State Management\n\nimport { reactive, watch } from \"vue\";\n\n// Parse and validate a string[] from localStorage, returning [] on any invalid value.\nconst storageToStringArray = (key) => {\n\ttry {\n\t\tconst raw = localStorage.getItem(key);\n\t\tif (!raw) return [];\n\t\tconst parsed = JSON.parse(raw);\n\t\tif (Array.isArray(parsed) && parsed.every((v) => typeof v === \"string\")) {\n\t\t\treturn parsed;\n\t\t}\n\t} catch {\n\t\t// ignore malformed JSON\n\t}\n\treturn [];\n};\n\n// global mailbox info\nexport const mailbox = reactive({\n\ttotal: 0, // total number of messages in database\n\tunread: 0, // total unread messages in database\n\tcount: 0, // total in mailbox or search\n\tmessages: [], // current messages\n\ttags: [], // all tags\n\tselected: [], // currently selected\n\tconnected: false, // websocket connection\n\tsearching: false, // current search, false for none\n\trefresh: false, // to listen from MessagesMixin\n\tautoPaginating: true, // allows temporary bypass of loadMessages() via auto-pagination\n\tnotificationsSupported: false, // browser supports notifications\n\tnotificationsEnabled: false, // user has enabled notifications\n\tskipConfirmations: false, // skip modal confirmations for \"Delete all\" & \"mark all read\"\n\tappInfo: {}, // application information\n\tuiConfig: {}, // configuration for UI\n\tlastMessage: false, // return scrolling\n\tdefaultReleaseAddresses: storageToStringArray(\"defaultReleaseAddresses\"), // default release addresses for released messages\n\n\t// settings\n\tshowTagColors: !localStorage.getItem(\"hideTagColors\"),\n\tshowHTMLCheck: !localStorage.getItem(\"hideHTMLCheck\"),\n\tshowLinkCheck: !localStorage.getItem(\"hideLinkCheck\"),\n\tshowSpamCheck: !localStorage.getItem(\"hideSpamCheck\"),\n\ttimeZone: localStorage.getItem(\"timeZone\")\n\t\t? localStorage.getItem(\"timeZone\")\n\t\t: Intl.DateTimeFormat().resolvedOptions().timeZone,\n\tshowAttachmentDetails: localStorage.getItem(\"showAttachmentDetails\"), // show attachment details\n});\n\nwatch(\n\t() => mailbox.count,\n\t() => {\n\t\tmailbox.selected = [];\n\t},\n);\n\nwatch(\n\t() => mailbox.showTagColors,\n\t(v) => {\n\t\tif (v) {\n\t\t\tlocalStorage.removeItem(\"hideTagColors\");\n\t\t} else {\n\t\t\tlocalStorage.setItem(\"hideTagColors\", \"1\");\n\t\t}\n\t},\n);\n\nwatch(\n\t() => mailbox.showHTMLCheck,\n\t(v) => {\n\t\tif (v) {\n\t\t\tlocalStorage.removeItem(\"hideHTMLCheck\");\n\t\t} else {\n\t\t\tlocalStorage.setItem(\"hideHTMLCheck\", \"1\");\n\t\t}\n\t},\n);\n\nwatch(\n\t() => mailbox.showLinkCheck,\n\t(v) => {\n\t\tif (v) {\n\t\t\tlocalStorage.removeItem(\"hideLinkCheck\");\n\t\t} else {\n\t\t\tlocalStorage.setItem(\"hideLinkCheck\", \"1\");\n\t\t}\n\t},\n);\n\nwatch(\n\t() => mailbox.showSpamCheck,\n\t(v) => {\n\t\tif (v) {\n\t\t\tlocalStorage.removeItem(\"hideSpamCheck\");\n\t\t} else {\n\t\t\tlocalStorage.setItem(\"hideSpamCheck\", \"1\");\n\t\t}\n\t},\n);\n\nwatch(\n\t() => mailbox.defaultReleaseAddresses,\n\t(v) => {\n\t\tif (v.length) {\n\t\t\tlocalStorage.setItem(\"defaultReleaseAddresses\", JSON.stringify(v));\n\t\t} else {\n\t\t\tlocalStorage.removeItem(\"defaultReleaseAddresses\");\n\t\t}\n\t},\n);\n\nwatch(\n\t() => mailbox.timeZone,\n\t(v) => {\n\t\tif (v === Intl.DateTimeFormat().resolvedOptions().timeZone) {\n\t\t\tlocalStorage.removeItem(\"timeZone\");\n\t\t} else {\n\t\t\tlocalStorage.setItem(\"timeZone\", v);\n\t\t}\n\t},\n);\n\nwatch(\n\t() => mailbox.showAttachmentDetails,\n\t(v) => {\n\t\tif (v) {\n\t\t\tlocalStorage.setItem(\"showAttachmentDetails\", \"1\");\n\t\t} else {\n\t\t\tlocalStorage.removeItem(\"showAttachmentDetails\");\n\t\t}\n\t},\n);\n"
  },
  {
    "path": "server/ui-src/stores/pagination.js",
    "content": "import { reactive } from \"vue\";\n\nexport const pagination = reactive({\n\tstart: 0, // pagination offset\n\tlimit: 50, // per page\n\tdefaultLimit: 50, // used to shorten URL's if current limit == defaultLimit\n\ttotal: 0, // total results of current view / filter\n\tcount: 0, // number of messages currently displayed\n});\n\nexport const limitOptions = [25, 50, 100, 200];\n"
  },
  {
    "path": "server/ui-src/views/MailboxView.vue",
    "content": "<script>\nimport About from \"../components/AppAbout.vue\";\nimport AjaxLoader from \"../components/AjaxLoader.vue\";\nimport CommonMixins from \"../mixins/CommonMixins\";\nimport ListMessages from \"../components/ListMessages.vue\";\nimport MessagesMixins from \"../mixins/MessagesMixins\";\nimport NavMailbox from \"../components/NavMailbox.vue\";\nimport NavTags from \"../components/NavTags.vue\";\nimport Pagination from \"../components/NavPagination.vue\";\nimport SearchForm from \"../components/SearchForm.vue\";\nimport { mailbox } from \"../stores/mailbox\";\nimport { pagination } from \"../stores/pagination\";\n\nexport default {\n\tcomponents: {\n\t\tAbout,\n\t\tAjaxLoader,\n\t\tListMessages,\n\t\tNavMailbox,\n\t\tNavTags,\n\t\tPagination,\n\t\tSearchForm,\n\t},\n\n\tmixins: [CommonMixins, MessagesMixins],\n\n\t// global event bus to handle message status changes\n\tinject: [\"eventBus\"],\n\n\tdata() {\n\t\treturn {\n\t\t\tmailbox,\n\t\t\tdelayedRefresh: false,\n\t\t\tpaginationDelayed: false, // for delayed pagination URL changes\n\t\t};\n\t},\n\n\twatch: {\n\t\t$route() {\n\t\t\tthis.loadMailbox();\n\t\t},\n\t},\n\n\tmounted() {\n\t\tmailbox.searching = false;\n\t\tthis.apiURI = this.resolve(`/api/v1/messages`);\n\t\tthis.loadMailbox();\n\n\t\t// subscribe to events\n\t\tthis.eventBus.on(\"new\", this.handleWSNew);\n\t\tthis.eventBus.on(\"update\", this.handleWSUpdate);\n\t\tthis.eventBus.on(\"delete\", this.handleWSDelete);\n\t\tthis.eventBus.on(\"truncate\", this.handleWSTruncate);\n\t},\n\n\tunmounted() {\n\t\t// unsubscribe from events\n\t\tthis.eventBus.off(\"new\", this.handleWSNew);\n\t\tthis.eventBus.off(\"update\", this.handleWSUpdate);\n\t\tthis.eventBus.off(\"delete\", this.handleWSDelete);\n\t\tthis.eventBus.off(\"truncate\", this.handleWSTruncate);\n\t},\n\n\tmethods: {\n\t\tloadMailbox() {\n\t\t\tconst paginationParams = this.getPaginationParams();\n\t\t\tif (paginationParams?.start) {\n\t\t\t\tpagination.start = paginationParams.start;\n\t\t\t} else {\n\t\t\t\tpagination.start = 0;\n\t\t\t}\n\t\t\tif (paginationParams?.limit) {\n\t\t\t\tpagination.limit = paginationParams.limit;\n\t\t\t}\n\n\t\t\tthis.loadMessages();\n\t\t},\n\n\t\t// This will only update the pagination offset at a maximum of 2x per second\n\t\t// when viewing the inbox on > page 1, while receiving an influx of new messages.\n\t\tdelayedPaginationUpdate() {\n\t\t\tif (this.paginationDelayed) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tthis.paginationDelayed = true;\n\n\t\t\twindow.setTimeout(() => {\n\t\t\t\tconst path = this.$route.path;\n\t\t\t\tconst p = {\n\t\t\t\t\t...this.$route.query,\n\t\t\t\t};\n\t\t\t\tif (pagination.start > 0) {\n\t\t\t\t\tp.start = pagination.start.toString();\n\t\t\t\t} else {\n\t\t\t\t\tdelete p.start;\n\t\t\t\t}\n\t\t\t\tif (pagination.limit !== pagination.defaultLimit) {\n\t\t\t\t\tp.limit = pagination.limit.toString();\n\t\t\t\t} else {\n\t\t\t\t\tdelete p.limit;\n\t\t\t\t}\n\n\t\t\t\tmailbox.autoPaginating = false; // prevent reload of messages when URL changes\n\t\t\t\tconst params = new URLSearchParams(p);\n\t\t\t\tthis.$router.replace(path + \"?\" + params.toString());\n\n\t\t\t\tthis.paginationDelayed = false;\n\t\t\t}, 500);\n\t\t},\n\n\t\t// handler for websocket new messages\n\t\thandleWSNew(data) {\n\t\t\tif (pagination.start < 1) {\n\t\t\t\t// push results directly into first page\n\t\t\t\tmailbox.messages.unshift(data);\n\t\t\t\tif (mailbox.messages.length > pagination.limit) {\n\t\t\t\t\tmailbox.messages.pop();\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// update pagination offset\n\t\t\t\tpagination.start++;\n\t\t\t\t// prevent \"Too many calls to Location or History APIs within a short time frame\"\n\t\t\t\tthis.delayedPaginationUpdate();\n\t\t\t}\n\t\t},\n\n\t\t// handler for websocket message updates\n\t\thandleWSUpdate(data) {\n\t\t\tfor (let x = 0; x < this.mailbox.messages.length; x++) {\n\t\t\t\tif (this.mailbox.messages[x].ID === data.ID) {\n\t\t\t\t\t// update message\n\t\t\t\t\tthis.mailbox.messages[x] = {\n\t\t\t\t\t\t...this.mailbox.messages[x],\n\t\t\t\t\t\t...data,\n\t\t\t\t\t};\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\n\t\t// handler for websocket message deletion\n\t\thandleWSDelete(data) {\n\t\t\tlet removed = 0;\n\t\t\tfor (let x = 0; x < this.mailbox.messages.length; x++) {\n\t\t\t\tif (this.mailbox.messages[x].ID === data.ID) {\n\t\t\t\t\t// remove message from the list\n\t\t\t\t\tthis.mailbox.messages.splice(x, 1);\n\t\t\t\t\tremoved++;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (!removed || this.delayedRefresh) {\n\t\t\t\t// nothing changed on this screen, or a refresh is queued,\n\t\t\t\t// don't refresh\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// delayedRefresh prevents unnecessary reloads when multiple messages are deleted\n\t\t\tthis.delayedRefresh = true;\n\n\t\t\twindow.setTimeout(() => {\n\t\t\t\tthis.delayedRefresh = false;\n\t\t\t\tthis.loadMessages();\n\t\t\t}, 500);\n\t\t},\n\n\t\t// handler for websocket message truncation\n\t\thandleWSTruncate() {\n\t\t\t// all messages gone, reload\n\t\t\tthis.loadMessages();\n\t\t},\n\t},\n};\n</script>\n\n<template>\n\t<div class=\"navbar navbar-expand-lg row flex-shrink-0 bg-primary text-white d-print-none\" data-bs-theme=\"dark\">\n\t\t<div class=\"col-xl-2 col-md-3 col-auto pe-0\">\n\t\t\t<RouterLink to=\"/\" class=\"navbar-brand text-white me-0\" @click=\"reloadMailbox\">\n\t\t\t\t<img :src=\"resolve('/mailpit.svg')\" alt=\"Mailpit\" />\n\t\t\t\t<span class=\"ms-2 d-none d-sm-inline\">Mailpit</span>\n\t\t\t</RouterLink>\n\t\t</div>\n\t\t<div class=\"col col-md-4k col-lg-5 col-xl-6\">\n\t\t\t<SearchForm />\n\t\t</div>\n\t\t<div class=\"col-12 col-md-auto col-lg-4 col-xl-4 text-end mt-2 mt-md-0\">\n\t\t\t<div class=\"float-start d-md-none\">\n\t\t\t\t<button\n\t\t\t\t\tclass=\"btn btn-outline-light me-2\"\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tdata-bs-toggle=\"offcanvas\"\n\t\t\t\t\tdata-bs-target=\"#offcanvas\"\n\t\t\t\t\taria-controls=\"offcanvas\"\n\t\t\t\t>\n\t\t\t\t\t<i class=\"bi bi-list\"></i>\n\t\t\t\t</button>\n\t\t\t</div>\n\t\t\t<Pagination :total=\"mailbox.total\" />\n\t\t</div>\n\t</div>\n\n\t<div\n\t\tid=\"offcanvas\"\n\t\tclass=\"offcanvas-md offcanvas-start d-md-none\"\n\t\tdata-bs-scroll=\"true\"\n\t\ttabindex=\"-1\"\n\t\taria-labelledby=\"offcanvasLabel\"\n\t>\n\t\t<div class=\"offcanvas-header\">\n\t\t\t<h5 id=\"offcanvasLabel\" class=\"offcanvas-title\">Mailpit</h5>\n\t\t\t<button\n\t\t\t\ttype=\"button\"\n\t\t\t\tclass=\"btn-close\"\n\t\t\t\tdata-bs-dismiss=\"offcanvas\"\n\t\t\t\tdata-bs-target=\"#offcanvas\"\n\t\t\t\taria-label=\"Close\"\n\t\t\t></button>\n\t\t</div>\n\t\t<div class=\"offcanvas-body pb-0\">\n\t\t\t<div class=\"d-flex flex-column h-100\">\n\t\t\t\t<div class=\"flex-grow-1 overflow-y-auto me-n3 pe-3\">\n\t\t\t\t\t<NavMailbox @load-messages=\"loadMessages\" />\n\t\t\t\t\t<NavTags />\n\t\t\t\t</div>\n\t\t\t\t<About />\n\t\t\t</div>\n\t\t</div>\n\t</div>\n\n\t<div class=\"row flex-fill\" style=\"min-height: 0\">\n\t\t<div class=\"d-none d-md-flex h-100 col-xl-2 col-md-3 flex-column\">\n\t\t\t<div class=\"flex-grow-1 overflow-y-auto me-n3 pe-3\">\n\t\t\t\t<NavMailbox @load-messages=\"loadMessages\" />\n\t\t\t\t<NavTags />\n\t\t\t</div>\n\t\t\t<About />\n\t\t</div>\n\n\t\t<div class=\"col-xl-10 col-md-9 mh-100 ps-0 ps-md-2 pe-0\">\n\t\t\t<div id=\"message-page\" class=\"mh-100\" style=\"overflow-y: auto\">\n\t\t\t\t<ListMessages :loading-messages=\"loading\" />\n\t\t\t</div>\n\t\t</div>\n\t</div>\n\n\t<NavMailbox modals @load-messages=\"loadMessages\" />\n\t<About modals />\n\t<AjaxLoader :loading=\"loading\" />\n</template>\n"
  },
  {
    "path": "server/ui-src/views/MessageView.vue",
    "content": "<script>\nimport AboutMailpit from \"../components/AppAbout.vue\";\nimport AjaxLoader from \"../components/AjaxLoader.vue\";\nimport CommonMixins from \"../mixins/CommonMixins\";\nimport Message from \"../components/message/MessageItem.vue\";\nimport Release from \"../components/message/MessageRelease.vue\";\nimport Screenshot from \"../components/message/MessageScreenshot.vue\";\nimport { mailbox } from \"../stores/mailbox\";\nimport { pagination } from \"../stores/pagination\";\nimport dayjs from \"dayjs\";\n\nexport default {\n\tcomponents: {\n\t\tAboutMailpit,\n\t\tAjaxLoader,\n\t\tMessage,\n\t\tScreenshot,\n\t\tRelease,\n\t},\n\n\tmixins: [CommonMixins],\n\n\t// global event bus to handle message status changes\n\tinject: [\"eventBus\"],\n\n\tdata() {\n\t\treturn {\n\t\t\tmailbox,\n\t\t\tpagination,\n\t\t\tmessage: false,\n\t\t\tloadReleaseModal: false,\n\t\t\terrorMessage: false,\n\t\t\tapiSideNavURI: false,\n\t\t\tapiSideNavParams: URLSearchParams,\n\t\t\tapiIsMore: true,\n\t\t\tmessagesList: [],\n\t\t\tliveLoaded: 0, // the number new messages prepended tp messageList\n\t\t\tscrollLoading: false,\n\t\t\tcanLoadMore: true,\n\t\t};\n\t},\n\n\tcomputed: {\n\t\t// get current message read status\n\t\tisRead() {\n\t\t\tconst l = this.messagesList.length;\n\t\t\tif (!this.message || !l) {\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\tfor (let x = 0; x < l; x++) {\n\t\t\t\tif (this.messagesList[x].ID === this.message.ID) {\n\t\t\t\t\treturn this.messagesList[x].Read;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn true;\n\t\t},\n\n\t\t// get the previous message ID\n\t\tpreviousID() {\n\t\t\tconst l = this.messagesList.length;\n\t\t\tif (!this.message || !l) {\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\tlet id = false;\n\t\t\tfor (let x = 0; x < l; x++) {\n\t\t\t\tif (this.messagesList[x].ID === this.message.ID) {\n\t\t\t\t\treturn id;\n\t\t\t\t}\n\t\t\t\tid = this.messagesList[x].ID;\n\t\t\t}\n\n\t\t\treturn false;\n\t\t},\n\n\t\t// get the next message ID\n\t\tnextID() {\n\t\t\tconst l = this.messagesList.length;\n\t\t\tif (!this.message || !l) {\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\tlet id = false;\n\t\t\tfor (let x = l - 1; x > 0; x--) {\n\t\t\t\tif (this.messagesList[x].ID === this.message.ID) {\n\t\t\t\t\treturn id;\n\t\t\t\t}\n\t\t\t\tid = this.messagesList[x].ID;\n\t\t\t}\n\n\t\t\treturn id;\n\t\t},\n\t},\n\n\twatch: {\n\t\t$route() {\n\t\t\tthis.loadMessage();\n\t\t},\n\t},\n\n\tcreated() {\n\t\tconst relativeTime = require(\"dayjs/plugin/relativeTime\");\n\t\tdayjs.extend(relativeTime);\n\n\t\tthis.initLoadMoreAPIParams();\n\t},\n\n\tmounted() {\n\t\tthis.loadMessage();\n\n\t\tthis.messagesList = JSON.parse(JSON.stringify(this.mailbox.messages));\n\t\tif (!this.messagesList.length) {\n\t\t\tthis.loadMore();\n\t\t}\n\n\t\tthis.refreshUI();\n\n\t\t// subscribe to events\n\t\tthis.eventBus.on(\"new\", this.handleWSNew);\n\t\tthis.eventBus.on(\"update\", this.handleWSUpdate);\n\t\tthis.eventBus.on(\"delete\", this.handleWSDelete);\n\t\tthis.eventBus.on(\"truncate\", this.handleWSTruncate);\n\t},\n\n\tunmounted() {\n\t\t// unsubscribe from events\n\t\tthis.eventBus.off(\"new\", this.handleWSNew);\n\t\tthis.eventBus.off(\"update\", this.handleWSUpdate);\n\t\tthis.eventBus.off(\"delete\", this.handleWSDelete);\n\t\tthis.eventBus.off(\"truncate\", this.handleWSTruncate);\n\t},\n\n\tmethods: {\n\t\tloadMessage() {\n\t\t\tthis.message = false;\n\t\t\tconst uri = this.resolve(\"/api/v1/message/\" + this.$route.params.id);\n\t\t\tthis.get(\n\t\t\t\turi,\n\t\t\t\tfalse,\n\t\t\t\t(response) => {\n\t\t\t\t\tthis.errorMessage = false;\n\t\t\t\t\tconst d = response.data;\n\n\t\t\t\t\t// update read status in case websockets is not working\n\t\t\t\t\tthis.handleWSUpdate({ ID: d.ID, Read: true });\n\n\t\t\t\t\t// replace inline images embedded as inline attachments\n\t\t\t\t\tif (d.HTML && d.Inline) {\n\t\t\t\t\t\tfor (const i in d.Inline) {\n\t\t\t\t\t\t\tconst a = d.Inline[i];\n\t\t\t\t\t\t\tif (a.ContentID !== \"\") {\n\t\t\t\t\t\t\t\tconst escapedCID = a.ContentID.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n\t\t\t\t\t\t\t\td.HTML = d.HTML.replace(\n\t\t\t\t\t\t\t\t\tnew RegExp(\"(=[\\\"']?)(cid:\" + escapedCID + \")([\\\"'|\\\\s|\\\\/|>|;])\", \"g\"),\n\t\t\t\t\t\t\t\t\t\"$1\" + this.resolve(\"/api/v1/message/\" + d.ID + \"/part/\" + a.PartID) + \"$3\",\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif (a.FileName.match(/^[a-zA-Z0-9_\\-.]+$/)) {\n\t\t\t\t\t\t\t\t// some old email clients use the filename\n\t\t\t\t\t\t\t\td.HTML = d.HTML.replace(\n\t\t\t\t\t\t\t\t\tnew RegExp(\"(=[\\\"']?)(\" + a.FileName + \")([\\\"|'|\\\\s|\\\\/|>|;])\", \"g\"),\n\t\t\t\t\t\t\t\t\t\"$1\" + this.resolve(\"/api/v1/message/\" + d.ID + \"/part/\" + a.PartID) + \"$3\",\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\t// replace inline images embedded as regular attachments\n\t\t\t\t\tif (d.HTML && d.Attachments) {\n\t\t\t\t\t\tfor (const i in d.Attachments) {\n\t\t\t\t\t\t\tconst a = d.Attachments[i];\n\t\t\t\t\t\t\tif (a.ContentID !== \"\") {\n\t\t\t\t\t\t\t\tconst escapedCID = a.ContentID.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n\t\t\t\t\t\t\t\td.HTML = d.HTML.replace(\n\t\t\t\t\t\t\t\t\tnew RegExp(\"(=[\\\"']?)(cid:\" + escapedCID + \")([\\\"'|\\\\s|\\\\/|>|;])\", \"g\"),\n\t\t\t\t\t\t\t\t\t\"$1\" + this.resolve(\"/api/v1/message/\" + d.ID + \"/part/\" + a.PartID) + \"$3\",\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif (a.FileName.match(/^[a-zA-Z0-9_\\-.]+$/)) {\n\t\t\t\t\t\t\t\t// some old email clients use the filename\n\t\t\t\t\t\t\t\td.HTML = d.HTML.replace(\n\t\t\t\t\t\t\t\t\tnew RegExp(\"(=[\\\"']?)(\" + a.FileName + \")([\\\"|'|\\\\s|\\\\/|>|;])\", \"g\"),\n\t\t\t\t\t\t\t\t\t\"$1\" + this.resolve(\"/api/v1/message/\" + d.ID + \"/part/\" + a.PartID) + \"$3\",\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tthis.message = d;\n\n\t\t\t\t\tthis.$nextTick(() => {\n\t\t\t\t\t\tthis.scrollSidebarToCurrent();\n\t\t\t\t\t});\n\t\t\t\t},\n\t\t\t\t(error) => {\n\t\t\t\t\tthis.errorMessage = true;\n\t\t\t\t\tif (error.response && error.response.data) {\n\t\t\t\t\t\tif (error.response.data.Error) {\n\t\t\t\t\t\t\tthis.errorMessage = error.response.data.Error;\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tthis.errorMessage = error.response.data;\n\t\t\t\t\t\t}\n\t\t\t\t\t} else if (error.request) {\n\t\t\t\t\t\t// The request was made but no response was received\n\t\t\t\t\t\tthis.errorMessage = \"Error sending data to the server. Please refresh the page.\";\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Something happened in setting up the request that triggered an Error\n\t\t\t\t\t\tthis.errorMessage = error.message;\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t);\n\t\t},\n\n\t\t// UI refresh ticker to adjust relative times\n\t\trefreshUI() {\n\t\t\twindow.setTimeout(() => {\n\t\t\t\tthis.$forceUpdate();\n\t\t\t\tthis.refreshUI();\n\t\t\t}, 30000);\n\t\t},\n\n\t\t// handler for websocket new messages\n\t\thandleWSNew(data) {\n\t\t\t// do not add when searching or >= 100 new messages have been received\n\t\t\tif (this.mailbox.searching || this.liveLoaded >= 100) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tthis.liveLoaded++;\n\t\t\tthis.messagesList.unshift(data);\n\t\t},\n\n\t\t// handler for websocket message updates\n\t\thandleWSUpdate(data) {\n\t\t\tfor (let x = 0; x < this.messagesList.length; x++) {\n\t\t\t\tif (this.messagesList[x].ID === data.ID) {\n\t\t\t\t\t// update message\n\t\t\t\t\tthis.messagesList[x] = { ...this.messagesList[x], ...data };\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\n\t\t// handler for websocket message deletion\n\t\thandleWSDelete(data) {\n\t\t\tfor (let x = 0; x < this.messagesList.length; x++) {\n\t\t\t\tif (this.messagesList[x].ID === data.ID) {\n\t\t\t\t\t// remove message from the list\n\t\t\t\t\tthis.messagesList.splice(x, 1);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\n\t\t// handler for websocket message truncation\n\t\thandleWSTruncate() {\n\t\t\t// all messages gone, go to inbox\n\t\t\tthis.$router.push(\"/\");\n\t\t},\n\n\t\t// return whether the sidebar is visible\n\t\tsidebarVisible() {\n\t\t\treturn this.$refs.MessageList.offsetParent !== null;\n\t\t},\n\n\t\t// scroll sidenav to current message if found\n\t\tscrollSidebarToCurrent() {\n\t\t\tconst cont = document.getElementById(\"MessageList\");\n\t\t\tif (!cont) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst c = cont.querySelector(\".router-link-active\");\n\t\t\tif (c) {\n\t\t\t\tconst outer = cont.getBoundingClientRect();\n\t\t\t\tconst li = c.getBoundingClientRect();\n\t\t\t\tif (outer.top > li.top || outer.bottom < li.bottom) {\n\t\t\t\t\tc.scrollIntoView({\n\t\t\t\t\t\tbehavior: \"smooth\",\n\t\t\t\t\t\tblock: \"center\",\n\t\t\t\t\t\tinline: \"nearest\",\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\n\t\tscrollHandler(e) {\n\t\t\tif (!this.canLoadMore || this.scrollLoading) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst { scrollTop, offsetHeight, scrollHeight } = e.target;\n\t\t\tif (scrollTop + offsetHeight + 150 >= scrollHeight) {\n\t\t\t\tthis.loadMore();\n\t\t\t}\n\t\t},\n\n\t\tloadMore() {\n\t\t\tif (this.messagesList.length) {\n\t\t\t\t// get last created timestamp\n\t\t\t\tconst oldest = this.messagesList[this.messagesList.length - 1].Created;\n\t\t\t\t// if set append `before=<ts>`\n\t\t\t\tthis.apiSideNavParams.set(\"before\", oldest);\n\t\t\t}\n\n\t\t\tthis.scrollLoading = true;\n\n\t\t\tthis.get(\n\t\t\t\tthis.apiSideNavURI,\n\t\t\t\tthis.apiSideNavParams,\n\t\t\t\t(response) => {\n\t\t\t\t\tif (response.data.messages.length) {\n\t\t\t\t\t\tthis.messagesList.push(...response.data.messages);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tthis.canLoadMore = false;\n\t\t\t\t\t}\n\t\t\t\t\tthis.$nextTick(() => {\n\t\t\t\t\t\tthis.scrollLoading = false;\n\t\t\t\t\t});\n\t\t\t\t},\n\t\t\t\tnull,\n\t\t\t\ttrue,\n\t\t\t);\n\t\t},\n\n\t\tinitLoadMoreAPIParams() {\n\t\t\tlet apiURI = this.resolve(`/api/v1/messages`);\n\t\t\tconst p = {};\n\n\t\t\tif (mailbox.searching) {\n\t\t\t\tapiURI = this.resolve(`/api/v1/search`);\n\t\t\t\tp.query = mailbox.searching;\n\t\t\t}\n\n\t\t\tif (pagination.limit !== pagination.defaultLimit) {\n\t\t\t\tp.limit = pagination.limit.toString();\n\t\t\t}\n\n\t\t\tthis.apiSideNavURI = apiURI;\n\n\t\t\tthis.apiSideNavParams = new URLSearchParams(p);\n\t\t},\n\n\t\tgetRelativeCreated(message) {\n\t\t\tconst d = new Date(message.Created);\n\t\t\treturn dayjs(d).fromNow();\n\t\t},\n\n\t\tgetPrimaryEmailTo(message) {\n\t\t\tif (message.To && message.To.length > 0) {\n\t\t\t\treturn message.To[0].Address;\n\t\t\t}\n\n\t\t\treturn \"[ Undisclosed recipients ]\";\n\t\t},\n\n\t\tisActive(id) {\n\t\t\treturn this.message.ID === id;\n\t\t},\n\n\t\ttoTagUrl(t) {\n\t\t\tif (t.match(/ /)) {\n\t\t\t\tt = `\"${t}\"`;\n\t\t\t}\n\t\t\tconst p = {\n\t\t\t\tq: \"tag:\" + t,\n\t\t\t};\n\t\t\tif (pagination.limit !== pagination.defaultLimit) {\n\t\t\t\tp.limit = pagination.limit.toString();\n\t\t\t}\n\t\t\tconst params = new URLSearchParams(p);\n\t\t\treturn \"/search?\" + params.toString();\n\t\t},\n\n\t\tdownloadMessageBody(str, ext) {\n\t\t\tconst dl = document.createElement(\"a\");\n\t\t\tdl.href = \"data:text/plain,\" + encodeURIComponent(str);\n\t\t\tdl.target = \"_blank\";\n\t\t\tdl.download = this.message.ID + \".\" + ext;\n\t\t\tdl.click();\n\t\t},\n\n\t\tscreenshotMessageHTML() {\n\t\t\tthis.$refs.ScreenshotRef.initScreenshot();\n\t\t},\n\n\t\t// toggle current message read status\n\t\ttoggleRead() {\n\t\t\tif (!this.message) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tconst read = !this.isRead;\n\n\t\t\tconst ids = [this.message.ID];\n\t\t\tconst uri = this.resolve(\"/api/v1/messages\");\n\t\t\tthis.put(uri, { Read: read, IDs: ids }, () => {\n\t\t\t\tif (!this.sidebarVisible()) {\n\t\t\t\t\treturn this.goBack();\n\t\t\t\t}\n\n\t\t\t\t// manually update read status in case websockets is not working\n\t\t\t\tthis.handleWSUpdate({ ID: this.message.ID, Read: read });\n\t\t\t});\n\t\t},\n\n\t\tdeleteMessage() {\n\t\t\tconst ids = [this.message.ID];\n\t\t\tconst uri = this.resolve(\"/api/v1/messages\");\n\t\t\t// calculate next ID before deletion to prevent WS race\n\t\t\tconst goToID = this.nextID ? this.nextID : this.previousID;\n\n\t\t\tthis.delete(uri, { IDs: ids }, () => {\n\t\t\t\tif (!this.sidebarVisible()) {\n\t\t\t\t\treturn this.goBack();\n\t\t\t\t}\n\t\t\t\tif (goToID) {\n\t\t\t\t\treturn this.$router.push(\"/view/\" + goToID);\n\t\t\t\t}\n\n\t\t\t\treturn this.goBack();\n\t\t\t});\n\t\t},\n\n\t\t// return to mailbox or search based on origin\n\t\tgoBack() {\n\t\t\tmailbox.lastMessage = this.$route.params.id;\n\n\t\t\tif (mailbox.searching) {\n\t\t\t\tconst p = {\n\t\t\t\t\tq: mailbox.searching,\n\t\t\t\t};\n\t\t\t\tif (pagination.start > 0) {\n\t\t\t\t\tp.start = pagination.start.toString();\n\t\t\t\t}\n\t\t\t\tif (pagination.limit !== pagination.defaultLimit) {\n\t\t\t\t\tp.limit = pagination.limit.toString();\n\t\t\t\t}\n\t\t\t\tthis.$router.push(\"/search?\" + new URLSearchParams(p).toString());\n\t\t\t} else {\n\t\t\t\tconst p = {};\n\t\t\t\tif (pagination.start > 0) {\n\t\t\t\t\tp.start = pagination.start.toString();\n\t\t\t\t}\n\t\t\t\tif (pagination.limit !== pagination.defaultLimit) {\n\t\t\t\t\tp.limit = pagination.limit.toString();\n\t\t\t\t}\n\t\t\t\tif (p.start || p.limit) {\n\t\t\t\t\tthis.$router.push(\"/?\" + new URLSearchParams(p).toString());\n\t\t\t\t} else {\n\t\t\t\t\tthis.$router.push(\"/\");\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\n\t\treloadWindow() {\n\t\t\tlocation.reload();\n\t\t},\n\n\t\tinitReleaseModal() {\n\t\t\t// reset releaseMessage to force re-render so default release addresses can be included\n\t\t\tthis.loadReleaseModal = false;\n\t\t\tthis.$nextTick(() => {\n\t\t\t\tthis.loadReleaseModal = true;\n\t\t\t\tthis.$nextTick(() => {\n\t\t\t\t\tthis.modal(\"ReleaseModal\").show();\n\t\t\t\t\twindow.setTimeout(() => {\n\t\t\t\t\t\t// delay to allow elements to load / focus\n\t\t\t\t\t\tthis.$refs.ReleaseRef.initTags();\n\t\t\t\t\t}, 250);\n\t\t\t\t});\n\t\t\t});\n\t\t},\n\t},\n};\n</script>\n\n<template>\n\t<div class=\"navbar navbar-expand-lg row flex-shrink-0 bg-primary text-white d-print-none\" data-bs-theme=\"dark\">\n\t\t<div class=\"d-none d-xl-block col-xl-3 col-auto pe-0\">\n\t\t\t<RouterLink to=\"/\" class=\"navbar-brand text-white me-0\" @click=\"pagination.start = 0\">\n\t\t\t\t<img :src=\"resolve('/mailpit.svg')\" alt=\"Mailpit\" />\n\t\t\t\t<span class=\"ms-2 d-none d-sm-inline\">Mailpit</span>\n\t\t\t</RouterLink>\n\t\t</div>\n\t\t<div v-if=\"!errorMessage\" class=\"col col-xl-5\">\n\t\t\t<button class=\"btn btn-outline-light me-3 d-xl-none\" title=\"Return to messages\" @click=\"goBack()\">\n\t\t\t\t<i class=\"bi bi-arrow-return-left\"></i>\n\t\t\t\t<span class=\"ms-2 d-none d-lg-inline\">Back</span>\n\t\t\t</button>\n\t\t\t<button class=\"btn btn-outline-light me-1 me-sm-2\" title=\"Mark unread\" @click=\"toggleRead()\">\n\t\t\t\t<i class=\"bi bi-eye-slash me-md-2\" :class=\"isRead ? 'bi-eye-slash' : 'bi-eye'\"></i>\n\t\t\t\t<span class=\"d-none d-md-inline\">Mark <template v-if=\"isRead\">un</template>read</span>\n\t\t\t</button>\n\t\t\t<button\n\t\t\t\tv-if=\"mailbox.uiConfig.MessageRelay && mailbox.uiConfig.MessageRelay.Enabled\"\n\t\t\t\tclass=\"btn btn-outline-light me-1 me-sm-2\"\n\t\t\t\ttitle=\"Release message\"\n\t\t\t\t@click=\"initReleaseModal()\"\n\t\t\t>\n\t\t\t\t<i class=\"bi bi-send me-md-2\"></i>\n\t\t\t\t<span class=\"d-none d-md-inline\">Release</span>\n\t\t\t</button>\n\t\t\t<button class=\"btn btn-outline-light me-1 me-sm-2\" title=\"Delete message\" @click=\"deleteMessage()\">\n\t\t\t\t<i class=\"bi bi-trash-fill me-md-2\"></i>\n\t\t\t\t<span class=\"d-none d-md-inline\">Delete</span>\n\t\t\t</button>\n\t\t</div>\n\t\t<div v-if=\"!errorMessage\" class=\"col-auto col-lg-4 col-xl-4 text-end\">\n\t\t\t<div id=\"DownloadBtn\" class=\"dropdown d-inline-block\">\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tclass=\"btn btn-outline-light dropdown-toggle\"\n\t\t\t\t\tdata-bs-toggle=\"dropdown\"\n\t\t\t\t\taria-expanded=\"false\"\n\t\t\t\t>\n\t\t\t\t\t<i class=\"bi bi-file-arrow-down-fill\"></i>\n\t\t\t\t\t<span class=\"d-none d-md-inline ms-1\">Download</span>\n\t\t\t\t</button>\n\t\t\t\t<ul class=\"dropdown-menu dropdown-menu-end\">\n\t\t\t\t\t<li>\n\t\t\t\t\t\t<a\n\t\t\t\t\t\t\t:href=\"resolve('/api/v1/message/' + message.ID + '/raw?dl=1')\"\n\t\t\t\t\t\t\tclass=\"dropdown-item\"\n\t\t\t\t\t\t\ttitle=\"Message source including headers, body and attachments\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tRaw message\n\t\t\t\t\t\t</a>\n\t\t\t\t\t</li>\n\t\t\t\t\t<li v-if=\"message.HTML\">\n\t\t\t\t\t\t<button class=\"dropdown-item\" @click=\"downloadMessageBody(message.HTML, 'html')\">\n\t\t\t\t\t\t\tHTML body\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</li>\n\t\t\t\t\t<li v-if=\"message.HTML\">\n\t\t\t\t\t\t<button class=\"dropdown-item\" @click=\"screenshotMessageHTML()\">HTML screenshot</button>\n\t\t\t\t\t</li>\n\t\t\t\t\t<li v-if=\"message.Text\">\n\t\t\t\t\t\t<button class=\"dropdown-item\" @click=\"downloadMessageBody(message.Text, 'txt')\">\n\t\t\t\t\t\t\tText body\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</li>\n\t\t\t\t\t<template v-if=\"message.Attachments && message.Attachments.length\">\n\t\t\t\t\t\t<li>\n\t\t\t\t\t\t\t<hr class=\"dropdown-divider\" />\n\t\t\t\t\t\t</li>\n\t\t\t\t\t\t<li>\n\t\t\t\t\t\t\t<h6 class=\"dropdown-header\">Attachments</h6>\n\t\t\t\t\t\t</li>\n\t\t\t\t\t\t<li v-for=\"part in message.Attachments\" :key=\"part.PartID\">\n\t\t\t\t\t\t\t<RouterLink\n\t\t\t\t\t\t\t\t:to=\"'/api/v1/message/' + message.ID + '/part/' + part.PartID\"\n\t\t\t\t\t\t\t\tclass=\"row m-0 dropdown-item d-flex\"\n\t\t\t\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\t\t\t\t:title=\"part.FileName !== '' ? part.FileName : '[ unknown ]'\"\n\t\t\t\t\t\t\t\tstyle=\"min-width: 350px\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<div class=\"col-auto p-0 pe-1\">\n\t\t\t\t\t\t\t\t\t<i class=\"bi\" :class=\"attachmentIcon(part)\"></i>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div class=\"col text-truncate p-0 pe-1\">\n\t\t\t\t\t\t\t\t\t{{ part.FileName !== \"\" ? part.FileName : \"[ unknown ]\" }}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div class=\"col-auto text-muted small p-0\">\n\t\t\t\t\t\t\t\t\t{{ getFileSize(part.Size) }}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</RouterLink>\n\t\t\t\t\t\t</li>\n\t\t\t\t\t</template>\n\t\t\t\t\t<template v-if=\"message.Inline && message.Inline.length\">\n\t\t\t\t\t\t<li>\n\t\t\t\t\t\t\t<hr class=\"dropdown-divider\" />\n\t\t\t\t\t\t</li>\n\t\t\t\t\t\t<li>\n\t\t\t\t\t\t\t<h6 class=\"dropdown-header\">Inline image<span v-if=\"message.Inline.length > 1\">s</span></h6>\n\t\t\t\t\t\t</li>\n\t\t\t\t\t\t<li v-for=\"part in message.Inline\" :key=\"part.PartID\">\n\t\t\t\t\t\t\t<RouterLink\n\t\t\t\t\t\t\t\t:to=\"'/api/v1/message/' + message.ID + '/part/' + part.PartID\"\n\t\t\t\t\t\t\t\tclass=\"row m-0 dropdown-item d-flex\"\n\t\t\t\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\t\t\t\t:title=\"part.FileName !== '' ? part.FileName : '[ unknown ]'\"\n\t\t\t\t\t\t\t\tstyle=\"min-width: 350px\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<div class=\"col-auto p-0 pe-1\">\n\t\t\t\t\t\t\t\t\t<i class=\"bi\" :class=\"attachmentIcon(part)\"></i>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div class=\"col text-truncate p-0 pe-1\">\n\t\t\t\t\t\t\t\t\t{{ part.FileName !== \"\" ? part.FileName : \"[ unknown ]\" }}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div class=\"col-auto text-muted small p-0\">\n\t\t\t\t\t\t\t\t\t{{ getFileSize(part.Size) }}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</RouterLink>\n\t\t\t\t\t\t</li>\n\t\t\t\t\t</template>\n\t\t\t\t</ul>\n\t\t\t</div>\n\n\t\t\t<RouterLink\n\t\t\t\t:to=\"'/view/' + previousID\"\n\t\t\t\tclass=\"btn btn-outline-light ms-1 ms-sm-2 me-1\"\n\t\t\t\t:class=\"previousID ? '' : 'disabled'\"\n\t\t\t\ttitle=\"View previous message\"\n\t\t\t>\n\t\t\t\t<i class=\"bi bi-caret-left-fill\"></i>\n\t\t\t</RouterLink>\n\t\t\t<RouterLink :to=\"'/view/' + nextID\" class=\"btn btn-outline-light\" :class=\"nextID ? '' : 'disabled'\">\n\t\t\t\t<i class=\"bi bi-caret-right-fill\" title=\"View next message\"></i>\n\t\t\t</RouterLink>\n\t\t</div>\n\t</div>\n\n\t<div class=\"row flex-fill\" style=\"min-height: 0\">\n\t\t<div class=\"d-none d-xl-flex col-xl-3 h-100 flex-column\">\n\t\t\t<div v-if=\"mailbox.uiConfig.Label\" class=\"text-center badge text-bg-primary py-2 my-2 w-100\">\n\t\t\t\t<div class=\"text-truncate fw-normal\" style=\"line-height: 1rem\">\n\t\t\t\t\t{{ mailbox.uiConfig.Label }}\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div class=\"list-group my-2\" :class=\"mailbox.uiConfig.Label ? 'mt-0' : ''\">\n\t\t\t\t<button class=\"list-group-item list-group-item-action\" @click=\"goBack()\">\n\t\t\t\t\t<i class=\"bi bi-arrow-return-left me-1\"></i>\n\t\t\t\t\t<span class=\"ms-1\">\n\t\t\t\t\t\tReturn to\n\t\t\t\t\t\t<template v-if=\"mailbox.searching\">search</template>\n\t\t\t\t\t\t<template v-else>inbox</template>\n\t\t\t\t\t</span>\n\t\t\t\t\t<span\n\t\t\t\t\t\tv-if=\"mailbox.unread && !errorMessage\"\n\t\t\t\t\t\tclass=\"badge rounded-pill ms-1 float-end text-bg-secondary\"\n\t\t\t\t\t\ttitle=\"Unread messages\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{{ formatNumber(mailbox.unread) }}\n\t\t\t\t\t</span>\n\t\t\t\t</button>\n\t\t\t</div>\n\n\t\t\t<div\n\t\t\t\tid=\"MessageList\"\n\t\t\t\tref=\"MessageList\"\n\t\t\t\tclass=\"flex-grow-1 overflow-y-auto px-1 me-n1\"\n\t\t\t\t@scroll=\"scrollHandler\"\n\t\t\t>\n\t\t\t\t<button v-if=\"liveLoaded >= 100\" class=\"w-100 alert alert-warning small\" @click=\"reloadWindow()\">\n\t\t\t\t\tReload to see newer messages\n\t\t\t\t</button>\n\t\t\t\t<template v-if=\"messagesList && messagesList.length\">\n\t\t\t\t\t<div class=\"list-group\">\n\t\t\t\t\t\t<RouterLink\n\t\t\t\t\t\t\tv-for=\"summary in messagesList\"\n\t\t\t\t\t\t\t:id=\"summary.ID\"\n\t\t\t\t\t\t\t:key=\"'summary_' + summary.ID\"\n\t\t\t\t\t\t\t:to=\"'/view/' + summary.ID\"\n\t\t\t\t\t\t\tclass=\"row gx-1 message d-flex small list-group-item list-group-item-action message\"\n\t\t\t\t\t\t\t:class=\"[summary.Read ? 'read' : '', isActive(summary.ID) ? 'active' : '']\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div class=\"col overflow-x-hidden\">\n\t\t\t\t\t\t\t\t<div class=\"text-truncate privacy small\">\n\t\t\t\t\t\t\t\t\t<strong v-if=\"summary.From\" :title=\"'From: ' + summary.From.Address\">\n\t\t\t\t\t\t\t\t\t\t{{ summary.From.Name ? summary.From.Name : summary.From.Address }}\n\t\t\t\t\t\t\t\t\t</strong>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div class=\"col-auto small\">\n\t\t\t\t\t\t\t\t<i v-if=\"summary.Attachments\" class=\"bi bi-paperclip h6\"></i>\n\t\t\t\t\t\t\t\t{{ getRelativeCreated(summary) }}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div class=\"col-12 overflow-x-hidden\">\n\t\t\t\t\t\t\t\t<div class=\"text-truncate privacy small\">\n\t\t\t\t\t\t\t\t\tTo: {{ getPrimaryEmailTo(summary) }}\n\t\t\t\t\t\t\t\t\t<span v-if=\"summary.To && summary.To.length > 1\">\n\t\t\t\t\t\t\t\t\t\t[+{{ summary.To.length - 1 }}]\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div class=\"col-12 overflow-x-hidden mt-1\">\n\t\t\t\t\t\t\t\t<div class=\"text-truncates small\">\n\t\t\t\t\t\t\t\t\t<b>{{ summary.Subject !== \"\" ? summary.Subject : \"[ no subject ]\" }}</b>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div v-if=\"summary.Tags.length\" class=\"col-12\">\n\t\t\t\t\t\t\t\t<RouterLink\n\t\t\t\t\t\t\t\t\tv-for=\"t in summary.Tags\"\n\t\t\t\t\t\t\t\t\t:key=\"t\"\n\t\t\t\t\t\t\t\t\tclass=\"badge me-1\"\n\t\t\t\t\t\t\t\t\t:to=\"toTagUrl(t)\"\n\t\t\t\t\t\t\t\t\t:style=\"\n\t\t\t\t\t\t\t\t\t\tmailbox.showTagColors\n\t\t\t\t\t\t\t\t\t\t\t? { backgroundColor: colorHash(t) }\n\t\t\t\t\t\t\t\t\t\t\t: { backgroundColor: '#6c757d' }\n\t\t\t\t\t\t\t\t\t\"\n\t\t\t\t\t\t\t\t\t:title=\"'Filter messages tagged with ' + t\"\n\t\t\t\t\t\t\t\t\t@click=\"pagination.start = 0\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{{ t }}\n\t\t\t\t\t\t\t\t</RouterLink>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</RouterLink>\n\t\t\t\t\t</div>\n\t\t\t\t</template>\n\t\t\t</div>\n\n\t\t\t<AboutMailpit />\n\t\t</div>\n\n\t\t<div class=\"col-xl-9 mh-100 ps-0 ps-md-2 pe-0\">\n\t\t\t<div id=\"message-page\" class=\"mh-100\" style=\"overflow-y: auto\">\n\t\t\t\t<template v-if=\"errorMessage\">\n\t\t\t\t\t<h3 class=\"text-center my-3\">\n\t\t\t\t\t\t{{ errorMessage }}\n\t\t\t\t\t</h3>\n\t\t\t\t</template>\n\t\t\t\t<Message v-else-if=\"message\" :key=\"message.ID\" :message=\"message\" />\n\t\t\t</div>\n\t\t</div>\n\t</div>\n\n\t<AboutMailpit modals />\n\t<AjaxLoader :loading=\"loading\" />\n\t<Release\n\t\tv-if=\"mailbox.uiConfig.MessageRelay && loadReleaseModal\"\n\t\tref=\"ReleaseRef\"\n\t\t:message=\"message\"\n\t\t@delete=\"deleteMessage\"\n\t/>\n\t<Screenshot v-if=\"message\" ref=\"ScreenshotRef\" :message=\"message\" />\n</template>\n"
  },
  {
    "path": "server/ui-src/views/NotFoundView.vue",
    "content": "<script>\nimport About from \"../components/AppAbout.vue\";\nimport CommonMixins from \"../mixins/CommonMixins\";\n\nexport default {\n\tcomponents: {\n\t\tAbout,\n\t},\n\n\tmixins: [CommonMixins],\n};\n</script>\n\n<template>\n\t<div class=\"h-100 bg-primary d-flex align-items-center justify-content-center my-2 text-white\">\n\t\t<div class=\"d-block text-center\">\n\t\t\t<RouterLink to=\"/\" class=\"text-white\">\n\t\t\t\t<img :src=\"resolve('/mailpit.svg')\" alt=\"Mailpit\" style=\"max-width: 80%; width: 100px\" />\n\t\t\t\t<p class=\"h2 my-3\">Page not found</p>\n\n\t\t\t\t<p>Click here to continue</p>\n\t\t\t</RouterLink>\n\t\t</div>\n\n\t\t<div class=\"d-none\">\n\t\t\t<About />\n\t\t</div>\n\t</div>\n</template>\n"
  },
  {
    "path": "server/ui-src/views/SearchView.vue",
    "content": "<script>\nimport About from \"../components/AppAbout.vue\";\nimport AjaxLoader from \"../components/AjaxLoader.vue\";\nimport CommonMixins from \"../mixins/CommonMixins\";\nimport ListMessages from \"../components/ListMessages.vue\";\nimport MessagesMixins from \"../mixins/MessagesMixins\";\nimport NavSearch from \"../components/NavSearch.vue\";\nimport NavTags from \"../components/NavTags.vue\";\nimport Pagination from \"../components/NavPagination.vue\";\nimport SearchForm from \"../components/SearchForm.vue\";\nimport { mailbox } from \"../stores/mailbox\";\nimport { pagination } from \"../stores/pagination\";\n\nexport default {\n\tcomponents: {\n\t\tAbout,\n\t\tAjaxLoader,\n\t\tListMessages,\n\t\tNavSearch,\n\t\tNavTags,\n\t\tPagination,\n\t\tSearchForm,\n\t},\n\n\tmixins: [CommonMixins, MessagesMixins],\n\n\t// global event bus to handle message status changes\n\tinject: [\"eventBus\"],\n\n\tdata() {\n\t\treturn {\n\t\t\tmailbox,\n\t\t\tpagination,\n\t\t\tdelayedRefresh: false,\n\t\t};\n\t},\n\n\twatch: {\n\t\t$route() {\n\t\t\tthis.doSearch();\n\t\t},\n\t},\n\n\tmounted() {\n\t\tmailbox.searching = this.getSearch();\n\t\tthis.doSearch();\n\n\t\t// subscribe to events\n\t\tthis.eventBus.on(\"update\", this.handleWSUpdate);\n\t\tthis.eventBus.on(\"delete\", this.handleWSDelete);\n\t\tthis.eventBus.on(\"truncate\", this.handleWSTruncate);\n\t},\n\n\tunmounted() {\n\t\t// unsubscribe from events\n\t\tthis.eventBus.off(\"update\", this.handleWSUpdate);\n\t\tthis.eventBus.off(\"delete\", this.handleWSDelete);\n\t\tthis.eventBus.off(\"truncate\", this.handleWSTruncate);\n\t},\n\n\tmethods: {\n\t\tdoSearch() {\n\t\t\tconst s = this.getSearch();\n\n\t\t\tif (!s) {\n\t\t\t\tmailbox.searching = false;\n\t\t\t\tthis.$router.push(\"/\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tmailbox.searching = s;\n\n\t\t\tthis.apiURI = this.resolve(`/api/v1/search`) + \"?query=\" + encodeURIComponent(s);\n\t\t\tif (mailbox.timeZone !== \"\" && (s.indexOf(\"after:\") !== -1 || s.indexOf(\"before:\") !== -1)) {\n\t\t\t\tthis.apiURI += \"&tz=\" + encodeURIComponent(mailbox.timeZone);\n\t\t\t}\n\t\t\tthis.loadMessages();\n\t\t},\n\n\t\t// handler for websocket message updates\n\t\thandleWSUpdate(data) {\n\t\t\tfor (let x = 0; x < this.mailbox.messages.length; x++) {\n\t\t\t\tif (this.mailbox.messages[x].ID === data.ID) {\n\t\t\t\t\t// update message\n\t\t\t\t\tthis.mailbox.messages[x] = { ...this.mailbox.messages[x], ...data };\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\n\t\t// handler for websocket message deletion\n\t\thandleWSDelete(data) {\n\t\t\tlet removed = 0;\n\t\t\tfor (let x = 0; x < this.mailbox.messages.length; x++) {\n\t\t\t\tif (this.mailbox.messages[x].ID === data.ID) {\n\t\t\t\t\t// remove message from the list\n\t\t\t\t\tthis.mailbox.messages.splice(x, 1);\n\t\t\t\t\tremoved++;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (!removed || this.delayedRefresh) {\n\t\t\t\t// nothing changed on this screen, or a refresh is queued, don't refresh\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// delayedRefresh prevents unnecessary reloads when multiple messages are deleted\n\t\t\tthis.delayedRefresh = true;\n\n\t\t\twindow.setTimeout(() => {\n\t\t\t\tthis.delayedRefresh = false;\n\t\t\t\tthis.loadMessages();\n\t\t\t}, 500);\n\t\t},\n\n\t\t// handler for websocket message truncation\n\t\thandleWSTruncate() {\n\t\t\t// all messages deleted, go back to inbox\n\t\t\tthis.$router.push(\"/\");\n\t\t},\n\t},\n};\n</script>\n\n<template>\n\t<div class=\"navbar navbar-expand-lg row flex-shrink-0 bg-primary text-white d-print-none\" data-bs-theme=\"dark\">\n\t\t<div class=\"col-xl-2 col-md-3 col-auto pe-0\">\n\t\t\t<RouterLink to=\"/\" class=\"navbar-brand text-white me-0\" @click=\"pagination.start = 0\">\n\t\t\t\t<img :src=\"resolve('/mailpit.svg')\" alt=\"Mailpit\" />\n\t\t\t\t<span class=\"ms-2 d-none d-sm-inline\">Mailpit</span>\n\t\t\t</RouterLink>\n\t\t</div>\n\t\t<div class=\"col col-md-4k col-lg-5 col-xl-6\">\n\t\t\t<SearchForm @load-messages=\"loadMessages\" />\n\t\t</div>\n\t\t<div class=\"col-12 col-md-auto col-lg-4 col-xl-4 text-end mt-2 mt-lg-0\">\n\t\t\t<div class=\"float-start d-md-none\">\n\t\t\t\t<button\n\t\t\t\t\tclass=\"btn btn-outline-light me-2\"\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tdata-bs-toggle=\"offcanvas\"\n\t\t\t\t\tdata-bs-target=\"#offcanvas\"\n\t\t\t\t\taria-controls=\"offcanvas\"\n\t\t\t\t>\n\t\t\t\t\t<i class=\"bi bi-list\"></i>\n\t\t\t\t</button>\n\t\t\t</div>\n\t\t\t<Pagination :total=\"mailbox.count\" />\n\t\t</div>\n\t</div>\n\n\t<div\n\t\tid=\"offcanvas\"\n\t\tclass=\"offcanvas-md offcanvas-start d-md-none\"\n\t\tdata-bs-scroll=\"true\"\n\t\ttabindex=\"-1\"\n\t\taria-labelledby=\"offcanvasLabel\"\n\t>\n\t\t<div class=\"offcanvas-header\">\n\t\t\t<h5 id=\"offcanvasLabel\" class=\"offcanvas-title\">Mailpit</h5>\n\t\t\t<button\n\t\t\t\ttype=\"button\"\n\t\t\t\tclass=\"btn-close\"\n\t\t\t\tdata-bs-dismiss=\"offcanvas\"\n\t\t\t\tdata-bs-target=\"#offcanvas\"\n\t\t\t\taria-label=\"Close\"\n\t\t\t></button>\n\t\t</div>\n\t\t<div class=\"offcanvas-body pb-0\">\n\t\t\t<div class=\"d-flex flex-column h-100\">\n\t\t\t\t<div class=\"flex-grow-1 overflow-y-auto me-n3 pe-3\">\n\t\t\t\t\t<NavSearch @load-messages=\"loadMessages\" />\n\t\t\t\t\t<NavTags />\n\t\t\t\t</div>\n\t\t\t\t<About />\n\t\t\t</div>\n\t\t</div>\n\t</div>\n\n\t<div class=\"row flex-fill\" style=\"min-height: 0\">\n\t\t<div class=\"d-none d-md-flex h-100 col-xl-2 col-md-3 flex-column\">\n\t\t\t<div class=\"flex-grow-1 overflow-y-auto me-n3 pe-3\">\n\t\t\t\t<NavSearch @load-messages=\"loadMessages\" />\n\t\t\t\t<NavTags />\n\t\t\t</div>\n\t\t\t<About />\n\t\t</div>\n\n\t\t<div class=\"col-xl-10 col-md-9 mh-100 ps-0 ps-md-2 pe-0\">\n\t\t\t<div id=\"message-page\" class=\"mh-100\" style=\"overflow-y: auto\">\n\t\t\t\t<ListMessages :loading-messages=\"loading\" />\n\t\t\t</div>\n\t\t</div>\n\t</div>\n\n\t<NavSearch modals @load-messages=\"loadMessages\" />\n\t<About modals />\n\t<AjaxLoader :loading=\"loading\" />\n</template>\n"
  },
  {
    "path": "server/webhook/webhook.go",
    "content": "// Package webhook will optionally call a preconfigured endpoint\npackage webhook\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/axllent/mailpit/config\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"golang.org/x/time/rate\"\n)\n\nvar (\n\t// RateLimit is the minimum number of seconds between requests.\n\t// Additional requests within this period will be ignored until\n\t// the time has elapsed.\n\tRateLimit = 1\n\n\t// Delay is the number of seconds to wait before sending each webhook request\n\t// This can allow for other processing to complete before the webhook is triggered.\n\tDelay = 0\n\n\trl rate.Sometimes\n\n\tonce sync.Once\n)\n\n// Send will post the MessageSummary to a webhook (if configured)\nfunc Send(msg any) {\n\tif config.WebhookURL == \"\" {\n\t\treturn\n\t}\n\n\tonce.Do(func() {\n\t\tif RateLimit > 0 {\n\t\t\trl = rate.Sometimes{Interval: time.Duration(RateLimit) * time.Second}\n\t\t} else {\n\t\t\t// allow every request\n\t\t\trl = rate.Sometimes{Every: 1}\n\t\t}\n\t})\n\n\trl.Do(func() {\n\t\tgo func() {\n\t\t\t// apply delay if configured\n\t\t\tif Delay > 0 {\n\t\t\t\ttime.Sleep(time.Duration(Delay) * time.Second)\n\t\t\t}\n\n\t\t\tb, err := json.Marshal(msg)\n\t\t\tif err != nil {\n\t\t\t\tlogger.Log().Errorf(\"[webhook] invalid data: %s\", err.Error())\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\treq, err := http.NewRequest(\"POST\", config.WebhookURL, bytes.NewBuffer(b))\n\t\t\tif err != nil {\n\t\t\t\tlogger.Log().Errorf(\"[webhook] error: %s\", err.Error())\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\treq.Header.Set(\"User-Agent\", \"Mailpit/\"+config.Version)\n\t\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tif config.Label != \"\" {\n\t\t\t\treq.Header.Set(\"Mailpit-Label\", config.Label)\n\t\t\t}\n\n\t\t\tclient := &http.Client{Timeout: 5 * time.Second}\n\t\t\tresp, err := client.Do(req)\n\t\t\tif err != nil {\n\t\t\t\tlogger.Log().Errorf(\"[webhook] error sending data: %s\", err.Error())\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer func() { _ = resp.Body.Close() }()\n\n\t\t\tif resp.StatusCode < 200 || resp.StatusCode > 299 {\n\t\t\t\tlogger.Log().Warnf(\"[webhook] %s returned a %d status\", config.WebhookURL, resp.StatusCode)\n\t\t\t\treturn\n\t\t\t}\n\t\t}()\n\t})\n}\n"
  },
  {
    "path": "server/websockets/client.go",
    "content": "// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license that can be found in the LICENSE file.\n\npackage websockets\n\nimport (\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/axllent/mailpit/internal/auth\"\n\t\"github.com/axllent/mailpit/internal/logger\"\n\t\"github.com/gorilla/websocket\"\n)\n\nconst (\n\t// Time allowed to write a message to the peer.\n\twriteWait = 10 * time.Second\n\n\t// Time allowed to read the next pong message from the peer.\n\tpongWait = 60 * time.Second\n\n\t// Send pings to peer with this period. Must be less than pongWait.\n\tpingPeriod = (pongWait * 9) / 10\n)\n\nvar (\n\tnewline = []byte{'\\n'}\n\n\t// MessageHub global\n\tMessageHub *Hub\n)\n\nvar upgrader = websocket.Upgrader{\n\tReadBufferSize:    1024,\n\tWriteBufferSize:   1024,\n\tEnableCompression: true,\n\tCheckOrigin: func(_ *http.Request) bool {\n\t\t// origin is checked via server.go's CORS settings\n\t\treturn true\n\t},\n}\n\n// Client is a middleman between the websocket connection and the hub.\ntype Client struct {\n\thub *Hub\n\n\t// The websocket connection.\n\tconn *websocket.Conn\n\n\t// Buffered channel of outbound messages.\n\tsend chan []byte\n}\n\n// ReadPump is used here solely to monitor the connection, not to actually receive messages.\nfunc (c *Client) readPump() {\n\tdefer func() {\n\t\tc.hub.unregister <- c\n\t}()\n\n\tfor {\n\t\t_, _, err := c.conn.NextReader()\n\t\tif err != nil {\n\t\t\tif websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {\n\t\t\t\tlogger.Log().Errorf(\"[websocket] error: %v\", err.Error())\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t}\n}\n\n// WritePump pumps messages from the hub to the websocket connection.\n//\n// A goroutine running writePump is started for each connection. The\n// application ensures that there is at most one writer to a connection by\n// executing all writes from this goroutine.\nfunc (c *Client) writePump() {\n\tticker := time.NewTicker(pingPeriod)\n\tdefer func() {\n\t\tticker.Stop()\n\t\tc.hub.unregister <- c\n\t}()\n\tfor {\n\t\tselect {\n\t\tcase message, ok := <-c.send:\n\t\t\t_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))\n\t\t\tif !ok {\n\t\t\t\t// The hub closed the channel.\n\t\t\t\t_ = c.conn.WriteMessage(websocket.CloseMessage, []byte{})\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tw, err := c.conn.NextWriter(websocket.TextMessage)\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\t_, _ = w.Write(message)\n\n\t\t\t// Add queued chat messages to the current websocket message.\n\t\t\tn := len(c.send)\n\t\t\tfor range n {\n\t\t\t\t_, _ = w.Write(newline)\n\t\t\t\t_, _ = w.Write(<-c.send)\n\t\t\t}\n\n\t\t\tif err := w.Close(); err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-ticker.C:\n\t\t\t_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))\n\t\t\t_ = c.conn.WriteMessage(websocket.PingMessage, []byte{})\n\t\t}\n\t}\n}\n\n// ServeWs handles websocket requests from the peer.\nfunc ServeWs(hub *Hub, w http.ResponseWriter, r *http.Request) {\n\tif auth.UICredentials != nil {\n\t\tuser, pass, ok := r.BasicAuth()\n\n\t\tif !ok {\n\t\t\tbasicAuthResponse(w)\n\t\t\treturn\n\t\t}\n\n\t\tif !auth.UICredentials.Match(user, pass) {\n\t\t\tbasicAuthResponse(w)\n\t\t\treturn\n\t\t}\n\t}\n\n\tconn, err := upgrader.Upgrade(w, r, nil)\n\tif err != nil {\n\t\tlogger.Log().Errorf(\"[websocket] %s\", err.Error())\n\t\treturn\n\t}\n\n\tclient := &Client{hub: hub, conn: conn, send: make(chan []byte, 256)}\n\tclient.hub.register <- client\n\n\t// Allow collection of memory referenced by the caller by doing all work in new goroutines.\n\tgo client.readPump()\n\tgo client.writePump()\n}\n\n// BasicAuthResponse returns an basic auth response to the browser\nfunc basicAuthResponse(w http.ResponseWriter) {\n\tw.Header().Set(\"WWW-Authenticate\", `Basic realm=\"Login\"`)\n\tw.WriteHeader(http.StatusUnauthorized)\n\t_, _ = w.Write([]byte(\"Unauthorized.\\n\"))\n}\n"
  },
  {
    "path": "server/websockets/hub.go",
    "content": "// Package websockets is used to broadcast messages to connected clients\npackage websockets\n\nimport (\n\t\"encoding/json\"\n\t\"time\"\n\n\t\"github.com/axllent/mailpit/internal/logger\"\n)\n\n// Hub maintains the set of active clients and broadcasts messages to the\n// clients.\ntype Hub struct {\n\t// Registered clients.\n\tClients map[*Client]bool\n\n\t// Inbound messages from the clients.\n\tBroadcast chan []byte\n\n\t// Register requests from the clients.\n\tregister chan *Client\n\n\t// Unregister requests from clients.\n\tunregister chan *Client\n}\n\n// WebsocketNotification struct for responses\ntype WebsocketNotification struct {\n\tType string\n\tData any\n}\n\n// NewHub returns a new hub configuration\nfunc NewHub() *Hub {\n\treturn &Hub{\n\t\tBroadcast:  make(chan []byte),\n\t\tregister:   make(chan *Client),\n\t\tunregister: make(chan *Client),\n\t\tClients:    make(map[*Client]bool),\n\t}\n}\n\n// Run runs the listener\nfunc (h *Hub) Run() {\n\tfor {\n\t\tselect {\n\t\tcase client := <-h.register:\n\t\t\tif _, ok := h.Clients[client]; !ok {\n\t\t\t\tlogger.Log().Debugf(\"[websocket] client %s connected\", client.conn.RemoteAddr().String())\n\t\t\t\th.Clients[client] = true\n\t\t\t}\n\t\tcase client := <-h.unregister:\n\t\t\tif _, ok := h.Clients[client]; ok {\n\t\t\t\tlogger.Log().Debugf(\"[websocket] client %s disconnected\", client.conn.RemoteAddr().String())\n\t\t\t\tdelete(h.Clients, client)\n\t\t\t\tclose(client.send)\n\t\t\t}\n\t\tcase message := <-h.Broadcast:\n\t\t\tfor client := range h.Clients {\n\t\t\t\tselect {\n\t\t\t\tcase client.send <- message:\n\t\t\t\tdefault:\n\t\t\t\t\tclose(client.send)\n\t\t\t\t\tdelete(h.Clients, client)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\n// Broadcast will spawn a broadcast message to all connected clients\nfunc Broadcast(t string, msg any) {\n\tif MessageHub == nil || len(MessageHub.Clients) == 0 {\n\t\treturn\n\t}\n\n\tw := WebsocketNotification{}\n\tw.Type = t\n\tw.Data = msg\n\tb, err := json.Marshal(w)\n\n\tif err != nil {\n\t\tlogger.Log().Errorf(\"[websocket] broadcast received invalid data: %s\", err.Error())\n\t\treturn\n\t}\n\n\t// add a very small delay to prevent broadcasts from being interpreted\n\t// as a multi-line messages (eg: storage.DeleteMessages() which can send a very quick series)\n\ttime.Sleep(time.Millisecond)\n\n\tgo func() { MessageHub.Broadcast <- b }()\n}\n\n// BroadCastClientError is a wrapper to broadcast client errors to the web UI\nfunc BroadCastClientError(severity, errorType, ip, message string) {\n\tmsg := struct {\n\t\tLevel   string\n\t\tType    string\n\t\tIP      string\n\t\tMessage string\n\t}{\n\t\tseverity,\n\t\terrorType,\n\t\tip,\n\t\tmessage,\n\t}\n\n\tBroadcast(\"error\", msg)\n}\n"
  }
]